<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Seth Wheeler</title>
    <description>The latest articles on DEV Community by Seth Wheeler (@megapixel99).</description>
    <link>https://dev.to/megapixel99</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4078131%2F27de8464-506f-4452-b786-105e5cbb74b7.png</url>
      <title>DEV Community: Seth Wheeler</title>
      <link>https://dev.to/megapixel99</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/megapixel99"/>
    <language>en</language>
    <item>
      <title>What a Language Needs Before It Can Compile Itself</title>
      <dc:creator>Seth Wheeler</dc:creator>
      <pubDate>Sat, 05 Sep 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/megapixel99/what-a-language-needs-before-it-can-compile-itself-2aif</link>
      <guid>https://dev.to/megapixel99/what-a-language-needs-before-it-can-compile-itself-2aif</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Code: &lt;a href="https://github.com/Megapixel99/lambda-language" rel="noopener noreferrer"&gt;Megapixel99/lambda-language&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;lm&lt;/code&gt; is a small low-level language I wrote: static types, explicit memory, no closures, no garbage collector, and four independent backends that emit C, WebAssembly, ARM64 and bytecode for a VM. Its compiler is about 4,400 lines of JavaScript. The obvious next question is whether the language can compile itself, and the obvious first step is the lexer, which is 129 lines.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;lm&lt;/code&gt; the same lexer is 355 lines. That ratio is the finding, because almost none of it is &lt;code&gt;lm&lt;/code&gt; being a verbose language. Six specific absences account for nearly all of it, and writing them down was a planned milestone rather than an afterthought: the point of porting the lexer first was to find out what the language could not do while the port was still small enough to abandon.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that cost the most
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;src/lexer.js&lt;/code&gt; has a single &lt;code&gt;advance(n)&lt;/code&gt; that moves &lt;code&gt;pos&lt;/code&gt;, &lt;code&gt;line&lt;/code&gt; and &lt;code&gt;col&lt;/code&gt; together, called from 14 places. &lt;code&gt;lm&lt;/code&gt; had no way to take the address of a scalar local, so a function could not mutate a caller's variable, and a function returning three values would need a struct allocated on every call. So &lt;code&gt;advance&lt;/code&gt; does not exist. All 14 sites write &lt;code&gt;pos += 1; col += 1;&lt;/code&gt; inline, and the newline case writes the three-line variant.&lt;/p&gt;

&lt;p&gt;That is the single largest source of the size difference, and it also caused the only correctness bug in the port. Column counting inside a string literal has to skip UTF-8 continuation bytes, and because the logic is inlined rather than centralised there is no one place to fix it. The two comment scanners over-count a column in exactly the same way. They get away with it only because a comment always ends at a newline, which resets the column before anything reads it.&lt;/p&gt;

&lt;p&gt;That is worth sitting with. A centralised &lt;code&gt;advance&lt;/code&gt; would have been fixed once and been right in all three places. Instead the code is right in one place by correction and in two others by luck, and the luck is load-bearing: change what terminates a comment and two latent bugs become live ones. Duplication did not make the bug; duplication made the fix local when the bug was not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other five
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;No globals or constants.&lt;/strong&gt; The keyword and operator tables get built inside &lt;code&gt;main&lt;/code&gt; at run time, every run. The waste is small; the real cost is that anything needing them takes them as parameters, and the same goes for the one scratch buffer the output helpers share, which has to be threaded through six functions by hand. Without that threading each helper would call &lt;code&gt;alloc&lt;/code&gt; itself, and with no &lt;code&gt;free&lt;/code&gt; that leaks one cell per call: emitting the token stream for this file is around ten thousand such calls, or 80 KB of unreclaimable heap out of 1 MiB.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No character literals and no hex.&lt;/strong&gt; Every byte constant is a decimal number. &lt;code&gt;c == 35&lt;/code&gt; for &lt;code&gt;#&lt;/code&gt;, &lt;code&gt;c == 92&lt;/code&gt; for a backslash, &lt;code&gt;c == 34&lt;/code&gt; for a quote, &lt;code&gt;+ 48&lt;/code&gt; to turn a digit into ASCII, &lt;code&gt;c &amp;amp; 192 == 128&lt;/code&gt; for a UTF-8 continuation byte. The JavaScript writes &lt;code&gt;c === "#"&lt;/code&gt;. The &lt;code&gt;lm&lt;/code&gt; version is correct and close to unreadable, and a comment naming each constant is the only defence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No string type, so no string comparison.&lt;/strong&gt; Matching an identifier against fifteen keywords is &lt;code&gt;strlen&lt;/code&gt; plus a byte loop per keyword. The JavaScript is &lt;code&gt;KEYWORDS.has(text)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No early exit from a search.&lt;/strong&gt; &lt;code&gt;break&lt;/code&gt; cannot carry a value and there are no labelled loops, so the keyword and operator scans run to completion and guard every iteration with a flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;34&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ops&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hit&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;i64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;matchesAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is 34 comparisons where &lt;code&gt;OPERATORS.find&lt;/code&gt; stops at the first hit. Correct, slower, and it reads worse than what it replaces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No growable array.&lt;/strong&gt; A token list cannot be built, because there is no &lt;code&gt;realloc&lt;/code&gt; and every token would own a string, so tokens are streamed to stdout as they are recognised. For a lexer that is arguably the better design; for a parser it is not, and I wrote at the time that this was the item most likely to decide whether the next stage was feasible.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did not come up
&lt;/h2&gt;

&lt;p&gt;The list above is misleading without this part. Structs, fixed arrays, arrays of pointers, function pointers, &lt;code&gt;for&lt;/code&gt; loops, compound assignment, the full integer type set and &lt;code&gt;u64&lt;/code&gt; arithmetic all did what was needed with no workaround at all. The array-of-strings table works exactly as you would write it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;ops&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;34&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ops&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"&amp;lt;&amp;lt;="&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"&amp;gt;&amp;gt;="&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"=="&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"!="&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The language was not the obstacle. Six specific absences were, and that distinction is the whole value of doing this as a measurement rather than an impression. "The language felt awkward" would have produced a wishlist. Counting what each absence cost produced an ordered one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The list was ordered, and two of them got built
&lt;/h2&gt;

&lt;p&gt;The recommendation, in the order they pay off, was: address-of on a scalar local, then a growable allocation, then top-level &lt;code&gt;const&lt;/code&gt;, then character and hex literals. The first two are now in the language, and the parser, checker and emitter stages that followed all used them.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&amp;amp;&lt;/code&gt; carries a cost the original note recorded and I want to repeat, because it is the sort of thing that is obvious only after you have it. An addressed local lives in a frame allocated from the flat region on entry, and there is still no &lt;code&gt;free&lt;/code&gt;, so a function that takes the address of its own local and is called N times leaks N frames. That suits a lexer, where &lt;code&gt;main&lt;/code&gt; holds the state and passes &lt;code&gt;&amp;amp;pos&lt;/code&gt; down; it does not suit a recursive descent parser, where the functions taking addresses are the ones called thousands of times.&lt;/p&gt;

&lt;h2&gt;
  
  
  The classification was wrong once, and the next stage proved it
&lt;/h2&gt;

&lt;p&gt;I filed "no globals" under &lt;em&gt;merely annoying&lt;/em&gt;; in the parser it was a correctness problem.&lt;/p&gt;

&lt;p&gt;With no globals, the operator precedence table is either threaded through every call or allocated where it is read, and the parser allocated it: a 54-entry allocation per precedence test, tens of thousands of them, against a heap with no &lt;code&gt;free&lt;/code&gt;. One of the test programs simply never finished parsing. The tables live in the parser state now.&lt;/p&gt;

&lt;p&gt;So the severity ranking in that document was right about which items cost the most lines and wrong about which one could break a program. Those turn out to be different questions, and the lexer could not distinguish them, because a lexer allocates once per token and a parser allocates inside its hot loop. A single-stage measurement produced a single-stage ranking, and I presented it as though it generalised.&lt;/p&gt;

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

&lt;p&gt;Self-hosting is usually described as a milestone, and it is more useful as an instrument. A language's missing features are invisible while you are writing programs that fit on a screen, because you route around each absence in a few lines and forget it. Porting a program you did not write, against a reference implementation you can diff, turns each of those routes into a measurable cost: 14 inlined sites, 34 comparisons instead of 1, 80 KB of unreclaimable heap, one bug that survives on the behaviour of comments.&lt;/p&gt;

&lt;p&gt;The gate I set for this milestone was to stop if the list ran long, on the grounds that growing the language to fit is a different project from self-hosting it. Six items with two clear priorities is not long. The judgement that mattered was not "is &lt;code&gt;lm&lt;/code&gt; good enough", which is unanswerable, but "which four absences are worth building, in what order", which the port answered on its own.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;LIMITS.md&lt;/code&gt; is in &lt;a href="https://github.com/Megapixel99/lambda-language/blob/master/selfhost/LIMITS.md" rel="noopener noreferrer"&gt;the repository&lt;/a&gt;, with each entry beside the code it cost.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>javascript</category>
      <category>node</category>
      <category>computerscience</category>
    </item>
    <item>
      <title>No Add-on Can Block Ads in This Browser</title>
      <dc:creator>Seth Wheeler</dc:creator>
      <pubDate>Thu, 03 Sep 2026 15:45:05 +0000</pubDate>
      <link>https://dev.to/megapixel99/no-add-on-can-block-ads-in-this-browser-16gj</link>
      <guid>https://dev.to/megapixel99/no-add-on-can-block-ads-in-this-browser-16gj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Code: &lt;a href="https://github.com/Megapixel99/kestrel" rel="noopener noreferrer"&gt;Megapixel99/kestrel&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Kestrel is a macOS browser I have been building on &lt;code&gt;WKWebView&lt;/code&gt; to test whether a browser can hold a memory budget the way a game engine holds a frame budget. It used to ship six built-in imitations of common extensions, including an ad blocker. When macOS 15.4 shipped &lt;code&gt;WKWebExtension&lt;/code&gt; I replaced all six with the real thing: Kestrel unpacks an &lt;code&gt;.xpi&lt;/code&gt; and hands it to WebKit, and all 25 add-ons from my Firefox profiles load.&lt;/p&gt;

&lt;p&gt;Then I measured whether the real ad blockers actually block anything. They do not. Neither manifest version works, the failure is silent in both, and the mechanism I deleted is the only one I can demonstrate working.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;blocktest&lt;/code&gt; loads an add-on, opens five known tracker URLs, and reports which of them the network layer actually fetched.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;kestrel &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ./.build/debug/kestrel blocktest ubolite 45
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;what&lt;/th&gt;
&lt;th&gt;how&lt;/th&gt;
&lt;th&gt;result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;MV2 blocking &lt;code&gt;webRequest&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;test add-on returns &lt;code&gt;{cancel:true}&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;request went through&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MV3 &lt;code&gt;declarativeNetRequest&lt;/code&gt;, uBO Lite, 6 enabled rulesets including EasyList&lt;/td&gt;
&lt;td&gt;5 tracker probes&lt;/td&gt;
&lt;td&gt;0 of 5 blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the same, after 180 s for rule compilation&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;td&gt;0 of 5 blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the same, in a web view built from the controller's own configuration&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;td&gt;loaded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;WKContentRuleList&lt;/code&gt; compiled by Kestrel itself&lt;/td&gt;
&lt;td&gt;same probe&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is the reason to believe the other four. A negative result from a probe is worthless unless you can show the probe detects the positive case; without that row "loaded" could just as easily mean the test never looked. The self-check runs every time, so "loaded" means the request really was not blocked.&lt;/p&gt;

&lt;p&gt;What WebKit does here is worse than refusing. It &lt;strong&gt;grants&lt;/strong&gt; &lt;code&gt;webRequestBlocking&lt;/code&gt; and then ignores what the handler returns. It &lt;strong&gt;accepts&lt;/strong&gt; &lt;code&gt;declarativeNetRequest&lt;/code&gt; rulesets, and &lt;code&gt;hasContentModificationRules&lt;/code&gt; reports &lt;code&gt;true&lt;/code&gt;, without applying them to any web view I can reach through the public API. That includes one built from &lt;code&gt;WKWebExtensionController.Configuration.webViewConfiguration&lt;/code&gt;, which is the configuration the extension controller itself hands you. An add-on has no way to detect any of this. It reports itself as working, its dashboard shows six enabled rulesets, and every request goes through.&lt;/p&gt;

&lt;h2&gt;
  
  
  I stated the opposite twice
&lt;/h2&gt;

&lt;p&gt;Before running the test properly I wrote in this project's own notes that MV2 blocking &lt;code&gt;webRequest&lt;/code&gt; was the problem and that an MV3 blocker would be the fix. I wrote it twice, with more confidence than the evidence supported, and it was wrong in a specific way worth naming: the manifest version is not the discriminator. Both fail; MV3 fails after accepting the rulesets and reporting them compiled, which is why it looked like the answer from the outside.&lt;/p&gt;

&lt;p&gt;What made the claim survive is that I checked whether uBO Lite &lt;em&gt;loaded&lt;/em&gt;, which it does, rather than whether it &lt;em&gt;blocked&lt;/em&gt;, which it does not. Those are different questions and only one of them needed a probe.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I have not ruled out
&lt;/h2&gt;

&lt;p&gt;Two possibilities are still open, and neither is settled by anything above.&lt;/p&gt;

&lt;p&gt;WebKit may honour only &lt;em&gt;dynamic&lt;/em&gt; rules, added through &lt;code&gt;declarativeNetRequest.updateDynamicRules&lt;/code&gt;, and not manifest-declared static rulesets. A test add-on that adds a single dynamic rule for a known URL would settle it in an afternoon, and it is the obvious next experiment. Note that this would not rescue uBO Lite, whose rulesets are static, but it would change the finding from "declarativeNetRequest does not work" to something narrower and more useful.&lt;/p&gt;

&lt;p&gt;Alternatively, Safari may implement extension content blocking through its own content-blocker plumbing, which a host application embedding WebKit does not inherit. If that is the case, this is not fixable from outside the engine and should be recorded that way rather than left looking like a bug someone could fix.&lt;/p&gt;

&lt;p&gt;I am stating both because the measurement supports "no add-on blocked anything through the public API" and does not support "WebKit cannot block." Those get conflated easily, and the second is a much larger claim than I have evidence for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing that works is the thing I deleted
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;WKContentRuleList&lt;/code&gt; compiled by the browser blocks reliably. The self-check proves it on every run of &lt;code&gt;blocktest&lt;/code&gt;, against the same probe the add-ons fail.&lt;/p&gt;

&lt;p&gt;That is precisely the mechanism the deleted built-in ad blocker used: it converted Adblock Plus filter lists into a &lt;code&gt;WKContentRuleList&lt;/code&gt; and handed it to the web view. I removed it, along with five other imitations, on the reasoning that real add-ons had made the imitations redundant. For five of the six that was true. For the ad blocker it was exactly backwards, and I did not find out until after the code was gone, because I checked that the replacements loaded rather than that they worked.&lt;/p&gt;

&lt;p&gt;Restoring a native content blocker is the only demonstrated way to block ads in this browser. Which is an odd conclusion for a project whose whole point was to stop reimplementing things the platform provides.&lt;/p&gt;

&lt;h2&gt;
  
  
  A related failure, for contrast
&lt;/h2&gt;

&lt;p&gt;Adblock Plus's options page is a separate problem with a clean answer, and it is worth putting beside the first one because it looks similar and is not.&lt;/p&gt;

&lt;p&gt;The page came up blank. ABP's options page is a shell whose only content is an iframe of &lt;code&gt;desktop-options.html&lt;/code&gt;, and WebKit refuses that subframe unless the add-on declared &lt;code&gt;web_accessible_resources&lt;/code&gt;, which is stricter than Firefox or Chrome and which ABP does not declare. Kestrel now navigates the tab to the inner page directly when a lone iframe fails, so the content loads with nothing loosened: no manifest rewriting, and nothing of the add-on made readable by arbitrary web pages. The check has to be repeated rather than made once, because at &lt;code&gt;didFinish&lt;/code&gt; the iframe carries only &lt;code&gt;data-src&lt;/code&gt;; ABP's &lt;code&gt;options.js&lt;/code&gt; is deferred and sets the real &lt;code&gt;src&lt;/code&gt; a beat later.&lt;/p&gt;

&lt;p&gt;The page now renders, and then refuses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;options page in a tab: …/desktop-options.html  nodes=8
  getBrowserInfo=undefined
  ua=Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:141.0) Gecko/20100101 Firefox/141.0
  text=Your browser version is no longer supported. Please upgrade
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is not the user agent, which is a clean Firefox string with no AppleWebKit prefix; it is &lt;code&gt;browser.runtime.getBrowserInfo&lt;/code&gt;, a Firefox-specific API that WebKit's runtime does not implement, so ABP has no version to compare against and defaults to unsupported. That one is shimmable by injecting a script into add-on pages, but shimming it means telling an add-on it is Firefox 141 and inviting every other Gecko-only path it might then take, which fail less visibly than this one does. uBO Lite is unaffected, since its dashboard is a page rather than a frame shell.&lt;/p&gt;

&lt;p&gt;The contrast is the useful part. The ABP failure is a missing API that announces itself: &lt;code&gt;undefined&lt;/code&gt; is checkable, the add-on notices, and it tells the user. The blocking failure announces nothing at all. An API that is present, accepts your input, reports success and does nothing is harder to find than one that is absent, and it is the reason this took a purpose-built probe with a control rather than an afternoon of clicking around.&lt;/p&gt;

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

&lt;p&gt;When you test whether a subsystem works, the cheap version of the test asks the subsystem. The expensive version asks something downstream that has no reason to cooperate. Here the cheap version was uBO Lite's dashboard reporting six enabled rulesets, and it was wrong for three minutes and then still wrong. The expensive version was five URLs and a check of what the network actually fetched.&lt;/p&gt;

&lt;p&gt;The control row is what turns that from an anecdote into a result, and it costs one extra case: block the same URL by a mechanism you know works, and confirm the probe notices. Without it a negative finding is indistinguishable from a broken test, which is the position I was in when I twice wrote down the wrong cause.&lt;/p&gt;

&lt;p&gt;The full measurement, including what has been ruled out and what has not, is in &lt;a href="https://github.com/Megapixel99/kestrel/blob/master/BROKEN.md" rel="noopener noreferrer"&gt;BROKEN.md&lt;/a&gt;. Kestrel is 10,746 lines of Swift across 45 files; measurements are on an M1 Max running macOS 15.5, Swift 6.1.2 and system WebKit.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>swift</category>
      <category>browser</category>
      <category>extensions</category>
    </item>
    <item>
      <title>Measuring the Wrong Process for Eight Months</title>
      <dc:creator>Seth Wheeler</dc:creator>
      <pubDate>Thu, 03 Sep 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/megapixel99/measuring-the-wrong-process-for-eight-months-3j9</link>
      <guid>https://dev.to/megapixel99/measuring-the-wrong-process-for-eight-months-3j9</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Code: &lt;a href="https://github.com/Megapixel99/kestrel" rel="noopener noreferrer"&gt;Megapixel99/kestrel&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In &lt;a href="https://sethwheeler.dev/blog/hibernated-tab-cost/" rel="noopener noreferrer"&gt;What a Hibernated Browser Tab Actually Costs&lt;/a&gt; I reported that Kestrel's memory ladder held 121.7 MB against an unmanaged browser's 319.9 MB on a set of real websites, and that it never went over its budget. Both numbers were wrong. Re-measured, the same run holds 345.9 MB against an unmanaged 255.3 MB, and it is over budget in 85% of samples. The ladder did not do less good than I published; on that workload it did harm, and the published figures reported it as the best result in the post.&lt;/p&gt;

&lt;p&gt;The correction is worth more than the original finding, because what broke was not the design. It was the instrument.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that gave it away
&lt;/h2&gt;

&lt;p&gt;Kestrel reported a Jira board as costing 52 MB. The process rendering that page held 511 MB. The 52 MB was real, and it belonged to a completely different application's WebContent process that had been running for eight days.&lt;/p&gt;

&lt;p&gt;I did not catch this with a test. I caught it with a screen recording, watching the same figure sit unchanged before and after loading an entirely different page. That is a temporal observation, and no single assertion or screenshot contains one. You have to see two moments next to each other and notice they agree when they should not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three mistakes, stacked
&lt;/h2&gt;

&lt;p&gt;Every memory figure in the earlier post was a &lt;strong&gt;per-tab attributed sum&lt;/strong&gt;: one process id per tab, claimed by diffing &lt;code&gt;ps&lt;/code&gt; output around tab creation, then added up. Three things were wrong with that, and each one hid the next.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ps&lt;/code&gt; lists every WebContent process on the machine.&lt;/strong&gt; They are XPC services parented to launchd with identical command lines, so there is no parent and no client tag to filter on. Nothing filtered at all, which made any content process on the system a candidate, including ones belonging to Safari or to an Electron app someone left open. The single sound discriminator available is age: a process older than the browser cannot belong to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tab creation picked &lt;code&gt;.first&lt;/code&gt; on an unordered set.&lt;/strong&gt; WebKit spawns several content processes at once (five, on a measured launch), and the diff-at-creation trick assumed it would see exactly one new pid. Whichever one it happened to grab, the tab reported for the rest of its life.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing distinguished "cannot measure" from "costs nothing."&lt;/strong&gt; Both rendered as &lt;code&gt;0 MB&lt;/code&gt;. A live page the browser had lost track of was therefore counted as free by the scheduler: never in the total, never a candidate for demotion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it survived eight months
&lt;/h2&gt;

&lt;p&gt;The number was always &lt;em&gt;plausible&lt;/em&gt;. 52 MB for a page is unremarkable; so is 115, and so is 399. A wrong number inside a believable range is invisible in a way that a crash is not, and every test written against it passed, because the tests asked the browser what it thought rather than checking the browser against the machine.&lt;/p&gt;

&lt;p&gt;That is the same failure this project had already recorded once. An earlier round of work improved what &lt;code&gt;about:memory&lt;/code&gt; reported while returning nothing to the OS, and I wrote it down at the time. I then built a benchmark with the identical shape and did not recognise it for eight months. Writing a lesson down is not the same as having learned it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The corrected numbers
&lt;/h2&gt;

&lt;p&gt;The benchmark now records both the attributed sum and the browser's real footprint, which is every WebContent process younger than the run, summed. That second figure needs no attribution at all, so it cannot fail in this way. Heavy pages, 800 MB budget, 40 events:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;policy&lt;/th&gt;
&lt;th&gt;attributed&lt;/th&gt;
&lt;th&gt;measured&lt;/th&gt;
&lt;th&gt;tabs accounted for&lt;/th&gt;
&lt;th&gt;over budget: attributed to measured&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;975.9 MB&lt;/td&gt;
&lt;td&gt;974.9 MB&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;82% to 82%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;discard-LRU&lt;/td&gt;
&lt;td&gt;547.1 MB&lt;/td&gt;
&lt;td&gt;802.2 MB&lt;/td&gt;
&lt;td&gt;68%&lt;/td&gt;
&lt;td&gt;2% to 62%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kestrel&lt;/td&gt;
&lt;td&gt;587.9 MB&lt;/td&gt;
&lt;td&gt;788.1 MB&lt;/td&gt;
&lt;td&gt;75%&lt;/td&gt;
&lt;td&gt;2% to 48%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 100% row is the one that matters. With every tab live and every process owned by a tab, the two methods agree to within 1 MB, which means attribution became exact once the web view named its own process. So the 25 to 32% gap under the demoting policies is not measurement error. It is real memory sitting in processes that outlived the tabs they belonged to.&lt;/p&gt;

&lt;p&gt;That splits a question the project could not previously answer. Of the gap, roughly 0% is mis-attribution and all of it is processes orphaned by demotion. Demoting a tab releases its web view; WebKit keeps the process; nobody owns that memory, and nobody was counting it.&lt;/p&gt;

&lt;p&gt;Per event, the shape is unmistakable once the two columns sit side by side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;policy=none     event 12:  attributed 1049   measured 1089   procs 10
policy=kestrel  event 12:  attributed  428   measured  668   procs 10
policy=kestrel  event 36:  attributed  749   measured  989   procs 13
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The error is not uniform, and it flatters exactly the policies the benchmark exists to evaluate.&lt;/strong&gt; With no policy running, every process belongs to a live tab and attribution captures effectively all of it. Start demoting and the processes stop being attributed to anything, so the reported number falls while the memory does not. A measurement whose error scales with the treatment is not a noisy measurement; it is a measurement that manufactures the result.&lt;/p&gt;

&lt;p&gt;Corrected against the unmanaged run, discard-LRU comes to 975 / 802 = &lt;strong&gt;1.22x&lt;/strong&gt; and Kestrel to 975 / 788 = &lt;strong&gt;1.24x&lt;/strong&gt;. Across three runs Kestrel measured 1.28x, 1.28x and 1.24x, and discard-LRU 1.21x, 1.20x and 1.22x. The "0% over budget" claim does not survive at all: measured, Kestrel is over budget in 48% of samples and discard-LRU in 62%. The scheduler was demoting until its own accounting said it was under budget, which turns out not to be the same thing as being under budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  The light pages inverted
&lt;/h2&gt;

&lt;p&gt;The published post's worst-looking table was the light-pages run, and I described it there as a loss. It was considerably worse than a loss. Eleven mixed-weight real sites, 150 MB budget, 40 events:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;policy&lt;/th&gt;
&lt;th&gt;published (attributed)&lt;/th&gt;
&lt;th&gt;measured&lt;/th&gt;
&lt;th&gt;vs unmanaged&lt;/th&gt;
&lt;th&gt;over budget&lt;/th&gt;
&lt;th&gt;tabs destroyed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;319.9 MB&lt;/td&gt;
&lt;td&gt;255.3 MB&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;85%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;discard-LRU&lt;/td&gt;
&lt;td&gt;104.6 MB&lt;/td&gt;
&lt;td&gt;414.8 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.62x worse&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;90%&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kestrel&lt;/td&gt;
&lt;td&gt;121.7 MB&lt;/td&gt;
&lt;td&gt;345.9 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.35x worse&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;85%&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Attribution accounted for 100% of the unmanaged run and only 25% and 36% of the managed ones. The policies were reporting roughly a quarter of what they actually held, and the error was largest precisely where the design looked best.&lt;/p&gt;

&lt;p&gt;This is not a new failure mode. It is this design's own predicted one, finally measured. &lt;a href="https://github.com/Megapixel99/kestrel/blob/master/DESIGN.md" rel="noopener noreferrer"&gt;DESIGN.md&lt;/a&gt; states the feasibility rule as &lt;code&gt;budget &amp;gt; live working set + (39 MB x parked tabs)&lt;/code&gt;. Eleven tabs at the 39 MB COLD floor need 429 MB before a single page is displayed, and this run was handed 150 MB, which is 2.9x below its own floor. The scheduler says so in its logs: &lt;code&gt;gave_up&lt;/code&gt; fires 5 times for discard-LRU and 7 times for Kestrel.&lt;/p&gt;

&lt;p&gt;What the design predicted below the floor was degradation toward discard-LRU. What actually happens is degradation below doing nothing. A demotion leaves the old WebContent process alive, the restore spawns another, and 45 demotions with 18 restores over 40 events churn processes faster than WebKit reclaims them. Measured peak reached 530 MB for Kestrel and 650 MB for discard-LRU, against 309 MB for the run that managed nothing at all, on the workload whose entire premise was that the pages were light.&lt;/p&gt;

&lt;h2&gt;
  
  
  What survives
&lt;/h2&gt;

&lt;p&gt;The ladder's own physics were measured correctly the whole time. Re-measured with the web view naming its own process, six tabs, same synthetic page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LIVE    128.0 MB
WARM    106.0 MB   (83% of live)
COLD     39.0 MB   (30% of live)
restore COLD -&amp;gt; LIVE: 87 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Identical to the published figures, to the decimal. Two warnings the re-measurement was watching for did not fire: the pid diff was never ambiguous in that run (one tab at a time, 3.5 s apart, exactly one WebContent process each), and navigating to &lt;code&gt;about:blank&lt;/code&gt; did not move the page to a different process, which was the way the 39 MB COLD figure could have been some abandoned process's footprint rather than the parked tab's. The feasibility floor built on 39 MB stands unchanged, which is why it was able to predict the inversion above.&lt;/p&gt;

&lt;p&gt;The comparative case also came out stronger than the old numbers suggested. Kestrel holds less real memory than discard-LRU (788 MB against 802 MB) while destroying fewer tabs (6 against 8) and spending far less time over budget (48% against 62%). On the attributed figures it looked marginally worse on memory and better only on state loss.&lt;/p&gt;

&lt;p&gt;So the honest summary is workload-dependent, and one side of it is negative:&lt;/p&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;budget vs. floor&lt;/th&gt;
&lt;th&gt;Kestrel vs unmanaged&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;heavy pages, 800 MB budget&lt;/td&gt;
&lt;td&gt;above the floor&lt;/td&gt;
&lt;td&gt;1.24x better&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;light pages, 150 MB budget&lt;/td&gt;
&lt;td&gt;2.9x below the floor&lt;/td&gt;
&lt;td&gt;1.35x worse&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The ladder helps when the budget is reachable and hurts when it is not, because below the floor the process churn costs more than the parked pages save. The old numbers hid that completely. They reported 2.63x better on precisely the run where the design was 1.35x worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed, and what I did not
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;WKWebView._webProcessIdentifier&lt;/code&gt; is private API, verified on macOS 15.5, and it is now asked at every point where a tab's process is established, with age-filtered &lt;code&gt;ps&lt;/code&gt; diffing as a fallback. The whole-browser total is measured independently by summing the run's own processes, which needs no attribution and therefore cannot fail this way. Where the two disagree by more than 20%, the interface says so rather than picking one.&lt;/p&gt;

&lt;p&gt;The scheduler itself is unfixed, and a tweak is the wrong response. A browser that cannot meet its budget should refuse the budget: surface the floor, name the number of tabs it can hold, and stop demoting, rather than thrash against a target it can prove is unreachable. That is recorded in &lt;a href="https://github.com/Megapixel99/kestrel/blob/master/BROKEN.md" rel="noopener noreferrer"&gt;BROKEN.md&lt;/a&gt; and not implemented.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that generalises
&lt;/h2&gt;

&lt;p&gt;Both times this project measured itself wrong, the instrument reported on the thing it was part of, and both times it reported success. Tier-down improved &lt;code&gt;about:memory&lt;/code&gt; while returning nothing to the operating system. Per-tab attribution improved the benchmark's total while the memory stayed in processes nobody counted. The fix was the same on both occasions: measure from outside the thing being measured, where the number cannot be produced by the component whose behaviour is in question.&lt;/p&gt;

&lt;p&gt;The tell is worth naming, because it is available before you know anything is wrong. Ask what your measurement would report if the feature did nothing at all, and then ask what it would report if the feature worked perfectly. If the second answer can be produced by the component simply losing track of its own work, the instrument is not measuring the feature. Mine could, and for eight months it did.&lt;/p&gt;

&lt;p&gt;The full account of the bug is in &lt;a href="https://github.com/Megapixel99/kestrel/blob/master/DEBUGGING.md" rel="noopener noreferrer"&gt;DEBUGGING.md&lt;/a&gt;, and the corrected tables with their per-event traces are at the end of &lt;a href="https://github.com/Megapixel99/kestrel/blob/master/RESULTS-ENGINE.md" rel="noopener noreferrer"&gt;RESULTS-ENGINE.md&lt;/a&gt;. Kestrel is 10,746 lines of Swift across 45 files on top of &lt;code&gt;WKWebView&lt;/code&gt;; the measurements here are all on one machine, an M1 Max running macOS 15.5, Swift 6.1.2 and system WebKit.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>swift</category>
      <category>debugging</category>
      <category>performance</category>
    </item>
    <item>
      <title>When a SQL Engine Records Column Types but Never Reads Them</title>
      <dc:creator>Seth Wheeler</dc:creator>
      <pubDate>Wed, 02 Sep 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/megapixel99/when-a-sql-engine-records-column-types-but-never-reads-them-45ee</link>
      <guid>https://dev.to/megapixel99/when-a-sql-engine-records-column-types-but-never-reads-them-45ee</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Code: &lt;a href="https://github.com/Megapixel99/sql-nodejs" rel="noopener noreferrer"&gt;Megapixel99/sql-nodejs&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;sql-nodejs is an in-memory SQL database I wrote to understand how a parser turns a statement into stored rows. It takes SQL strings, creates tables, stores rows, and answers &lt;code&gt;SELECT&lt;/code&gt;. Its &lt;code&gt;CREATE TABLE&lt;/code&gt; accepts column types, and it records them.&lt;/p&gt;

&lt;p&gt;It never reads them again.&lt;/p&gt;

&lt;p&gt;Here is the whole thing, against the published package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SqlParser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sql-nodejs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// 0.0.6&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SqlParser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CREATE DATABASE mydb;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CREATE TABLE users (id INT, name VARCHAR, age INT);&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INSERT INTO users (id, name, age) VALUES (1, alice, 30);&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INSERT INTO users (id, name, age) VALUES (2, bob, 25);&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM users;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// [ [ '1', 'alice', '30' ], [ '2', 'bob', '25' ] ]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;id&lt;/code&gt; was declared &lt;code&gt;INT&lt;/code&gt; and comes back as &lt;code&gt;'1'&lt;/code&gt;. Every value there is a string, including the two columns whose declared type says otherwise. The type survives parsing and reaches storage; nothing downstream consults it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The query that finds nothing
&lt;/h2&gt;

&lt;p&gt;Storing numbers as strings is a limitation the README already admits. On its own it is not very interesting. What makes it worth writing down is that &lt;code&gt;WHERE&lt;/code&gt; appears to work anyway:&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="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM users WHERE age=25;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// [ [ '2', 'bob', '25' ] ]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the right row, and it is exactly why the problem is easy to miss, because the obvious test passes and nothing suggests looking further. Now ask for the same row a different way:&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="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM users WHERE age=25.0;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// []&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;25.0&lt;/code&gt; and &lt;code&gt;25&lt;/code&gt; are the same integer, bob is still 25 years old, and the result is empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the first query works
&lt;/h2&gt;

&lt;p&gt;The comparison is a strict equality between two strings, in &lt;code&gt;table.js&lt;/code&gt;:&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="nx"&gt;_where&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getColmunNames&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_where&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The left side is a fragment of the query text: everything after the &lt;code&gt;=&lt;/code&gt; in &lt;code&gt;age=25&lt;/code&gt;, which is the string &lt;code&gt;'25'&lt;/code&gt;. The right side is what &lt;code&gt;INSERT&lt;/code&gt; put in the row, which is also the string &lt;code&gt;'25'&lt;/code&gt;, because that is how the value arrived and nothing converted it. So &lt;code&gt;'25' === '25'&lt;/code&gt; is true and the row matches.&lt;/p&gt;

&lt;p&gt;Nothing in that expression knows the column is an &lt;code&gt;INT&lt;/code&gt;. It matches because the two pieces of text are spelled identically, and &lt;code&gt;25.0&lt;/code&gt; is spelled differently. The equality operator is doing string comparison and getting the right answer for integers by coincidence. That coincidence held for every example I wrote while building it, because I wrote the same integer on both sides every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that has not bitten yet
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;WHERE&lt;/code&gt; currently supports one &lt;code&gt;column=value&lt;/code&gt; equality and nothing else, and that limit is what has been hiding the rest of this, because equality is the one operator where comparing strings usually agrees with comparing numbers. Ordering does not have that property:&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;9&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;10&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;   &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding &lt;code&gt;&amp;gt;&lt;/code&gt; by reusing the existing comparison would produce a database in which nine is greater than ten, and it would pass any test whose numbers happen to have the same digit count. The bug would not be in the new operator, but in the much earlier decision to keep the parsed type and never look at it, and the new operator would only be the first thing to ask.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would fix, and what that costs
&lt;/h2&gt;

&lt;p&gt;The fix is coercion at &lt;code&gt;INSERT&lt;/code&gt;, using the type &lt;code&gt;CREATE TABLE&lt;/code&gt; already captured: store &lt;code&gt;30&lt;/code&gt; rather than &lt;code&gt;'30'&lt;/code&gt;, and parse the right-hand side of a &lt;code&gt;WHERE&lt;/code&gt; before comparing it rather than slicing it out of the query string. That is where the recorded type starts doing work.&lt;/p&gt;

&lt;p&gt;It is also a behaviour change for anyone reading results today, since &lt;code&gt;SELECT&lt;/code&gt; would begin returning numbers where it now returns strings. That belongs in a version bump rather than a patch.&lt;/p&gt;

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

&lt;p&gt;Parsing a type and honouring a type are separate pieces of work, and it is possible to ship the first while believing you shipped both. &lt;code&gt;CREATE TABLE users (id INT, ...)&lt;/code&gt; is accepted, stored, and displayed back, so every surface agrees the type is real. The only way to find out it is decorative is to ask a question where the string and the number disagree. &lt;code&gt;age=25&lt;/code&gt; is not that question; &lt;code&gt;age=25.0&lt;/code&gt; is, and it costs one line to ask.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>node</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Choosing Video Frames by Content Instead of by a Clock</title>
      <dc:creator>Seth Wheeler</dc:creator>
      <pubDate>Tue, 01 Sep 2026 08:21:06 +0000</pubDate>
      <link>https://dev.to/megapixel99/choosing-video-frames-by-content-instead-of-by-a-clock-i4m</link>
      <guid>https://dev.to/megapixel99/choosing-video-frames-by-content-instead-of-by-a-clock-i4m</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Code: &lt;a href="https://github.com/Megapixel99/video-timeline" rel="noopener noreferrer"&gt;Megapixel99/video-timeline&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The usual way to hand a video to a language model is to pull a handful of screenshots and let it reason over them. The trouble isn't in reading any one frame; it's in reading the gaps between them. A screenshot doesn't say when it happened, how long after the last one, or what moved in between. So the model fills that in with a plausible story. VTL replaces those screenshots with a measured timeline. The first question worth asking is whether it actually sees more of a video than evenly spaced sampling does at the same cost.&lt;/p&gt;

&lt;p&gt;It does, and the gap is large. Both methods get the same frame budget, run over five real videos: eighteen minutes of a narrated slide deck, two screen recordings, a scroll capture, and handheld phone footage. Then you count what each one misses. Across 28 shots, uniform sampling never looks at 9 of them. That is a third of the video, gone. It also spends 12 of its 40 frames re-photographing pictures that differ from the previous frame taken by under 7%. VTL misses no shot and wastes 2 frames. You can check this yourself. &lt;code&gt;python3 tests/benchmark.py&lt;/code&gt; runs the comparison on generated fixtures, or on your own files if you pass them.&lt;/p&gt;

&lt;p&gt;The mechanism is simple enough to disagree with. Uniform sampling puts a frame every T seconds, regardless of what is on screen. VTL puts frames where the picture changes, drops the near-duplicates, and enforces a floor. No shot goes without at least one frame, and no stretch longer than the coverage limit goes unobserved. The budget uniform sampling spends re-photographing a motionless slide, VTL spends on the shots uniform never reached.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it does not win
&lt;/h2&gt;

&lt;p&gt;That is not the same as "smaller gaps everywhere," and the benchmark is built to catch me if I claim it is. On the size of the largest unobserved gap, the two methods come out level. On one video, the dashboard capture, uniform sampling is the better of the two: a worst gap of 0.07 against VTL's 0.14. That result is real, and it follows from the design. VTL concentrates its frames at the moments of change, and it deliberately declines to re-photograph a slide that is sitting still. Across the still part, that can leave a wider gap than even spacing would. The difference I will defend is not that the gaps are smaller. It is that VTL names its gaps in the timeline as frozen spans, where uniform sampling's gaps are simply unexplained.&lt;/p&gt;

&lt;p&gt;One honest caveat on the coverage number. "Shots never seen" is counted against VTL's own shot boundaries, which are the thing under test, so it is not an independent oracle. It is fair for measuring coverage, because a shot boundary is a measured discontinuity whichever tool you ask. I would not push it further than that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I trust the numbers
&lt;/h2&gt;

&lt;p&gt;Most of these figures are checked against ground truth I can compute rather than eyeball. The camera-motion rates are asserted against a fixture whose every frame is rendered in Python, by moving a viewport along a closed-form path. The true pan, tilt and zoom rates are therefore known exactly. Pan and tilt land within about 10%. Zoom reads roughly 30% low: true +0.115, measured +0.080. That is not noise. Block displacement under a zoom is only a fraction of a proxy pixel near the frame centre, so the direction is reliable and the magnitude is a lower bound. Both facts are printed in the bundle, next to the measurement that produced them. On real footage there is no ground truth to assert against, so a second script measures the same clips with an independent algorithm: FFT phase correlation at 480 px, sharing no code with the converter's block matching on a 96 px proxy. On a pan across a grass field the two agree, at -0.088 and -0.090.&lt;/p&gt;

&lt;p&gt;Every one of those checks began by finding the code wrong. A fade from black fabricated a confident &lt;code&gt;zoom_in&lt;/code&gt; on a static title card, because the matching is brightness-sensitive and read the brightening as the frame growing toward the camera. Aliasing on the downscaled proxy turned a true +0.94 px/frame drift into a confident -3.94, wrong in both sign and magnitude, until one blur pass before matching fixed it. OCR read the tool's own timestamps, burned into each frame header, back out and reported them as text found in the video. None of these looked broken; they looked like measurements, which is exactly why the fixture has to carry an answer you already know.&lt;/p&gt;

&lt;h2&gt;
  
  
  On how it was built
&lt;/h2&gt;

&lt;p&gt;The README is straight about this, and the post should be too. VTL was written with heavy AI assistance (Claude Code). The typing was assisted. The design judgement, and the "is this actually true?" loop that caught every bug above, were the work. Two of those bugs were found only because a fixture I had written was itself wrong. That is why the motion fixture renders each frame from a closed-form path instead of leaning on video filters. A fixture you have to debug is not a fixture.&lt;/p&gt;

&lt;p&gt;The general point is the one the screenshots get wrong. When you sample a video on a fixed clock, the frames you get are an accident of the clock's phase against the content, and the gaps between them go unexamined. VTL measures where the content actually changes, then states the worst gap it left rather than hoping there wasn't one. That costs about 67 seconds for eighteen minutes of video. It turns those intervals from a guess into a number. The errors were never in the frames; they were always in the spaces between them.&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>machinelearning</category>
      <category>computervision</category>
    </item>
    <item>
      <title>Choosing an Unreleased API Over the One Already There</title>
      <dc:creator>Seth Wheeler</dc:creator>
      <pubDate>Tue, 01 Sep 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/megapixel99/choosing-an-unreleased-api-over-the-one-already-there-4h45</link>
      <guid>https://dev.to/megapixel99/choosing-an-unreleased-api-over-the-one-already-there-4h45</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/wesleytodd/express-openapi" rel="noopener noreferrer"&gt;express-openapi&lt;/a&gt; is &lt;a href="https://github.com/wesleytodd" rel="noopener noreferrer"&gt;Wesley Todd&lt;/a&gt;'s library, not mine. It generates an OpenAPI document from an Express app by reading the routes the app actually registered, which beats maintaining a spec by hand and hoping it still matches the code.&lt;/p&gt;

&lt;p&gt;I opened &lt;a href="https://github.com/wesleytodd/express-openapi/pull/77" rel="noopener noreferrer"&gt;a pull request&lt;/a&gt; to add Express 5 support, and I had hoped it would be small. It came to &lt;code&gt;+244/-259&lt;/code&gt; across eight files. That was not the mistake. The mistake came later and took two lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the routes are found
&lt;/h2&gt;

&lt;p&gt;Express does not offer a list of registered routes. It has a router, the router has a &lt;code&gt;stack&lt;/code&gt; of layers, and each layer holds a handler plus a compiled regular expression for the path it matches. The library walks that stack directly.&lt;/p&gt;

&lt;p&gt;Walking is the easy half; the hard half is that a layer knows its path as a regular expression and an OpenAPI document needs it as a string. There is no inverse for "compile this path", so the library recovers the path by stringifying the regular expression and deleting the parts the router put 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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;thing&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;/?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;(?=&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;/|$)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// Added this line to catch the express v5 case after the v4 part is stripped off&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;(?:&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;/(?=$))?$&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\/\^((?:\\[&lt;/span&gt;&lt;span class="sr"&gt;.*+?^${}()|[&lt;/span&gt;&lt;span class="se"&gt;\]\\/]&lt;/span&gt;&lt;span class="sr"&gt;|&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;.*+?^${}()|[&lt;/span&gt;&lt;span class="se"&gt;\]\\/])&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;)\$\/&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That comment is not mine and it predates my patch, which tells you this had already caught someone. Express 5 compiles paths differently again, so the tests that pinned particular regular expressions to particular strings had nothing left to say. That is why &lt;code&gt;test/_regexRoutes.js&lt;/code&gt; lost 117 lines instead of gaining a case, and why &lt;code&gt;lib/generate-doc.js&lt;/code&gt; came to &lt;code&gt;+87/-122&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line that needed an API that did not exist
&lt;/h2&gt;

&lt;p&gt;Rewriting the walk left one problem. When a sub-router is mounted, the document needs the prefix it was mounted at, and my rewrite asked the router for it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRoutes&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;getRoutes()&lt;/code&gt; is not part of the router. It is &lt;a href="https://github.com/pillarjs/router/pull/174" rel="noopener noreferrer"&gt;pillarjs/router#174&lt;/a&gt;, an open pull request by &lt;a href="https://github.com/bjohansebas" rel="noopener noreferrer"&gt;bjohansebas&lt;/a&gt; that adds a method for listing registered routes, which is exactly what a consumer needs so that nobody has to un-compile a regular expression again. It is the right feature and I wanted it.&lt;/p&gt;

&lt;p&gt;So my &lt;code&gt;package.json&lt;/code&gt; pointed at a branch:&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="nl"&gt;"router"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"github:bjohansebas/router#maproutes"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and I said in the thread, on the day I opened it, that this should not merge until &lt;code&gt;getRoutes()&lt;/code&gt; was released. That was August 2025.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four days later I did not need it
&lt;/h2&gt;

&lt;p&gt;On 2 September I replaced that call with a property the layer was already carrying:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-      const p = router.getRoutes()[0].path.split('/')[1]
&lt;/span&gt;&lt;span class="gi"&gt;+      const p = routeLayer.pathPatterns
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-      "router": "github:bjohansebas/router#maproutes",
&lt;/span&gt;&lt;span class="gi"&gt;+      "router": "^1.3.8",
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lines, and the dependency on an unreleased feature was gone. &lt;a href="https://github.com/wesleytodd/express-openapi/commit/b835063a73baf4ab907f216e7cc7dcc6bdf7684b" rel="noopener noreferrer"&gt;The commit&lt;/a&gt; is titled "remove dependency on the yet to be released feature &lt;code&gt;getRoutes()&lt;/code&gt;". It passed CI on Node 20, 22 and 24.&lt;/p&gt;

&lt;p&gt;Twenty minutes after the last of those pushes, I force-pushed the branch back to the version that calls &lt;code&gt;getRoutes()&lt;/code&gt;, and that is what the pull request contains today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I put it back
&lt;/h2&gt;

&lt;p&gt;Two reasons, and I still think the first one is right.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;routeLayer.pathPatterns&lt;/code&gt; is another internal. It is an undocumented property on a layer object, subject to exactly the same drift as the regular expression I had just finished being burned by. &lt;code&gt;getRoutes()&lt;/code&gt; is a public method whose entire purpose is to answer this question. Given a choice between reading one more private field and calling an API designed for the job, the API is the better code, and it is still the better code now.&lt;/p&gt;

&lt;p&gt;The second reason was an estimate, and the estimate was wrong. I assumed &lt;code&gt;getRoutes()&lt;/code&gt; was weeks away. It was opened in July 2025, it is &lt;code&gt;+350/-3&lt;/code&gt;, it carries twenty-nine reviews, and it is still open. None of that is unusual or anyone's fault: adding a method to the router that sits under Express is exactly the kind of change that should collect twenty-nine reviews, and the people doing that review are doing it for free, around whatever else their lives contain. A careful API takes as long as it takes.&lt;/p&gt;

&lt;p&gt;What I got wrong was treating someone else's review cycle as a schedule I could plan against. I did not decide to wait a year; I decided to wait a few weeks, and then never revisited it, because a pull request that is blocked does not remind you that it is blocked.&lt;/p&gt;

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

&lt;p&gt;Choosing the cleaner interface over the available one is not really a code decision, it is a scheduling bet on people who never agreed to your schedule. That bet is often worth making. What made it expensive here is that I placed it once and never priced it again, and nothing in my setup would ever have prompted me to.&lt;/p&gt;

&lt;p&gt;The version without the dependency still exists. It is unreferenced now, reachable only because GitHub keeps orphaned commits after a force-push, and it is green. My patch has sat still for a year on top of a two-line change that was already written, already tested, and already passing.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>api</category>
      <category>express</category>
    </item>
    <item>
      <title>Reimplementing Enough of Kubernetes to Fool kubectl</title>
      <dc:creator>Seth Wheeler</dc:creator>
      <pubDate>Mon, 31 Aug 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/megapixel99/reimplementing-enough-of-kubernetes-to-fool-kubectl-2nbi</link>
      <guid>https://dev.to/megapixel99/reimplementing-enough-of-kubernetes-to-fool-kubectl-2nbi</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Code: &lt;a href="https://github.com/Megapixel99/nodejs-k8s" rel="noopener noreferrer"&gt;Megapixel99/nodejs-k8s&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I have a repo that reimplements Kubernetes' core APIs in Node (pods, deployments, replica sets, services, jobs, namespaces, configmaps, the rest) backed by MongoDB instead of etcd, running "pods" as sibling Docker containers. It's the only project I've published that strangers actually looked at.&lt;/p&gt;

&lt;p&gt;I don't think that's because the code is good. I think it's because of the claim in the README: &lt;strong&gt;point your real &lt;code&gt;kubectl&lt;/code&gt; at it.&lt;/strong&gt; Not a diagram of Kubernetes' architecture, not a from-scratch exercise, a thing you can falsify in one command. Every other repo I've written leads with how it was built, and none of them got read.&lt;/p&gt;

&lt;p&gt;Making that claim true turned out to be almost entirely about protocol details that aren't in the resource schemas at all. Here are the ones I didn't see coming. Everything below is against &lt;code&gt;kubectl&lt;/code&gt; v1.34.1, and you can reproduce the header captures yourself in about a minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  kubectl's first move is discovery, and it will not proceed without it
&lt;/h2&gt;

&lt;p&gt;Before &lt;code&gt;kubectl get pods&lt;/code&gt; fetches a single pod, it asks the server what exists. Point kubectl at a server that 404s and watch what it sends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /api?timeout=32s
Accept: application/json;g=apidiscovery.k8s.io;v=v2;as=APIGroupDiscoveryList,
        application/json;g=apidiscovery.k8s.io;v=v2beta1;as=APIGroupDiscoveryList,
        application/json

GET /apis?timeout=32s
Accept: (same)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It asks for &lt;em&gt;aggregated discovery&lt;/em&gt; first (a single document describing every group and version) and falls back to plain &lt;code&gt;application/json&lt;/code&gt; if the server doesn't offer it. Then it retries. In my capture it hit &lt;code&gt;/api&lt;/code&gt; and &lt;code&gt;/apis&lt;/code&gt; five times each and gave up without ever requesting a pod.&lt;/p&gt;

&lt;p&gt;This is the thing to implement first, and I didn't. You can have a flawless &lt;code&gt;PodList&lt;/code&gt; handler and kubectl will never reach it, because the client refuses to guess at what the server supports. Two endpoints returning almost-empty JSON are the difference between "nothing works" and "everything works."&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;kubectl get&lt;/code&gt; asks the server to do the formatting
&lt;/h2&gt;

&lt;p&gt;This is the one that genuinely surprised me. Once discovery succeeds, here's what &lt;code&gt;kubectl get pods&lt;/code&gt; actually requests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /api/v1/namespaces/default/pods
Accept: application/json;as=Table;v=v1;g=meta.k8s.io,
        application/json;as=Table;v=v1beta1;g=meta.k8s.io,
        application/json
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;as=Table&lt;/code&gt;. kubectl is not asking for pods and then formatting them. It's asking the server for a &lt;strong&gt;table&lt;/strong&gt; (column definitions and rows of pre-rendered cells) and printing what comes back nearly verbatim. The &lt;code&gt;NAME  READY  STATUS  RESTARTS AGE&lt;/code&gt; header you've read ten thousand times is a string the API server chose.&lt;/p&gt;

&lt;p&gt;Which means you can produce a convincing &lt;code&gt;kubectl get pods&lt;/code&gt; without implementing Kubernetes at all. This is a complete server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http&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;J&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;J&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;APIVersions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;versions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/apis&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;J&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;APIGroupList&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;J&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;APIResourceList&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;groupVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pods&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;singularName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pod&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;namespaced&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Pod&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;get&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;list&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;watch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/pods&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;J&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Table&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;meta.k8s.io/v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;columnDefinitions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
      &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;demo-pod&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"kind":"Status","code":404}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8899&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point a kubeconfig at &lt;code&gt;http://127.0.0.1:8899&lt;/code&gt; and run &lt;code&gt;kubectl get pods&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME
demo-pod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is real kubectl, fully satisfied, talking to sixty lines of Node that has no concept of a pod.&lt;/p&gt;

&lt;p&gt;I find this genuinely good design once the surprise wears off. It's why &lt;code&gt;kubectl get&lt;/code&gt; works on resource types your kubectl binary has never heard of, including CRDs that shipped after it was built: the server knows how to display them and the client doesn't have to. But it does mean "API-compatible" is a much bigger surface than the resource schemas suggest. Every one of the ~55 kinds I route needs its own &lt;code&gt;table()&lt;/code&gt; returning its own columns. Pods return Name, Ready, Status, Restarts and Age; each column carries a &lt;code&gt;type&lt;/code&gt;, a &lt;code&gt;format&lt;/code&gt;, a &lt;code&gt;priority&lt;/code&gt; and a description string.&lt;/p&gt;

&lt;p&gt;There's a subtlety in the streaming case too. When kubectl &lt;em&gt;watches&lt;/em&gt; a table, the column definitions should only appear on the first event: repeat them on every update and the client re-prints headers. So the watch path sends them once and then nulls the field:&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="nx"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;asJson&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eventType&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ADDED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;columnDefinitions&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="nx"&gt;eventStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;object&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;table&lt;/span&gt; &lt;span class="p"&gt;})}&lt;/span&gt;&lt;span class="s2"&gt;\n`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The protobuf wire format is not protobuf
&lt;/h2&gt;

&lt;p&gt;kubectl itself asked for JSON in every capture above. But &lt;code&gt;client-go&lt;/code&gt; (which is what operators and controllers are built on) negotiates &lt;code&gt;application/vnd.kubernetes.protobuf&lt;/code&gt;, and if you want those clients to work you have to speak it.&lt;/p&gt;

&lt;p&gt;I assumed that meant "serialize the object with the .proto definitions." It's two layers more than that. Every message is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A &lt;strong&gt;four-byte magic prefix&lt;/strong&gt;: &lt;code&gt;0x6b 0x38 0x73 0x00&lt;/code&gt;, the ASCII bytes &lt;code&gt;k8s&lt;/code&gt; followed by a null. Decoding starts by skipping it.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;&lt;code&gt;Unknown&lt;/code&gt; envelope&lt;/strong&gt;, a protobuf message with a &lt;code&gt;typeMeta&lt;/code&gt; field (&lt;code&gt;kind&lt;/code&gt;, &lt;code&gt;apiVersion&lt;/code&gt;) and a &lt;code&gt;raw&lt;/code&gt; field.&lt;/li&gt;
&lt;li&gt;The actual object, encoded separately, stuffed into &lt;code&gt;raw&lt;/code&gt; as bytes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So you encode twice (the object into bytes, then those bytes into a wrapper) and prepend the magic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;dataInfo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;prepareForProto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;encoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;unknownType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;typeMeta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&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="na"&gt;apiVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apiVersion&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="na"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dataInfo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;contentEncoding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;107&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;56&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;115&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="nx"&gt;encoded&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;typeMeta&lt;/code&gt; duplication is the interesting part: kind and apiVersion appear in the envelope &lt;em&gt;and&lt;/em&gt; inside the payload, so a client can route a message to the right decoder without decoding the payload first. Reasonable, and impossible to guess from the schemas.&lt;/p&gt;

&lt;p&gt;Watch events add a third layer; each event is a &lt;code&gt;WatchEvent&lt;/code&gt; message wrapping the already-wrapped object.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode is a wrong number, not an error
&lt;/h2&gt;

&lt;p&gt;Kubernetes has scalar types that are structs on the wire and strings in JSON, and converting between them is where I lost the most time. Four of them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;JSON&lt;/th&gt;
&lt;th&gt;protobuf&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;"2026-08-13T10:00:00Z"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Time { seconds, nanos }&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;"100m"&lt;/code&gt;, &lt;code&gt;"512Mi"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Quantity { string }&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;8080&lt;/code&gt; or &lt;code&gt;"http"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IntOrString { type, intVal, strVal }&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;large integers&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Long { low, high, unsigned }&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;Quantity&lt;/code&gt; is the dangerous one. It looks like a string, so the obvious thing is to encode it as one. If you do, the Go client doesn't reject it: &lt;strong&gt;it decodes as zero&lt;/strong&gt;. A pod whose CPU limit you carefully set to &lt;code&gt;100m&lt;/code&gt; arrives with a limit of nothing, and every layer reports success.&lt;/p&gt;

&lt;p&gt;The fix is to know which keys hold quantities and wrap their values, which can't be inferred from the value's own shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;QUANTITY_MAP_KEYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;limits&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;requests&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;min&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;defaultRequest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;maxLimitRequestRatio&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;capacity&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;allocatable&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;used&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I've now written this same paragraph about three different projects. A crawler whose rate limiter reported a correct delay while sending a hundred simultaneous requests. A browser whose memory instrumentation was the largest consumer of the resource it measured. And a serializer that turns a resource limit into zero and returns 200. In all three the code was locally correct, nothing threw, and the only way to see the bug was to look at what the other side received.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I couldn't make work
&lt;/h2&gt;

&lt;p&gt;One protobuf shape defeated me, and the comment in the code says so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Known-bad proto shape: our Event schema stores a populated&lt;/span&gt;
&lt;span class="c1"&gt;// series/deprecated* tree that protobufjs can't encode back into&lt;/span&gt;
&lt;span class="c1"&gt;// a wire-compatible EventSeries, which causes client-side decode&lt;/span&gt;
&lt;span class="c1"&gt;// failures. Force JSON for Event responses.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Events fall back to JSON. Clients that accept both are fine; a strict protobuf-only client would not be. I'd rather ship that with a comment than pretend the encoder is complete.&lt;/p&gt;

&lt;p&gt;The larger honest list is in the README, and it's long: no CNI, so pods get a synthetic ClusterIP that routes nowhere. No CoreDNS. No CRDs, which rules out most real operators. No RBAC enforcement: every request is effectively cluster-admin. No server-side apply; &lt;code&gt;apply-patch+yaml&lt;/code&gt; is accepted and quietly treated as a strategic merge. Conformance tests that touch any of that will fail no matter how much API surface I add, and I'd rather say which ones up front than let someone discover it after an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part worth generalizing
&lt;/h2&gt;

&lt;p&gt;The technical lesson is that wire compatibility lives almost entirely outside the data model. Discovery, content negotiation, server-side printing, envelope framing, scalar coercion: none of it appears in a resource schema, and all of it is load-bearing. If I'd written the schemas first and the protocol second I'd have had a thing that looked complete and worked with nothing.&lt;/p&gt;

&lt;p&gt;The other lesson is about how I describe work. "Point your real kubectl at it" is a claim a stranger can refute in one command, and that turns out to be the whole difference. My other projects are described in terms of the effort that went into them (written from scratch, no dependencies, built by hand) which asks the reader to take my word for something and gives them nothing to do. This one handed them a test. It's the only one anybody ran.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>node</category>
      <category>protocols</category>
      <category>apicompatibility</category>
    </item>
    <item>
      <title>Checking a Cost Model Against a Stranger's Config File</title>
      <dc:creator>Seth Wheeler</dc:creator>
      <pubDate>Sun, 30 Aug 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/megapixel99/checking-a-cost-model-against-a-strangers-config-file-390b</link>
      <guid>https://dev.to/megapixel99/checking-a-cost-model-against-a-strangers-config-file-390b</guid>
      <description>&lt;p&gt;I wrote &lt;a href="https://sethwheeler.dev/blog/ssd-streaming-prediction/" rel="noopener noreferrer"&gt;a cost model for streaming a mixture-of-experts model off an SSD&lt;/a&gt;, and the part I was proudest of was that it gated itself twice against artifacts I had nothing to do with. Gate 1 reproduced a third-party runtime's on-disk container byte-for-byte. Gate 2 predicted the size of a model conversion published by different people using a different tool, and came within 1.1% against a 2% tolerance.&lt;/p&gt;

&lt;p&gt;The experiment behind that post has a limitations section I now want to revisit. It listed an ambiguity in the model's &lt;code&gt;config.json&lt;/code&gt;, bounded the damage, and moved on:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;dense_mlp_idx=2&lt;/code&gt; is ambiguous: one dense layer, or layers 0 and 1. This takes one, which moves bytes/token by 2.4%. Immaterial to the conclusion, unresolved regardless.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The bound was right. The assumption was wrong. Correcting it took Gate 2 from 1.1% off to &lt;strong&gt;3.4% off&lt;/strong&gt;, which is outside the tolerance it had been passing, and the reason is more interesting than the arithmetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The header states the fact outright
&lt;/h2&gt;

&lt;p&gt;A config file describes a model's architecture in the vocabulary of the framework that trained it; reading one therefore means inferring what a field name meant to its author. &lt;code&gt;dense_mlp_idx=2&lt;/code&gt; could reasonably be "the layer at index 2 is dense" or "layers below index 2 are dense", and those give 41 and 40 mixture-of-experts layers respectively.&lt;/p&gt;

&lt;p&gt;I eventually downloaded the model in GGUF form (the container format llama.cpp uses) to run it. GGUF puts a block of key/value metadata at the front of the file, and that metadata is written by the conversion script after it has already resolved every question of this kind. So the answer was sitting in a file on my own disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;inkling.block_count&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;42&lt;/span&gt;
&lt;span class="py"&gt;inkling.dense_block_count&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;2&lt;/span&gt;
&lt;span class="py"&gt;inkling.expert_count&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;256&lt;/span&gt;
&lt;span class="py"&gt;inkling.expert_used_count&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;6&lt;/span&gt;
&lt;span class="py"&gt;inkling.expert_feed_forward_length&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;2048&lt;/span&gt;
&lt;span class="py"&gt;inkling.expert_shared_count&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;dense_block_count = 2&lt;/code&gt;. Forty mixture-of-experts layers, not forty-one.&lt;/p&gt;

&lt;p&gt;Reading it took about forty lines of Python. The parser was wrong the first time in a way worth repeating: GGUF stores arrays of strings length-prefixed, and I read only the first few elements without consuming the rest. Every subsequent offset was then shifted, and the parse failed later with an invalid-looking type code rather than at the array that caused it. If you write one of these, consume every element even when you intend to display four.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the correction does
&lt;/h2&gt;

&lt;p&gt;The expert size does not change, so the per-token cost moves purely through the layer count:&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;assumed (1 dense)&lt;/th&gt;
&lt;th&gt;header-confirmed (2 dense)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;mixture-of-experts layers&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;40&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;routed experts on disk&lt;/td&gt;
&lt;td&gt;148.58 GB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;144.96 GB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;streamed per token&lt;/td&gt;
&lt;td&gt;3.482 GB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.397 GB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gate 2 against a published 153.5 GB&lt;/td&gt;
&lt;td&gt;151.77 GB, 1.1% off&lt;/td&gt;
&lt;td&gt;148.23 GB, &lt;strong&gt;3.4% off&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The model now under-predicts the published file by 5.27 GB, and the script does what I built it to do: it prints &lt;code&gt;GATE 2: FAIL&lt;/code&gt; and refuses to emit any token-rate estimate at all. That refusal was written months earlier for exactly this case; it is the only reason I noticed, rather than quietly publishing a slightly different number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two errors, pointing opposite ways
&lt;/h2&gt;

&lt;p&gt;Here's the mechanism, and it's the whole point of the post.&lt;/p&gt;

&lt;p&gt;Gate 2 predicts a whole container: routed experts, shared experts, attention, embeddings, dense layers. The 1.1% agreement was the sum of two errors that happened to have opposite signs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error one, mine.&lt;/strong&gt; An extra mixture-of-experts layer added 256 experts of 14,155,776 bytes each, inflating the prediction by 3.62 GB of routed experts; net of the dense layer it displaced, &lt;strong&gt;+3.54 GB&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error two, structural and disclosed.&lt;/strong&gt; The model does not attempt the vision and audio encoders, the multi-token-prediction module, the RMS norms, or the fact that the published conversion keeps non-expert tensors at 8 bits rather than 4. With the layer count correct, those account for &lt;strong&gt;-5.27 GB&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Add +3.54 to -5.27 and you land 1.73 GB low, which reads as 1.1% and looks like a model that understands the format. Remove the inflation and the deflation stands alone at 5.27 GB, or 3.4%. &lt;strong&gt;The residual was always that large. The agreement was partly luck, and a tolerance was never evidence that the two sides matched for the same reason.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I want to be precise about what this does and does not undermine, because "a gate failed" is easy to over-read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gate 1 is untouched and still byte-exact.&lt;/strong&gt; It compares computed integers against a published layout with no tolerance at all: expert stride &lt;code&gt;1,769,472&lt;/code&gt;, per-layer blob &lt;code&gt;452,984,832&lt;/code&gt;, 256 experts, 40 layers. Equality has no room for two errors to meet inside it; that is the argument for preferring an exact gate to a tolerance wherever the domain offers one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The load-bearing number got better, not worse.&lt;/strong&gt; The whole model exists to produce one figure: bytes streamed per token. That figure depends on the expert stride (Gate 1 verifies it exactly), the number of experts routed per token, and the mixture-of-experts layer count. The header now confirms the last two outright, so 3.397 GB/token rests on three measured quantities where 3.482 rested on two measured and one inferred.&lt;/p&gt;

&lt;p&gt;So a gate went from pass to fail while the number I actually care about became better grounded. That is only a contradiction if you think a container-size prediction and a per-token-cost prediction are the same claim. They share inputs and they fail differently, and this is the case that separates them.&lt;/p&gt;

&lt;p&gt;Two independent checks agree with the corrected figure. A separate script that estimates per-token bytes from published file sizes rather than from architecture gives 3.405 GB against 3.397 computed directly, 0.2% apart. And when I finally ran the model, the kernel's page-in counters put actual disk traffic between 1.80 and 3.96 GB per generated token, a bracket that contains it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take from this
&lt;/h2&gt;

&lt;p&gt;The rule I had been operating under is that a gate passing within tolerance means the model is right. It doesn't. It means the sum of every error sits inside the tolerance, and a sum can be small because the terms are small, or because they cancel. Those two situations look identical from outside and behave completely differently when you fix something: one improves, the other exposes what the cancellation was hiding.&lt;/p&gt;

&lt;p&gt;There's a cheaper habit buried in this, too. I spent real effort inferring what &lt;code&gt;dense_mlp_idx&lt;/code&gt; meant, wrote a careful bound around my uncertainty, and shipped the inference. The artifact stated the fact outright in a header I could read in forty lines. &lt;strong&gt;When you find yourself bounding an inference about someone else's format, check first whether some downstream tool has already resolved it and written the answer down&lt;/strong&gt;, because conversion scripts have to answer these questions to do their job at all.&lt;/p&gt;

&lt;p&gt;And the disclosed limitation earns its keep again. The last post's failure was attributable because the caveat had been written in advance; this one was detectable because the tolerance and the refusal-to-proceed were written in advance. I keep finding that the value isn't in the estimate, which gets superseded. It's in having decided, before the measurement, what would count as being wrong.&lt;/p&gt;

&lt;p&gt;The code for this one isn't public. It's a research repo with no release, and I'd rather link nothing than link something nobody can run.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>measurement</category>
      <category>mixtureofexperts</category>
    </item>
    <item>
      <title>Predicting the Speed of a 276B Model Streamed From an SSD</title>
      <dc:creator>Seth Wheeler</dc:creator>
      <pubDate>Sat, 29 Aug 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/megapixel99/predicting-the-speed-of-a-276b-model-streamed-from-an-ssd-50f8</link>
      <guid>https://dev.to/megapixel99/predicting-the-speed-of-a-276b-model-streamed-from-an-ssd-50f8</guid>
      <description>&lt;p&gt;A mixture-of-experts model only activates a few of its experts per token, which means (unlike a dense model, where every forward pass touches every weight) you can leave the weights on disk and read in just the ones each token routes to. That turns "does this model fit in RAM" into "how fast is your SSD," and it puts models far larger than your machine nominally supports within reach, at some token rate.&lt;/p&gt;

&lt;p&gt;I wanted to know what that rate actually is. The question that started it was concrete: can Inkling-Small (276B total parameters, 12B active) run on a 24 GB Mac mini?&lt;/p&gt;

&lt;p&gt;The answer wanted to be arithmetic rather than a guess. This is what happened when the arithmetic met the machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 1: a prediction is worthless unless it can be wrong
&lt;/h2&gt;

&lt;p&gt;I wrote a ~250-line cost model with no dependencies that downloads no weights. Every input is a &lt;code&gt;config.json&lt;/code&gt; or &lt;code&gt;manifest.json&lt;/code&gt;; every output is a byte count derived from it. It models MLX affine group quantization the way it's actually stored on disk: per quantized projection, the weight bytes plus one bf16 scale and one bf16 bias per group of 64 values along the input dimension.&lt;/p&gt;

&lt;p&gt;The obvious failure mode is a model tuned until it agrees with the one thing you checked it against. So it's gated twice, against artifacts I had nothing to do with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gate 1: reproduce a real container byte-for-byte.&lt;/strong&gt; &lt;a href="https://github.com/leonickson1/Swiftlet" rel="noopener noreferrer"&gt;Swiftlet&lt;/a&gt; is a third-party Swift/Metal runtime that stores MoE weights in its own on-disk format. From a model's &lt;code&gt;config.json&lt;/code&gt; alone, predict the layout it publishes. These are exact integers, so the gate demands equality, not closeness:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;quantity&lt;/th&gt;
&lt;th&gt;computed&lt;/th&gt;
&lt;th&gt;published&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;expert stride&lt;/td&gt;
&lt;td&gt;1,769,472&lt;/td&gt;
&lt;td&gt;1,769,472&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;experts per layer&lt;/td&gt;
&lt;td&gt;256&lt;/td&gt;
&lt;td&gt;256&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;per-layer blob bytes&lt;/td&gt;
&lt;td&gt;452,984,832&lt;/td&gt;
&lt;td&gt;452,984,832&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;layer count&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All four exact. The full container total came within 0.137%, the residual being the tokenizer and chat template, which the model doesn't attempt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gate 2: predict a stranger's conversion.&lt;/strong&gt; Gate 1 could still pass by being fitted to the one container it was checked against. So: predict the on-disk size of a different model, converted by different people using a different tool. &lt;strong&gt;Predicted 151.8 GB against a published 153.5 GB, 1.1% off&lt;/strong&gt;, against a 2% tolerance. The residual is unmodelled and deliberately not fudged: vision and audio encoders, RMS norms, 8-bit router gates, safetensors headers.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Correction, 2026-08-14.&lt;/strong&gt; This gate no longer passes, and the 1.1% was two errors cancelling. The model assumed one dense-MLP layer where the published model's own header states two, so it counted 41 mixture-of-experts layers instead of 40. Correcting that drops the prediction to 148.23 GB, which is &lt;strong&gt;3.4% off and outside the 2% tolerance&lt;/strong&gt;. The extra layer had been inflating the prediction by 3.54 GB while the genuinely unmodelled components listed above deflated it by 5.27 GB, so the two nearly cancelled and the residual was always larger than 1.1% suggested. Gate 1 above is unaffected and still exact. &lt;a href="https://sethwheeler.dev/blog/cancelling-errors/" rel="noopener noreferrer"&gt;The follow-up post works through it&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Both gates run on every invocation, and the script refuses to print any token-rate estimate if either one fails. That mattered later.&lt;/p&gt;

&lt;p&gt;The interesting structural result was that total parameter count is nearly irrelevant. What sets the per-token cost is &lt;code&gt;experts_per_token × layers × expert_size&lt;/code&gt;. And the useful finding was a cliff rather than a gradient: the fraction of each model's expert set that fits in 24 GB of RAM:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;model&lt;/th&gt;
&lt;th&gt;cacheable in 24 GB&lt;/th&gt;
&lt;th&gt;streamed per token&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.6-35B-A3B&lt;/td&gt;
&lt;td&gt;93.7%&lt;/td&gt;
&lt;td&gt;0.566 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inkling-Small (276B)&lt;/td&gt;
&lt;td&gt;10.0%&lt;/td&gt;
&lt;td&gt;3.397 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Correction, 2026-08-14.&lt;/strong&gt; That last figure read &lt;strong&gt;3.482 GB&lt;/strong&gt; when this post went up, for the layer-count reason described above. The corrected 3.397 GB raises the predicted token rate quoted later in this post by a factor of 1.025, which is far too small to affect the 23× conclusion. The Qwen row is unaffected.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That 93.7% explains something I'd otherwise have taken at face value. Swiftlet's README reports its decode loop is "dispatch bound, not IO bound," which sounds like a claim about kernel quality. On a 24 GB machine, after warmup, almost all of the 35B's experts are resident; it is barely streaming at all. The streaming machinery is what lets it &lt;em&gt;start&lt;/em&gt; in a couple of gigabytes, not what it lives in. Past the cliff, no amount of kernel work helps.&lt;/p&gt;

&lt;p&gt;Now I just needed a GB/s number to turn bytes per token into tokens per second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 2: the benchmark was measuring RAM
&lt;/h2&gt;

&lt;p&gt;An earlier experiment of mine had priced disk reads at "a nominal 100 µs" and said so in its own limitations; the latency was an assumption, not an observation. So before trusting any rate, I measured the SSD.&lt;/p&gt;

&lt;p&gt;The first version reported &lt;strong&gt;11–24 GB/s on a device that does about 7&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The cause is a specific and easy misreading of a macOS API. &lt;code&gt;fcntl(fd, F_NOCACHE, 1)&lt;/code&gt; stops the kernel from caching &lt;em&gt;future&lt;/em&gt; reads on that descriptor. It does not evict pages that are already resident. I was reading a file I had just written, so it was entirely in the unified buffer cache. The flag was set, it did exactly what it says, and the benchmark measured memory bandwidth.&lt;/p&gt;

&lt;p&gt;What makes this the interesting half is that no flag fixes it. The fix has to be structural: write a file &lt;strong&gt;2.5× larger than physical RAM&lt;/strong&gt; (160 GiB on this machine) and sample only from its &lt;strong&gt;first quarter&lt;/strong&gt;, whose pages the later 120 GiB of writes necessarily evicted.&lt;/p&gt;

&lt;p&gt;I also stopped trusting myself and added two gates that run every time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Content gate&lt;/strong&gt;: every timed read must contain a magic header written at that offset. This catches short reads, misalignment, and filesystem tricks returning zeros at implausible speed. It immediately caught a page-alignment bug: 8 of 384 reads verified.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache gate&lt;/strong&gt;, read the same offsets twice. With caching genuinely off, the two passes must agree. If pass 2 is much faster, the cache is serving reads and the run is void. It came out at 1.10×.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The real numbers, in the access pattern a router actually produces (top-k random experts inside one layer's blob, then the next layer) were &lt;strong&gt;3.54 GB/s issued serially and 7.82 GB/s with six reads in flight&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That 2.2× is the single biggest lever in the whole exercise. Issuing the top-k expert reads one at a time leaves half the device idle. For Inkling-Small it is the difference between roughly 1.1 and 2.5 tok/s: between unusable and marginal.&lt;/p&gt;

&lt;p&gt;A separate measurement at 4 KB found something worth designing against: the mean read is 154.1 µs, but &lt;strong&gt;p99 is 757.6 µs, 5.8× the median&lt;/strong&gt;. A mean-based estimate is structurally blind to that, and any paging design needs a deadline policy that answers from whatever is resident rather than waiting on the tail.&lt;/p&gt;

&lt;p&gt;The generalizable version of this act: &lt;strong&gt;when a mechanism is supposed to disable an optimization, verify the optimization is actually off rather than trusting the switch.&lt;/strong&gt; I had a flag that did what it promised, and a benchmark that was wrong by 3×.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 3: running it, and missing by 23×
&lt;/h2&gt;

&lt;p&gt;Prediction, with the measured SSD: &lt;strong&gt;1.46–3.22 tok/s&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Getting to a real number was mostly an exercise in discovering that published weights are not evidence that anything can load them. Four plausible runtimes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;path&lt;/th&gt;
&lt;th&gt;status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Swiftlet&lt;/td&gt;
&lt;td&gt;Different architecture family. A port, not a config change.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MLX / mlx-lm&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;mlx-lm&lt;/code&gt; 0.29.1 ships 100 model modules and this architecture isn't one n/a despite a converted 4-bit build existing on HuggingFace.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;llama.cpp upstream&lt;/td&gt;
&lt;td&gt;Release b10288's &lt;code&gt;libllama.dylib&lt;/code&gt; contains &lt;strong&gt;zero&lt;/strong&gt; occurrences of the architecture name. The GGUFs declare it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;llama.cpp PR #25731&lt;/td&gt;
&lt;td&gt;The only path. Still open, 9 commits, 63 files.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three of those four have published quantized weights and no working loader. The GGUF ladder exists because its author built it against their own unmerged branch.&lt;/p&gt;

&lt;p&gt;It runs. On an M1 Max, CPU-only, streaming 127.4 GB from SSD via mmap:&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;measured&lt;/th&gt;
&lt;th&gt;predicted&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;decode rate&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.064 tok/s&lt;/strong&gt; (15,678 ms/token)&lt;/td&gt;
&lt;td&gt;1.46–3.22 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;paged in from disk&lt;/td&gt;
&lt;td&gt;59.5 GB over 33 tokens&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;→ per generated token&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.80–3.96 GB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;2.926 GB&lt;/strong&gt; ✓&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The bytes side held.&lt;/strong&gt; 14,516,912 page-ins over 18 prompt plus 15 generated tokens brackets the predicted 2.926 GB/token: 3.96 GB if every page-in is charged to the generated tokens, 1.80 GB if spread across all 33. The batched prompt routes to far more than six experts per layer, so the true figure sits inside that bracket. Config arithmetic and kernel page-in counters are entirely independent methods, and they agree. That's the thing the experiment existed to get.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rate side missed by 23×.&lt;/strong&gt; And here is the part I actually care about.&lt;/p&gt;

&lt;p&gt;Act 1's write-up carried this in its limitations, written months before any of this ran: &lt;em&gt;compute is not modelled at all; every rate here is an IO ceiling, the real rate is the minimum of IO and compute, and this experiment cannot tell you which binds.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That is precisely what happened. The only configuration that completes a forward pass is CPU-only: every Metal configuration dies with an out-of-memory error, because ~125 GB of experts leak onto a GPU with a 51.5 GB working set, and the flag that's supposed to keep them off it doesn't work for this architecture. So 12B active parameters get multiplied on 8 CPU threads with no GPU, and &lt;em&gt;that&lt;/em&gt; sets 15.7 seconds per token. The SSD was never the constraint.&lt;/p&gt;

&lt;p&gt;The IO ceiling was never wrong. It was just not the binding term in the only configuration available, and the run that works is therefore also the run that cannot test the ceiling. The 24 GB mini's predicted rate remains unmeasured, and I'd rather say that than quote 0.064 tok/s as though it answered the question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that was worth the whole exercise
&lt;/h2&gt;

&lt;p&gt;There's an operational footnote I'll keep short: a process streaming an oversized mmap'd model can become genuinely unkillable, sitting in uninterruptible-exiting state for 40 minutes after &lt;code&gt;SIGKILL&lt;/code&gt;, because it cannot finish unwinding 127 GB of mapped memory while blocked in the page-fault path. &lt;code&gt;pkill&lt;/code&gt; reports success. Only &lt;code&gt;ps -o stat=&lt;/code&gt; shows otherwise. On unified memory this starves everything else on the machine, which is a good reason not to do this on a box something else depends on.&lt;/p&gt;

&lt;p&gt;But the thing I'd take away is about the caveat.&lt;/p&gt;

&lt;p&gt;The estimate was not the valuable output. Estimates get superseded. What made this worth doing is that the failure was &lt;strong&gt;attributable&lt;/strong&gt;: the prediction missed for a reason that had been written down, in advance, as the thing the model could not account for. I didn't have to reconstruct an explanation after the fact, and I didn't get to choose a flattering one.&lt;/p&gt;

&lt;p&gt;That is the difference between a model that taught me something and a number that happened to be wrong. A caveat written before the measurement is a prediction about your own blind spot; a caveat written after it is an excuse. They read identically in the final write-up, which is exactly why the order matters.&lt;/p&gt;

&lt;p&gt;I've now had this pattern three times in a row. A crawler whose rate limiter enforced a perfect delay while sending a hundred simultaneous requests. A browser whose hibernated tabs cost three orders of magnitude more than I'd modelled. And a cost model that was right about every byte and wrong about the clock. In all three the arithmetic was fine and the boundary of the arithmetic was where the truth was hiding, so the useful discipline isn't being more careful inside the model, it's being explicit about where the model stops.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>measurement</category>
      <category>macos</category>
      <category>ssd</category>
    </item>
    <item>
      <title>What a Hibernated Browser Tab Actually Costs</title>
      <dc:creator>Seth Wheeler</dc:creator>
      <pubDate>Fri, 28 Aug 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/megapixel99/what-a-hibernated-browser-tab-actually-costs-i10</link>
      <guid>https://dev.to/megapixel99/what-a-hibernated-browser-tab-actually-costs-i10</guid>
      <description>&lt;p&gt;I've been building a macOS browser called Kestrel (about 7,900 lines of Swift on top of &lt;code&gt;WKWebView&lt;/code&gt;) to test one idea: that a browser should hold a memory budget you give it, the way a game engine holds a frame budget. You say 800 MB, and the browser demotes background tabs down a ladder of progressively cheaper states until it fits, rather than either swelling without limit or destroying tabs outright.&lt;/p&gt;

&lt;p&gt;Before writing any of it I simulated the policy against real tab-usage distributions. The simulation said the scheduler would use &lt;strong&gt;7.9–11.5× less memory&lt;/strong&gt; than an unmanaged browser, with &lt;strong&gt;zero&lt;/strong&gt; state-losing reloads.&lt;/p&gt;

&lt;p&gt;Built against an actual engine, it delivers &lt;strong&gt;1.4–2.6×&lt;/strong&gt;, and state loss is reduced by 18–42% rather than eliminated.&lt;/p&gt;

&lt;p&gt;The whole gap traces to a single number. The simulation priced a hibernated tab at &lt;strong&gt;32 KB&lt;/strong&gt;: a session descriptor and a thumbnail. WebKit charges &lt;strong&gt;39 MB&lt;/strong&gt;, because the renderer process survives and cannot be terminated on request. Three orders of magnitude, in the one parameter every downstream claim rested on.&lt;/p&gt;

&lt;p&gt;Kestrel isn't public yet, so this is a description rather than an invitation to read the code. The measurements below are all on the same machine: M1 Max, macOS 15.5, Swift 6.1.2, system WebKit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ladder, and what each rung actually costs
&lt;/h2&gt;

&lt;p&gt;The design has four states per tab. LIVE is a normal loaded page. WARM is frozen but resident. COLD keeps a serialised session image and throws the page away. STUB destroys the web view entirely and remembers only a URL.&lt;/p&gt;

&lt;p&gt;The premise is that these get monotonically cheaper, so a scheduler can walk a tab down the ladder until the budget is satisfied. Driving one tab through every rung, measured with &lt;code&gt;phys_footprint&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;rung&lt;/th&gt;
&lt;th&gt;measured&lt;/th&gt;
&lt;th&gt;% of LIVE&lt;/th&gt;
&lt;th&gt;recovered&lt;/th&gt;
&lt;th&gt;design target&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LIVE&lt;/td&gt;
&lt;td&gt;128 MB&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;&amp;lt; 25 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WARM&lt;/td&gt;
&lt;td&gt;106 MB&lt;/td&gt;
&lt;td&gt;83%&lt;/td&gt;
&lt;td&gt;17%&lt;/td&gt;
&lt;td&gt;&amp;lt; 2 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;COLD&lt;/td&gt;
&lt;td&gt;39 MB&lt;/td&gt;
&lt;td&gt;30%&lt;/td&gt;
&lt;td&gt;70%&lt;/td&gt;
&lt;td&gt;~2 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;STUB&lt;/td&gt;
&lt;td&gt;59 MB&lt;/td&gt;
&lt;td&gt;46%&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;~2 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things in that table are wrong in ways I didn't anticipate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WARM recovers 17%, against a target of under 2 MB.&lt;/strong&gt; WARM is everything a host application is permitted to do to a page short of destroying it: detach it from the view hierarchy, &lt;code&gt;setAllMediaPlaybackSuspended(true)&lt;/code&gt;, clear every timer and interval. That buys 17%, and the reason is structural: &lt;strong&gt;a host app cannot compact WebKit's heap.&lt;/strong&gt; The design's WARM assumed a full compacting GC followed by &lt;code&gt;madvise&lt;/code&gt;, and compaction is the step that actually returns pages to the OS. There is no API for it. WARM as specified is an engine-internal operation being attempted from outside, and it doesn't work.&lt;/p&gt;

&lt;p&gt;I had swept this parameter in simulation from 0.02 to 0.50. Reality is 0.83: worse than the most pessimistic case I tested.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;STUB costs more than COLD.&lt;/strong&gt; 59 MB against 39 MB. Destroying the web view is more expensive than keeping it and navigating it to &lt;code&gt;about:blank&lt;/code&gt;. So the ladder is not monotonic on this engine, which means a scheduler cannot assume an ordering; it has to measure each rung's cost and sort by the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why COLD can't get below a whole process
&lt;/h2&gt;

&lt;p&gt;COLD captures &lt;code&gt;WKWebView.interactionState&lt;/code&gt; and navigates the page away. That recovers 70%, and leaves 39 MB of WebContent process behind that nothing I can reach will reclaim.&lt;/p&gt;

&lt;p&gt;I tried three separate things to kill that process, because I did not believe it at first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Release the last reference to the &lt;code&gt;WKWebView&lt;/code&gt;.&lt;/strong&gt; Process still alive after 110 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give each tab its own &lt;code&gt;WKProcessPool&lt;/code&gt;&lt;/strong&gt;, so the pool dies with the view. No effect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Navigate to &lt;code&gt;about:blank&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; release the view.&lt;/strong&gt; 59 MB, still alive: worse than navigating away and keeping the view, which is why the COLD path now does the latter.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is WebKit's WebProcessCache doing exactly its job. It keeps renderer processes warm so the next navigation is fast. It's a reasonable trade that happens to be precisely the wrong one for a memory scheduler, and there's no supported way to opt out. Private SPI (&lt;code&gt;_WKProcessPoolConfiguration.usesWebProcessCache&lt;/code&gt;) would likely make teardown deterministic, at the cost of leaving supported API.&lt;/p&gt;

&lt;p&gt;One caveat I want to be honest about: the process cache is bounded and evicts under memory pressure, so the floor at scale is probably not 39 MB × N tabs. I haven't tested that. The naive extrapolation says 80 tabs would floor at 3.1 GB, and I don't believe that number; I just haven't disproved it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A null result that read like a finding
&lt;/h2&gt;

&lt;p&gt;The first end-to-end comparison of three policies on real sites returned this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;none        mean 197.8 MB  peak 320.0 MB  over budget 0%  demotions 0
discardlru  mean 182.3 MB  peak 278.0 MB  over budget 0%  demotions 0
kestrel     mean 181.0 MB  peak 274.0 MB  over budget 0%  demotions 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three nearly identical rows look like "the policies are equivalent." They actually meant the experiment never ran. The budget was 400 MB and the peak was 320 MB, so no policy ever had anything to do.&lt;/p&gt;

&lt;p&gt;There was a second methodological bug underneath. My revisit trace was a Zipf distribution over an LRU stack (the same model the simulation used) which concentrates so hard on recently-used tabs that with 10 tabs and 40 events, only 9 were ever loaded. In simulation this was masked, because tabs were &lt;em&gt;created&lt;/em&gt; by the trace as it ran. Replaying the same model against a fixed tab set is not the same experiment.&lt;/p&gt;

&lt;p&gt;The fix was to open each tab once before revisiting, the way a user filling a window does, and to lower the budget until it binds. I'm recording it because the failure mode is easy to miss in the direction that flatters you: when every arm of an experiment agrees, check that the independent variable actually varied before believing the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the advantage went
&lt;/h2&gt;

&lt;p&gt;With the budget binding, two workloads. Light pages are ten real sites (Wikipedia, MDN, Hacker News, go.dev) at a 150 MB budget:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;policy&lt;/th&gt;
&lt;th&gt;mean&lt;/th&gt;
&lt;th&gt;peak&lt;/th&gt;
&lt;th&gt;over budget&lt;/th&gt;
&lt;th&gt;tabs destroyed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;unmanaged&lt;/td&gt;
&lt;td&gt;319.9 MB&lt;/td&gt;
&lt;td&gt;368.6 MB&lt;/td&gt;
&lt;td&gt;88%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;discard-LRU&lt;/td&gt;
&lt;td&gt;104.6 MB&lt;/td&gt;
&lt;td&gt;140 MB&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kestrel&lt;/td&gt;
&lt;td&gt;121.7 MB&lt;/td&gt;
&lt;td&gt;144 MB&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's a loss. More memory than discard-LRU for a rounding error's worth of saved tabs.&lt;/p&gt;

&lt;p&gt;The warm-up ramp explains it. Per-tab LIVE cost on those ten sites was 37, 50, 32, 24, 5, 17, 111, 31, 22 and 40 MB: a median of 31 MB. The COLD floor is 39 MB. &lt;strong&gt;On the light set, hibernating a tab costs more than leaving it running.&lt;/strong&gt; Seven of ten tabs are cheaper live than cold; the compression ratio is 0.95×.&lt;/p&gt;

&lt;p&gt;So the ladder had nowhere to put anything. Kestrel parked &lt;strong&gt;zero&lt;/strong&gt; tabs at COLD in that run (the rung does not appear once in the trace) and fell through to STUB, which is what discard-LRU already does, only with more bookkeeping.&lt;/p&gt;

&lt;p&gt;Heavy pages are a synthetic 20,000-node DOM with a retained JS heap, at a 500 MB budget. Compression ratio 3.28×:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;policy&lt;/th&gt;
&lt;th&gt;mean&lt;/th&gt;
&lt;th&gt;peak&lt;/th&gt;
&lt;th&gt;over budget&lt;/th&gt;
&lt;th&gt;tabs destroyed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;unmanaged&lt;/td&gt;
&lt;td&gt;983.7 MB&lt;/td&gt;
&lt;td&gt;1111 MB&lt;/td&gt;
&lt;td&gt;90%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;discard-LRU&lt;/td&gt;
&lt;td&gt;420.0 MB&lt;/td&gt;
&lt;td&gt;452 MB&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kestrel&lt;/td&gt;
&lt;td&gt;411.5 MB&lt;/td&gt;
&lt;td&gt;482 MB&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;Same memory as LRU, 42% fewer destroyed tabs. The advantage tracks the compression ratio, which is the one piece of evidence here that I understand the mechanism rather than merely observing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule the simulation never surfaced
&lt;/h2&gt;

&lt;p&gt;Falling out of the 39 MB floor is a feasibility condition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;budget must exceed  (live working set) + (COLD floor × parked tabs)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For 10 heavy tabs at a 500 MB budget: roughly 384 MB of protected live set plus 273 MB of parked tabs is 657 MB of demand against 500 MB of budget. Which is exactly why 7 tabs still had to be destroyed: below that floor, no policy can do better.&lt;/p&gt;

&lt;p&gt;That's a prediction, so I tested it. Four protected live tabs at 128 MB plus six parked at 39 MB is about 746 MB, so 800 MB should be enough for the ladder to reach zero state loss:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;policy&lt;/th&gt;
&lt;th&gt;mean&lt;/th&gt;
&lt;th&gt;peak&lt;/th&gt;
&lt;th&gt;over budget&lt;/th&gt;
&lt;th&gt;tabs destroyed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;unmanaged&lt;/td&gt;
&lt;td&gt;968.2 MB&lt;/td&gt;
&lt;td&gt;1111 MB&lt;/td&gt;
&lt;td&gt;82%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;discard-LRU&lt;/td&gt;
&lt;td&gt;668.5 MB&lt;/td&gt;
&lt;td&gt;765 MB&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kestrel&lt;/td&gt;
&lt;td&gt;678.8 MB&lt;/td&gt;
&lt;td&gt;787 MB&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Kestrel destroys nothing where discard-LRU destroys 5, for 1.5% more memory. State loss across all three runs tracks the rule exactly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;run&lt;/th&gt;
&lt;th&gt;budget&lt;/th&gt;
&lt;th&gt;headroom vs COLD floor&lt;/th&gt;
&lt;th&gt;LRU destroys&lt;/th&gt;
&lt;th&gt;Kestrel destroys&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;light pages&lt;/td&gt;
&lt;td&gt;150 MB&lt;/td&gt;
&lt;td&gt;far below&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;heavy pages&lt;/td&gt;
&lt;td&gt;500 MB&lt;/td&gt;
&lt;td&gt;below&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;heavy pages&lt;/td&gt;
&lt;td&gt;800 MB&lt;/td&gt;
&lt;td&gt;above&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note what the 800 MB run does &lt;em&gt;not&lt;/em&gt; show: a big memory reduction. It's 1.43× below unmanaged, against 2.4× at the tighter budget. That isn't a regression; the budget is the control input, and a looser budget buys less reduction by construction. What the ladder buys isn't a multiplier. It's the ability to hit whatever budget you name without shredding tabs: 0% of samples over budget against unmanaged's 82%, with zero tabs destroyed.&lt;/p&gt;

&lt;p&gt;Two things did survive intact. Restore from COLD to a live page takes &lt;strong&gt;82 ms&lt;/strong&gt;, timed to &lt;code&gt;didFinish&lt;/code&gt; rather than to the API returning, against a design target of 100. And p95 restore latency was &lt;em&gt;lower&lt;/em&gt; for Kestrel than for discard-LRU on both workloads (397 ms vs 400 ms, and 3541 ms vs 3546 ms) because deserialising a session image beats refetching the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The instrumentation became the largest cost in the system
&lt;/h2&gt;

&lt;p&gt;This is my favourite bug in the project, because of what it is a bug &lt;em&gt;in&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The complaint was "scrolling seems clunky." Not a hang, not a crash, just slightly wrong. A screen recording put a number on it: during a ten-second scroll, &lt;strong&gt;25% of frames were pixel-identical to their predecessor.&lt;/strong&gt; One frame in four, dropped.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Tab.currentBytes&lt;/code&gt; returned a real, current measurement of a tab's memory. It was correct by construction. It got that measurement by shelling out to &lt;code&gt;/usr/bin/footprint&lt;/code&gt;, which costs &lt;strong&gt;226 ms per call&lt;/strong&gt;, and it did so on every read. The scheduler's budget loop read it. The status bar read it. Every row of the tab strip read it. About eight times per 1.5-second UI tick: roughly &lt;strong&gt;1.8 seconds of main-thread blocking per 1.5 seconds of wall clock.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It didn't present as a hang because WebKit composites pages in their own process. The page kept scrolling in a process that wasn't blocked, just badly, while the process responsible for deciding how much memory to use spent all of its time finding out.&lt;/p&gt;

&lt;p&gt;The fix is unglamorous: sample on a background queue, let the UI read a cache. 1000 reads now take 0.13 ms, with a regression test asserting that reads stay free.&lt;/p&gt;

&lt;p&gt;But I'd already made this mistake once, in a different place. An earlier tier-down implementation optimised the number reported by &lt;code&gt;about:memory&lt;/code&gt; while returning nothing at all to the OS: the wrong number, measured well. This is its mirror image. &lt;strong&gt;A memory manager that stalls the UI to find out how much memory it's using has spent more than it can possibly save.&lt;/strong&gt; Anything you sample per frame or per tick has to be cheap enough to be free, or it becomes the problem it was added to observe.&lt;/p&gt;

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

&lt;p&gt;Three things the real engine confirmed, and they're the three the design needs to exist at all. Per-tab memory is genuinely attributable; each &lt;code&gt;WKWebView&lt;/code&gt; gets its own WebContent process, and four identical tabs measured 130.0 MB each, identical to the decimal, which is what you want from a control. (&lt;code&gt;WKWebView&lt;/code&gt; exposes no pid, so I attribute processes by set-diffing &lt;code&gt;ps&lt;/code&gt; output as tabs are created one at a time.) The session image I thought I'd have to invent already ships as &lt;code&gt;interactionState&lt;/code&gt;: 138 bytes for my probe page. And restore is fast enough that a user doesn't perceive it.&lt;/p&gt;

&lt;p&gt;What broke is cleanly separated from what held, and the split is not where I expected. Everything in the browser's &lt;strong&gt;policy&lt;/strong&gt; layer (the budget, the scoring, the floors, the fail-safe that refuses to demote a tab for a trivial gain) ported to a real engine without modification. Everything in the &lt;strong&gt;engine&lt;/strong&gt; layer (heap compaction, cheap frozen tabs, process teardown) cannot be built on top of an engine that doesn't offer it. No amount of additional simulation would have told me which of my components were which, because the simulation was where I'd encoded the assumption.&lt;/p&gt;

&lt;p&gt;The specific correction I'd give my earlier self: I modelled a hibernated tab as a data structure, when on this platform it's a process. Data structures are free to throw away. Processes are only free to throw away if something will let you kill them.&lt;/p&gt;

&lt;p&gt;It also produces an argument I didn't expect for a fork-server design, from a direction I wasn't looking: everyone talks about cheap process &lt;em&gt;startup&lt;/em&gt;. Cheap process &lt;em&gt;teardown&lt;/em&gt; turns out to matter just as much, and only one of those is something a host application can work around.&lt;/p&gt;

</description>
      <category>memory</category>
      <category>swift</category>
      <category>webkit</category>
      <category>measurement</category>
    </item>
    <item>
      <title>A Better FP4 Gradient Quantizer That Training Couldn't Notice</title>
      <dc:creator>Seth Wheeler</dc:creator>
      <pubDate>Tue, 25 Aug 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/megapixel99/a-better-fp4-gradient-quantizer-that-training-couldnt-notice-379l</link>
      <guid>https://dev.to/megapixel99/a-better-fp4-gradient-quantizer-that-training-couldnt-notice-379l</guid>
      <description>&lt;p&gt;Four-bit training is the current frontier of making LLM pretraining cheaper. NVIDIA's Blackwell GPUs do 4-bit matrix math several times faster than 16-bit, but 4 bits means every number gets rounded to one of 16 values. The NVFP4 format's menu is exactly &lt;code&gt;{0, ±0.5, ±1, ±1.5, ±2, ±3, ±4, ±6}&lt;/code&gt;, stretched by one scale factor per block of 16 numbers. Choosing that scale is most of the game. The published state of the art for gradients (an estimator called MS-EDEN, from the Quartet II paper, arXiv:2601.22813) chooses it the safe way: scale so the block's largest value lands exactly on 6, and nothing clips.&lt;/p&gt;

&lt;p&gt;I found a scale rule that beats it by 14% on its own metric, on every real gradient tensor I tested. Then I rented two GPUs to watch the improvement show up in training loss, and it never did; four estimators landed within 0.09% of each other at 2.8B parameters. The number that explains both halves cost nothing to measure, and I measured it last, which is the embarrassing part. My batches were between 35 and 643 times too small for the difference to be visible. The batch size where it does become visible is the one frontier labs actually train at.&lt;/p&gt;

&lt;p&gt;The code isn't public, so this post carries the numbers instead. MS-EDEN here is my reimplementation (it reproduces the paper's error figures, not its unbiasedness proof), and the training corpus is FineWeb-Edu, tokenized with GPT-2's BPE.&lt;/p&gt;

&lt;h2&gt;
  
  
  The win, and where it comes from
&lt;/h2&gt;

&lt;p&gt;A block of 16 gradient values is roughly bell-curved. Its largest value sits around 2 to 2.5 standard deviations. Scaling that value onto the grid's top level spends the two coarsest levels (4 and 6) on numbers that almost never occur, and it starves the middle of the distribution where nearly everything lives.&lt;/p&gt;

&lt;p&gt;The alternative is what anyone tuning a lossy codec would try: sweep 17 candidate scales per block and keep the one that minimizes mean squared error. Values above the top level clip; sacrificing the rare outlier buys precision for the common case. On synthetic data that's worth &lt;strong&gt;+14.0%&lt;/strong&gt; against MS-EDEN on Gaussian blocks and &lt;strong&gt;+25.0%&lt;/strong&gt; on heavy-tailed ones. On 45 real weight-gradient tensors captured across three phases of training a small transformer, it wins on &lt;strong&gt;45 of 45&lt;/strong&gt;, mean &lt;strong&gt;+13.8%&lt;/strong&gt; (worst tensor +9.6%, best +15.3%).&lt;/p&gt;

&lt;p&gt;It stays hardware-valid, since the output is still the FP4 grid times one per-block scalar. And the caveat the experiment printed in its own output at the time matters for everything that follows: MSE is a proxy, and the training-loss impact was unvalidated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wall, then a landmine
&lt;/h2&gt;

&lt;p&gt;Making the improvement &lt;em&gt;provably unbiased&lt;/em&gt; failed, and the failure generalizes. On a fixed 16-level grid you can have a provably unbiased estimator (stochastic rounding, no clipping) or a low-error one (clipping), never both. The error reduction &lt;em&gt;is&lt;/em&gt; the clipping, and stochastically rounding a clipped value is biased by construction. I later checked whether this was just NVFP4's menu being bad. It isn't: optimizing the palette itself moves the number but not the shape, and every 16-value palette I tested holds an unbiased-to-biased error ratio between 2.2× and 2.6×.&lt;/p&gt;

&lt;p&gt;The standard escape is error feedback: keep the part you rounded off and add it back next step, Kahan summation applied to gradients. Each step stays biased, but the running sum doesn't. That works exactly as advertised under SGD. On a coarse 3-level quantizer, a quantized-plus-feedback run lands on the full-precision trajectory to three digits (2.31e-5 against 2.33e-5, distance to the optimum).&lt;/p&gt;

&lt;p&gt;Under Adam it's a landmine. Adam rescales every update by a running second-moment estimate. The carried residual passes through that nonlinearity, and the guarantee quietly dies. Measured on the same harness, with orderings that hold at three sigma across seeds:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;arm&lt;/th&gt;
&lt;th&gt;distance to optimum (Adam)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;full precision&lt;/td&gt;
&lt;td&gt;8.86e-5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;quantized, no feedback&lt;/td&gt;
&lt;td&gt;9.59e-5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;quantized + error feedback&lt;/td&gt;
&lt;td&gt;1.78e-4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;quantized + error feedback, second moment frozen after warmup&lt;/td&gt;
&lt;td&gt;1.67e-5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Error feedback under Adam was &lt;em&gt;worse than no correction at all&lt;/em&gt;. Freezing the second moment (the fix the 1-bit Adam paper used for compressed communication) produced the best arm in that table, and I originally wrote that it repairs error feedback completely. &lt;a href="https://sethwheeler.dev/blog/error-feedback-adam/" rel="noopener noreferrer"&gt;The follow-up post&lt;/a&gt; ran the ablation I skipped, and the credit was misassigned: on this harness a frozen &lt;code&gt;v&lt;/code&gt; helps just as much with no feedback, and with no quantization in the run at all. The fix was real; it just wasn't fixing what I said it was. What survives is the landmine itself: don't bolt error feedback onto Adam. I'd been running exactly that broken configuration on the GPUs without knowing it. At FP4's error magnitudes it was too small to hurt, but the default was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two GPUs that saw nothing
&lt;/h2&gt;

&lt;p&gt;On a rented RTX 4090, a 0.5B-parameter GPT trained on 98M tokens. FP4 gradient quantization costs a real &lt;strong&gt;+2.14%&lt;/strong&gt; in held-out loss against bf16, and my estimator differs from MS-EDEN by &lt;strong&gt;0.01%&lt;/strong&gt; (6.4442 against 6.4433, which is noise). The error-feedback arm never finished. Its fp32 residual buffer is a full parameter-sized copy, the card has 24 GB, and the log ends in a CUDA out-of-memory error trying to allocate 1.54 GiB with 773.69 MiB free.&lt;/p&gt;

&lt;p&gt;So: a B200 with 192 GB, at $8.619 an hour, and a 2.8B-parameter model on 198M tokens, with the residual buffer halved to bf16 and activations checkpointed. All four arms ran, 2.06 hours of training time across them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;arm&lt;/th&gt;
&lt;th&gt;final loss&lt;/th&gt;
&lt;th&gt;vs bf16&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bf16&lt;/td&gt;
&lt;td&gt;7.6890&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MS-EDEN&lt;/td&gt;
&lt;td&gt;7.6868&lt;/td&gt;
&lt;td&gt;-0.03%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;optimal scale&lt;/td&gt;
&lt;td&gt;7.6823&lt;/td&gt;
&lt;td&gt;-0.09%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;optimal scale + error feedback&lt;/td&gt;
&lt;td&gt;7.6896&lt;/td&gt;
&lt;td&gt;+0.01%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Everything sits inside a 0.09% band, which is seed noise. Even the 4090's 2.14% bf16-versus-FP4 gap vanished, because 800 steps into a 2.8B model is nowhere near convergence. There was very little converged signal for quantization error to corrupt.&lt;/p&gt;

&lt;p&gt;(An operational note, because it cost an hour of B200 billing: a relaunch command that ran &lt;code&gt;pkill -f scale_experiment.py&lt;/code&gt; in the same shell that then launched &lt;code&gt;scale_experiment.py&lt;/code&gt; matches its own command line and kills its own launcher. Every relaunch mechanism I tried "mysteriously" died until I read the exit code properly. Two other sessions on this machine have since hit the same trap. The fix is &lt;code&gt;pgrep -f "patt[e]rn"&lt;/code&gt;, where the brackets break the self-match.)&lt;/p&gt;

&lt;p&gt;At this point the honest summary was: real on the proxy, invisible on the metric that matters, at every scale I could afford. I nearly wrote exactly that and stopped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number I should have measured first
&lt;/h2&gt;

&lt;p&gt;The reason every run came back "within noise" has a formula. A gradient estimated from a batch of B tokens carries sampling noise that shrinks as 1/B. Quantization error doesn't shrink with batch size at all. Divide one by the other and you get the batch size where a quantizer's error, or the &lt;em&gt;difference&lt;/em&gt; between two quantizers, pokes above the sampling-noise floor. Both quantities are measurable on a tiny model in minutes, on a laptop, for free.&lt;/p&gt;

&lt;p&gt;Measured on real gradients (64-microbatch statistics, three training phases):&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;step 30&lt;/th&gt;
&lt;th&gt;step 100&lt;/th&gt;
&lt;th&gt;step 300&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;estimator gap (relative MSE)&lt;/td&gt;
&lt;td&gt;+16.0%&lt;/td&gt;
&lt;td&gt;+13.9%&lt;/td&gt;
&lt;td&gt;+16.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gradient-noise scale (tokens)&lt;/td&gt;
&lt;td&gt;1,035&lt;/td&gt;
&lt;td&gt;5,778&lt;/td&gt;
&lt;td&gt;2,469&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;estimator gap visible at batch ≈&lt;/td&gt;
&lt;td&gt;868,331&lt;/td&gt;
&lt;td&gt;5,264,559&lt;/td&gt;
&lt;td&gt;1,915,591&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My runs used batches of 8,192 and 24,576 tokens. Depending on the run and the training phase, that is &lt;strong&gt;35 to 643 times below&lt;/strong&gt; the batch where the gap could have appeared in loss. The experiments were not measuring a small effect; they were structurally incapable of measuring any effect. It's the benchmarking mistake in different clothes: evaluating a 1% optimization on a machine with 20% run-to-run variance, then concluding the optimization does nothing.&lt;/p&gt;

&lt;p&gt;The crossover isn't in exotic territory either. One to five million tokens per batch is the standard operating point of frontier-lab pretraining. Measured where I could afford to train, the difference is invisible; extrapolated to where the labs train, it sits right at the threshold of visibility. Two caveats travel with that. The crossover was measured on a 3M-parameter model, so treat it as an order of magnitude. And the confirming experiment (a ~124M-parameter model at a ~1.6M-token accumulated batch, where quantization overhead amortizes to nothing) is specced and smoke-tested but has not run. The prediction is a prediction, not a result.&lt;/p&gt;

&lt;p&gt;One consolation prize from the same afternoon of cheap experiments: after the standard Hadamard rotation makes blocks Gaussian, plain evenly-spaced INT4 beats NVFP4's float-style palette on both rounding modes (0.01445 against 0.01789 unbiased, 0.00621 against 0.00685 biased, normalized MSE). I first attached a condition to that, reasoning the uniform grid only wins after the rotation has removed the outliers the float spacing exists for. &lt;a href="https://sethwheeler.dev/blog/int4-vs-nvfp4/" rel="noopener noreferrer"&gt;The follow-up that tested the condition on real tensors&lt;/a&gt; killed it: uniform INT4 wins on 41 of 45 gradient tensors with no rotation at all, because my heavy-tailed synthetic data had put the outliers somewhere real gradients don't. The practical half survives untouched; integer 4-bit math predates Blackwell by years.&lt;/p&gt;

&lt;h2&gt;
  
  
  What generalizes
&lt;/h2&gt;

&lt;p&gt;A null result is a claim about your noise floor at least as much as a claim about the effect. I ran the training experiment twice, on two GPUs, before measuring the floor once. The floor measurement took minutes, cost nothing, and retroactively explained both runs; it also converted "needs a converged frontier run to settle" into "needs one afternoon at a large batch." The expensive experiments weren't wrong, but every dollar of them was spent below the detection threshold, and the threshold was computable in advance.&lt;/p&gt;

&lt;p&gt;I keep relearning the same discipline in different domains. Before paying to measure an effect, price the noise you'll be measuring it through. The signal has to clear the floor, and the floor rarely announces itself. Mine was 35 to 643 times too high, and nothing in the loss curves even hinted at it.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>measurement</category>
      <category>quantization</category>
      <category>training</category>
    </item>
    <item>
      <title>Why a Small Transformer Can't Copy a Word It Hasn't Seen</title>
      <dc:creator>Seth Wheeler</dc:creator>
      <pubDate>Mon, 24 Aug 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/megapixel99/why-a-small-transformer-cant-copy-a-word-it-hasnt-seen-2fln</link>
      <guid>https://dev.to/megapixel99/why-a-small-transformer-cant-copy-a-word-it-hasnt-seen-2fln</guid>
      <description>&lt;p&gt;I have a small transformer that turns a one-line spec into a working web app. It is 11.9M parameters, 6 layers, d=384, trained on 4,176 generated programs. Given &lt;em&gt;"a support ticket system with marking a ticket closed, a stats page, searching tickets and creating and viewing tickets"&lt;/em&gt; it writes a 1,158-token Python file that compiles, serves HTTP, implements search, stats and toggle, and does not implement comments, edit, delete or category. On held-out feature combinations it does that 5 times out of 6.&lt;/p&gt;

&lt;p&gt;Ask it for a book catalogue and it writes an appointment booker.&lt;/p&gt;

&lt;p&gt;I built this arm to justify a specific claim, and the claim turned out to be wrong. This post is that retraction, plus the three experiments it took to find out &lt;em&gt;why&lt;/em&gt; the model fails, one of which corrected a diagnosis I had already written down and believed.&lt;/p&gt;

&lt;p&gt;(I have written about this project once before, on &lt;a href="https://sethwheeler.dev/blog/appgen-verification-gap/" rel="noopener noreferrer"&gt;how its verification sweep reports on itself&lt;/a&gt;. That post is about the symbolic half of the same system; this one is about the learned half and does not depend on it. The code is in a private research repo, so there is no link. Every figure below comes from a results file or a command I ran, and I say which.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The claim I was defending
&lt;/h2&gt;

&lt;p&gt;The shipped tool does not use the model at all. It uses compositional synthesis: a parser turns your request into an entity schema plus a feature set, and hand-written emitters assemble exactly that program. It is exact within its grammar and about 2 ms on a CPU.&lt;/p&gt;

&lt;p&gt;Its cost is human labour. Each program kind is 160 to 287 lines of hand-written emitter, each language is another 104 to 231, and there is no transfer between them. I wrote in two separate experiment write-ups that this is "precisely the labour a learned generator would amortise." A model, the argument went, would learn the mapping once and cover new cases for free.&lt;/p&gt;

&lt;p&gt;For a long time that was untestable, because the model scored 0%.&lt;/p&gt;

&lt;h2&gt;
  
  
  0% was the plumbing, four times over
&lt;/h2&gt;

&lt;p&gt;Five experiments in a row scored &lt;strong&gt;0/6 compile&lt;/strong&gt;. The diagnosis I kept reaching for was capacity or training time, and it was wrong every time. Training to 3.5× lower loss left the failure distribution identical.&lt;/p&gt;

&lt;p&gt;What actually fixed it was two one-line changes to the data pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Word-split docstrings.&lt;/strong&gt; Every app in the corpus gets a spec-derived docstring, and docstrings were tokenised atomically, so &lt;strong&gt;every held-out app's docstring was &lt;code&gt;&amp;lt;unk&amp;gt;&lt;/code&gt; by construction&lt;/strong&gt;. 4,141 of 5,953 vocabulary entries were one-off docstrings. Splitting them into words took the vocabulary to &lt;strong&gt;1,817&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An &lt;code&gt;&amp;lt;eos&amp;gt;&lt;/code&gt; token.&lt;/strong&gt; Training apps had nothing marking the end, so nothing taught the model to stop, and every previous arm's output was mechanically truncated at the 3,200-token cap.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Same architecture, same data, same optimizer. The results file (&lt;code&gt;021-pipeline-fixes/fixed_combo.json&lt;/code&gt;) records what happened:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;metric&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;emitted &lt;code&gt;&amp;lt;eos&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;6/6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;compile&lt;/td&gt;
&lt;td&gt;0/6&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6/6&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;serves HTTP&lt;/td&gt;
&lt;td&gt;0/6&lt;/td&gt;
&lt;td&gt;5/6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;all features, strict&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0/6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5/6 (83%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Strict means the requested features work &lt;em&gt;and&lt;/em&gt; the unrequested ones are absent. The model does both. Dev loss improved as well, 0.034 to 0.026, from deleting 4,100 vocabulary entries: entries that can only ever be copied are pure liability, since they consume capacity and are unusable on held-out input.&lt;/p&gt;

&lt;p&gt;So the generator worked, and the claim became testable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test, and the retraction
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;library&lt;/code&gt; domain (entity &lt;code&gt;book&lt;/code&gt;, table &lt;code&gt;books&lt;/code&gt;) was held out of the corpus before any training. If the amortisation argument were right, this is where it would show: a new noun costs the symbolic system a schema synthesiser of about 15 lines, and should cost the model nothing at all.&lt;/p&gt;

&lt;p&gt;From &lt;code&gt;025-generator-transfer/fixed_domain.json&lt;/code&gt;, on 8 held-out requests:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;metric&lt;/th&gt;
&lt;th&gt;seen domains&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;unseen domain&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;emitted &lt;code&gt;&amp;lt;eos&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;6/6&lt;/td&gt;
&lt;td&gt;8/8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;compile&lt;/td&gt;
&lt;td&gt;6/6&lt;/td&gt;
&lt;td&gt;7/8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;serves HTTP&lt;/td&gt;
&lt;td&gt;5/6&lt;/td&gt;
&lt;td&gt;3/8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;strict, all features&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5/6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0/8&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;table hit / route hit&lt;/td&gt;
&lt;td&gt;6/6&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0/8&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mean purity&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.00&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zero. Not a degradation, a floor.&lt;/p&gt;

&lt;p&gt;I checked what it wrote instead by reading the eight generated files rather than trusting the summary. Every one names a table it was trained on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;000_library_basic.py      contacts
001_library_delete.py     tickets
002_library_edit.py       bookings
003_library_search.py     contacts
004_library_toggle.py     bookings   (with `tickets` leaking in elsewhere)
005_library_comments.py   bookings + requests
006_library_stats.py      bookings
007_library_category.py   bookings + requests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;bookings&lt;/code&gt; for &lt;code&gt;books&lt;/code&gt; is the tell: the model reaches for the lexically nearest entity it was trained on. The apps are internally consistent for the wrong entity, which is why 7 of 8 still compile while 0 of 8 are right.&lt;/p&gt;

&lt;p&gt;Here is the part that made me stop defending the argument. This is the top of &lt;code&gt;006_library_stats.py&lt;/code&gt;, unedited:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Spec:a simple book lending app offering a stats page and creating and viewing books
&lt;/span&gt;&lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;A simple address book app offering a stats page and creating and viewing bookings&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="n"&gt;PORT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;PORT&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;8806&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;DB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;DB&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;bookings.db&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;CREATE TABLE IF NOT EXISTS bookings (id INTEGER PRIMARY KEY AUTOINCREMENT, guest TEXT, details TEXT)&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;Line 1 is the prompt. The model then copies nine consecutive words of it verbatim (&lt;em&gt;"offering a stats page and creating and viewing"&lt;/em&gt;), swaps &lt;code&gt;book lending&lt;/code&gt; for &lt;code&gt;address book&lt;/code&gt;, swaps &lt;code&gt;books&lt;/code&gt; for &lt;code&gt;bookings&lt;/code&gt;, and gives the table a &lt;code&gt;guest&lt;/code&gt; column. The word it needed was in its context, on line 1, and I confirmed that across all eight files: the string &lt;code&gt;books&lt;/code&gt; appears exactly once in each, always on line 1, never in the code.&lt;/p&gt;

&lt;p&gt;So the model can copy a nine-word span and cannot copy the one word that decides correctness. The amortisation claim is dead: on the axis where a learned model was supposed to win, it scores 0/8 while 15 lines of symbolic schema synthesis score 10/10.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diagnosis I got wrong
&lt;/h2&gt;

&lt;p&gt;I wrote down the obvious conclusion: this is a copying problem, and the remedy is pointer attention or byte-level entity tokens.&lt;/p&gt;

&lt;p&gt;Before building any of that, a 30-line probe against the checkpoint (&lt;code&gt;029-entity-generalization/probe_vocab.py&lt;/code&gt;) checked whether copying was even the binding constraint. It was not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;books&lt;/code&gt; is not in the 1,817-token vocabulary.&lt;/strong&gt; It never occurs in training, so it is &lt;code&gt;&amp;lt;unk&amp;gt;&lt;/code&gt; on the way in and unreachable on the way out. Every SQL statement naming it is a single atomic string token, so &lt;code&gt;'INSERT INTO books (...)'&lt;/code&gt; is also one out-of-vocabulary token. Only &lt;strong&gt;3.9%&lt;/strong&gt; of a held-out app's tokens are OOV, and they are exactly the load-bearing ones.&lt;/p&gt;

&lt;p&gt;No mechanism defined over that vocabulary could have produced the right program, pointer or otherwise. "It substitutes a trained entity" was a symptom of an unreachable output slot, not evidence about copying. That is the fifth representation defect in this line of work, and all five were invisible in the loss curve.&lt;/p&gt;

&lt;p&gt;So: make the noun expressible. Spell the entity at character level (&lt;code&gt;&amp;lt;w&amp;gt; b o o k s &amp;lt;/w&amp;gt;&lt;/code&gt;) and split literals at the entity boundary so the template fragments stay in vocabulary. Vocabulary went &lt;em&gt;down&lt;/em&gt;, 1,817 to 1,623, and dev loss was unchanged at 0.027.&lt;/p&gt;

&lt;p&gt;It still scored 0/8 on fidelity. But it failed in a new and much more informative way. At &lt;strong&gt;epoch 1&lt;/strong&gt; the model visibly attempted the character channel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;booooooooooookings
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Starting &lt;code&gt;b&lt;/code&gt;, &lt;code&gt;o&lt;/code&gt;, &lt;code&gt;o&lt;/code&gt;, then degenerating into a repetition loop. By &lt;strong&gt;epoch 9&lt;/strong&gt; it wrote &lt;code&gt;bookings&lt;/code&gt; cleanly, with no character attempt at all. More training made it &lt;em&gt;abandon&lt;/em&gt; the channel it had started to use.&lt;/p&gt;

&lt;p&gt;That is not a capability failure. It is the objective working as specified. Every training app's entity was one of nine in-vocabulary nouns, so the entity was always predictable from the app's own internal consistency, and spelling a novel noun was never required to be right. Predicting the familiar sequence &lt;code&gt;b-o-o-k-i-n-g-s&lt;/code&gt; is simply lower loss. &lt;strong&gt;The model was never asked to copy, so it did not learn to.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Making it expressible was necessary and not sufficient. It also cost real quality: in-domain compile fell from 6/6 to 1/6, because spelling an identifier as eight tokens gives eight chances to break it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Randomise the nouns, and the substitution disappears
&lt;/h2&gt;

&lt;p&gt;The fix the diagnosis implies is to change the data, not the model: draw a fresh entity noun for every app, so the mapping cannot be memorised and copying out of the spec is the only way to be right.&lt;/p&gt;

&lt;p&gt;I regenerated that corpus while writing this. It takes 9 seconds and reproduces byte-for-byte against the tracked manifest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;600 training nouns, 10 held-out nouns, 128 feature combos
wrote 4240 apps ({'train': 4200, 'test_combo': 20, 'test_entity': 20}), 0 skipped
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ten held-out nouns include &lt;code&gt;book&lt;/code&gt;, so the number stays comparable rather than becoming an easier case, and the script asserts no held-out noun leaks into training.&lt;/p&gt;

&lt;p&gt;Trained on that corpus and decoded greedily from the spec alone, &lt;strong&gt;substitution is gone&lt;/strong&gt;: 0/10 generations write a trained noun, against 8/8 before. The model now attempts the novel noun. And the new failure names the next blocker precisely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;books      -&amp;gt; bs
sprockets  -&amp;gt; sckets
lectures   -&amp;gt; letes
harvests   -&amp;gt; heves
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It gets the first character right and the ending right and loses the middle. Spelling a symbol character by character needs state for how far through it you are, and nothing in the architecture carries that. The blocker was never vocabulary, and after the randomised corpus it is no longer distribution; it is positional.&lt;/p&gt;

&lt;h2&gt;
  
  
  The zero-parameter mechanism that just does it
&lt;/h2&gt;

&lt;p&gt;Meanwhile a different line of work in the same repo had a document cache: a count-based model over the text &lt;em&gt;currently being written&lt;/em&gt;, with no parameters and no training.&lt;/p&gt;

&lt;p&gt;Copying a noun out of the prompt is exactly what such a cache is for, and nobody had put the two together. The measurement walks each held-out program left to right and, at every position whose target is part of the entity noun, asks each system to predict it. I re-ran it (&lt;code&gt;cache_copy.py&lt;/code&gt;, 63 seconds):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WHOLE-NOUN accuracy where the program FIRST names it:
  corpus   0/20 apps
  cache    20/20 apps
  mix      17/20 apps

system       spec (floor)    in the code      first in code
corpus             0.243          0.254             0.100
cache              0.662          0.970             1.000
mix                0.719          0.978             1.000

corpus-model errors that were a TRAINED noun's character: 3960
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A cache with zero parameters gets the noun right 20 times out of 20. A count model trained on all 4,200 apps gets it right 0 times out of 20&lt;/strong&gt;, and its errors are a trained noun's characters &lt;strong&gt;3,960&lt;/strong&gt; times. The corpus arm is the control that makes this mean something: it fails the same way the neural model did, and it is not a data-scale problem, because 4,200 apps is the whole corpus and the score is zero.&lt;/p&gt;

&lt;p&gt;The information was always extractable from the request. What was missing was a mechanism that emits what it just read.&lt;/p&gt;

&lt;p&gt;A sweep over the mixing weight is worth putting next to that, because it is the kind of result that flips on the metric you pick. Per-position accuracy peaks at a blend (0.978 at β=0.70) while whole-noun correctness is monotone and peaks at pure cache (20/20 at β=1.00). Blend when the unit of correctness is a token; do not blend when it is a multi-token symbol that is wrong if any character is.&lt;/p&gt;

&lt;p&gt;The honest catch, and it is a big one: all of that is teacher-forced. Under free generation the cache's best weight collapses from about 1.0 to about 0.3, because it begins reading the model's own output rather than true text. Pushed further, it amplifies whatever was just emitted: the gated variant runs degeneracy from 0.34 to &lt;strong&gt;0.92&lt;/strong&gt; and takes "no table at all" from 1 of 10 to &lt;strong&gt;10 of 10&lt;/strong&gt;. &lt;strong&gt;The component that wins on prediction is the one whose input degrades under generation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What did survive was constraining decoding to spell one of the spans the request actually contains: a trie over the user's own words, again with zero parameters. With the cache at low weight, that takes table hit from 0/10 to &lt;strong&gt;10/10&lt;/strong&gt; and purity from 0.00 to &lt;strong&gt;1.00&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The obvious objection is that masking to "spell one of the two nouns in the request" leaves so little freedom that a model which learned nothing would look correct. That was tested by injecting a second, equally novel, equally legal noun into each request: &lt;strong&gt;9 of 10 chose the requested noun, 1 chose the distractor&lt;/strong&gt;, against a chance rate of 0.5 (binomial p = 0.011). The constraint supplies legal spellings; the model supplies which one.&lt;/p&gt;

&lt;p&gt;It fixes the entity, not the program. Whole-app validity is untouched, and a line like &lt;code&gt;PORT = int(os.environ.get('PORT', deleting them&lt;/code&gt; in the same outputs is a fair reminder of that.&lt;/p&gt;

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

&lt;p&gt;Three things, and the first one is the one I would actually carry anywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A 30-line probe beat five experiments of intuition, twice.&lt;/strong&gt; "Needs more capacity" and "needs more training" were wrong at every stage, and the loss curve never once indicated the real defect. Both diagnoses that mattered came from cheap instruments: a teacher-forced per-position probe that localised two pipeline bugs to single token positions, and a vocabulary probe that showed the target string was unreachable before anyone built a copy mechanism for it. Five separate representation defects, all invisible in the loss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A model will not learn a capability its training data never requires.&lt;/strong&gt; The character channel was available, expressible, and visibly attempted at epoch 1, and training removed it, because being right never depended on it. That is not the model failing to generalise; that is me failing to specify. The fix was a corpus change, and it worked on the first try.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check whether the failing thing is even representable before designing the fix.&lt;/strong&gt; I had written "this is a copying problem, use pointer attention" into my notes as a conclusion. It was a plausible, standard, entirely wrong remedy for a slot that was &lt;code&gt;&amp;lt;unk&amp;gt;&lt;/code&gt; on the way in and unreachable on the way out. The probe cost 30 lines and saved building the wrong mechanism.&lt;/p&gt;

&lt;p&gt;The arm still does not do what I built it to do. Inside domains it has seen it writes correct software at 83%, which is a genuinely stronger result than I expected from 11.9M parameters, and it is compositional generalisation over 2⁷ feature subsets with no combination-specific code. It just is not the labour-amortising property that would have justified preferring it, and the thing that finally did the copying had no parameters at all.&lt;/p&gt;

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