<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-US"><generator uri="https://jekyllrb.com/" version="4.3.4">Jekyll</generator><link href="https://codesilva.com/en/feed.xml" rel="self" type="application/atom+xml" /><link href="https://codesilva.com/en/" rel="alternate" type="text/html" hreflang="en-US" /><updated>2026-08-11T13:18:39+00:00</updated><id>https://codesilva.com/en/feed.xml</id><title type="html">CodeSilva</title><subtitle>Welcome to my personal blog, CodeSilva! As an aspiring Software Engineer, I&apos;m here to share my experiences, insights, and occasional programming misadventures.</subtitle><entry xml:lang="en-US"><title type="html">Amdahl’s Law: TypeScript 7 is 10x faster. Your CI is still slow</title><link href="https://codesilva.com/programacao/2026/08/07/amdahls-law-typescript-7-is-10x-faster-your-ci-is-still-slow.html" rel="alternate" type="text/html" title="Amdahl&apos;s Law: TypeScript 7 is 10x faster. Your CI is still slow" /><published>2026-08-07T00:00:00+00:00</published><updated>2026-08-07T00:00:00+00:00</updated><id>https://codesilva.com/programacao/2026/08/07/amdahls-law-typescript-7-is-10x-faster-your-ci-is-still-slow</id><content type="html" xml:base="https://codesilva.com/programacao/2026/08/07/amdahls-law-typescript-7-is-10x-faster-your-ci-is-still-slow.html"><![CDATA[<p>Your application’s CI keeps getting slower over time. A little more every month, until the day it stops being an annoyance and turns into a blocker.</p>

<p>What’s your first move?</p>

<p>I watched this happen last week.</p>

<p>A Node + TypeScript service had a 15-minute CI. Unit tests with coverage, around 1,200 of them.</p>

<p>And the reasoning that shows up in the moment is always the same: if it’s slow, something is wrong, so it must be possible to optimize. Upgrade TypeScript to the Go version, which typechecks much faster. Swap eslint and prettier for ox, which are much faster.</p>

<p>None of that is false. TypeScript in Go <strong>is</strong> absurdly faster. Oxlint <strong>is</strong> faster than eslint.</p>

<p>And it still wasn’t going to fix anything.</p>

<h2 id="typescript-7-really-is-that-fast">TypeScript 7 really is that fast</h2>

<p>The <code class="language-plaintext highlighter-rouge">tsc</code> you know was a TypeScript compiler written in TypeScript, running on Node. Version 7 is a faithful port of it to Go, shipped as a native binary, with type-checking parallelized across 4 threads.</p>

<p>Microsoft’s numbers are not modest: VS Code went from 125.7s to 10.6s, Sentry from 139.8s to 15.7s. In the editor, opening a file with errors dropped from ~17.5s to under 1.3s.</p>

<p>The emitted JavaScript is the same. The type system is the same. This is a compile-time story, full stop.</p>

<p>One detail if you plan to migrate: <strong>7.0 has no programmatic API</strong>, and it only arrives in 7.1. Any tool that embeds the compiler - typescript-eslint, Volar, and by extension Vue, Svelte, Astro, Angular - stays on TypeScript 6.</p>

<h2 id="the-japanese-knife-and-the-pot-of-beans">The Japanese knife and the pot of beans</h2>

<p><a href="https://en.wikipedia.org/wiki/Amdahl%27s_law">Amdahl’s Law</a> explains why swapping the compiler was never going to move the needle:</p>

<figure class="post-figure">
  <img src="/assets/images/amdahl/amdahls-law-en.png" alt="Amdahl's Law: S equals 1 divided by ((1 - p) + p / s). Two bars of equal length compare before and after: in BEFORE, a narrow slice p and a very wide slice 1 - p; in AFTER, the p slice is a hair-thin sliver and the 1 - p slice is unchanged. As s grows, S approaches 1 / (1 - p)." />
  <figcaption>The slice you optimized shrinks to a sliver. The other one doesn't move - and it's the one setting the total.</figcaption>
</figure>

<p><code class="language-plaintext highlighter-rouge">p</code> is the fraction of the work you improved, <code class="language-plaintext highlighter-rouge">s</code> is how much you improved it. The painful part is the limit: as <code class="language-plaintext highlighter-rouge">s</code> approaches infinity, the maximum speedup of the whole system becomes <code class="language-plaintext highlighter-rouge">1 / (1 - p)</code>.</p>

<p>In other words: <strong>the part you didn’t optimize is your ceiling.</strong> And it doesn’t move.</p>

<p>Think about Sunday lunch. It takes you 40 minutes. You buy an expensive Japanese knife that chops onions 10x faster, and the marketing wasn’t lying. Except chopping onions took 2 minutes, and now it takes 12 seconds.</p>

<p>Lunch still takes a little over 38 minutes.</p>

<p>Because what takes time is the pot of beans on the stove. And beans are not a chopping problem, they are a waiting problem. No knife solves waiting.</p>

<h2 id="they-did-the-upgrade">They did the upgrade</h2>

<p>Everything went up at once: eslint 8.57 -&gt; oxlint 1.76, prettier 3.0 -&gt; oxfmt 0.61, TypeScript 5.9 -&gt; TypeScript 7.</p>

<table>
  <thead>
    <tr>
      <th>step</th>
      <th>before</th>
      <th>after</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">lint</code></td>
      <td>5.56s</td>
      <td>3.73s</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">format:check</code></td>
      <td>6.51s</td>
      <td>3.42s</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">typecheck</code></td>
      <td>4.46s</td>
      <td>4.04s</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">typecheck</code> for tests</td>
      <td>4.50s</td>
      <td>3.93s</td>
    </tr>
    <tr>
      <td><strong>total</strong></td>
      <td><strong>21.03s</strong></td>
      <td><strong>15.12s</strong></td>
    </tr>
  </tbody>
</table>

<p>The new engines delivered the promised 6x to 10x. <code class="language-plaintext highlighter-rouge">tsc</code> 7 compiles the entire project in 1.22s. What eats the difference is the fixed overhead of each invocation: spinning up the process and loading the tool costs more than the actual work.</p>

<p>Net result: <strong>5.91 seconds.</strong> Out of 900.</p>

<p>And note that those four steps together were 21s in a 900s pipeline, which puts <code class="language-plaintext highlighter-rouge">p</code> at 2.3%. If the tools were infinitely fast, zero cost:</p>

<figure class="post-figure">
  <img src="/assets/images/amdahl/concrete-case-en.png" alt="S equals 1 divided by (1 - 0.023) equals 1.024x. A long bar labeled 900s pipeline with a hair-thin red sliver at its left end, marked as 21s of lint, format, typecheck (p = 2.3%). Below it, a bar of almost the same length: 879s in the best possible case. Net gain: 21 seconds." />
  <figcaption>With infinitely fast tooling at zero cost, the 900s pipeline drops to 879s. That's the ceiling.</figcaption>
</figure>

<p><strong>In the impossible scenario, CI drops from 15m00s to 14m39s.</strong> That was the ceiling, and it was available before a single line of code was written, from one thirty-second division.</p>

<p>Microsoft’s numbers aren’t a lie, either. Slack cut CI type-checking from 7.5 minutes to 1.25 minutes, but there it’s a dedicated <code class="language-plaintext highlighter-rouge">tsc</code> step, where the compiler is 100% of the work. Same compiler, same engine gain, opposite outcome: <strong>the difference isn’t in the tool, it’s in the denominator.</strong></p>

<h2 id="measuring-costs-five-minutes">Measuring costs five minutes</h2>

<p>GitHub Actions already shows the duration of every step in the UI, for free. Open the last run and read it before forming any theory. Then divide the step’s time by the total: that is the maximum you can gain by attacking it. If it comes out to 2%, you just saved yourself a week.</p>

<p>If you need to dig deeper, the tools already exist and are criminally underused:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">jest --verbose</code> gives you the duration per file. Three files usually account for half the time.</li>
  <li><code class="language-plaintext highlighter-rouge">jest --detectOpenHandles</code> finds promises that never resolve and timers that never clear, which leave the runner sitting around waiting for the event loop to drain after the tests already finished. It has existed for years.</li>
</ul>

<p>Knuth wrote this in 1974, on the same page of <a href="https://pic.plover.com/knuth-GOTO.pdf"><em>Structured Programming with go to Statements</em></a> that gave us the “premature optimization is the root of all evil” everyone quotes at half length:</p>

<blockquote>
  <p>“It is often a mistake to make a priori judgments about what parts of a program are really critical, since the universal experience of programmers who have been using measurement tools has been that their intuitive guesses fail.”</p>
</blockquote>

<h2 id="the-problem-is-the-jump">The problem is the jump</h2>

<p>None of this means “don’t upgrade.” Do upgrade - TypeScript 7 is an impressive piece of engineering and I plan to migrate everything I can. Those 6 seconds don’t move CI, but they do move the agent loop, which runs <code class="language-plaintext highlighter-rouge">typecheck</code> and <code class="language-plaintext highlighter-rouge">lint</code> dozens of times an hour. That is a real gain, just in a different number.</p>

<p>What’s wrong is something else: going from “it’s slow” straight to “swap it for something faster,” without the thirty-second division in between. <strong>Tool speed is not system speed.</strong></p>

<p>And swapping tools is comfortable because it looks productive: there’s a PR, there’s a changelog, there’s a nice benchmark to show. Measuring first looks like bureaucracy.</p>

<p>But measuring is what separates engineering from cheering.</p>

<p>Thanks for reading!</p>

<hr />

<ul>
  <li><a href="https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/">Announcing TypeScript 7.0</a> - the official announcement, with the benchmarks and the full list of breaking changes.</li>
  <li><a href="https://dev.to/nazar-boyko/typescript-7-went-native-what-actually-changes-and-what-doesnt-6b3">TypeScript 7 Went Native: What Actually Changes and What Doesn’t</a> - a good read on what the migration does not change.</li>
  <li><em>Systems Performance</em>, by Brendan Gregg. If you only read one chapter, read chapter 2.</li>
</ul>]]></content><author><name>CodeSilva</name></author><category term="typescript" /><category term="performance" /><category term="ci" /><category term="testing" /><category term="software engineering" /><summary type="html"><![CDATA[Your application’s CI keeps getting slower over time. A little more every month, until the day it stops being an annoyance and turns into a blocker.]]></summary></entry><entry xml:lang="en-US"><title type="html">In Java, equals was always the medicine. Nobody told you the disease</title><link href="https://codesilva.com/programacao/2026/08/02/in-java-equals-was-always-the-medicine-nobody-told-you-the-disease.html" rel="alternate" type="text/html" title="In Java, equals was always the medicine. Nobody told you the disease" /><published>2026-08-02T00:00:00+00:00</published><updated>2026-08-02T00:00:00+00:00</updated><id>https://codesilva.com/programacao/2026/08/02/in-java-equals-was-always-the-medicine-nobody-told-you-the-disease</id><content type="html" xml:base="https://codesilva.com/programacao/2026/08/02/in-java-equals-was-always-the-medicine-nobody-told-you-the-disease.html"><![CDATA[<p>At some point in your Java career you wrote this and did a double take:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">Integer</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">1</span><span class="o">,</span> <span class="n">j</span> <span class="o">=</span> <span class="mi">1</span><span class="o">;</span>
<span class="n">i</span> <span class="o">==</span> <span class="n">j</span>        <span class="c1">// true</span>

<span class="nc">Integer</span> <span class="n">x</span> <span class="o">=</span> <span class="mi">1996</span><span class="o">,</span> <span class="n">y</span> <span class="o">=</span> <span class="mi">1996</span><span class="o">;</span>
<span class="n">x</span> <span class="o">==</span> <span class="n">y</span>        <span class="c1">// false</span>
</code></pre></div></div>

<p>So you did what everyone does: you searched for it.</p>

<p>You found the answer fast, because <a href="https://stackoverflow.com/questions/1700081/why-is-128-128-false-but-127-127-is-true-when-comparing-integer-wrappers-in-ja">the question has been on Stack Overflow since November 2009</a> with a couple hundred votes. The explanation is always the same: <code class="language-plaintext highlighter-rouge">Integer x = 1996</code> doesn’t call <code class="language-plaintext highlighter-rouge">new Integer(1996)</code>, it calls <code class="language-plaintext highlighter-rouge">Integer.valueOf(1996)</code>. And <code class="language-plaintext highlighter-rouge">valueOf</code> keeps a cache of ready-made instances from -128 to 127. Inside that range you get the same object back every time. Outside it, a fresh object per call.</p>

<p>Fair enough. You filed away “compare wrappers with <code class="language-plaintext highlighter-rouge">equals</code>”, closed the tab, and moved on.</p>

<p>And it’s easy to dismiss this case. Boxing is your choice: use <code class="language-plaintext highlighter-rouge">int</code>, stop comparing wrappers with <code class="language-plaintext highlighter-rouge">==</code>, and the problem evaporates. Interview trivia, lab curiosity.</p>

<p>But what happens when there’s no primitive to fall back on?</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">LocalDate</span> <span class="n">d1</span> <span class="o">=</span> <span class="nc">LocalDate</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="mi">1996</span><span class="o">,</span> <span class="mi">1</span><span class="o">,</span> <span class="mi">23</span><span class="o">);</span>
<span class="nc">LocalDate</span> <span class="n">d2</span> <span class="o">=</span> <span class="n">d1</span><span class="o">.</span><span class="na">plusYears</span><span class="o">(</span><span class="mi">30</span><span class="o">);</span>      <span class="c1">// 2026-01-23</span>
<span class="nc">LocalDate</span> <span class="n">d3</span> <span class="o">=</span> <span class="n">d2</span><span class="o">.</span><span class="na">minusYears</span><span class="o">(</span><span class="mi">30</span><span class="o">);</span>     <span class="c1">// 1996-01-23</span>

<span class="n">d1</span><span class="o">.</span><span class="na">equals</span><span class="o">(</span><span class="n">d3</span><span class="o">)</span>   <span class="c1">// true</span>
<span class="n">d1</span> <span class="o">==</span> <span class="n">d3</span>        <span class="c1">// false</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">LocalDate</code> has no literal syntax. It has no primitive version. If you need a date, you’re using an object.</p>

<p>And there’s no cache in this story to blame. <code class="language-plaintext highlighter-rouge">d1</code> and <code class="language-plaintext highlighter-rouge">d3</code> are the same date, same year, same month, same day. <code class="language-plaintext highlighter-rouge">equals</code> agrees. <code class="language-plaintext highlighter-rouge">==</code> doesn’t.</p>

<p>So “use <code class="language-plaintext highlighter-rouge">equals</code>” is the medicine, not the diagnosis. The question nobody answered is why <code class="language-plaintext highlighter-rouge">==</code> compares memory addresses in the first place.</p>

<p>It has an answer. On July 31st, 2026, a commit of <strong>208 thousand lines</strong> landed in the OpenJDK and changed that answer for the first time since Java 1.0.</p>

<h2 id="identity-is-being-able-to-tell-two-identical-things-apart">Identity is being able to tell two identical things apart</h2>

<p>Before Java, think about two things in the physical world.</p>

<p>Nobody cares which twenty-dollar bill they got as change. If I swap yours for another one while you look away, nothing happened. Two twenties are interchangeable.</p>

<p>Now your car. You lend it to a neighbor and you want THAT car back, not an identical one.</p>

<p>The difference between those two cases is what we call <strong>identity</strong>: the ability to distinguish two things that hold exactly the same content.</p>

<p>And here’s the thing. Identity is only useful for things that change.</p>

<p>The car matters individually because it accumulates history: mileage, a dent, an empty tank. If two cars were frozen and never changed, it wouldn’t matter which one you got back. It becomes a twenty-dollar bill.</p>

<p>That gives you the one sentence everything else follows from: <strong>identity is a capability only mutable data uses.</strong></p>

<h2 id="you-already-know-this-by-another-name">You already know this by another name</h2>

<p>If you do DDD, this distinction is nothing new. It’s <strong>Entity versus Value Object</strong>, an idea Ward Cunningham was already describing in 1994 and that <a href="https://martinfowler.com/eaaCatalog/valueObject.html">Fowler catalogued</a> before Evans put it at the center of modeling.</p>

<p>Entity is the car. Order <code class="language-plaintext highlighter-rouge">#4712</code> is still the same order after changing status three times.</p>

<p>Value Object is the twenty-dollar bill. <code class="language-plaintext highlighter-rouge">$50.00</code> is <code class="language-plaintext highlighter-rouge">$50.00</code>, and an <code class="language-plaintext highlighter-rouge">EmailAddress</code> is the string it carries.</p>

<p>Except in DDD this was always design discipline and nothing more. You wrote <code class="language-plaintext highlighter-rouge">Money</code> immutable, no setters, <code class="language-plaintext highlighter-rouge">equals</code> by hand, and documented that it was a value object. The team understood. The compiler didn’t. The JVM even less.</p>

<p>At runtime, your value object was an entity like every other one: its own address, a header, an identity, and <code class="language-plaintext highlighter-rouge">==</code> lying to you. You drew the distinction in the domain diagram and paid full price in memory.</p>

<p><strong>That gap is what JEP 401 closes.</strong> For the first time <code class="language-plaintext highlighter-rouge">value</code> is a word the compiler reads, not a comment in the documentation.</p>

<h2 id="java-assumed-everything-was-a-car">Java assumed everything was a car</h2>

<p>Here’s the decision Java made: <strong>every object has identity</strong>. No exceptions, since 1995.</p>

<p>It was a design choice, and it was reasonable at the time, because objects in Java were born mutable by default.</p>

<p>The problem is that it became a law of physics for the language. And laws of physics have consequences that fall out by gravity, whether you want them or not.</p>

<p><strong><code class="language-plaintext highlighter-rouge">==</code> compares addresses</strong> because the address <em>is</em> the identity. Two distinct objects have to live in distinct places, otherwise you can’t tell them apart.</p>

<p><strong>Every object carries a header</strong>, and it exists because identity needs somewhere to live. That’s where lock state and the identity hash sit.</p>

<p>You don’t have to take my word for it. <a href="https://github.com/openjdk/jol">JOL</a>, short for Java Object Layout, is an OpenJDK tool that reads the layout the JVM actually chose for an object, field by field, instead of estimating it. I ran it against a <code class="language-plaintext highlighter-rouge">LocalDate</code> on JDK 28:</p>

<p><img src="https://codesilva.com/assets/images/java-object-header-anatomy.png" alt="Anatomy of a LocalDate object in memory, bands drawn to scale. On top, a tall dark band labelled header, 8 bytes, annotated lock state plus identity hash. Below it, three blue bands grouped by a brace labelled your data: y equals 1996 at 4 bytes, m equals 1 at 1 byte, and d equals 23 at 1 byte. Last, a hatched padding band at 2 bytes. At the bottom, the total: 16 bytes." /></p>

<p>Sixteen bytes of object to carry six bytes of date. The header alone is bigger than the data, and there are still two bytes of padding on top.</p>

<p>And that’s already the slim version. On JDK 28 HotSpot enables <em>compact object headers</em> by default, folding the class pointer into the mark word. Run with <code class="language-plaintext highlighter-rouge">-XX:-UseCompactObjectHeaders</code> and the same <code class="language-plaintext highlighter-rouge">LocalDate</code> goes back to 24 bytes. The JVM was already fighting this cost from another angle, and even after shrinking the header it remains the largest piece of the object.</p>

<p>Hold on to that figure, because the header comes back at the end of this post. The lock lives in it, and that’s why <code class="language-plaintext highlighter-rouge">synchronized</code> is about to stop working.</p>

<p><strong>An array of objects is an array of pointers.</strong> If every element needs its own address, the array can’t hold the data. It holds the path to it.</p>

<p>Compare an <code class="language-plaintext highlighter-rouge">int[5]</code> with a <code class="language-plaintext highlighter-rouge">LocalDate[5]</code>, which is the example the JEP itself uses:</p>

<p><img src="https://codesilva.com/assets/images/java-int-array-vs-localdate-array.png" alt="Memory layout comparison. On the left, an int array of five slots: one contiguous block holding the values 1996, 2006, 1996, 1 and 23, marked contiguous. On the right, a LocalDate array of five slots: a block of cells where each one holds an arrow pointing outward, the arrows crossing over to loose scattered objects, each with a dark header band on top and the fields y, m and d below. Marked pointers, scattered." /></p>

<p>The <code class="language-plaintext highlighter-rouge">int</code> array is a single block. The <code class="language-plaintext highlighter-rouge">LocalDate</code> one doesn’t hold dates, it holds pointers, and each object ended up wherever the allocator found room, carrying its own header along. Walking that array is a sequence of memory jumps with a cache miss on each one.</p>

<p>Measured with JOL: 32 bytes against 80. Two and a half times the memory to represent the same thing.</p>

<p>All of it to carry an <code class="language-plaintext highlighter-rouge">int</code> and two <code class="language-plaintext highlighter-rouge">byte</code>s of useful information per date.</p>

<h2 id="the-integer-cache-is-a-workaround-for-not-paying-identity">The Integer cache is a workaround for not paying identity</h2>

<p>Now the surprise from the beginning makes sense.</p>

<p>Identity costs. Every <code class="language-plaintext highlighter-rouge">new</code> is an allocation, a header, an address, and one more object for the GC to visit later. Since boxing <code class="language-plaintext highlighter-rouge">int</code> happens constantly, somebody decided it was worth reusing the most common instances instead of creating a new object every time.</p>

<p>Hence the cache from -128 to 127.</p>

<p>The catch is that reusing an instance means reusing an identity. And identity is observable through <code class="language-plaintext highlighter-rouge">==</code>.</p>

<p>In other words: <strong>the <code class="language-plaintext highlighter-rouge">Integer</code> gotcha is the model showing through.</strong> An allocation optimization leaked into the semantics of the language, and it could only leak because <code class="language-plaintext highlighter-rouge">==</code> talks about identity instead of value.</p>

<p>You weren’t confused. The model was strange. I’ve written before about <a href="/carreira/2025/05/05/perguntaram-me-porque-java-e-dificil.html">why Java feels hard</a> (in Portuguese), and a good part of the answer is exactly this: the language asks you to understand old decisions nobody tells you about.</p>

<h2 id="but-immutable-data-never-needed-it">But immutable data never needed it</h2>

<p><code class="language-plaintext highlighter-rouge">LocalDate</code> is immutable. <code class="language-plaintext highlighter-rouge">Integer</code> is immutable. So are <code class="language-plaintext highlighter-rouge">Optional</code>, <code class="language-plaintext highlighter-rouge">Duration</code>, <code class="language-plaintext highlighter-rouge">BigDecimal</code>, and the <code class="language-plaintext highlighter-rouge">Money</code> class you wrote last week.</p>

<p>None of them is a car. They’re all twenty-dollar bills.</p>

<p>You’ve never, in any code you’ve written, needed to know <em>which</em> instance of January 23rd, 1996 you were holding. The JVM had no way to know that, so it charged identity to everyone, at full price, as a guarantee.</p>

<p>That’s what JEP 401 fixes. It gives you a way out.</p>

<h2 id="jep-401-lets-you-opt-out-of-identity">JEP 401 lets you opt out of identity</h2>

<p>Commit <a href="https://github.com/openjdk/jdk/commit/cc278dbb8a1ca0754d5842708b9029441055d361"><code class="language-plaintext highlighter-rouge">cc278dbb</code></a> implements two JEPs at once, both as preview in JDK 28: <a href="https://openjdk.org/jeps/401">JEP 401 (Value Objects)</a> and <a href="https://openjdk.org/jeps/539">JEP 539 (Strict Field Initialization)</a>. That’s 208,011 lines added, 13,161 removed, 300 files, 64 co-authors and 14 reviewers. It’s the largest Project Valhalla delivery so far.</p>

<p>And the API for it’s one word:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">jshell</span><span class="o">&gt;</span> <span class="n">value</span> <span class="kd">record</span> <span class="nf">Point</span><span class="o">(</span><span class="kt">int</span> <span class="n">x</span><span class="o">,</span> <span class="kt">int</span> <span class="n">y</span><span class="o">)</span> <span class="o">{}</span>
<span class="o">|</span>  <span class="n">created</span> <span class="kd">record</span> <span class="nc">Point</span>

<span class="n">jshell</span><span class="o">&gt;</span> <span class="nc">Point</span> <span class="n">p</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Point</span><span class="o">(</span><span class="mi">17</span><span class="o">,</span> <span class="mi">3</span><span class="o">)</span>
<span class="n">p</span> <span class="o">==&gt;</span> <span class="nc">Point</span><span class="o">[</span><span class="n">x</span><span class="o">=</span><span class="mi">17</span><span class="o">,</span> <span class="n">y</span><span class="o">=</span><span class="mi">3</span><span class="o">]</span>

<span class="n">jshell</span><span class="o">&gt;</span> <span class="nc">Objects</span><span class="o">.</span><span class="na">hasIdentity</span><span class="o">(</span><span class="n">p</span><span class="o">)</span>
<span class="err">$</span><span class="mi">3</span> <span class="o">==&gt;</span> <span class="kc">false</span>

<span class="n">jshell</span><span class="o">&gt;</span> <span class="k">new</span> <span class="nf">Point</span><span class="o">(</span><span class="mi">17</span><span class="o">,</span> <span class="mi">3</span><span class="o">)</span> <span class="o">==</span> <span class="n">p</span>
<span class="err">$</span><span class="mi">4</span> <span class="o">==&gt;</span> <span class="kc">true</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">value</code> modifier gives you three things for free. Fields become <code class="language-plaintext highlighter-rouge">final</code>, the class becomes <code class="language-plaintext highlighter-rouge">final</code>, and <code class="language-plaintext highlighter-rouge">==</code> starts comparing field by field.</p>

<p>The terms look alike and mean different things:</p>

<ul>
  <li><strong>value class</strong> is what you declare, with the modifier</li>
  <li><strong>value object</strong> is an instance of it, the object without identity</li>
  <li><strong>Value Objects</strong> is the name of the feature, and <strong>Project Valhalla</strong> is the umbrella</li>
</ul>

<p>The platform has already migrated 30 classes:</p>

<table>
  <thead>
    <tr>
      <th>Package</th>
      <th>Classes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">java.lang</code></td>
      <td><code class="language-plaintext highlighter-rouge">Integer</code>, <code class="language-plaintext highlighter-rouge">Long</code>, <code class="language-plaintext highlighter-rouge">Float</code>, <code class="language-plaintext highlighter-rouge">Double</code>, <code class="language-plaintext highlighter-rouge">Byte</code>, <code class="language-plaintext highlighter-rouge">Short</code>, <code class="language-plaintext highlighter-rouge">Character</code>, <code class="language-plaintext highlighter-rouge">Boolean</code>, <code class="language-plaintext highlighter-rouge">Number</code>, <code class="language-plaintext highlighter-rouge">Record</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">java.util</code></td>
      <td><code class="language-plaintext highlighter-rouge">Optional</code>, <code class="language-plaintext highlighter-rouge">OptionalInt</code>, <code class="language-plaintext highlighter-rouge">OptionalLong</code>, <code class="language-plaintext highlighter-rouge">OptionalDouble</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">java.time</code></td>
      <td><code class="language-plaintext highlighter-rouge">LocalDate</code>, <code class="language-plaintext highlighter-rouge">LocalTime</code>, <code class="language-plaintext highlighter-rouge">LocalDateTime</code>, <code class="language-plaintext highlighter-rouge">ZonedDateTime</code>, <code class="language-plaintext highlighter-rouge">OffsetTime</code>, <code class="language-plaintext highlighter-rouge">OffsetDateTime</code>, <code class="language-plaintext highlighter-rouge">Duration</code>, <code class="language-plaintext highlighter-rouge">Instant</code>, <code class="language-plaintext highlighter-rouge">Period</code>, <code class="language-plaintext highlighter-rouge">Year</code>, <code class="language-plaintext highlighter-rouge">YearMonth</code>, <code class="language-plaintext highlighter-rouge">MonthDay</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">java.time.chrono</code></td>
      <td><code class="language-plaintext highlighter-rouge">MinguoDate</code>, <code class="language-plaintext highlighter-rouge">HijrahDate</code>, <code class="language-plaintext highlighter-rouge">JapaneseDate</code>, <code class="language-plaintext highlighter-rouge">ThaiBuddhistDate</code></td>
    </tr>
  </tbody>
</table>

<p>Which answers the surprise from the opening. Real output, run on a build with JEP 401 enabled:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Integer 1996 == 1996      : true
d1 == d3                  : true
Objects.hasIdentity(d1)   : false
Objects.hasIdentity("abcd"): true
</code></pre></div></div>

<p>Keep an eye on that last line. It comes back at the end.</p>

<h2 id="writing-your-own">Writing your own</h2>

<p>If your data is already a record, it’s one word:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">value</span> <span class="kd">record</span> <span class="nf">Point</span><span class="o">(</span><span class="kt">int</span> <span class="n">x</span><span class="o">,</span> <span class="kt">int</span> <span class="n">y</span><span class="o">)</span> <span class="o">{}</span>
</code></pre></div></div>

<p>A record is <strong>transparent</strong>: its fields are exactly the constructor components. When you store state one way and expose it another, say money as a <code class="language-plaintext highlighter-rouge">long</code> of cents, you need a plain <code class="language-plaintext highlighter-rouge">value class</code>:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">value</span> <span class="kd">class</span> <span class="nc">EURCurrency</span> <span class="o">{</span>
    <span class="kd">private</span> <span class="kt">long</span> <span class="n">cs</span><span class="o">;</span>  <span class="c1">// implicitly final</span>
    <span class="kd">public</span> <span class="nf">EURCurrency</span><span class="o">(</span><span class="kt">long</span> <span class="n">e</span><span class="o">,</span> <span class="kt">int</span> <span class="n">c</span><span class="o">)</span> <span class="o">{</span> <span class="n">cs</span> <span class="o">=</span> <span class="n">e</span> <span class="o">*</span> <span class="mi">100</span> <span class="o">+</span> <span class="n">c</span><span class="o">;</span> <span class="o">}</span>
    <span class="kd">public</span> <span class="kt">long</span> <span class="nf">euros</span><span class="o">()</span> <span class="o">{</span> <span class="k">return</span> <span class="n">cs</span> <span class="o">/</span> <span class="mi">100</span><span class="o">;</span> <span class="o">}</span>
    <span class="kd">public</span> <span class="kt">int</span> <span class="nf">cents</span><span class="o">()</span> <span class="o">{</span> <span class="k">return</span> <span class="o">(</span><span class="kt">int</span><span class="o">)</span> <span class="n">cs</span> <span class="o">%</span> <span class="mi">100</span><span class="o">;</span> <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">value</code> modifier closes doors, and the compiler is direct about which ones:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>error: cannot assign a value to final variable x
error: cannot inherit from final V
error: The concrete class Base is not allowed to be a super class
       of the value class E either directly or indirectly
</code></pre></div></div>

<p>Fields become <code class="language-plaintext highlighter-rouge">final</code>, the class becomes <code class="language-plaintext highlighter-rouge">final</code>, and inheriting from a class with identity would mean inheriting the identity along with it. You still get hierarchy: you can implement interfaces, and you can extend an <code class="language-plaintext highlighter-rouge">abstract value class</code>, which is how <code class="language-plaintext highlighter-rouge">Integer</code> and <code class="language-plaintext highlighter-rouge">BigInteger</code> now coexist under <code class="language-plaintext highlighter-rouge">Number</code>.</p>

<p>The rule that catches most people is the constructor one. A value object has to be complete before anyone can observe it, so the entire body runs before <code class="language-plaintext highlighter-rouge">super()</code>, and <code class="language-plaintext highlighter-rouge">this</code> doesn’t exist there yet:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">value</span> <span class="kd">class</span> <span class="nc">Name</span> <span class="o">{</span>
    <span class="nc">String</span> <span class="n">name</span><span class="o">;</span>
    <span class="kt">int</span> <span class="n">length</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kt">int</span> <span class="nf">strLength</span><span class="o">()</span> <span class="o">{</span> <span class="k">return</span> <span class="n">name</span><span class="o">.</span><span class="na">length</span><span class="o">();</span> <span class="o">}</span>

    <span class="nc">Name</span><span class="o">(</span><span class="nc">String</span> <span class="n">n</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">name</span> <span class="o">=</span> <span class="n">n</span><span class="o">;</span>
        <span class="n">length</span> <span class="o">=</span> <span class="n">strLength</span><span class="o">();</span>   <span class="c1">// error: reference to strLength() may only</span>
    <span class="o">}</span>                           <span class="c1">// appear after an explicit constructor invocation</span>
<span class="o">}</span>
</code></pre></div></div>

<p>The way out is making the method <code class="language-plaintext highlighter-rouge">static</code>, or calling <code class="language-plaintext highlighter-rouge">super()</code> by hand after setting every field. And with preview enabled this applies to <strong>all</strong> records, value or not, so a record that uses <code class="language-plaintext highlighter-rouge">this</code> in its canonical constructor stops compiling.</p>

<p>As for what to mark, the rule is short: immutable state, and you never need to distinguish two instances holding the same content. If you model with DDD, <strong>your value objects package is the first place to look</strong>. What stays out is anything mutable, anything used as a lock, and anything holding sensitive data, since <code class="language-plaintext highlighter-rouge">==</code> compares private fields.</p>

<h2 id="without-identity-the-jvm-no-longer-needs-to-hand-out-addresses">Without identity, the JVM no longer needs to hand out addresses</h2>

<p>Go back to the list of consequences above and invert each one.</p>

<p>If the object has no identity, it doesn’t need to be distinguishable. If it doesn’t need to be distinguishable, <strong>it doesn’t need its own address</strong>. Which buys the JVM two freedoms.</p>

<p><strong>Flattening</strong> means putting the fields directly inside the array or the field that references the object:</p>

<p><img src="https://codesilva.com/assets/images/java-value-objects-flattening.png" alt="Before and after diagram. On the left, BEFORE: an array whose cells point with arrows to loose scattered objects, each with a dark header band and the fields y, m and d. A large arrow points right. On the right, AFTER: a single contiguous block of five rows, each row holding the values 1, 1996, 01 and 23 written directly inside it, with no arrows and no headers. Marked flattened." /></p>

<p>No pointers, no headers, all contiguous, with the first bit saying whether the reference is <code class="language-plaintext highlighter-rouge">null</code>. The JEP says this array may end up with performance characteristics similar to an <code class="language-plaintext highlighter-rouge">int[]</code>.</p>

<p>That’s what the JEP describes. I wanted to watch it happen.</p>

<p>I allocated a <code class="language-plaintext highlighter-rouge">LocalDate[]</code> of two million slots, all distinct dates, and measured the heap. Same program, same JDK, same machine, changing only <code class="language-plaintext highlighter-rouge">--enable-preview</code>:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>bytes per element</th>
      <th>total</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>no preview, <code class="language-plaintext highlighter-rouge">LocalDate</code> is identity</td>
      <td>28.5</td>
      <td>56.9 MB</td>
    </tr>
    <tr>
      <td>with preview, <code class="language-plaintext highlighter-rouge">LocalDate</code> is value</td>
      <td><strong>8.4</strong></td>
      <td><strong>16.8 MB</strong></td>
    </tr>
  </tbody>
</table>

<p>Eight bytes per element, which is exactly the 64-bit word the JEP predicted.</p>

<p>And you don’t need to trust my heap measurement to accept it, because the arithmetic closes on its own: if each element were still a pointer to a separate object, the objects alone would take two million times 16 bytes, the minimum size of an object on the heap. That’s 32 MB. It doesn’t fit in 16.8.</p>

<p>The objects aren’t there. Only the values.</p>

<p>Now, a caveat worth more than the measurement: <strong>none of this is in the spec.</strong> JEP 401 doesn’t even have a Specification section, and it states outright that flattening and scalarization are “optimizations, not language features”, done at the discretion of the JVM. Guaranteeing memory layout is a declared non-goal.</p>

<p>What the JEP guarantees is semantics. A value object has no identity, <code class="language-plaintext highlighter-rouge">==</code> compares fields, synchronizing throws. That’s the contract.</p>

<p>The contiguous array is permission, not a promise. The JEP removes what was stopping the JVM from flattening, and each implementation decides whether it does. I showed you one that did.</p>

<p><strong>Scalarization</strong> is the next step, inside the JIT. When the object sits in a local variable or a parameter, it gets decomposed into loose values. The compiled <code class="language-plaintext highlighter-rouge">plusYears</code> stops taking a pointer and starts taking <code class="language-plaintext highlighter-rouge">(boolean isNull, int year, byte month, byte day)</code>, returning another tuple like it.</p>

<p>The object simply never exists in memory.</p>

<p>Escape analysis already did something similar for ordinary objects, but a single code path comparing identity makes the optimization evaporate. With a value class the guarantee is static, and it crosses method boundaries.</p>

<h2 id="less-allocation-is-less-gc">Less allocation is less GC</h2>

<p>This is where it shows up in your Grafana.</p>

<p>Every object the JVM doesn’t allocate is an object the GC doesn’t have to mark, sweep or move. A <code class="language-plaintext highlighter-rouge">LocalDate[]</code> of a million slots stops being a million live objects on the heap and becomes a block of memory.</p>

<p>That loop you wrote without thinking, creating a <code class="language-plaintext highlighter-rouge">LocalDate</code> per iteration only to throw it away, stops generating garbage. It isn’t that the GC got faster: there’s nothing left to collect. And contiguous data is still data the CPU fetches with fewer cache misses, which usually matters more than the allocation time itself.</p>

<p>And since it isn’t a promise, there are ways for it not to happen. Three things get in the way in practice:</p>

<ul>
  <li><strong>A mutable field has a 64-bit ceiling</strong>, because reads and writes need to be atomic. A <code class="language-plaintext highlighter-rouge">LocalDateTime</code> doesn’t fit and goes back to being a pointer.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">Object</code> kills flattening.</strong> <code class="language-plaintext highlighter-rouge">Integer[]</code> is flattenable, <code class="language-plaintext highlighter-rouge">Object[]</code> isn’t, and erased generics fall in the same bucket. It changes no semantics, only layout.</li>
  <li><strong>Old code needs recompiling</strong>, because the JVM relies on a new class file attribute to learn in time that a class is a value class.</li>
</ul>

<h2 id="what-you-lose-is-exactly-what-depended-on-identity">What you lose is exactly what depended on identity</h2>

<p>And there’s a logic to the price: the same design decision charging you on the way out what it charged on the way in. Everything that breaks is something that needed to distinguish instances. I ran each one to get the real message:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>synchronized via Object : java.lang.IdentityException:
                          Cannot synchronize on an instance of value class java.time.LocalDate
d.notify()              : java.lang.IllegalMonitorStateException: java.time.LocalDate
new WeakReference&lt;&gt;(d)  : java.lang.IdentityException:
                          java.time.LocalDate is not an identity class
weakHashMap.put(d, "x") : java.lang.IdentityException:
                          java.time.LocalDate is not an identity class
</code></pre></div></div>

<p>The lock lives in the header, in that mark word from earlier, so <code class="language-plaintext highlighter-rouge">synchronized</code> stops working. All of <em>Java Concurrency in Practice</em> assumes any object can serve as a lock, and now it can’t. <code class="language-plaintext highlighter-rouge">wait</code> and <code class="language-plaintext highlighter-rouge">notify</code> fall with it, since they depend on that same lock, and so do <code class="language-plaintext highlighter-rouge">WeakHashMap</code> and all of <code class="language-plaintext highlighter-rouge">java.lang.ref</code>, because a weak reference needs to point at one specific instance.</p>

<p>Beyond that, some things keep working but not the way you expect.</p>

<p><code class="language-plaintext highlighter-rouge">==</code> now compares internal fields, so it can diverge from your <code class="language-plaintext highlighter-rouge">equals</code>, which might look at something else. It also became an operation with a cost, because the comparison is recursive and a deep tree of value objects can hit <code class="language-plaintext highlighter-rouge">StackOverflowError</code>. And since it reads private fields, it became an inference channel. The JEP says it plainly: value objects weren’t designed to protect sensitive data.</p>

<p>Even <code class="language-plaintext highlighter-rouge">==</code> on identity objects got marginally more expensive, because the <code class="language-plaintext highlighter-rouge">if_acmpeq</code> bytecode now needs an extra test to detect value objects. The identity path became a fast path, but it exists, and it’s charged to code that uses none of this.</p>

<h2 id="the-missing-piece-jep-539">The missing piece: JEP 539</h2>

<p>There was still a hole. A value object promises its value never changes, but in Java a field can be read <strong>before</strong> it’s initialized, holding <code class="language-plaintext highlighter-rouge">0</code> or <code class="language-plaintext highlighter-rouge">null</code>.</p>

<p>The JEP’s example is a circular dependency:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nc">App</span> <span class="o">{</span>
    <span class="kd">public</span> <span class="kd">static</span> <span class="kd">final</span> <span class="kt">long</span> <span class="n">appID</span> <span class="o">=</span> <span class="nc">Log</span><span class="o">.</span><span class="na">currentPID</span><span class="o">();</span>
    <span class="kd">public</span> <span class="kd">static</span> <span class="kt">void</span> <span class="nf">main</span><span class="o">()</span> <span class="o">{</span>
        <span class="no">IO</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="s">"App["</span> <span class="o">+</span> <span class="n">appID</span> <span class="o">+</span> <span class="s">"] has started"</span><span class="o">);</span>
        <span class="nc">Log</span><span class="o">.</span><span class="na">log</span><span class="o">(</span><span class="s">"Completed 'main'"</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>

<span class="kd">class</span> <span class="nc">Log</span> <span class="o">{</span>
    <span class="kd">private</span> <span class="kd">static</span> <span class="kd">final</span> <span class="nc">String</span> <span class="n">prefix</span> <span class="o">=</span> <span class="s">"App["</span> <span class="o">+</span> <span class="nc">App</span><span class="o">.</span><span class="na">appID</span> <span class="o">+</span> <span class="s">"]: "</span><span class="o">;</span>
    <span class="kd">public</span> <span class="kd">static</span> <span class="kt">void</span> <span class="nf">log</span><span class="o">(</span><span class="nc">String</span> <span class="n">msg</span><span class="o">)</span> <span class="o">{</span> <span class="no">IO</span><span class="o">.</span><span class="na">println</span><span class="o">(</span><span class="n">prefix</span> <span class="o">+</span> <span class="n">msg</span><span class="o">);</span> <span class="o">}</span>
    <span class="kd">public</span> <span class="kd">static</span> <span class="kt">long</span> <span class="nf">currentPID</span><span class="o">()</span> <span class="o">{</span> <span class="k">return</span> <span class="nc">ProcessHandle</span><span class="o">.</span><span class="na">current</span><span class="o">().</span><span class="na">pid</span><span class="o">();</span> <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>Running it prints:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>App[8145] has started
App[0]: Completed 'main'
</code></pre></div></div>

<p>Two reads of the same <code class="language-plaintext highlighter-rouge">final</code> field, two different values. <code class="language-plaintext highlighter-rouge">Log</code> gets initialized in the middle of initializing <code class="language-plaintext highlighter-rouge">App</code>, reads <code class="language-plaintext highlighter-rouge">appID</code> holding the default <code class="language-plaintext highlighter-rouge">0</code>, and bakes that zero into <code class="language-plaintext highlighter-rouge">prefix</code>. And here’s the nasty part: if <code class="language-plaintext highlighter-rouge">Log</code> were initialized first, the bug would disappear. It’s the kind of bug that vanishes when you go looking for it.</p>

<p>A <code class="language-plaintext highlighter-rouge">final</code> field that yields two different values destroys the entire premise of a value object. That’s why JEP 539 introduces the <code class="language-plaintext highlighter-rouge">ACC_STRICT_INIT</code> flag: a field marked with it has no default value and must be written before any read. <code class="language-plaintext highlighter-rouge">javac</code> marks <strong>every</strong> field of a value class with it, which is why both JEPs landed in the same commit.</p>

<p>If you go run that example, don’t expect it to fix itself: I turned on <code class="language-plaintext highlighter-rouge">--enable-preview</code> and <code class="language-plaintext highlighter-rouge">App[0]</code> is still there. Imposing strict initialization on existing code is a declared non-goal of JEP 539, so only value class fields get the flag.</p>

<h2 id="trying-it-today">Trying it today</h2>

<p>There’s a logistics trap here, and I only found it because I went and ran things.</p>

<p>The obvious path is grabbing the JDK 28 early access from <a href="https://jdk.java.net/28/">jdk.java.net/28</a>. <strong>It doesn’t work yet.</strong> Build 9 shipped on July 31st, 2026, the same day as the integration, and it was cut before that landed: <code class="language-plaintext highlighter-rouge">value record</code> is a syntax error, <code class="language-plaintext highlighter-rouge">Objects.hasIdentity</code> doesn’t exist, and <code class="language-plaintext highlighter-rouge">Integer 1996 == 1996</code> is still <code class="language-plaintext highlighter-rouge">false</code>.</p>

<p>What runs today is Valhalla’s own early access, at <a href="https://jdk.java.net/valhalla/">jdk.java.net/valhalla</a>. Build <code class="language-plaintext highlighter-rouge">27-jep401ea3+1-1</code> implements JEP 401, and it’s where I ran everything in this post that produces output.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>javac <span class="nt">--release</span> 27 <span class="nt">--enable-preview</span> Demo.java
java <span class="nt">--enable-preview</span> Demo
</code></pre></div></div>

<p>Preview has to be on at both ends, and you can’t pick the identity version of <code class="language-plaintext highlighter-rouge">LocalDate</code> in that mode: it’s all or nothing.</p>

<h2 id="what-didnt-change">What didn’t change</h2>

<p>Two things got left behind, and both are funny.</p>

<p><strong>The <code class="language-plaintext highlighter-rouge">Integer</code> cache still exists.</strong> The <a href="https://github.com/openjdk/jdk/blob/cc278dbb8a1ca0754d5842708b9029441055d361/doc/value-class-preview.md"><code class="language-plaintext highlighter-rouge">doc/value-class-preview.md</code></a> that shipped with the commit says it was kept on purpose, for performance, and that it now has no semantic impact whatsoever. The workaround that created the gotcha keeps running under the hood. You just can’t observe it anymore.</p>

<p><strong>And <code class="language-plaintext highlighter-rouge">String</code> didn’t migrate.</strong> The class has identity dependencies in its API and its implementation, so <code class="language-plaintext highlighter-rouge">Objects.hasIdentity("abcd")</code> still returns <code class="language-plaintext highlighter-rouge">true</code>. Java’s most famous gotcha, <code class="language-plaintext highlighter-rouge">==</code> on <code class="language-plaintext highlighter-rouge">String</code>, is still standing.</p>

<p>Past that, the rest is foundation. JEP 402 will improve primitive boxing on top of this, and JEP 218 will let generics specialize layout when parameterized with a value class, which is <code class="language-plaintext highlighter-rouge">List&lt;int&gt;</code> without boxing.</p>

<p>But the big change already happened, and it’s conceptual before it’s technical.</p>

<p><code class="language-plaintext highlighter-rouge">==</code> stopped asking “do you two live at the same address?” and started asking “can you two be told apart?”. For a car, the answer is still the address. For a twenty-dollar bill, it’s now the value.</p>

<p>That surprise you had at the beginning never had “use <code class="language-plaintext highlighter-rouge">equals</code>” as its answer, let alone “use a primitive”. For <code class="language-plaintext highlighter-rouge">LocalDate</code> there was never a primitive to use.</p>

<p>The answer was that the date never needed identity, and you had been paying for it all along.</p>

<p>Thanks for reading!</p>

<hr />

<h2 id="references">References</h2>

<ul>
  <li><a href="https://openjdk.org/jeps/401">JEP 401: Value Objects</a> and <a href="https://openjdk.org/jeps/539">JEP 539: Strict Field Initialization</a>, both Integrated in JDK 28</li>
  <li><a href="https://github.com/openjdk/jdk/commit/cc278dbb8a1ca0754d5842708b9029441055d361">Commit <code class="language-plaintext highlighter-rouge">cc278dbb</code></a>, July 31st, 2026, and the <a href="https://github.com/openjdk/jdk/blob/cc278dbb8a1ca0754d5842708b9029441055d361/doc/value-class-preview.md"><code class="language-plaintext highlighter-rouge">doc/value-class-preview.md</code></a> that shipped with it</li>
  <li><a href="https://jdk.java.net/valhalla/">Valhalla early access</a>, build <code class="language-plaintext highlighter-rouge">27-jep401ea3+1-1</code>, used for the measurements in this post</li>
  <li><a href="https://stackoverflow.com/questions/1700081/why-is-128-128-false-but-127-127-is-true-when-comparing-integer-wrappers-in-ja">The original Stack Overflow question</a>, from 2009</li>
</ul>]]></content><author><name>CodeSilva</name></author><category term="java" /><category term="jvm" /><category term="value objects" /><category term="valhalla" /><category term="performance" /><category term="low-level" /><summary type="html"><![CDATA[At some point in your Java career you wrote this and did a double take:]]></summary></entry><entry xml:lang="en-US"><title type="html">OpenTelemetry Is the Langfuse SDK for Go</title><link href="https://codesilva.com/ai/2026/07/30/opentelemetry-is-the-langfuse-sdk-for-go.html" rel="alternate" type="text/html" title="OpenTelemetry Is the Langfuse SDK for Go" /><published>2026-07-30T00:00:00+00:00</published><updated>2026-07-30T00:00:00+00:00</updated><id>https://codesilva.com/ai/2026/07/30/opentelemetry-is-the-langfuse-sdk-for-go</id><content type="html" xml:base="https://codesilva.com/ai/2026/07/30/opentelemetry-is-the-langfuse-sdk-for-go.html"><![CDATA[<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>EVAL PASSED - gate model "qwen2.5:3b": acc 95.1%, answer 95.7%
</code></pre></div></div>

<p>I stared at that line for a week feeling pretty good about myself. Then I changed a prompt, ran it again, got <code class="language-plaintext highlighter-rouge">94.6%</code>, and asked the obvious question: is that worse than yesterday, or is that noise?</p>

<p>I scrolled up to check. My terminal had eaten yesterday.</p>

<p>That’s it. That’s the entire reason I put Langfuse in front of a Go agent. Not because I wanted a dashboard, but because <strong>a number you can’t compare to last week is not a measurement, it’s a vibe.</strong></p>

<p>The agent is Ava, a research PoC in Go: a voice agent that runs opinion polls out loud and knows when to hang up. She exists because a team here had a voice agent that didn’t know when a conversation was over, so I built <a href="/ia/2026/07/24/claude-code-the-unreasonable-effectiveness-of-simplicity">the dumbest PoC that worked instead of writing a spec</a>. One afternoon, no document.</p>

<p>This post is what happened after that afternoon, once “it works” stopped being enough and I wanted to know whether it <em>kept</em> working.</p>

<p>And Go is where the first small wall shows up, because Langfuse ships SDKs for Python and JS and nothing for us. Turns out that’s fine. What follows is the whole path: what an eval even is, the crude one I wrote first, and how it got to Langfuse with nothing but the standard OpenTelemetry SDK and <code class="language-plaintext highlighter-rouge">net/http</code>.</p>

<h2 id="first-how-do-you-know-an-llm-app-is-any-good">First, how do you know an LLM app is any good?</h2>

<p>If you write a function that adds two numbers, the test is obvious:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">Add</span><span class="p">(</span><span class="m">2</span><span class="p">,</span> <span class="m">2</span><span class="p">)</span> <span class="o">!=</span> <span class="m">4</span> <span class="p">{</span>
    <span class="n">t</span><span class="o">.</span><span class="n">Fatal</span><span class="p">(</span><span class="s">"math is broken"</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Now write that test for a model. Same input, run it twice, get two different sentences. Both correct. <code class="language-plaintext highlighter-rouge">!=</code> is useless to you.</p>

<p>This is the part that trips people up coming from normal software: <strong>the model is not the unit under test, the behavior is.</strong> You are not asserting that the output is a specific string. You are asserting that the output has a property you care about. So the whole job becomes: pick the property, and find a way to score it.</p>

<p>Chip Huyen catalogs the ways to do that in <em>AI Engineering</em>. I ended up using three of them, and I’d learn those three before touching any platform.</p>

<h3 id="1-functional-correctness">1. Functional correctness</h3>

<p>Did the system do the thing? Not “does the text look nice” - did it <em>work</em>.</p>

<p>This is the strongest kind of eval and always the one to reach for first, because there’s no interpretation involved. If you ask a model to write <code class="language-plaintext highlighter-rouge">gcd(a, b)</code>, you don’t grade the code, you run it and check that <code class="language-plaintext highlighter-rouge">gcd(15, 20)</code> returns 5. It’s how LeetCode grades you and how HumanEval grades models.</p>

<p>Ava’s version: on every reply, a classifier decides what the conversation does next - advance, re-read the question, ask for clarification, or hang up. That decision has a right answer, so the eval is a comparison:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">turn</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">classifier</span><span class="o">.</span><span class="n">ClassifyTurn</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">c</span><span class="o">.</span><span class="n">q</span><span class="p">,</span> <span class="n">c</span><span class="o">.</span><span class="n">reply</span><span class="p">)</span>
<span class="k">if</span> <span class="n">turn</span><span class="o">.</span><span class="n">Intent</span> <span class="o">==</span> <span class="n">c</span><span class="o">.</span><span class="n">want</span> <span class="p">{</span>
    <span class="n">report</span><span class="o">.</span><span class="n">correct</span><span class="o">++</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Boring. <code class="language-plaintext highlighter-rouge">==</code> on a label. <strong>That is a feature.</strong> The classifier’s output is constrained enough to be checked exactly, and everything downstream of that decision is a state machine I can test like any other Go code. When you can push a fuzzy thing into a small set of labels, do it - you get your <code class="language-plaintext highlighter-rouge">==</code> back.</p>

<h3 id="2-similarity-against-reference-data">2. Similarity against reference data</h3>

<p>Some things have no single right answer. Ava’s sign-off is a personalized callback to what the respondent said. There are a thousand good ones.</p>

<p>Here you compare the output against reference data: a labeled corpus of <code class="language-plaintext highlighter-rouge">(input, expected)</code> pairs, where <code class="language-plaintext highlighter-rouge">expected</code> is a <em>reference answer</em> rather than a label. And “compare” splits in two.</p>

<p><strong>Lexical similarity</strong> works on the words themselves. Overlap, edit distance, BLEU, ROUGE. Cheap, deterministic, no model in the loop, and it has no idea that <em>“pricey”</em> and <em>“expensive”</em> mean the same thing.</p>

<p><strong>Semantic similarity</strong> works on meaning. You embed both texts into vectors and measure the angle between them, so paraphrase scores high. Costs an embedding call per comparison, and returns a float you now have to pick a threshold for.</p>

<p>In Go both are the same shape of function:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// Lexical: words in common, no model involved.</span>
<span class="k">func</span> <span class="n">WordOverlap</span><span class="p">(</span><span class="n">got</span><span class="p">,</span> <span class="n">reference</span> <span class="kt">string</span><span class="p">)</span> <span class="kt">float64</span>

<span class="c">// Semantic: embed both, measure the angle between the vectors.</span>
<span class="k">func</span> <span class="n">CosineSimilarity</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">e</span> <span class="n">Embedder</span><span class="p">,</span> <span class="n">got</span><span class="p">,</span> <span class="n">reference</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="kt">float64</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</code></pre></div></div>

<p>Then you score a case with whichever one fits, against a threshold you own:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">score</span> <span class="o">:=</span> <span class="n">WordOverlap</span><span class="p">(</span><span class="n">got</span><span class="p">,</span> <span class="n">c</span><span class="o">.</span><span class="n">reference</span><span class="p">)</span>
<span class="k">if</span> <span class="n">score</span> <span class="o">&lt;</span> <span class="m">0.6</span> <span class="p">{</span>
    <span class="n">report</span><span class="o">.</span><span class="n">miss</span><span class="p">(</span><span class="n">c</span><span class="p">,</span> <span class="n">score</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>I went lexical, and not because I benchmarked anything. I only needed one property: <strong>did the agent talk about things the respondent actually said?</strong> That question is about which words showed up, so meaning was never the axis.</p>

<p>A dozen lines of <code class="language-plaintext highlighter-rouge">strings.Fields</code> and a <code class="language-plaintext highlighter-rouge">map[string]bool</code> answered it. <code class="language-plaintext highlighter-rouge">UnsupportedWords(line, answers)</code> gives me the words in the sign-off that appear in neither the respondent’s answers nor the agent’s own register. A cheap groundedness check: it doesn’t ask whether the sentence is right, only whether anything in it was invented.</p>

<p>It caught a real one. I added a nice example closing line to the prompt, and the 3B model copied it verbatim into all 13 cases, cheerfully telling a respondent who had only said <em>“Vanilla.”</em> all about lavender. Every exact-match score still read 100%, because none of them were checking whether the words were <strong>earned</strong>.</p>

<p>There’s a tax, and it’s specifically the lexical tax: a legitimate paraphrase (“scent” for “smell”) lands in that list as a violation. Semantic scoring would forgive it, at the price of an embedding call and a threshold I’d have to defend. Either way I report the number and never gate on it. It tells me where to go look and nothing more.</p>

<h3 id="3-ai-as-a-judge">3. AI as a judge</h3>

<p>And then there’s the stuff no string comparison will ever reach. Ava says a short acknowledgment before the next question so she doesn’t sound like a form. Is <em>“Lavender, nice one”</em> a good ack? You know instantly. Your code has no idea.</p>

<p>So you ask a model, constrain it to <code class="language-plaintext highlighter-rouge">{"good": bool, "reason": string}</code>, and parse the JSON. In Go that’s an API call and a <code class="language-plaintext highlighter-rouge">json.Unmarshal</code>, and there’s nothing clever about it.</p>

<p>Two rules I’d hand anyone doing this. <strong>Pin one judge model</strong> for every model you evaluate, or your scores stop being comparable to each other. And <strong>never let the judge fail your build</strong> - it’s a paid, non-deterministic dependency, and a judge outage should not turn CI red.</p>

<p>Cost and reach go up as you move down that list. Trust goes the other way. So gate on the first kind, and merely watch the other two.</p>

<h2 id="the-rustic-eval-a-slice-a-loop-and-an-exit-code">The rustic eval: a slice, a loop, and an exit code</h2>

<p>Here’s the part I wish someone had said to me earlier: <strong>an eval is not a platform.</strong> It’s three things.</p>

<p>A dataset:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">evalCase</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">q</span>       <span class="kt">string</span>
    <span class="n">reply</span>   <span class="kt">string</span>
    <span class="n">want</span>    <span class="n">llm</span><span class="o">.</span><span class="n">Intent</span>
    <span class="n">clarity</span> <span class="n">llm</span><span class="o">.</span><span class="n">Clarity</span>
<span class="p">}</span>

<span class="k">var</span> <span class="n">dataset</span> <span class="o">=</span> <span class="p">[]</span><span class="n">evalCase</span><span class="p">{</span>
    <span class="p">{</span><span class="s">"What's your favorite scent?"</span><span class="p">,</span> <span class="s">"Vanilla, definitely."</span><span class="p">,</span> <span class="n">llm</span><span class="o">.</span><span class="n">IntentAnswer</span><span class="p">,</span> <span class="n">clear</span><span class="p">},</span>
    <span class="p">{</span><span class="s">"What could we do better?"</span><span class="p">,</span> <span class="s">"Nothing that comes to my mind actually."</span><span class="p">,</span> <span class="n">llm</span><span class="o">.</span><span class="n">IntentAnswer</span><span class="p">,</span> <span class="n">clear</span><span class="p">},</span>
    <span class="p">{</span><span class="s">"How do you like it?"</span><span class="p">,</span> <span class="s">"(coughing)"</span><span class="p">,</span> <span class="n">llm</span><span class="o">.</span><span class="n">IntentUnintellig</span><span class="p">,</span> <span class="n">na</span><span class="p">},</span>
    <span class="c">// ~80 of these, hand-labeled</span>
<span class="p">}</span>
</code></pre></div></div>

<p>A scorer - the loop above, in a worker pool. And a threshold:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">minAcc</span> <span class="o">:=</span> <span class="n">flag</span><span class="o">.</span><span class="n">Float64</span><span class="p">(</span><span class="s">"min-acc"</span><span class="p">,</span> <span class="m">0.90</span><span class="p">,</span> <span class="s">"minimum overall intent accuracy to pass"</span><span class="p">)</span>
<span class="n">minAns</span> <span class="o">:=</span> <span class="n">flag</span><span class="o">.</span><span class="n">Float64</span><span class="p">(</span><span class="s">"min-answer"</span><span class="p">,</span> <span class="m">0.95</span><span class="p">,</span> <span class="s">"minimum valid-answer acceptance to pass"</span><span class="p">)</span>

<span class="c">// ...</span>

<span class="k">if</span> <span class="n">gate</span><span class="o">.</span><span class="n">acc</span><span class="p">()</span> <span class="o">&gt;=</span> <span class="o">*</span><span class="n">minAcc</span> <span class="o">&amp;&amp;</span> <span class="n">gate</span><span class="o">.</span><span class="n">ansRate</span><span class="p">()</span> <span class="o">&gt;=</span> <span class="o">*</span><span class="n">minAns</span> <span class="p">{</span>
    <span class="n">fmt</span><span class="o">.</span><span class="n">Printf</span><span class="p">(</span><span class="s">"</span><span class="se">\n</span><span class="s">EVAL PASSED - gate model %q: acc %.1f%%, answer %.1f%%</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="o">...</span><span class="p">)</span>
    <span class="k">return</span>
<span class="p">}</span>
<span class="n">os</span><span class="o">.</span><span class="n">Exit</span><span class="p">(</span><span class="m">1</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">go run ./cmd/eval</code>, and a non-zero exit code when the agent’s behavior regresses. That’s a real eval. Zero dependencies, and it caught actual bugs.</p>

<blockquote>
  <p><strong>NOTA:</strong> the two thresholds are deliberately different. Misreading an answer as something else loses a real answer, so it’s gated hard. A flat acknowledgment is cosmetic, so it’s reported and never blocks. Gate on what loses data.</p>
</blockquote>

<p>I’m not showing you the crude version because it’s charming. I’m showing it because everything Langfuse gave me afterwards is a <em>view</em> over exactly these three pieces. <strong>The scorer is disposable. The dataset is the asset.</strong></p>

<p>Once you see that, the platform reads as storage rather than magic. Which is all I ever wanted from it, since my actual problem was that yesterday’s run no longer existed.</p>

<h2 id="two-doors-into-langfuse-and-neither-one-is-an-sdk">Two doors into Langfuse, and neither one is an SDK</h2>

<p>Langfuse has no Go SDK. What it has is two HTTP surfaces, and between them they cover everything:</p>

<ol>
  <li><strong>An OTLP endpoint</strong> at <code class="language-plaintext highlighter-rouge">/api/public/otel/v1/traces</code>. This is the officially supported path for any language without an SDK - their <a href="https://langfuse.com/integrations/native/opentelemetry">OpenTelemetry docs</a> say it outright: <em>“For other languages, use the native OpenTelemetry API for your language and export spans to Langfuse.”</em> You point the standard OpenTelemetry Go SDK at it. This carries traces.</li>
  <li><strong>A REST API</strong> at <code class="language-plaintext highlighter-rouge">/api/public/*</code> for the things OpenTelemetry has no concept of: datasets, experiment runs, and scores. Plain <code class="language-plaintext highlighter-rouge">net/http</code>. The <a href="https://langfuse.com/docs/api-and-data-platform/features/public-api">Public API docs</a> cover auth and conventions; the <a href="https://api.reference.langfuse.com/">full API reference</a> is the page you’ll actually keep open.</li>
</ol>

<p>That’s the whole architecture. <code class="language-plaintext highlighter-rouge">internal/obs</code> in my project is two files, one per door.</p>

<h3 id="door-1-traces-over-otlp">Door 1: traces over OTLP</h3>

<p>The entire configuration is one exporter:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">Init</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">)</span> <span class="p">(</span><span class="n">shutdown</span> <span class="k">func</span><span class="p">(</span><span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">)</span> <span class="kt">error</span><span class="p">,</span> <span class="n">enabled</span> <span class="kt">bool</span><span class="p">,</span> <span class="n">err</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">noop</span> <span class="o">:=</span> <span class="k">func</span><span class="p">(</span><span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span> <span class="k">return</span> <span class="no">nil</span> <span class="p">}</span>
    <span class="n">pk</span> <span class="o">:=</span> <span class="n">strings</span><span class="o">.</span><span class="n">TrimSpace</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">Getenv</span><span class="p">(</span><span class="s">"LANGFUSE_PUBLIC_KEY"</span><span class="p">))</span>
    <span class="n">sk</span> <span class="o">:=</span> <span class="n">strings</span><span class="o">.</span><span class="n">TrimSpace</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">Getenv</span><span class="p">(</span><span class="s">"LANGFUSE_SECRET_KEY"</span><span class="p">))</span>
    <span class="k">if</span> <span class="n">pk</span> <span class="o">==</span> <span class="s">""</span> <span class="o">||</span> <span class="n">sk</span> <span class="o">==</span> <span class="s">""</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">noop</span><span class="p">,</span> <span class="no">false</span><span class="p">,</span> <span class="no">nil</span> <span class="c">// no creds: tracing off, everything is a no-op</span>
    <span class="p">}</span>

    <span class="n">auth</span> <span class="o">:=</span> <span class="n">base64</span><span class="o">.</span><span class="n">StdEncoding</span><span class="o">.</span><span class="n">EncodeToString</span><span class="p">([]</span><span class="kt">byte</span><span class="p">(</span><span class="n">pk</span> <span class="o">+</span> <span class="s">":"</span> <span class="o">+</span> <span class="n">sk</span><span class="p">))</span>
    <span class="n">exp</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">otlptracehttp</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span>
        <span class="n">otlptracehttp</span><span class="o">.</span><span class="n">WithEndpointURL</span><span class="p">(</span><span class="n">Host</span><span class="p">()</span><span class="o">+</span><span class="s">"/api/public/otel/v1/traces"</span><span class="p">),</span>
        <span class="n">otlptracehttp</span><span class="o">.</span><span class="n">WithHeaders</span><span class="p">(</span><span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">string</span><span class="p">{</span>
            <span class="s">"Authorization"</span><span class="o">:</span>                <span class="s">"Basic "</span> <span class="o">+</span> <span class="n">auth</span><span class="p">,</span>
            <span class="s">"x-langfuse-ingestion-version"</span><span class="o">:</span> <span class="s">"4"</span><span class="p">,</span>
        <span class="p">}),</span>
    <span class="p">)</span>
    <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">noop</span><span class="p">,</span> <span class="no">false</span><span class="p">,</span> <span class="n">err</span>
    <span class="p">}</span>

    <span class="n">res</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">sdkresource</span><span class="o">.</span><span class="n">Merge</span><span class="p">(</span><span class="n">sdkresource</span><span class="o">.</span><span class="n">Default</span><span class="p">(),</span>
        <span class="n">sdkresource</span><span class="o">.</span><span class="n">NewSchemaless</span><span class="p">(</span><span class="n">semconv</span><span class="o">.</span><span class="n">ServiceName</span><span class="p">(</span><span class="s">"voicesurvey"</span><span class="p">)))</span>
    <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">noop</span><span class="p">,</span> <span class="no">false</span><span class="p">,</span> <span class="n">err</span>
    <span class="p">}</span>
    <span class="n">tp</span> <span class="o">:=</span> <span class="n">sdktrace</span><span class="o">.</span><span class="n">NewTracerProvider</span><span class="p">(</span><span class="n">sdktrace</span><span class="o">.</span><span class="n">WithBatcher</span><span class="p">(</span><span class="n">exp</span><span class="p">),</span> <span class="n">sdktrace</span><span class="o">.</span><span class="n">WithResource</span><span class="p">(</span><span class="n">res</span><span class="p">))</span>
    <span class="n">otel</span><span class="o">.</span><span class="n">SetTracerProvider</span><span class="p">(</span><span class="n">tp</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">tp</span><span class="o">.</span><span class="n">Shutdown</span><span class="p">,</span> <span class="no">true</span><span class="p">,</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Three things in there are worth more than the rest.</p>

<p><strong>Auth is Basic auth over your key pair.</strong> Public key as user, secret key as password, base64, done. No token dance.</p>

<p><strong><code class="language-plaintext highlighter-rouge">x-langfuse-ingestion-version: 4</code></strong> is the current ingestion contract, and their docs are blunt about it: include it <em>“so that new data appears in real time.”</em> Send it. Without it the endpoint falls back to older mapping behavior and your spans land looking subtly wrong.</p>

<p><strong><code class="language-plaintext highlighter-rouge">NewSchemaless</code>, not <code class="language-plaintext highlighter-rouge">NewWithAttributes</code>.</strong> This one cost me an afternoon. If you pin your own semconv schema URL on the resource, it conflicts with the one the SDK’s default resource already carries, and <code class="language-plaintext highlighter-rouge">Merge</code> fails your whole init on a schema mismatch. Schemaless attributes merge cleanly and survive SDK upgrades.</p>

<p>And notice what happens with no credentials: the global tracer stays a noop, so every instrumented call in the codebase costs nothing. The PoC still runs fully offline, which is not a small thing when your gate model is local.</p>

<h3 id="instrumenting-wrap-dont-edit">Instrumenting: wrap, don’t edit</h3>

<p>Now, what gets traced? I did not sprinkle spans through the agent. Every LLM caller in the project is an interface, so tracing is a decorator:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">TraceClassifier</span><span class="p">(</span><span class="n">inner</span> <span class="n">llm</span><span class="o">.</span><span class="n">Classifier</span><span class="p">,</span> <span class="n">model</span> <span class="kt">string</span><span class="p">)</span> <span class="n">llm</span><span class="o">.</span><span class="n">Classifier</span> <span class="p">{</span>
    <span class="k">return</span> <span class="o">&amp;</span><span class="n">tracedClassifier</span><span class="p">{</span><span class="n">inner</span><span class="o">:</span> <span class="n">inner</span><span class="p">,</span> <span class="n">model</span><span class="o">:</span> <span class="n">model</span><span class="p">}</span>
<span class="p">}</span>

<span class="k">func</span> <span class="p">(</span><span class="n">t</span> <span class="o">*</span><span class="n">tracedClassifier</span><span class="p">)</span> <span class="n">ClassifyTurn</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">question</span><span class="p">,</span> <span class="n">reply</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="n">llm</span><span class="o">.</span><span class="n">Turn</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">ctx</span><span class="p">,</span> <span class="n">span</span> <span class="o">:=</span> <span class="n">otel</span><span class="o">.</span><span class="n">Tracer</span><span class="p">(</span><span class="s">"voicesurvey"</span><span class="p">)</span><span class="o">.</span><span class="n">Start</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="s">"classify_turn"</span><span class="p">)</span>
    <span class="k">defer</span> <span class="n">span</span><span class="o">.</span><span class="n">End</span><span class="p">()</span>

    <span class="n">input</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">json</span><span class="o">.</span><span class="n">Marshal</span><span class="p">(</span><span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">string</span><span class="p">{</span><span class="s">"question"</span><span class="o">:</span> <span class="n">question</span><span class="p">,</span> <span class="s">"reply"</span><span class="o">:</span> <span class="n">reply</span><span class="p">})</span>
    <span class="n">span</span><span class="o">.</span><span class="n">SetAttributes</span><span class="p">(</span>
        <span class="n">attribute</span><span class="o">.</span><span class="n">String</span><span class="p">(</span><span class="s">"gen_ai.request.model"</span><span class="p">,</span> <span class="n">t</span><span class="o">.</span><span class="n">model</span><span class="p">),</span>
        <span class="n">attribute</span><span class="o">.</span><span class="n">String</span><span class="p">(</span><span class="s">"langfuse.observation.type"</span><span class="p">,</span> <span class="s">"generation"</span><span class="p">),</span>
        <span class="n">attribute</span><span class="o">.</span><span class="n">String</span><span class="p">(</span><span class="s">"langfuse.observation.input"</span><span class="p">,</span> <span class="kt">string</span><span class="p">(</span><span class="n">input</span><span class="p">)),</span>
        <span class="n">attribute</span><span class="o">.</span><span class="n">String</span><span class="p">(</span><span class="s">"langfuse.trace.name"</span><span class="p">,</span> <span class="s">"classify_turn"</span><span class="p">),</span>
        <span class="n">attribute</span><span class="o">.</span><span class="n">String</span><span class="p">(</span><span class="s">"langfuse.trace.metadata.prompt_version"</span><span class="p">,</span> <span class="n">llm</span><span class="o">.</span><span class="n">ClassifyPromptVersion</span><span class="p">()),</span>
    <span class="p">)</span>

    <span class="n">turn</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">t</span><span class="o">.</span><span class="n">inner</span><span class="o">.</span><span class="n">ClassifyTurn</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">question</span><span class="p">,</span> <span class="n">reply</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="n">span</span><span class="o">.</span><span class="n">RecordError</span><span class="p">(</span><span class="n">err</span><span class="p">)</span>
        <span class="n">span</span><span class="o">.</span><span class="n">SetStatus</span><span class="p">(</span><span class="n">codes</span><span class="o">.</span><span class="n">Error</span><span class="p">,</span> <span class="n">err</span><span class="o">.</span><span class="n">Error</span><span class="p">())</span>
        <span class="k">return</span> <span class="n">turn</span><span class="p">,</span> <span class="n">err</span>
    <span class="p">}</span>
    <span class="n">output</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">json</span><span class="o">.</span><span class="n">Marshal</span><span class="p">(</span><span class="n">turn</span><span class="p">)</span>
    <span class="n">span</span><span class="o">.</span><span class="n">SetAttributes</span><span class="p">(</span><span class="n">attribute</span><span class="o">.</span><span class="n">String</span><span class="p">(</span><span class="s">"langfuse.observation.output"</span><span class="p">,</span> <span class="kt">string</span><span class="p">(</span><span class="n">output</span><span class="p">)))</span>
    <span class="k">return</span> <span class="n">turn</span><span class="p">,</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>One line at the call site turns it on: <code class="language-plaintext highlighter-rouge">cl = obs.TraceClassifier(cl, name)</code>. The wrapper is a pure pass-through, and it never touches the <code class="language-plaintext highlighter-rouge">Turn</code> or the error. A wrapper that can alter a result is a bug you’ll eventually blame on the model.</p>

<p>The <code class="language-plaintext highlighter-rouge">langfuse.*</code> attribute prefix is the part you can’t guess from the OTel docs. Those are Langfuse’s own conventions: <code class="language-plaintext highlighter-rouge">observation.type</code> makes the span render as a generation with input/output panels instead of a bare timing bar, and any <code class="language-plaintext highlighter-rouge">langfuse.trace.metadata.*</code> key becomes a filterable field in the UI.</p>

<p>I stamp <code class="language-plaintext highlighter-rouge">prompt_version</code> on every single call. Without it a prompt edit is invisible, because old and new output land in one pile with no axis to split them.</p>

<p>This is what those attributes look like once they land. One <code class="language-plaintext highlighter-rouge">classify_turn</code> trace, 0.31s, from a Go program with no SDK:</p>

<p><img src="/assets/images/langfuse-go-trace.png" alt="Langfuse trace detail view for a span named classify_turn. Input shows the question 'What would make you buy our candles again?' and the reply 'Price might be a bit steep but if they had a loyalty program or discounts I'd buy again.' Output shows intent 'answer', sufficient true, clarity 'clear', ack empty. The metadata block lists prompt_version 79bec7b42725 alongside the raw span attributes: gen_ai.request.model, langfuse.observation.type 'generation', langfuse.trace.name, langfuse.session.id, and the classify.intent / classify.clarity / classify.sufficient fields. Caption: everything in that panel came from span attributes set in Go." /></p>

<p>Every field in that screenshot came from a <code class="language-plaintext highlighter-rouge">SetAttributes</code> call. Nothing was configured in the UI. And notice the left sidebar while you’re there, because it settles the misconception I’ll get to below: <strong>Evaluation</strong> and <strong>Prompt Management</strong> are two separate sections. Neither depends on the other.</p>

<h2 id="why-traces-and-not-just-numbers">Why traces, and not just numbers</h2>

<p>Here’s what I underestimated. I went to Langfuse for run history and got something I hadn’t asked for.</p>

<p>A voice conversation isn’t one model call. It’s transcribe, classify, write the acknowledgment, synthesize, and around again, twenty turns deep. When a conversation feels wrong, the aggregate accuracy number is useless. It tells me how often something broke and never what happened.</p>

<p>A trace is the whole turn as one nested object: inputs, outputs, latency per step, in order, tagged with the session id. <strong>Scoring a conversation turns out to be much easier than scoring a sentence</strong>, because the sentence finally has context around it. The respondent’s previous answer sits right there, above the decision that misread it.</p>

<p>And latency stops hiding. In a voice call, TTS and transcription time <em>is dead air</em>, and it never shows up in the LLM spans. So those get traced too, with a tiny helper for steps that aren’t LLM calls:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">op</span> <span class="o">:=</span> <span class="n">obs</span><span class="o">.</span><span class="n">StartOp</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="s">"tts"</span><span class="p">)</span>
<span class="n">op</span><span class="o">.</span><span class="n">In</span><span class="p">(</span><span class="n">text</span><span class="p">)</span><span class="o">.</span><span class="n">Bytes</span><span class="p">(</span><span class="s">"audio.bytes"</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">pcm</span><span class="p">))</span>
<span class="k">defer</span> <span class="n">op</span><span class="o">.</span><span class="n">End</span><span class="p">()</span>
</code></pre></div></div>

<p>Those spans carry the session id, which is what turns twenty separate traces back into one readable conversation:</p>

<p><img src="/assets/images/langfuse-go-session.png" alt="Langfuse session view for a voice survey conversation, showing 'Total traces: 24'. The left column lists inputs and outputs in order: 'Hi!', 'I'm Ava.', 'How's everything going this morning?', then the respondent's answer 'Hey, Ava, just getting ready for a busy day ahead. How about you?'. The right column shows the tts and stt spans that produced each one, with timestamps seconds apart. Caption: 24 traces, one conversation, in the order it actually happened." /></p>

<p>24 traces for one poll. That’s the number that convinced me a dashboard of averages was never going to be enough.</p>

<h2 id="the-thing-i-got-wrong-evals-live-on-top-of-traces">The thing I got wrong: evals live on top of traces</h2>

<p>Now the misconception that kept me from starting, which is the real reason I wanted to write this post.</p>

<p>I walked into this assuming that to run evals in Langfuse I’d have to move my prompts into Langfuse first. Adopt their prompt management, version prompts in their UI, let their runner execute them. That’s a real migration, and it’s the kind of price that makes you close the tab and decide the terminal is fine actually.</p>

<p><strong>It’s not true.</strong> Evaluation in Langfuse hangs off <em>traces</em>. The unit is a <strong>Score</strong>, and the <a href="https://langfuse.com/docs/evaluation/scores/data-model">score data model</a> is the page that settled it for me: a name, a value, a data type, and exactly one subject - a trace, an observation, a session, or a dataset run. Trace is listed as the common case. Nothing about a score cares where your prompt lives. You emit the trace from Go, you attach scores to it, and you’re doing evals.</p>

<p>You don’t hand a restaurant critic your recipe. They eat the dish.</p>

<p>Prompt management only becomes a prerequisite for one specific thing: asking Langfuse to <em>execute a prompt itself</em> against a dataset. I never wanted that. My prompt lives in Go, next to the code that depends on it, and it stays there.</p>

<h3 id="door-2-scores-over-rest">Door 2: scores over REST</h3>

<p>A score is a small POST. The whole client is <code class="language-plaintext highlighter-rouge">net/http</code>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// Score is one judgment. Exactly ONE subject must be set.</span>
<span class="k">type</span> <span class="n">Score</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">Name</span>         <span class="kt">string</span>
    <span class="n">Value</span>        <span class="kt">float64</span>
    <span class="n">DataType</span>     <span class="kt">string</span> <span class="c">// NUMERIC or BOOLEAN</span>
    <span class="n">Comment</span>      <span class="kt">string</span>
    <span class="n">TraceID</span>      <span class="kt">string</span>
    <span class="n">DatasetRunID</span> <span class="kt">string</span>
    <span class="n">ID</span>           <span class="kt">string</span> <span class="c">// supply it and the write is idempotent</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Attaching a per-case verdict to the trace that produced it:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">c</span><span class="o">.</span><span class="n">Score</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">obs</span><span class="o">.</span><span class="n">Score</span><span class="p">{</span>
    <span class="n">ID</span><span class="o">:</span>       <span class="n">obs</span><span class="o">.</span><span class="n">StableID</span><span class="p">(</span><span class="n">runName</span><span class="p">,</span> <span class="n">itemID</span><span class="p">(</span><span class="n">cr</span><span class="o">.</span><span class="n">c</span><span class="p">),</span> <span class="s">"intent_correct"</span><span class="p">),</span>
    <span class="n">Name</span><span class="o">:</span>     <span class="s">"intent_correct"</span><span class="p">,</span>
    <span class="n">Value</span><span class="o">:</span>    <span class="n">correct</span><span class="p">,</span>
    <span class="n">DataType</span><span class="o">:</span> <span class="s">"BOOLEAN"</span><span class="p">,</span>
    <span class="n">TraceID</span><span class="o">:</span>  <span class="n">cr</span><span class="o">.</span><span class="n">traceID</span><span class="p">,</span>
    <span class="n">Comment</span><span class="o">:</span>  <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"want %s, got %s"</span><span class="p">,</span> <span class="n">cr</span><span class="o">.</span><span class="n">c</span><span class="o">.</span><span class="n">want</span><span class="p">,</span> <span class="n">cr</span><span class="o">.</span><span class="n">got</span><span class="o">.</span><span class="n">Intent</span><span class="p">),</span>
<span class="p">})</span>
</code></pre></div></div>

<p>That single call is what turned my scrolling terminal into something usable. In the UI I filter to <code class="language-plaintext highlighter-rouge">intent_correct = 0</code> and read only the misses, each one clickable straight through to the full conversation that produced it.</p>

<p>To capture the trace id, the tracing wrapper drops it into a ref carried on the context:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="n">ref</span> <span class="n">obs</span><span class="o">.</span><span class="n">TraceRef</span>
<span class="n">ctx</span> <span class="o">=</span> <span class="n">obs</span><span class="o">.</span><span class="n">WithTraceRef</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">ref</span><span class="p">)</span>
<span class="n">turn</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">cl</span><span class="o">.</span><span class="n">ClassifyTurn</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">c</span><span class="o">.</span><span class="n">q</span><span class="p">,</span> <span class="n">c</span><span class="o">.</span><span class="n">reply</span><span class="p">)</span>
<span class="n">outcomes</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">=</span> <span class="n">caseResult</span><span class="p">{</span><span class="n">c</span><span class="o">:</span> <span class="n">c</span><span class="p">,</span> <span class="n">got</span><span class="o">:</span> <span class="n">turn</span><span class="p">,</span> <span class="n">err</span><span class="o">:</span> <span class="n">err</span><span class="p">,</span> <span class="n">traceID</span><span class="o">:</span> <span class="n">ref</span><span class="o">.</span><span class="n">ID</span><span class="p">()}</span>
</code></pre></div></div>

<p>And the offline side - my hand-written dataset - maps onto Langfuse’s <a href="https://langfuse.com/docs/evaluation/experiments/data-model">dataset experiments</a> the same way. The corpus becomes a dataset, each model’s pass becomes a run, each case becomes a run item that <strong>links the dataset item to the trace it produced</strong>. Aggregate metrics attach to the run instead of a trace:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">c</span><span class="o">.</span><span class="n">Score</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">obs</span><span class="o">.</span><span class="n">Score</span><span class="p">{</span>
    <span class="n">ID</span><span class="o">:</span>           <span class="n">obs</span><span class="o">.</span><span class="n">StableID</span><span class="p">(</span><span class="n">runName</span><span class="p">,</span> <span class="s">"intent_accuracy"</span><span class="p">),</span>
    <span class="n">Name</span><span class="o">:</span>         <span class="s">"intent_accuracy"</span><span class="p">,</span>
    <span class="n">Value</span><span class="o">:</span>        <span class="n">r</span><span class="o">.</span><span class="n">acc</span><span class="p">(),</span>
    <span class="n">DataType</span><span class="o">:</span>     <span class="s">"NUMERIC"</span><span class="p">,</span>
    <span class="n">DatasetRunID</span><span class="o">:</span> <span class="n">runID</span><span class="p">,</span>
<span class="p">})</span>
</code></pre></div></div>

<p>Which makes the point concrete: even an offline eval run goes <em>through</em> tracing, because a run item requires a trace id. There’s no path into Langfuse that skips the traces.</p>

<p>And this is the payoff for the whole exercise, the thing my terminal could never give me:</p>

<p><img src="/assets/images/langfuse-go-experiments.png" alt="Langfuse Experiments tab for a dataset named closing-line, listing four runs of the same 13 cases against qwen2.5:3b, each labeled with the prompt hash that produced it. Columns show run items, average latency dropping from 1.24s on the baseline run to 0.84s, and run-level scores where clean_opener goes from 0.6923 on the baseline to 1.0000 after the fix while model_clean_opener sits at 0.8462. Caption: four runs of the same dataset, comparable because the prompt version rode along with each one." /></p>

<p>That’s the sign-off eval from earlier, the one <code class="language-plaintext highlighter-rouge">UnsupportedWords</code> belongs to. Four runs of the same 13 cases, each row tagged with the prompt hash that produced it, lined up so a change is something I read instead of something I remember. The baseline row and the row after the fix are both still there weeks later, which is the entire thing my terminal failed to do.</p>

<p>And that’s what finally answers the question I opened with, which storage alone never could. <em>Is 94.6% worse than yesterday, or is it noise?</em> is really two questions, and the one that matters is whether the <strong>prompt</strong> moved.</p>

<p>Two rows carrying the same fingerprint and different scores: that’s noise, and the honest response is to widen the dataset or stop reading that decimal place. Two rows with different fingerprints and different scores: that’s your change, and now you can argue about it.</p>

<p>The history is what let me ask the question next week. The fingerprint is what made it answerable at all.</p>

<h3 id="four-gotchas-that-will-bite-you">Four gotchas that will bite you</h3>

<p><strong>Flush before you link.</strong> The OTLP batch exporter is asynchronous, and the run-item endpoint rejects a trace it hasn’t ingested yet. So force a flush first, and treat a 404 as retryable:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">obs</span><span class="o">.</span><span class="n">Flush</span><span class="p">(</span><span class="n">ctx</span><span class="p">);</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
    <span class="k">return</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"flush traces: %w"</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>Retry the 404, and only the 404.</strong> A 404 there means nothing was created, so retrying is safe. That endpoint is otherwise not idempotent - the server mints the id - so no other status is ever worth retrying.</p>

<p><strong>Learn which endpoints upsert.</strong> Datasets upsert by name, dataset items upsert by id, scores upsert by supplied id. So I derive ids from a content hash and re-push the whole corpus on every run without creating a single duplicate:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">itemID</span><span class="p">(</span><span class="n">c</span> <span class="n">evalCase</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
    <span class="k">return</span> <span class="n">obs</span><span class="o">.</span><span class="n">StableID</span><span class="p">(</span><span class="s">"turn-classifier"</span><span class="p">,</span> <span class="n">c</span><span class="o">.</span><span class="n">q</span><span class="p">,</span> <span class="n">c</span><span class="o">.</span><span class="n">reply</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Edit <code class="language-plaintext highlighter-rouge">dataset.go</code> and only the cases that actually changed change upstream. Run items are the exception, so they’re posted exactly once and never blind-retried.</p>

<p><strong>You now own prompt versioning.</strong> This is the bill for keeping prompts in Go, and it arrives quietly. Langfuse’s prompt management would have versioned them for me. My prompts are Go string constants, so nothing versions them but me, and a score I can’t attribute to a prompt is back to being a vibe.</p>

<p>My first instinct was a constant to bump by hand. Terrible idea: I would edit the prompt, forget the bump, and quietly attribute new output to the old version. Worse than no version at all, because it looks trustworthy.</p>

<p>So the version is derived from the prompt instead:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// ClassifyPromptVersion is a short, stable fingerprint of the classifier's</span>
<span class="c">// instructions: the system prompt plus the few-shot anchors. Content-addressed,</span>
<span class="c">// so it cannot drift out of sync the way a hand-bumped number would.</span>
<span class="k">func</span> <span class="n">ClassifyPromptVersion</span><span class="p">()</span> <span class="kt">string</span> <span class="p">{</span>
    <span class="n">_</span><span class="p">,</span> <span class="n">shots</span> <span class="o">:=</span> <span class="n">classifyPrompt</span><span class="p">(</span><span class="s">""</span><span class="p">,</span> <span class="s">""</span><span class="p">)</span>
    <span class="n">h</span> <span class="o">:=</span> <span class="n">sha256</span><span class="o">.</span><span class="n">New</span><span class="p">()</span>
    <span class="n">h</span><span class="o">.</span><span class="n">Write</span><span class="p">([]</span><span class="kt">byte</span><span class="p">(</span><span class="n">classifySystem</span><span class="p">))</span>
    <span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">m</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">shots</span> <span class="p">{</span>
        <span class="n">h</span><span class="o">.</span><span class="n">Write</span><span class="p">([]</span><span class="kt">byte</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">Role</span><span class="p">))</span>
        <span class="n">h</span><span class="o">.</span><span class="n">Write</span><span class="p">([]</span><span class="kt">byte</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">Content</span><span class="p">))</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">hex</span><span class="o">.</span><span class="n">EncodeToString</span><span class="p">(</span><span class="n">h</span><span class="o">.</span><span class="n">Sum</span><span class="p">(</span><span class="no">nil</span><span class="p">))[</span><span class="o">:</span><span class="m">12</span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Twelve hex characters, and that’s the <code class="language-plaintext highlighter-rouge">79bec7b42725</code> in the trace screenshot above. Change a single word of the prompt and the fingerprint changes with it, whether I remembered to think about it or not.</p>

<p>Two details worth stealing. <strong>Hash the few-shot examples too</strong>, not just the system prompt, because an edited example changes behavior every bit as much as an edited instruction. And the test asserts only that the value is stable across calls and 12 characters long, never what the value <em>is</em> - a test pinning the hash would fail on every legitimate prompt edit, which is the fastest way to teach yourself to ignore a red test.</p>

<p>The trade is real and I’d make it again: an opaque <code class="language-plaintext highlighter-rouge">79bec7b42725</code> is less readable than <code class="language-plaintext highlighter-rouge">v3</code>, and it cannot lie to me.</p>

<h2 id="what-id-tell-a-friend-starting-this-in-go">What I’d tell a friend starting this in Go</h2>

<p>The absence of an SDK looked like a blocker and was a two-file inconvenience. <code class="language-plaintext highlighter-rouge">internal/obs</code>: one file for OTLP traces, one for the REST client. That’s the whole integration.</p>

<p>So, in order:</p>

<ol>
  <li><strong>Write the dataset first.</strong> Twenty labeled cases in a <code class="language-plaintext highlighter-rouge">[]struct{}</code> beats any tool you haven’t picked yet. The cases are the asset; everything else is downstream of them.</li>
  <li><strong>Score exactly, where you can.</strong> Push fuzzy outputs into small label sets and get your <code class="language-plaintext highlighter-rouge">==</code> back. Reach for similarity and judges only for what genuinely resists that, and don’t gate on them.</li>
  <li><strong>Point the standard OTel SDK at <code class="language-plaintext highlighter-rouge">/api/public/otel/v1/traces</code>.</strong> Basic auth, <code class="language-plaintext highlighter-rouge">x-langfuse-ingestion-version: 4</code>, <code class="language-plaintext highlighter-rouge">NewSchemaless</code>. You are done in 30 lines.</li>
  <li><strong>Instrument by wrapping interfaces</strong>, never by editing call sites. And keep the wrapper a pure pass-through.</li>
  <li><strong>Your prompts can stay in Go.</strong> Scores hang on traces. Fingerprint the prompt yourself and stamp it on every span, and you keep both the prompt and its history where the code is.</li>
</ol>

<p>Ava’s numbers still print in the terminal, exactly like before. The difference is that they no longer vanish when I close it.</p>

<p>Thanks for reading!</p>

<blockquote>
  <p><strong>Book recommendation</strong>: <em>AI Engineering</em>, by Chip Huyen - the evaluation chapters are the clearest treatment of this I’ve found, and the taxonomy in the first half of this post comes from there.</p>
</blockquote>]]></content><author><name>CodeSilva</name></author><category term="go" /><category term="langfuse" /><category term="opentelemetry" /><category term="evals" /><category term="llm" /><category term="observability" /><summary type="html"><![CDATA[Langfuse ships SDKs for Python and JS and nothing for Go. It doesn't need to: the standard OpenTelemetry SDK carries the traces and net/http carries the scores, and the whole integration is two files. Along the way, what an eval actually is, and why your prompts can stay in Go.]]></summary></entry><entry xml:lang="en-US"><title type="html">How SIMD Turned 146 Seconds of Tokenization Into Less Than a Second</title><link href="https://codesilva.com/low-level/2026/07/27/how-simd-turned-146-seconds-of-tokenization-into-less-than-a-second.html" rel="alternate" type="text/html" title="How SIMD Turned 146 Seconds of Tokenization Into Less Than a Second" /><published>2026-07-27T00:00:00+00:00</published><updated>2026-07-27T00:00:00+00:00</updated><id>https://codesilva.com/low-level/2026/07/27/how-simd-turned-146-seconds-of-tokenization-into-less-than-a-second</id><content type="html" xml:base="https://codesilva.com/low-level/2026/07/27/how-simd-turned-146-seconds-of-tokenization-into-less-than-a-second.html"><![CDATA[<p>When you send a prompt to an LLM, the model never reads your text. Before anything else, a tokenizer breaks your sentence into pieces and swaps each piece for a number. That is what the model receives: a list of integers. Never the letters.</p>

<svg viewBox="0 0 750 190" role="img" aria-label="Flow: your text prompt goes through the tokenizer and becomes a list of tokens before the model receives it" style="width:100%;height:auto;max-width:750px;font-family:inherit">
  <defs>
    <marker id="tok-arw" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto">
      <path d="M0,0 L6,3 L0,6 Z" fill="var(--secondary-text,#6a737d)" />
    </marker>
  </defs>
  <rect x="15" y="45" width="150" height="72" rx="8" fill="var(--card-bg,#f6f8fa)" stroke="var(--border-color,#d0d7de)" />
  <rect x="205" y="45" width="150" height="72" rx="8" fill="var(--card-bg,#f6f8fa)" stroke="var(--link-color,#0969da)" stroke-width="2" />
  <rect x="395" y="45" width="150" height="72" rx="8" fill="var(--card-bg,#f6f8fa)" stroke="var(--border-color,#d0d7de)" />
  <rect x="585" y="45" width="150" height="72" rx="8" fill="var(--card-bg,#f6f8fa)" stroke="var(--border-color,#d0d7de)" />
  <text x="90" y="78" text-anchor="middle" font-size="15" fill="var(--text-color,#24292e)">your prompt</text>
  <text x="90" y="99" text-anchor="middle" font-size="12" fill="var(--secondary-text,#6a737d)">"explain SIMD to me"</text>
  <text x="280" y="86" text-anchor="middle" font-size="15" font-weight="700" fill="var(--link-color,#0969da)">tokenizer</text>
  <text x="470" y="78" text-anchor="middle" font-size="15" fill="var(--text-color,#24292e)">tokens</text>
  <text x="470" y="99" text-anchor="middle" font-size="12" font-family="monospace" fill="var(--secondary-text,#6a737d)">[1859, 40151, …]</text>
  <text x="660" y="86" text-anchor="middle" font-size="15" fill="var(--text-color,#24292e)">model</text>
  <line x1="167" y1="81" x2="203" y2="81" stroke="var(--secondary-text,#6a737d)" stroke-width="1.5" marker-end="url(#tok-arw)" />
  <line x1="357" y1="81" x2="393" y2="81" stroke="var(--secondary-text,#6a737d)" stroke-width="1.5" marker-end="url(#tok-arw)" />
  <line x1="547" y1="81" x2="583" y2="81" stroke="var(--secondary-text,#6a737d)" stroke-width="1.5" marker-end="url(#tok-arw)" />
  <line x1="280" y1="117" x2="280" y2="150" stroke="var(--link-color,#0969da)" stroke-width="1.5" stroke-dasharray="3 3" />
  <text x="280" y="170" text-anchor="middle" font-size="13" fill="var(--link-color,#0969da)">the step nobody times</text>
</svg>

<p>For a single prompt, this step is instant. You don’t even notice it happened.</p>

<p>But you almost never tokenize just one thing. Think about RAG: you take your company’s documents and tokenize every one of them before indexing. Or a fine-tune: you tokenize the entire dataset before training. Or an eval running over millions of examples. That invisible step turns into machine-hours.</p>

<p>And nobody times it. We accept slow tokenization as if it were a law of physics - “that’s the price of working with LLMs at scale”. It isn’t. When I finally measured it, there was a <strong>200x</strong> sitting there, waiting for someone to look.</p>

<p>That 200x lives in a specific place: pipelines that tokenize in bulk and write the ids to disk. If your case is a user’s prompt in production, the math changes. And between those two ends sits <code class="language-plaintext highlighter-rouge">TTFT</code>, the time to first token, where tokenization shows up again. We’ll get to all of them.</p>

<h2 id="first-how-much-of-the-time-is-tokenization">First: how much of the time is tokenization?</h2>

<p>Before selling a solution, it’s worth measuring the problem. Optimizing something that carries no weight is wasted time - we’ll come back to that at the end.</p>

<p>I set up the simplest possible scenario: 281,664 real text documents, 863 MB from the fineweb dataset. Read from disk, run through the Qwen3-8B tokenizer, write out the token ids. That’s it - no model running anywhere in the middle.</p>

<p><a href="https://github.com/karpathy/nanoGPT/blob/master/data/openwebtext/prepare.py">nanoGPT’s <code class="language-plaintext highlighter-rouge">prepare.py</code></a> does exactly this: it tokenizes the corpus once and writes the ids to a <code class="language-plaintext highlighter-rouge">.bin</code> file that training reads later. Tokenizing again every epoch would be waste.</p>

<p>Then I timed each step. The result:</p>

<table>
  <thead>
    <tr>
      <th>step</th>
      <th style="text-align: right">time</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>read 863 MB from disk</td>
      <td style="text-align: right">0.8 s</td>
    </tr>
    <tr>
      <td><strong>tokenize</strong></td>
      <td style="text-align: right"><strong>146 s</strong></td>
    </tr>
    <tr>
      <td>write 760 MB of ids</td>
      <td style="text-align: right">0.1 s</td>
    </tr>
  </tbody>
</table>

<p>Tokenization was <strong>99.4% of the time.</strong> It wasn’t <em>a</em> bottleneck among others. It was the whole pipeline. Reading and writing nearly a gigabyte each was noise next to it.</p>

<p>That changes the game. When one step eats 99% of the time, optimizing it stops being polish and becomes priority number one.</p>

<h2 id="so-killing-tokenization-speeds-up-the-entire-pipeline">SO: killing tokenization speeds up the entire pipeline</h2>

<p>Enter <a href="https://github.com/marcelroed/gigatoken">Gigatoken</a>, a tokenizer Marcel Roed wrote in Rust. Its promise is aggressive: up to <strong>~1000x faster</strong> than HuggingFace’s <code class="language-plaintext highlighter-rouge">tokenizers</code>.</p>

<p>Too round a number, so I got suspicious. I ran my own benchmark - same corpus, same tokenizer, on my machine (a MacBook M4 Max). Tokenization only:</p>

<table>
  <thead>
    <tr>
      <th>tool</th>
      <th style="text-align: right">time</th>
      <th style="text-align: right">throughput</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>HuggingFace <code class="language-plaintext highlighter-rouge">tokenizers</code></td>
      <td style="text-align: right">146 s</td>
      <td style="text-align: right">0.01 GB/s</td>
    </tr>
    <tr>
      <td>Gigatoken</td>
      <td style="text-align: right">0.74 s</td>
      <td style="text-align: right">~1.2 GB/s</td>
    </tr>
  </tbody>
</table>

<p><strong>About 200x</strong> on my machine. Not the 1000x from their README, but that 1000x came from a 144-core server - the more cores, the wider the gap opens. On my laptop, 200x is enough to hurt.</p>

<p>And the whole pipeline, I/O and all? It dropped from 147 seconds to under 2. <strong>88x faster end to end.</strong> Two and a half minutes of waiting became less than two seconds, on the same corpus, with the same result.</p>

<blockquote>
  <p>NOTE: the most important number in those tables isn’t the time. It’s that both produced <strong>exactly the same 190,429,497 tokens.</strong> Byte for byte. One of them is 200x faster doing identical work. That’s not a shortcut, that’s engineering.</p>
</blockquote>

<h2 id="this-is-not-premature-optimization">This is not premature optimization</h2>

<p>An objection popped into my head before it could pop into yours: isn’t swapping tokenizers to shave off seconds the very picture of premature optimization?</p>

<p>That’s what I used to think, and it’s why I had never timed this step. HuggingFace’s <code class="language-plaintext highlighter-rouge">tokenizers</code> helps you not look, too: it’s written in Rust, it’s multithreaded, it’s maintained by serious people. Any reasonable person glances at it and says “this is optimized”. And there were still 200x on the table.</p>

<p>Premature optimization would have been tuning the I/O that took 0.8 seconds. Attacking the step that eats 99% of the time is the obvious move, and it only looks bold because nobody had checked the clock before.</p>

<p>What does exist, and nobody has named it, is the inverse problem: <strong>late optimization</strong> - accepting a bottleneck as untouchable without ever having timed it. “Tokenization is slow” was exactly that. A wall everybody saw, until someone saw a door.</p>

<h2 id="but-didnt-you-pick-the-slowest-tokenizer-on-purpose">“But didn’t you pick the slowest tokenizer on purpose?”</h2>

<p>Fair question. If I had tested only Qwen, you’d have every right to be suspicious. So I tested more.</p>

<p>With <strong>GPT-2</strong>, the classic: HuggingFace took 129 seconds, Gigatoken took 0.22. The 193,502,159 tokens, once again, identical. Switching tokenizers within HuggingFace doesn’t close the gap - it widens it.</p>

<p>But the test that really matters is against <strong>tiktoken</strong>, from OpenAI. It’s the tokenizer with a reputation for speed, the one running behind GPT-4. And it <em>is</em> fast: it did the same corpus in 12 seconds, about 10x faster than HuggingFace.</p>

<p>Gigatoken did it in 0.22 seconds. <strong>57x faster than the fast tokenizer</strong>, with token-for-token identical output.</p>

<p>And here’s the interesting part: <strong>HuggingFace being slow doesn’t mean “tokenization is slow”.</strong> tiktoken does the same tokenization 10x faster just by swapping one internal mechanism. The bottleneck has a specific name, and it isn’t BPE.</p>

<h2 id="how-simd-in-the-part-nobody-was-looking-at">How? SIMD in the part nobody was looking at</h2>

<p>The bottleneck’s name is <code class="language-plaintext highlighter-rouge">pretokenization</code>.</p>

<p>Before the actual tokenization algorithm (BPE), there’s a mundane step: splitting the text into pre-chunks. Almost everybody does it with a <strong>regular expression</strong>. And a regex scanning gigabytes of text is slow - it crawls along more or less character by character, hunting for the pattern.</p>

<p>What Marcel did was replace the regex with an implementation that does the same thing with <strong>SIMD</strong>. And this is where the magic falls apart.</p>

<p>Imagine you need to find every comma in a thousand-page text. You can read it word by word, looking. Or you can open the text into 64 parallel columns and ask all at once: “is there a comma in any of these 64 characters right now?”. Same result. Very different time.</p>

<p><strong>That’s SIMD.</strong> Single Instruction, Multiple Data. One instruction, many pieces of data. The processor grabs 16, 32, 64 bytes and runs the same operation on all of them in a single cycle.</p>

<blockquote>
  <p>NOTE: if you want to pull on this thread, SIMD is an entire axis of concurrency most of us ignore - it isn’t threads or processes, it’s the processor’s own ALU working in batches. Paul Butcher dedicates a chapter to it in <em>Seven Concurrency Models in Seven Weeks</em> (data parallelism), and Mitchell Hashimoto has a <a href="https://mitchellh.com/writing/everyone-should-know-simd">great essay</a> arguing that every developer should know SIMD. But for what matters here, one thing is enough: this is the technique that turns 146 seconds into 0.7.</p>
</blockquote>

<h2 id="the-honest-part-200x-on-one-step-is-not-200x-on-your-life">The honest part: 200x on one step is not 200x on your life</h2>

<p>Now the warning that separates this post from a sales brochure.</p>

<p>I said tokenization was 99% of the time. Notice: 99% of the time of THAT test. That holds for any pipeline where <strong>no model runs alongside</strong>: you read text, tokenize, write the ids. That’s all I measured, and there tokenization dominates on its own.</p>

<p>The moment a model enters the same pipeline, the math changes. In a RAG you tokenize and then push each chunk through an embedding model - that forward pass is real work, and tokenization’s slice shrinks.</p>

<p>And if you’re tokenizing a user’s prompt before calling a model in production, the picture flips completely. During generation itself, tokenization is a crumb next to the forward pass - a 200x there changes almost nothing, because it was never the problem.</p>

<p>The narrow exception is the time to first token, <code class="language-plaintext highlighter-rouge">TTFT</code>. Gigatoken’s own author showed, in the <a href="https://news.ycombinator.com/item?id=49010167">Hacker News discussion</a>, that on smaller models you can shave 5-10% off TTFT. It’s real - but it’s a different universe from the 88x in preprocessing. The size of the prize depends on where tokenization sits in your equation. That’s <strong>Amdahl’s Law.</strong></p>

<p>The total gain from optimizing one part is capped by how much that part weighed in the whole. Optimizing 99% of the time transforms everything. Optimizing 1% changes nothing, no matter how spectacular the 200x looks.</p>

<p>I learned that the hard way. This year I entered the rinha de backend (a Brazilian backend performance contest) and, for the first time in my life, wrote a SIMD kernel by hand - the application did a lot of repetitive math, the perfect candidate. The math got much faster. And the final gain was disappointing.</p>

<p>Because the math was never my bottleneck. The time lived in everything running around it. I had brilliantly accelerated a piece that carried no weight. The same law that makes Gigatoken look like magic tripped me up in the rinha - just in the opposite direction.</p>

<h2 id="what-remains">What remains</h2>

<p>The real work isn’t “install Gigatoken”. It’s <strong>measuring where your time lives</strong> before you start optimizing.</p>

<p>If you do data preprocessing for LLMs and have never timed tokenization, time it. If your path looks like the test’s - read text, tokenize, write the ids - it may be eating almost everything. And if it is, there’s a 200x sitting there:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install </span>gigatoken
</code></pre></div></div>

<p>Its API mimics the tokenizers you already use, so the swap itself is one line. How much of that gain shows up on your end is another conversation, and it depends on how much of your time was in tokenization. Only the stopwatch can answer that.</p>

<p>But if tokenization is a crumb in your pipeline, save Gigatoken for the right day and go hunt the real bottleneck. Because in the end it’s always the same story: time lives somewhere specific, and it’s almost never where we think it does.</p>

<p>Thanks for reading!</p>]]></content><author><name>CodeSilva</name></author><category term="ai" /><category term="llm" /><category term="performance" /><category term="tokenization" /><category term="simd" /><summary type="html"><![CDATA[I timed reading, tokenizing, and writing 863 MB of text: tokenization was 99.4% of the total. A SIMD tokenizer turned 146 seconds into 0.74.]]></summary></entry><entry xml:lang="en-US"><title type="html">Claude Code: The Unreasonable Effectiveness of Simplicity</title><link href="https://codesilva.com/ia/2026/07/24/claude-code-the-unreasonable-effectiveness-of-simplicity.html" rel="alternate" type="text/html" title="Claude Code: The Unreasonable Effectiveness of Simplicity" /><published>2026-07-24T00:00:00+00:00</published><updated>2026-07-24T00:00:00+00:00</updated><id>https://codesilva.com/ia/2026/07/24/claude-code-the-unreasonable-effectiveness-of-simplicity</id><content type="html" xml:base="https://codesilva.com/ia/2026/07/24/claude-code-the-unreasonable-effectiveness-of-simplicity.html"><![CDATA[<p>A team here at work had a very specific problem: their voice agent didn’t know when to end a conversation.</p>

<p>The product is neat. Someone opens a link, an AI asks a sequence of questions by voice, and at the end it all turns into structured data. A conversational survey. The kind of thing you send to a client and they answer by talking, no form involved.</p>

<p>The missing piece was the ending. The agent asked the questions, got the answers, and just stayed there. Floating. A human <em>feels</em> when it’s time to wrap up a chat. The machine doesn’t.</p>

<p>Lately I’ve been taking this kind of problem and building a PoC to leave as an example - a running case my colleagues can open, understand, and adapt to their own context. That’s what I did here.</p>

<p>I could have done what’s fashionable now: sit down and write a giant document before touching any code. A spec. Requirements, state diagram, the fifteen academic ways to detect end of conversation, acceptance criteria for each one. Spec-driven development, which is being sold as the grown-up way to work with AI.</p>

<p>I didn’t. And that’s what this post is about: why the simplest possible thing solved it, and why the big document would have slowed me down.</p>

<h2 id="the-only-document-worth-writing-came-before-everything---and-it-wasnt-a-spec">The only document worth writing came before everything - and it wasn’t a spec</h2>

<p>The first thing I did was ask for research. One prompt, written in a hurry, typos and all:</p>

<blockquote>
  <p>claude, my teammates are working on a project that is an AI agent, a voice AI agent. The agent goes through a sequence of questions and has some interactions in the middle. The agent, however, cannot understand when to finish a conversation by itself. Websearch algorithms, models and all techniques that can be used for it. Fan out agents, look into HuggingFace, GitHub, Kaggle, wherever you find useful to look into.</p>
</blockquote>

<p>Look at what this document is. It doesn’t describe what I was going to build. It maps what the world already knows about the problem: how LiveKit, Pipecat, and Vapi end a call, which <em>endpointing</em> models exist, what others have already tried and where they broke.</p>

<p>That’s the distinction almost nobody draws properly.</p>

<p>Research reduces an unknown you <strong>cannot guess from your chair.</strong> It’s external knowledge, something that already exists outside your head, waiting for you to go get it.</p>

<p>A spec tries to guess an unknown that only the running system can answer. It’s an internal guess dressed up as certainty.</p>

<p>One you collect. The other you invent. And guess which of the two everyone spends their first day producing.</p>

<p>I saved the result:</p>

<blockquote>
  <p>save all this research and reference to a file, in a new folder.</p>
</blockquote>

<p>And only <em>after</em> having the map in hand did I ask for the prototype:</p>

<blockquote>
  <p>plan a PoC on this. A voice agent that runs on browser, with a human voice. It should go through a list of AI-crafted questions. The backend team is in Go, so it’s preferable to keep it that way.</p>
</blockquote>

<p>The first real line of code was born 37 minutes after the first prompt. No PRD. No alignment meeting about the schema. Research, a half-page plan, and hands on the keyboard.</p>

<h2 id="that-prompt-was-not-a-google-search">That prompt was not a Google search</h2>

<p>When I ask for research like that, Claude Code doesn’t open Google and paste the first result. That prompt of mine up there became a <a href="https://code.claude.com/docs/en/workflows"><code class="language-plaintext highlighter-rouge">/deep-research</code></a>, a command that ships with the tool. I described the problem in sloppy English; Claude picked the tool and ran it.</p>

<p>Under the hood, <code class="language-plaintext highlighter-rouge">/deep-research</code> is a <em>workflow</em>: a script that orchestrates several subagents, each on a slice of the task, storing results outside the main context window so it doesn’t clog up. It runs five phases - the logic any good human researcher would follow, except in parallel and without laziness:</p>

<ol>
  <li><strong>Scope</strong> - takes my question and breaks it into about 5 different angles. One goes after the state of the art, another the academic papers, another the skeptical/contrarian view, another the practical implementation. Angles that don’t overlap.</li>
  <li><strong>Search</strong> - fires 5 search agents <strong>in parallel</strong>, one per angle. Five simultaneous searches, not one.</li>
  <li><strong>Fetch</strong> - merges everything, removes duplicate URLs, and actually fetches the ~15 best sources. Not the SERP snippet: the whole page. From each one, it extracts <em>falsifiable</em> claims, with the direct quote backing each one.</li>
  <li><strong>Verify</strong> - and here’s the trick. Every claim goes through 3 skeptical reviewers, each with an explicit order: <strong>try to refute this.</strong> If 2 out of 3 knock it down, the claim dies. Marketing, cherry-picked benchmarks, forums, old papers from a fast-moving field: all of it gets filtered before it reaches me.</li>
  <li><strong>Synthesize</strong> - merges the semantic duplicates, ranks by confidence, and spits out a report with cited sources.</li>
</ol>

<p>Now compare that with what you do on Google.</p>

<p>You type a query, get ten blue links, open eight tabs, read half of three, forget two, and end up saving a bookmark you’ll never revisit. One query. One angle. Zero verification. <strong>You are the loop</strong> - the tired one, the one who skips the seventh tab because enough is enough.</p>

<p>In Claude Code the loop is the machine, which doesn’t get tired at the seventh tab or the fiftieth. And there’s a difference deeper than speed: on Google you search for <em>pages</em>; in the workflow you search for <em>verified claims</em>. One hands you a pile of tabs. The other hands you a report where every sentence has already been beaten up by three skeptics.</p>

<p>The result of that afternoon is open in the project’s <a href="https://github.com/geeksilva97/voice-survey-agent/blob/main/docs/RESEARCH.md">RESEARCH.md</a>. It has <strong>45 distinct sources</strong> - arXiv, GitHub, HuggingFace, LiveKit and Vapi docs - organized by theme, with the open source turn-taking models separated from the academic datasets separated from the vendor tools. That came out of <em>one</em> prompt.</p>

<p>Sit down and try to assemble that same bibliography by hand, on Google, in one afternoon. Good luck. You’ll get to the tenth tab and give up - and the 35 missing sources were exactly the ones that would have saved you from reinventing <em>endpointing</em> from scratch.</p>

<h2 id="the-real-design-was-written-by-the-bugs">The real design was written by the bugs</h2>

<p>Here’s the part no spec would have caught, because it’s impossible for it to catch.</p>

<p>The real product was born from me <strong>talking to the thing</strong> and complaining about what was bad. Prompt by prompt, each one fired right after I spoke into the microphone and heard the agent get it wrong:</p>

<blockquote>
  <p>I’m still speaking and she asks if I’m still there, but then she could get my answer…</p>
</blockquote>

<p>VAD timeout too short. She would cut me off mid-sentence to ask if I was still there. That wasn’t in the spec because it <em>couldn’t</em> be. You only find that out with a microphone in your hand and half a sentence in your mouth.</p>

<blockquote>
  <p>she only says “that’s everything I wanted to ask” and it cuts. Why is this happening?</p>
</blockquote>

<p>The goodbye cut off in the middle of its own goodbye.</p>

<blockquote>
  <p>it repeated an already-answered question.</p>
</blockquote>

<p>She was re-asking something I had already answered, because a classifier mislabeled my answer.</p>

<p>And the most subjective one of all, the one that really ties the knot:</p>

<blockquote>
  <p>this intro is not like a human would talk.</p>
</blockquote>

<p>How do you write the rule “the opening has to sound human” in a document? You don’t. That was the whole point of the product - fluid, not robotic - and that is <strong>unspecifiable in prose.</strong> You don’t feel a PRD. You feel a conversation.</p>

<p>Each of these defects was discovered floating in front of the prototype, not predicted in a document. And it couldn’t have gone any other way. A voice agent is made of what happens in the gap between speech and response, and that gap doesn’t fit in a bullet point.</p>

<h2 id="the-eval-became-the-spec---only-growing-backwards">The eval became the spec - only growing backwards</h2>

<p>Every time I found a new behavior, the request was the same:</p>

<blockquote>
  <p>add this to the eval.</p>
</blockquote>

<p>That’s how the “spec” was born. It didn’t predict the behaviors. It <strong>accumulated</strong> them as they showed up. The document grew backwards, from the reality that ran, not from my Friday-afternoon imagination.</p>

<p>Notice the inversion. In spec-driven, the document comes first and reality tries to catch up. In what I did, reality came first and the document ran after it to record what had already proven itself true.</p>

<p>The same went for writing things down:</p>

<blockquote>
  <p>keep it documented, every single step must be documented and revalidated on every single change.</p>
</blockquote>

<p>Docs <em>after</em> validation, describing what a run had proven. Not before, describing what I hoped would work. The project’s CLAUDE.md only showed up at the very end, when there was something to describe.</p>

<h2 id="what-was-left-at-the-end">What was left at the end</h2>

<p>A PoC good enough to solve the team’s internal problem.</p>

<p>It runs in the browser. It generates the questions from a preset, the agent runs the survey with a human voice, reacts to what the person says, and - the thing that mattered from the start - <strong>knows when to end.</strong> Including when the person disappears halfway through and she has to detect the silence and finish on her own, with dignity.</p>

<p>No hundred-page document. A tight loop, repeated to exhaustion: build, test live, listen to what breaks, fix it, pin it in the eval. Again. And again.</p>

<p>It was fast not <em>despite</em> being simple. It was fast <strong>because</strong> it was simple.</p>

<h2 id="why-simplicity-was-too-effective">Why simplicity was too effective</h2>

<p>There’s a famous Wigner essay about the unreasonable effectiveness of mathematics in the natural sciences. The idea is that a simple tool sometimes explains far more than it had any right to explain. That was exactly the feeling here.</p>

<p>And the dumb trial-and-error loop that handled everything has a name: it’s just a chat with Claude Code open next to the browser. Short prompt, eyes on the prototype, short prompt again. No ceremony. A problem the big-document crowd would treat as a two-week project became an afternoon of conversation.</p>

<p>If you got this far looking for the secret trick, I owe you a disappointment: there isn’t one. No clever architecture, no magic prompt, no secret technique. I described the problem in sloppy English, let the tool do the research, built the dumbest thing that worked, and kept fixing whatever broke in front of me. <strong>I didn’t do anything fancy - and that’s the entire post.</strong> Simplicity wasn’t a detail of the path. It was the path.</p>

<p>And the reason is kind of obvious once you stop to look.</p>

<p>Spec-driven tries to turn the hardest unknowns - the ones that only exist in the behavior of the running system - into confident prose, before the system exists. It spends the most expensive bullet early, at the exact moment you know the least about the problem.</p>

<p>I only front-loaded the one unknown that could be front-loaded: the domain research, the knowledge that already existed out there. The rest of the unknowns - the design ones - I let resolve themselves, empirically, in the friction with the microphone.</p>

<p>It’s not that documents are useless. It’s that a document is good for recording what you <strong>discovered</strong>, and terrible for pretending you already <strong>know.</strong> Its time is after the first contact with reality, not before.</p>

<p>And yes, I’m aware this is a PoC, not a product running in production with an SLA and a pager going off at three in the morning. But the order doesn’t change with the size of the thing. Product, task, one-afternoon prototype: you start with research, understand what you want to do, and <em>then</em> you do it. That was literally the arc of this post. Spec-driven doesn’t invert that order - it just pushes the <strong>understanding</strong> part to before its time, while it’s still a guess.</p>

<p>The spec-driven crowd will spend day one writing an exquisite section about the barge-in timeout - which a real microphone corrects in ninety seconds.</p>

<p>You probably don’t need that. Research what can be researched, build the dumbest thing that works, and let reality write the rest of the spec for you. It writes better, and it never delays the delivery.</p>

<p>Thanks for reading!</p>]]></content><author><name>CodeSilva</name></author><category term="ai" /><category term="claude-code" /><category term="deep-research" /><category term="workflows" /><category term="voice-agents" /><category term="poc" /><category term="software engineering" /><summary type="html"><![CDATA[Spec-driven development became the 'grown-up' way to work with AI: write a big document before touching any code. On a real voice agent problem I did the opposite - researched what could be researched, built the dumbest PoC that worked, and let the bugs write the spec. Solved it in an afternoon.]]></summary></entry><entry xml:lang="en-US"><title type="html">Your AI agent needs to forget</title><link href="https://codesilva.com/ai/2026/07/23/your-ai-agent-needs-to-forget.html" rel="alternate" type="text/html" title="Your AI agent needs to forget" /><published>2026-07-23T00:00:00+00:00</published><updated>2026-07-23T00:00:00+00:00</updated><id>https://codesilva.com/ai/2026/07/23/your-ai-agent-needs-to-forget</id><content type="html" xml:base="https://codesilva.com/ai/2026/07/23/your-ai-agent-needs-to-forget.html"><![CDATA[<p>A month ago I had a bot that treated a two-week-old result as breaking news.</p>

<p>The context: I built a World Cup betting pool for my team (a “bolão”, the classic Brazilian prediction pool), with everyone guessing scores from the terminal, over SSH. Then we put an AI in the game, just for fun. It placed bets, commented on the pool, teased whoever was doing badly. We named it BETanIA.</p>

<p>And the problem showed up in the commentary. Every time it opened its mouth, it rediscovered the same facts with the same astonishment. It teased the same player about the same bad guess three days in a row, as if it were news. It dragged a two-week-old result into the conversation with the excitement of someone who had just watched the ball hit the net.</p>

<p>The intuitive reaction - and maybe yours right now - is: <strong>give it more memory.</strong></p>

<p>That’s not where I went. I had already worked on an AI assistant for veterinarians, and that’s where I first ran head-on into this business of managing agent context and memory. So it clicked fast: more memory would only push the problem down the road - what was missing was a smarter architecture for its memory.</p>

<p>Add to that some time poking around how Claude Code handles context in long sessions, and I already had a suspicion: the problem is almost never a lack of memory, it’s what you do with it.</p>

<p>BETanIA didn’t need more memory. It needed to forget properly.</p>

<h2 id="the-goldfish">The goldfish</h2>

<p>There’s a cliché about goldfish having a three-second memory. It’s not true about the fish, but it’s a perfect description of an LLM call.</p>

<p>Each invocation of the model is a brand-new mind that has never seen your data. It wakes up, looks at whatever you put in the context, answers, and dies. The next call remembers nothing.</p>

<p>So a commentator that only receives the current standings does what a goldfish would do: discovers everything again, every time. That’s not a character. It’s a slot machine that pays out in repeated jokes.</p>

<p><img src="/assets/images/betania-goldfish-commentator.png" alt="A goldfish in a fishbowl sitting at a commentator desk, in front of an old microphone, saying in a speech bubble: breaking news, the same news as yesterday. A desk calendar shows two weeks have already gone by." /></p>

<p>This is one of the two pits of agent memory. And it’s the obvious one: <strong>forgetting what matters.</strong> Amnesia.</p>

<p>But there’s a second pit, on the opposite side, and that one is far less intuitive.</p>

<h2 id="funes-the-man-who-couldnt-think">Funes, the man who couldn’t think</h2>

<p>Borges has a short story called <em>Funes the Memorious</em>. A young man, Ireneo Funes, falls off a horse and, after the accident, can no longer forget anything.</p>

<p>Every leaf of every tree he has ever seen. The exact shape of every cloud. The exact wording of a page read once. Everything, permanently, in full detail.</p>

<p>It sounds like a superpower, but it’s a curse.</p>

<p>Borges’ point is that Funes <strong>cannot think.</strong> Thinking is generalizing - it’s seeing a dog in profile at 3:14 pm and the same dog head-on at 3:15 pm and calling both of them “dog”.</p>

<blockquote>
  <p>To think is to forget differences, to generalize, to abstract.</p>
</blockquote>

<p>Funes can’t. To him those are two distinct, incomparable things, because they in fact <em>are</em> different. Drowning in the specific, he loses the ability to abstract.</p>

<p>Here’s the part that matters to anyone building agents: <strong>every long-running agent is on its way to becoming Funes.</strong></p>

<p>BETanIA’s diary grew by one paragraph per match. A World Cup has more than a hundred games. Push all of it forward, forever, and the context stops being a notebook and becomes a haystack.</p>

<p>You don’t want either one. Not the goldfish that forgets what matters, not the Funes that remembers everything and locks up. Good design lives in between, and it has a name.</p>

<p><img src="/assets/images/esquecer-agente-soterrado-vs-calmo.png" alt="On the left, a robot completely buried under an avalanche of papers and folders stacked to the ceiling, looking overloaded and frozen. On the right, the same kind of robot, calm and smiling, working at a clean desk with a single small notebook." /></p>

<h2 id="the-right-question">The right question</h2>

<p>Every agent project eventually runs into the question “how much should my agent remember?”.</p>

<p>It’s the wrong question.</p>

<p>The right question is: <strong>what is safe to forget?</strong></p>

<p>And here comes the thing I want you to take from this post. This is not an implementation detail you sort out later. Deciding what can be thrown away <em>is</em> the memory design. It’s context engineering in its purest form.</p>

<p>Best of all: you already use a tool that does this all the time, right in front of you, and you probably never noticed.</p>

<h2 id="claude-code-forgets-on-purpose">Claude Code forgets on purpose</h2>

<p>Open a long session in Claude Code. Two hours, dozens of tool calls, files read, commands executed. At some point the context would fill up and the whole thing would break.</p>

<p>It doesn’t break. Because Claude Code forgets, in layers.</p>

<p>The cheapest layer it calls <em>microcompact</em>. That tool result from 90 minutes ago - the giant log it has already read and already analyzed - it no longer needs the raw text. So it swaps the content for a <code class="language-plaintext highlighter-rouge">[Old tool result cleared to save context]</code> and moves on. Cost: zero model calls.</p>

<p>When that’s not enough, it goes up a layer: it calls the model to <em>summarize</em> the entire conversation and replaces the history with the summary plus the most recent messages.</p>

<p>Notice what happens in both layers. It throws away the <strong>text</strong> and keeps the <strong>conclusion</strong>. It forgets the 250KB log, remembers that “the database connection pool was exhausted, I restarted the pods”. The information that matters survives; the raw material rots away.</p>

<p><img src="/assets/images/esquecer-claude-code-descarta.png" alt="A robot in a long work session throws a pile of old papers into the trash with one hand while, with the other, it pins to a board a single short note summarizing those papers." /></p>

<p>This is not a hack to save tokens. It’s the only way an agent can last two hours without becoming Funes.</p>

<h2 id="a-notebook-not-a-tape">A notebook, not a tape</h2>

<p>When I had to build BETanIA’s memory from scratch, I landed in the same place - only the hard way.</p>

<p>Think about how a real sports commentator prepares. He doesn’t rewatch the whole season before going on air. He shows up with a small notebook: one line per game, a few facts per player, who’s feuding with whom.</p>

<p>The skill is not remembering everything. It’s keeping notes good enough that the past stays <em>available</em> without being <em>relived</em>.</p>

<p>That’s what BETanIA got. A diary: when a match ends, one model call condenses everything - the score, everyone’s guess, the best live comments - into a single paragraph. The story of that match. The paragraph is kept; the rest can rot.</p>

<p>And the diary can be <strong>compacted</strong>. One call reads the whole diary and merges everything into a single narrative, weighted toward recency: the old rounds become “the early chaos when nobody could get Brazil right”, the latest matches keep their detail.</p>

<p>In the final database backup, the 104 matches of the World Cup live in exactly three diary entries: one merged narrative carrying the entire tournament up to the semifinals, plus the last two matches in full detail. A month of football, compressed, but never lost.</p>

<p>And “compressed” here is not a figure of speech. In that first entry, rounds that were once dramatic become a single clause. A real excerpt, verbatim from the database (it writes in English):</p>

<blockquote>
  <p>The tournament’s most democratic disasters are worth cataloguing: Jul 5 saw Norway eliminate Brazil 1-2 for a thirteen-pick, thirteen-zero collective wipeout; Jun 30 handed all fifteen players unified zeroes on Germany-Paraguay.</p>
</blockquote>

<p>Each of those was an entire match, with a minute-by-minute score and the standings dancing at every goal. One sentence is what remains. That’s what compacting well looks like: the detail disappears, the shape stays.</p>

<p><img src="/assets/images/betania-compaction.png" alt="A robot operates a mechanical press compressing a tall stack of papers labeled 104 matches into a thin little book labeled 3 entries: one merged narrative plus the last 2 matches in detail." /></p>

<p>A notebook, not a cassette tape of everything that ever happened. Funes keeps the tape. And Funes would never win the pool.</p>

<h2 id="not-everything-can-be-compressed">Not everything can be compressed</h2>

<p>Now, the part that separates engineering from a quick hack. It’s tempting to think the rule is “compress everything you can”. It isn’t.</p>

<p>BETanIA has a second compaction, with the <strong>opposite</strong> contract - and the contrast is the real lesson.</p>

<p>Besides the diary, I kept notes with facts about the players. One of them is a Gentoo user who compiles his own kernel. That kind of thing. When those notes grow, they get compacted too - but the prompt literally says “PRESERVE every distinct fact”. Merge duplicates, group what’s similar, but nothing can be lost.</p>

<p>Why? Because those facts are <strong>non-reproducible truth</strong>.</p>

<p>If it forgets that someone compiles his own kernel, no amount of standings data will ever bring that back. A match summary, on the other hand, can be tolerated if it comes out sloppy - the core information is still there.</p>

<p>Same operation, two contracts. A summary can lose detail. A fact cannot.</p>

<p>And here the whole rule collapses into one sentence, which I left written in the project’s docs:</p>

<p><strong>Non-reproducible truth gets persisted. Reconstructible presentation gets thrown away and rebuilt later.</strong></p>

<p>The pool standings, for example, are never stored. They’re computed on the fly, from the guesses and the results. Storing them would create a second copy that can drift from the truth. So: throw it away, rebuild it when someone asks.</p>

<p>That criterion - store or rebuild - is what organized BETanIA’s entire memory into a shelf, each type with a rule for how long it lives:</p>

<p><img src="/assets/images/esquecer-tiers-memoria.png" alt="Isometric bookcase with five shelves, one per memory type, and a small robot beside it. From top to bottom: volatile (gone on restart), hybrid (snapshot on shutdown), persistent in SQLite, append-only in JSONL logs, and derived (computed on read)." /></p>

<p>In the end, only two shelves hold actual truth: the persistent one (the diary, the player facts) and the append-only log. The rest is presentation it rebuilds on its own - and the derived kind, like the standings, is never stored at all.</p>

<p>Deciding which of your memories is fact and which is presentation - that’s not implementation. That’s the design.</p>

<h2 id="forgetting-properly-takes-work">Forgetting properly takes work</h2>

<p>I don’t want to sell you the idea that forgetting is just hitting delete. Forgetting well is dated and deliberate, and I learned both the hard way, with bugs in production.</p>

<p>First: <strong>timestamp everything.</strong> The first diary entries were text with no notion of time, so it treated a two-week-old result as if it happened today - the problem from the beginning of this post. The fix was stamping every entry with <code class="language-plaintext highlighter-rouge">[Jun 22]</code> and opening every prompt with a “today is…” line. The model needs an anchor to figure out what “old” means. Give it the anchor, explicitly.</p>

<p>Second: <strong>don’t reconstruct the past.</strong> If the memory boots up mid-tournament, it adopts the already-finished matches as “settled” without writing the story of any of them. Without that, the first boot fires off thirty fresh little stories about old matches - and now you’ve manufactured a Funes with vivid false memories on day one.</p>

<p>In other words: forgetting is not the absence of a memory system. Forgetting <em>is</em> the memory system. The hard part isn’t storing. It’s deciding what goes, when, and what must never go.</p>

<h2 id="funes-never-wins-the-pool">Funes never wins the pool</h2>

<p>In the end, BETanIA won the World Cup pool. It finished first, 50 points ahead of the runner-up - which was me, the guy who built it.</p>

<p><img src="/assets/images/betania-final-leaderboard.png" alt="BEThoven's final leaderboard in the terminal: BETanIA in 1st place with 370 points, Edy Silva (admin) in 2nd with 320, helton in 3rd with 317, Jeff in 4th with 316, and the rest of the pool below." /></p>

<p>Let me be honest about one thing: memory is not why it won. It won on the guesses - web search, no ego, no favorite team. A bot with no memory at all would have scored the same points.</p>

<p>Memory is why <strong>nobody minded losing to it.</strong> The difference between a tool that spits out results and a character that shares a story with the team.</p>

<p>And that continuity doesn’t come from a bigger context window. It comes from the opposite. Every technique in this post is the same move repeated: condense the truth at the moment it’s about to become unrecoverable, store it small, date it, and hand the model only what the current task needs.</p>

<p>Funes kept everything and couldn’t think. The goldfish kept nothing and couldn’t remember. Your agent needs to sit in between - and sitting in between is a design decision, not an accident.</p>

<p>If you’re building an agent right now, stop asking how much it should remember. Ask what it can forget. It’s the same question we ask about our own heads without noticing - and Borges had answered it decades before a single token existed.</p>

<p>All of BETanIA’s code is on <a href="https://github.com/geeksilva97/bethoven">GitHub</a>, memory design doc included. If you enjoy looking at the guts of one of these things, that’s the place.</p>

<p>Thanks for reading!</p>]]></content><author><name>CodeSilva</name></author><category term="ai" /><category term="agents" /><category term="memory" /><category term="context-engineering" /><summary type="html"><![CDATA[Every long-running AI agent risks becoming Borges' Funes: remembering everything and locking up because of it. Why forgetting the right things separates an agent that works from one that can't think - with Claude Code and BETanIA as examples.]]></summary></entry><entry xml:lang="en-US"><title type="html">The Bugs That Made Bun Migrate From Zig to Rust</title><link href="https://codesilva.com/low-level/2026/07/21/the-bugs-that-made-bun-migrate-from-zig-to-rust.html" rel="alternate" type="text/html" title="The Bugs That Made Bun Migrate From Zig to Rust" /><published>2026-07-21T00:00:00+00:00</published><updated>2026-07-21T00:00:00+00:00</updated><id>https://codesilva.com/low-level/2026/07/21/the-bugs-that-made-bun-migrate-from-zig-to-rust</id><content type="html" xml:base="https://codesilva.com/low-level/2026/07/21/the-bugs-that-made-bun-migrate-from-zig-to-rust.html"><![CDATA[<p>In May 2026, Bun’s 535,000 lines of Zig became Rust in a single PR with <a href="https://github.com/oven-sh/bun/pull/30412">6,755 commits</a>. The community split.</p>

<p>The detail that explains the 6,755 commits: Jarred Sumner, Bun’s creator, didn’t write it by hand. He orchestrated around 50 Claude Code workflows running non-stop for 11 days.</p>

<p>And days before merging, he was still treating the whole thing as an experiment - <em>“There’s a very high chance all this code gets thrown out completely.”</em> It got merged anyway. What unlocked it was empirical: when 100% of the test suite passed on every platform, his opinion went from “worth trying” to “I’m merging this”.</p>

<p>In the <a href="https://bun.com/blog/bun-in-rust">post about the migration</a>, Jarred presents a list of bugs that lived in the codebase - use-after-free, double-free, memory leak - all from the same class of problem, which he claims is easier to prevent in Rust.</p>

<p>Let’s go through these bugs and what they mean.</p>

<h2 id="the-class-of-problem">The class of problem</h2>

<p>Bun is a JavaScript runtime. It manages two worlds of memory at the same time:</p>

<ol>
  <li><strong>GC-managed memory</strong> - JS objects, strings, <code class="language-plaintext highlighter-rouge">ArrayBuffer</code>s that JavaScriptCore’s garbage collector controls</li>
  <li><strong>Manually managed memory</strong> - pointers, buffers, C/C++/Zig handles that you allocate and free yourself</li>
</ol>

<p>The problem is when the two worlds mix. JS code can come back in the middle of a native operation (reentrancy). Callbacks like <code class="language-plaintext highlighter-rouge">valueOf()</code> and <code class="language-plaintext highlighter-rouge">toString()</code> can run arbitrary code. Errors can exit through paths nobody tested. And then:</p>

<ul>
  <li>You free memory that is still in use (<strong>use-after-free</strong>)</li>
  <li>You free the same memory twice (<strong>double-free</strong>)</li>
  <li>You forget to free (<strong>memory leak</strong>)</li>
</ul>

<p>Let’s take each one.</p>

<h2 id="use-after-free">Use-after-free</h2>

<p>In garbage-collected languages, when you no longer need an object, it just goes away. The GC handles everything.</p>

<p>In C, there is no GC. You ask the system for memory with <code class="language-plaintext highlighter-rouge">malloc()</code> and give it back with <code class="language-plaintext highlighter-rouge">free()</code>. After <code class="language-plaintext highlighter-rouge">free()</code>, that address is no longer yours. But the pointer still points there.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Compile: gcc -fsanitize=address -g uaf.c -o uaf &amp;&amp; ./uaf</span>
<span class="cp">#include</span> <span class="cpf">&lt;stdio.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;stdlib.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;string.h&gt;</span><span class="cp">
</span>
<span class="kt">char</span><span class="o">*</span> <span class="n">cache</span><span class="p">;</span>   <span class="c1">// a reference stashed away for later reuse</span>

<span class="kt">void</span> <span class="nf">processa</span><span class="p">(</span><span class="kt">char</span><span class="o">*</span> <span class="n">msg</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">printf</span><span class="p">(</span><span class="s">"Processing: %s</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">msg</span><span class="p">);</span>
    <span class="n">cache</span> <span class="o">=</span> <span class="n">msg</span><span class="p">;</span>    <span class="c1">// stash the pointer</span>
    <span class="n">free</span><span class="p">(</span><span class="n">msg</span><span class="p">);</span>      <span class="c1">// and give the memory back to the system</span>
<span class="p">}</span>

<span class="kt">int</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
    <span class="kt">char</span><span class="o">*</span> <span class="n">msg</span> <span class="o">=</span> <span class="n">malloc</span><span class="p">(</span><span class="mi">100</span><span class="p">);</span>
    <span class="n">strcpy</span><span class="p">(</span><span class="n">msg</span><span class="p">,</span> <span class="s">"hello world"</span><span class="p">);</span>

    <span class="n">processa</span><span class="p">(</span><span class="n">msg</span><span class="p">);</span>

    <span class="c1">// later, another piece of code uses what's in the cache:</span>
    <span class="n">printf</span><span class="p">(</span><span class="s">"From cache: %s</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">cache</span><span class="p">);</span>   <span class="c1">// use-after-free! cache points to freed memory</span>
    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>It compiles without errors. It may even seem to work. But the behavior is <strong>undefined</strong> - it can print garbage, it can crash, it can look fine for months and blow up in production.</p>

<p>AddressSanitizer (<code class="language-plaintext highlighter-rouge">-fsanitize=address</code>) catches this kind of bug on the spot. Run with it and you’ll see:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
</code></pre></div></div>

<h3 id="diagnosis-vs-prevention">Diagnosis vs prevention</h3>

<p>Great. Amazing tool. So why not stay in C/C++ with sanitizers?</p>

<p>Because a sanitizer is <strong>diagnosis</strong>. Rust is <strong>prevention</strong>.</p>

<p>You might think that passing <code class="language-plaintext highlighter-rouge">-fsanitize=address</code> to the compiler makes this a compile-time thing. It doesn’t: the compiler doesn’t analyze whether the bug can exist, it just <strong>instruments</strong> the binary with checks that run along with the program. The flag is a compile flag; the detection happens at runtime.</p>

<p>A sanitizer runs the code and tells you: “here, on this specific input, on this specific execution path, at that moment, you accessed freed memory”. If the path with the bug never runs, the sanitizer catches nothing. It’s a testing tool - it only finds bugs on the paths you actually execute.</p>

<p>Rust’s borrow checker is static analysis. It tells you: “this code <strong>can</strong> have a use-after-free, no matter the input, the path, or the moment”. It catches the bug <strong>before</strong> the code exists. Before compiling. Before running. Before production.</p>

<p>Static analysis is not exclusive to Rust - your editor may have caught the example above. But in C it’s heuristic: it nails the easy, straight-line case and misses the hard ones. The borrow checker is neither optional nor a guess - it either proves the code is safe, or it doesn’t compile.</p>

<p>It’s the difference between a blood test and a vaccine. A sanitizer detects the disease after it shows up. Rust keeps it from showing up.</p>

<h3 id="how-this-bit-bun">How this bit Bun</h3>

<p>In Bun, the reentrancy bugs were non-deterministic. They depend on timing, on which callback runs first, on how many requests are in flight. A sanitizer can run a thousand tests and catch nothing, because the timing never lined up. The borrow checker catches it every time, because the problem is structural - two mutable references to the same data at the same time is not a timing issue, it’s a design issue.</p>

<p>A good share of the bugs listed in Bun are use-after-free. The most common pattern is <strong>reentrancy</strong>: JS code comes back in the middle of a native operation and invalidates the state the operation was using. Example: a hashmap that grows and reallocates everything internally, leaving dangling pointers to the old memory.</p>

<p>The real bugs:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">node:zlib</code></strong> - <code class="language-plaintext highlighter-rouge">heap-use-after-free</code> when calling <code class="language-plaintext highlighter-rouge">.reset()</code> while an async <code class="language-plaintext highlighter-rouge">.write()</code> is still running on the threadpool. <code class="language-plaintext highlighter-rouge">valueOf()</code>/<code class="language-plaintext highlighter-rouge">toString()</code> as the attack vector.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">node:http2</code></strong> - reentrant JS callbacks (<code class="language-plaintext highlighter-rouge">session.request()</code> inside a listener) trigger a hashmap rehash, invalidating internal stream pointers.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">UDPSocket.send()</code>/<code class="language-plaintext highlighter-rouge">sendMany()</code></strong> - <code class="language-plaintext highlighter-rouge">valueOf()</code> detaches the <code class="language-plaintext highlighter-rouge">ArrayBuffer</code> between capturing the payload and actually sending it.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">Buffer#copy</code>/<code class="language-plaintext highlighter-rouge">Buffer#fill</code></strong> - <code class="language-plaintext highlighter-rouge">valueOf()</code> detaches/resizes the <code class="language-plaintext highlighter-rouge">ArrayBuffer</code> during argument coercion.</li>
</ul>

<h2 id="double-free">Double-free</h2>

<p>If use-after-free is accessing memory after freeing it, double-free is freeing the same memory <strong>twice</strong>. But it’s not as simple as <code class="language-plaintext highlighter-rouge">free(ptr); free(ptr);</code> in the same function. Nobody does that. The problem is when <strong>two owners</strong> don’t know about each other.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Compile: gcc -fsanitize=address -g df.c -o df &amp;&amp; ./df</span>
<span class="cp">#include</span> <span class="cpf">&lt;stdio.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;stdlib.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;string.h&gt;</span><span class="cp">
</span>
<span class="k">typedef</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="kt">int</span> <span class="n">fd</span><span class="p">;</span>
    <span class="kt">char</span> <span class="n">name</span><span class="p">[</span><span class="mi">64</span><span class="p">];</span>
<span class="p">}</span> <span class="n">Pipe</span><span class="p">;</span>

<span class="c1">// Function A: schedules an async close</span>
<span class="c1">// In practice, this would be libuv scheduling a callback for the next tick</span>
<span class="kt">void</span> <span class="nf">close_pipe_async</span><span class="p">(</span><span class="n">Pipe</span><span class="o">*</span> <span class="n">pipe</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">printf</span><span class="p">(</span><span class="s">"  [async] Close scheduled. Callback will run later...</span><span class="se">\n</span><span class="s">"</span><span class="p">);</span>
    <span class="c1">// The on_pipe_close callback will run on the next tick of the event loop</span>
    <span class="c1">// and will call free(pipe) as well</span>
<span class="p">}</span>

<span class="c1">// Function B: the async close callback (runs later)</span>
<span class="kt">void</span> <span class="nf">on_pipe_close</span><span class="p">(</span><span class="n">Pipe</span><span class="o">*</span> <span class="n">pipe</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">printf</span><span class="p">(</span><span class="s">"  [callback] Freeing pipe...</span><span class="se">\n</span><span class="s">"</span><span class="p">);</span>  <span class="c1">// doesn't touch pipe-&gt;fd: it's already been freed</span>
    <span class="n">free</span><span class="p">(</span><span class="n">pipe</span><span class="p">);</span>  <span class="c1">// Second free - but the scope already freed it!</span>
<span class="p">}</span>

<span class="kt">void</span> <span class="nf">spawn_subprocess</span><span class="p">()</span> <span class="p">{</span>
    <span class="n">Pipe</span><span class="o">*</span> <span class="n">pipe</span> <span class="o">=</span> <span class="n">malloc</span><span class="p">(</span><span class="k">sizeof</span><span class="p">(</span><span class="n">Pipe</span><span class="p">));</span>
    <span class="n">pipe</span><span class="o">-&gt;</span><span class="n">fd</span> <span class="o">=</span> <span class="mi">42</span><span class="p">;</span>
    <span class="n">strcpy</span><span class="p">(</span><span class="n">pipe</span><span class="o">-&gt;</span><span class="n">name</span><span class="p">,</span> <span class="s">"stdout"</span><span class="p">);</span>

    <span class="c1">// Schedule the async close - the callback will free the pipe later</span>
    <span class="n">close_pipe_async</span><span class="p">(</span><span class="n">pipe</span><span class="p">);</span>

    <span class="c1">// But at the end of the scope, the pipe is freed too</span>
    <span class="c1">// In Zig/C, it's easy to forget the callback already owns it</span>
    <span class="n">printf</span><span class="p">(</span><span class="s">"  [scope] Freeing pipe (fd=%d)...</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">pipe</span><span class="o">-&gt;</span><span class="n">fd</span><span class="p">);</span>
    <span class="n">free</span><span class="p">(</span><span class="n">pipe</span><span class="p">);</span>  <span class="c1">// First free</span>

    <span class="c1">// ... later, on the next tick of the event loop:</span>
    <span class="n">on_pipe_close</span><span class="p">(</span><span class="n">pipe</span><span class="p">);</span>  <span class="c1">// Second free - DOUBLE-FREE!</span>
<span class="p">}</span>

<span class="kt">int</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
    <span class="n">spawn_subprocess</span><span class="p">();</span>
    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Two different functions, two paths that both believe they own the same pointer. In practice, that’s how double-free happens: an async path that frees, and a sync path that also frees, and neither knows about the other.</p>

<p>In Bun, this bug came from the exact pattern the code above shows: <code class="language-plaintext highlighter-rouge">uv_close</code> schedules an async close, and the scope that called <code class="language-plaintext highlighter-rouge">close</code> also frees the pointer. Jarred’s <a href="https://bun.com/blog/bun-in-rust">adversarial review</a> caught this bug before the merge - the fix was <code class="language-plaintext highlighter-rouge">Box::leak(pipe)</code> to transfer ownership to the callback (<a href="https://github.com/oven-sh/bun/commit/f0a454376c7">commit <code class="language-plaintext highlighter-rouge">f0a454376c7</code></a>).</p>

<h2 id="memory-leak">Memory leak</h2>

<p>A leak is allocating memory and never giving it back. It doesn’t crash. It just keeps eating RAM until the process dies or the OS kills it.</p>

<p>The most common pattern: <strong>error paths</strong>. You test the happy path. Nobody tests the error path.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Compile: gcc -g leak.c -o leak &amp;&amp; ./leak</span>
<span class="cp">#include</span> <span class="cpf">&lt;stdio.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;stdlib.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;string.h&gt;</span><span class="cp">
</span>
<span class="kt">int</span> <span class="nf">read_config</span><span class="p">(</span><span class="k">const</span> <span class="kt">char</span><span class="o">*</span> <span class="n">path</span><span class="p">)</span> <span class="p">{</span>
    <span class="kt">char</span><span class="o">*</span> <span class="n">buffer</span> <span class="o">=</span> <span class="n">malloc</span><span class="p">(</span><span class="mi">4096</span><span class="p">);</span>
    <span class="kt">char</span><span class="o">*</span> <span class="n">temp</span> <span class="o">=</span> <span class="n">malloc</span><span class="p">(</span><span class="mi">1024</span><span class="p">);</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">strcmp</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="s">"invalid"</span><span class="p">)</span> <span class="o">==</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
        <span class="c1">// Error! But buffer and temp were allocated and never freed.</span>
        <span class="c1">// On the happy path, there's a free() at the end.</span>
        <span class="c1">// Here? Forgotten.</span>
        <span class="k">return</span> <span class="o">-</span><span class="mi">1</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="c1">// ... process config ...</span>
    <span class="n">free</span><span class="p">(</span><span class="n">buffer</span><span class="p">);</span>
    <span class="n">free</span><span class="p">(</span><span class="n">temp</span><span class="p">);</span>
    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>

<span class="kt">int</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
    <span class="n">read_config</span><span class="p">(</span><span class="s">"invalid"</span><span class="p">);</span>
    <span class="c1">// buffer and temp leaked. If this runs on a 24/7 server,</span>
    <span class="c1">// that's 5 KB per call. A thousand calls = 5 MB. A million = 5 GB.</span>
    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Bun’s most subtle bug was a <strong>reference count underflow</strong>: the reference counter went below zero and wrapped into a huge number (4,294,967,295 in unsigned). The GC thought there were still billions of references and never collected the object. <code class="language-plaintext highlighter-rouge">fs.watch()</code> leaked permanently because of this.</p>

<p>The real leaks:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">crypto.scrypt</code></strong> - callback buffers and protected password/salt never freed when the output buffer allocation fails.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">SSLWrapper.init</code></strong> - the <code class="language-plaintext highlighter-rouge">strdup</code> of the passphrase leaked on error paths.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">tlsSocket.setSession()</code></strong> - every call leaked an <code class="language-plaintext highlighter-rouge">SSL_SESSION</code> (~6.5 KB). Missing <code class="language-plaintext highlighter-rouge">SSL_SESSION_free</code> after <code class="language-plaintext highlighter-rouge">d2i_SSL_SESSION</code>.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">fs.watch()</code></strong> - reference count underflow pinned watchers as GC roots permanently. Never collected, even after <code class="language-plaintext highlighter-rouge">.close()</code>.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">DuplexUpgradeContext</code></strong> - full leak via <code class="language-plaintext highlighter-rouge">tls.connect({ socket: duplex })</code>. Never freed.</li>
</ul>

<p>Bun improved its integration with <a href="https://github.com/oven-sh/bun/pull/30875">LeakSanitizer to track native memory allocations</a> - but as we saw in the use-after-free section, sanitizers are diagnosis, not prevention.</p>

<h2 id="raii-the-concept-that-solves-all-of-this">RAII: the concept that solves all of this</h2>

<p>By now it sounds like C is a nightmare and we should all give up. But the solution exists, and it’s older than many people reading this post.</p>

<p>RAII is a pattern from the early days of C++, named by Bjarne Stroustrup (the creator of the language). The name is terrible: <strong>R</strong>esource <strong>A</strong>cquisition <strong>I</strong>s <strong>I</strong>nitialization. The idea is simple: the resource is acquired when the object is constructed and released when it’s destroyed. Automatic. Nothing to forget.</p>

<p>Let’s see the difference in practice.</p>

<h3 id="without-raii-c">Without RAII (C)</h3>

<p>In C, you are responsible for every <code class="language-plaintext highlighter-rouge">malloc()</code> and every <code class="language-plaintext highlighter-rouge">free()</code>. Every error path is a place where you can forget.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Compile: gcc -g no_raii.c -o no_raii &amp;&amp; ./no_raii</span>
<span class="cp">#include</span> <span class="cpf">&lt;stdio.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;stdlib.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;string.h&gt;</span><span class="cp">
</span>
<span class="k">typedef</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="kt">char</span><span class="o">*</span> <span class="n">data</span><span class="p">;</span>
    <span class="kt">size_t</span> <span class="n">len</span><span class="p">;</span>
<span class="p">}</span> <span class="n">Buffer</span><span class="p">;</span>

<span class="n">Buffer</span><span class="o">*</span> <span class="nf">buf_create</span><span class="p">(</span><span class="kt">size_t</span> <span class="n">size</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">Buffer</span><span class="o">*</span> <span class="n">b</span> <span class="o">=</span> <span class="n">malloc</span><span class="p">(</span><span class="k">sizeof</span><span class="p">(</span><span class="n">Buffer</span><span class="p">));</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">b</span><span class="p">)</span> <span class="k">return</span> <span class="nb">NULL</span><span class="p">;</span>

    <span class="n">b</span><span class="o">-&gt;</span><span class="n">data</span> <span class="o">=</span> <span class="n">malloc</span><span class="p">(</span><span class="n">size</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">b</span><span class="o">-&gt;</span><span class="n">data</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">free</span><span class="p">(</span><span class="n">b</span><span class="p">);</span>      <span class="c1">// have to remember to free b if data fails</span>
        <span class="k">return</span> <span class="nb">NULL</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="n">b</span><span class="o">-&gt;</span><span class="n">len</span> <span class="o">=</span> <span class="n">size</span><span class="p">;</span>
    <span class="k">return</span> <span class="n">b</span><span class="p">;</span>
<span class="p">}</span>

<span class="kt">void</span> <span class="nf">buf_destroy</span><span class="p">(</span><span class="n">Buffer</span><span class="o">*</span> <span class="n">b</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">b</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">free</span><span class="p">(</span><span class="n">b</span><span class="o">-&gt;</span><span class="n">data</span><span class="p">);</span>
        <span class="n">free</span><span class="p">(</span><span class="n">b</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="kt">int</span> <span class="nf">process</span><span class="p">(</span><span class="k">const</span> <span class="kt">char</span><span class="o">*</span> <span class="n">path</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">Buffer</span><span class="o">*</span> <span class="n">buf</span> <span class="o">=</span> <span class="n">buf_create</span><span class="p">(</span><span class="mi">4096</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">buf</span><span class="p">)</span> <span class="k">return</span> <span class="o">-</span><span class="mi">1</span><span class="p">;</span>

    <span class="n">Buffer</span><span class="o">*</span> <span class="n">extra</span> <span class="o">=</span> <span class="n">buf_create</span><span class="p">(</span><span class="mi">1024</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">extra</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">buf_destroy</span><span class="p">(</span><span class="n">buf</span><span class="p">);</span>   <span class="c1">// have to remember</span>
        <span class="k">return</span> <span class="o">-</span><span class="mi">1</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">strcmp</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="s">"bad"</span><span class="p">)</span> <span class="o">==</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">buf_destroy</span><span class="p">(</span><span class="n">extra</span><span class="p">);</span>  <span class="c1">// have to remember</span>
        <span class="n">buf_destroy</span><span class="p">(</span><span class="n">buf</span><span class="p">);</span>    <span class="c1">// have to remember</span>
        <span class="k">return</span> <span class="o">-</span><span class="mi">1</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="n">printf</span><span class="p">(</span><span class="s">"  OK: %s</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">path</span><span class="p">);</span>
    <span class="n">buf_destroy</span><span class="p">(</span><span class="n">extra</span><span class="p">);</span>
    <span class="n">buf_destroy</span><span class="p">(</span><span class="n">buf</span><span class="p">);</span>
    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>

<span class="kt">int</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
    <span class="n">process</span><span class="p">(</span><span class="s">"good"</span><span class="p">);</span>
    <span class="n">process</span><span class="p">(</span><span class="s">"bad"</span><span class="p">);</span>
    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>3 resources, 2 error paths, 6 places where you have to remember to free. Forget one? Leak. It scales linearly with the number of resources.</p>

<h3 id="with-raii-c">With RAII (C++)</h3>

<p>In C++, destructors run automatically when the object goes out of scope. Even on error paths. Even with exceptions.</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Compile: g++ -g raii.cpp -o raii &amp;&amp; ./raii</span>
<span class="cp">#include</span> <span class="cpf">&lt;iostream&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;memory&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;string&gt;</span><span class="cp">
</span>
<span class="k">class</span> <span class="nc">Buffer</span> <span class="p">{</span>
    <span class="n">std</span><span class="o">::</span><span class="n">unique_ptr</span><span class="o">&lt;</span><span class="kt">char</span><span class="p">[]</span><span class="o">&gt;</span> <span class="n">data</span><span class="p">;</span>
    <span class="kt">size_t</span> <span class="n">len</span><span class="p">;</span>
<span class="nl">public:</span>
    <span class="k">explicit</span> <span class="n">Buffer</span><span class="p">(</span><span class="kt">size_t</span> <span class="n">size</span><span class="p">)</span>
        <span class="o">:</span> <span class="n">data</span><span class="p">(</span><span class="n">std</span><span class="o">::</span><span class="n">make_unique</span><span class="o">&lt;</span><span class="kt">char</span><span class="p">[]</span><span class="o">&gt;</span><span class="p">(</span><span class="n">size</span><span class="p">)),</span> <span class="n">len</span><span class="p">(</span><span class="n">size</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">std</span><span class="o">::</span><span class="n">cout</span> <span class="o">&lt;&lt;</span> <span class="s">"  + Buffer allocated ("</span> <span class="o">&lt;&lt;</span> <span class="n">len</span> <span class="o">&lt;&lt;</span> <span class="s">" bytes)</span><span class="se">\n</span><span class="s">"</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="o">~</span><span class="n">Buffer</span><span class="p">()</span> <span class="p">{</span>
        <span class="n">std</span><span class="o">::</span><span class="n">cout</span> <span class="o">&lt;&lt;</span> <span class="s">"  - Buffer freed ("</span> <span class="o">&lt;&lt;</span> <span class="n">len</span> <span class="o">&lt;&lt;</span> <span class="s">" bytes)</span><span class="se">\n</span><span class="s">"</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="n">Buffer</span><span class="p">(</span><span class="k">const</span> <span class="n">Buffer</span><span class="o">&amp;</span><span class="p">)</span> <span class="o">=</span> <span class="k">delete</span><span class="p">;</span>
    <span class="n">Buffer</span><span class="o">&amp;</span> <span class="k">operator</span><span class="o">=</span><span class="p">(</span><span class="k">const</span> <span class="n">Buffer</span><span class="o">&amp;</span><span class="p">)</span> <span class="o">=</span> <span class="k">delete</span><span class="p">;</span>
<span class="p">};</span>

<span class="kt">int</span> <span class="nf">process</span><span class="p">(</span><span class="k">const</span> <span class="n">std</span><span class="o">::</span><span class="n">string</span><span class="o">&amp;</span> <span class="n">path</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">Buffer</span> <span class="n">buf</span><span class="p">(</span><span class="mi">4096</span><span class="p">);</span>
    <span class="n">Buffer</span> <span class="n">extra</span><span class="p">(</span><span class="mi">1024</span><span class="p">);</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">path</span> <span class="o">==</span> <span class="s">"bad"</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">std</span><span class="o">::</span><span class="n">cout</span> <span class="o">&lt;&lt;</span> <span class="s">"  Error! Bailing out...</span><span class="se">\n</span><span class="s">"</span><span class="p">;</span>
        <span class="k">return</span> <span class="o">-</span><span class="mi">1</span><span class="p">;</span>
        <span class="c1">// buf and extra are DESTROYED automatically here.</span>
        <span class="c1">// Even on the error path. Impossible to forget.</span>
    <span class="p">}</span>

    <span class="n">std</span><span class="o">::</span><span class="n">cout</span> <span class="o">&lt;&lt;</span> <span class="s">"  OK: "</span> <span class="o">&lt;&lt;</span> <span class="n">path</span> <span class="o">&lt;&lt;</span> <span class="s">"</span><span class="se">\n</span><span class="s">"</span><span class="p">;</span>
    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
    <span class="c1">// buf and extra are DESTROYED automatically here too.</span>
<span class="p">}</span>

<span class="kt">int</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
    <span class="n">std</span><span class="o">::</span><span class="n">cout</span> <span class="o">&lt;&lt;</span> <span class="s">"=== Happy path ===</span><span class="se">\n</span><span class="s">"</span><span class="p">;</span>
    <span class="n">process</span><span class="p">(</span><span class="s">"good"</span><span class="p">);</span>

    <span class="n">std</span><span class="o">::</span><span class="n">cout</span> <span class="o">&lt;&lt;</span> <span class="s">"</span><span class="se">\n</span><span class="s">=== Error path ===</span><span class="se">\n</span><span class="s">"</span><span class="p">;</span>
    <span class="n">process</span><span class="p">(</span><span class="s">"bad"</span><span class="p">);</span>

    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Run it and see:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>=== Happy path ===
  + Buffer allocated (4096 bytes)
  + Buffer allocated (1024 bytes)
  OK: good
  - Buffer freed (1024 bytes)
  - Buffer freed (4096 bytes)

=== Error path ===
  + Buffer allocated (4096 bytes)
  + Buffer allocated (1024 bytes)
  Error! Bailing out...
  - Buffer freed (1024 bytes)
  - Buffer freed (4096 bytes)
</code></pre></div></div>

<p>The destructors run on <strong>every path</strong>. Happy, sad, exception, early return. The compiler guarantees it. There is no way to forget.</p>

<p>The same idea fixes the <code class="language-plaintext highlighter-rouge">SSL_SESSION</code> bug that leaked 6.5 KB per call in Bun:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Compile: g++ -g ssl_session.cpp -o ssl_session &amp;&amp; ./ssl_session</span>
<span class="cp">#include</span> <span class="cpf">&lt;iostream&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;cstdlib&gt;</span><span class="cp">
</span>
<span class="k">class</span> <span class="nc">SSLSession</span> <span class="p">{</span>
    <span class="kt">void</span><span class="o">*</span> <span class="n">session</span><span class="p">;</span>
<span class="nl">public:</span>
    <span class="k">explicit</span> <span class="n">SSLSession</span><span class="p">()</span> <span class="p">{</span>
        <span class="n">session</span> <span class="o">=</span> <span class="n">malloc</span><span class="p">(</span><span class="mi">6500</span><span class="p">);</span>  <span class="c1">// simulates d2i_SSL_SESSION</span>
        <span class="n">std</span><span class="o">::</span><span class="n">cout</span> <span class="o">&lt;&lt;</span> <span class="s">"  + SSL_SESSION allocated</span><span class="se">\n</span><span class="s">"</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="o">~</span><span class="n">SSLSession</span><span class="p">()</span> <span class="p">{</span>
        <span class="n">free</span><span class="p">(</span><span class="n">session</span><span class="p">);</span>  <span class="c1">// simulates SSL_SESSION_free</span>
        <span class="n">std</span><span class="o">::</span><span class="n">cout</span> <span class="o">&lt;&lt;</span> <span class="s">"  - SSL_SESSION freed</span><span class="se">\n</span><span class="s">"</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="n">SSLSession</span><span class="p">(</span><span class="k">const</span> <span class="n">SSLSession</span><span class="o">&amp;</span><span class="p">)</span> <span class="o">=</span> <span class="k">delete</span><span class="p">;</span>
<span class="p">};</span>

<span class="kt">void</span> <span class="nf">set_session</span><span class="p">()</span> <span class="p">{</span>
    <span class="n">SSLSession</span> <span class="n">sess</span><span class="p">;</span>
    <span class="c1">// sess is destroyed when it goes out of scope</span>
    <span class="c1">// IMPOSSIBLE to forget to free</span>
<span class="p">}</span>

<span class="kt">int</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
    <span class="n">set_session</span><span class="p">();</span>  <span class="c1">// allocates and frees automatically, no hand-written SSL_SESSION_free</span>
    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>If the developer forgot to call <code class="language-plaintext highlighter-rouge">SSL_SESSION_free()</code> by hand, it leaked. With RAII, the destructor does it automatically. It’s the compiler guaranteeing what human memory forgets.</p>

<h3 id="raii-in-rust-drop--borrow-checker">RAII in Rust: Drop + borrow checker</h3>

<p>Rust adopts RAII through <code class="language-plaintext highlighter-rouge">Drop</code> - the equivalent of a C++ destructor. The difference is that Rust goes further: the <strong>borrow checker</strong> prevents use-after-free at compile time.</p>

<p>In C++, RAII solves leaks and double-free. But use-after-free is still possible - a raw pointer can point to memory the destructor already released. In Rust, the borrow checker closes that door: it won’t let two mutable references to the same data coexist.</p>

<p>That <code class="language-plaintext highlighter-rouge">http2</code> reentrancy bug - taking a mutable reference to the stream and, midway through, letting JS mutate the same structure - doesn’t even compile in Rust. The compiler refuses with <code class="language-plaintext highlighter-rouge">error[E0499]: cannot borrow as mutable more than once at a time</code>. It’s not discipline, it’s not convention, it’s not a test you have to remember to run: the code simply doesn’t get past the compiler.</p>

<p>What in Zig was a non-deterministic crash in production, in Rust becomes a deterministic compile error.</p>

<p>The Bun team even tried to emulate this in Zig, with <a href="https://github.com/oven-sh/bun/blob/3a79bd746b11601c9db970b608c73f0b9f96ac81/src/ptr/shared.zig#L569">homegrown smart pointers</a>:</p>

<div class="language-zig highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">fn</span> <span class="n">foo</span><span class="p">(</span><span class="n">a_ptr</span><span class="p">:</span> <span class="n">SharedPtr</span><span class="p">(</span><span class="n">TCPSocket</span><span class="p">))</span> <span class="o">!</span><span class="k">void</span> <span class="p">{</span>
  <span class="k">const</span> <span class="n">a</span><span class="p">:</span> <span class="o">*</span><span class="n">TCPSocket</span> <span class="o">=</span> <span class="n">a_ptr</span><span class="p">.</span><span class="nf">get</span><span class="p">();</span>
  <span class="k">defer</span> <span class="n">a_ptr</span><span class="p">.</span><span class="nf">deref</span><span class="p">();</span>

  <span class="k">const</span> <span class="n">b</span> <span class="o">=</span> <span class="k">try</span> <span class="n">do_something_with_a</span><span class="p">(</span><span class="n">a</span><span class="p">);</span>
  <span class="k">defer</span> <span class="n">b</span><span class="p">.</span><span class="nf">deref</span><span class="p">();</span>

  <span class="c">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Notice that every resource requires a hand-written <code class="language-plaintext highlighter-rouge">defer</code> - and someone has to remember to write each one. Jarred himself admits it:</p>

<blockquote>
  <p><em>“Homegrown smart pointers offer worse ergonomics than Rust, with none of the guarantees.”</em></p>
</blockquote>

<h2 id="summary">Summary</h2>

<p>Adapted from the table in Jarred’s post, the cleanup mechanism of each language:</p>

<table>
  <thead>
    <tr>
      <th>Language</th>
      <th>Cleanup mechanism</th>
      <th>Guarantee</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Zig</strong></td>
      <td><code class="language-plaintext highlighter-rouge">defer</code>, <code class="language-plaintext highlighter-rouge">errdefer</code></td>
      <td>Manual - you write it, you can forget it</td>
    </tr>
    <tr>
      <td><strong>C</strong></td>
      <td>explicit <code class="language-plaintext highlighter-rouge">free()</code></td>
      <td>Manual - every error path needs auditing</td>
    </tr>
    <tr>
      <td><strong>C++</strong></td>
      <td><code class="language-plaintext highlighter-rouge">~Destructor</code>, <code class="language-plaintext highlighter-rouge">std::unique_ptr</code></td>
      <td>RAII - automatic on scope exit</td>
    </tr>
    <tr>
      <td><strong>Rust</strong></td>
      <td><code class="language-plaintext highlighter-rouge">Drop</code>, ownership + borrow checker</td>
      <td>RAII + verified at compile time</td>
    </tr>
  </tbody>
</table>

<p>And what each concept solves in practice:</p>

<table>
  <thead>
    <tr>
      <th>Concept</th>
      <th>What it solves</th>
      <th>Without it</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>RAII / Drop</strong></td>
      <td>Memory leaks on error paths, double-free</td>
      <td>Manual <code class="language-plaintext highlighter-rouge">defer</code> that can be forgotten</td>
    </tr>
    <tr>
      <td><strong>Ownership</strong></td>
      <td>Double-free - only one owner frees</td>
      <td>Anyone can free</td>
    </tr>
    <tr>
      <td><strong>Borrow checker</strong></td>
      <td>Use-after-free from reentrancy</td>
      <td>Non-deterministic crash in production</td>
    </tr>
    <tr>
      <td><strong>Lifetimes</strong></td>
      <td>Use-after-free from expired references</td>
      <td>Reference to memory that was already freed</td>
    </tr>
  </tbody>
</table>

<p>Strip away the noise and Bun’s migration is about one thing: the class of bug that shows up when GC and manual memory mix, and which tool prevents it by construction. RAII takes care of leaks and double-free. The borrow checker takes care of use-after-free from reentrancy.</p>

<p>A counterpoint is worth mentioning. <a href="https://andrewkelley.me/post/my-thoughts-bun-rust-rewrite.html">Andrew Kelley, Zig’s creator, responded to the migration</a> saying the problem was never the language - it was code quality. And he has a point: TigerBeetle writes Zig without these bugs. You can avoid all of this in Zig. Jarred himself admits he doesn’t blame Zig.</p>

<p>But “you can avoid it with discipline” and “the compiler won’t let it happen” are different things. It’s the same difference from the beginning of this post: the sanitizer you need to run on the right path, versus the borrow checker that catches it every time. Kelley bets on the team; Rust bets on not needing a perfect team for this class of bug.</p>

<p>The bugs listed in Jarred’s post? In safe Rust, almost none would survive. Use-after-free and double-free become compile errors - the borrow checker and ownership refuse them. The leaks, <code class="language-plaintext highlighter-rouge">Drop</code> prevents on its own.</p>

<p>Thanks for reading!</p>]]></content><author><name>CodeSilva</name></author><category term="rust" /><category term="c" /><category term="cpp" /><category term="memory-safety" /><category term="systems-programming" /><summary type="html"><![CDATA[In May 2026, Bun’s 535,000 lines of Zig became Rust in a single PR with 6,755 commits. The community split.]]></summary></entry><entry xml:lang="en-US"><title type="html">Claude Built My Compiler in 3 Minutes. Should That Worry Me?</title><link href="https://codesilva.com/programacao/2026/03/29/claude-built-my-compiler-in-3-minutes-should-that-worry-me.html" rel="alternate" type="text/html" title="Claude Built My Compiler in 3 Minutes. Should That Worry Me?" /><published>2026-03-29T00:00:00+00:00</published><updated>2026-03-29T00:00:00+00:00</updated><id>https://codesilva.com/programacao/2026/03/29/claude-built-my-compiler-in-3-minutes-should-that-worry-me</id><content type="html" xml:base="https://codesilva.com/programacao/2026/03/29/claude-built-my-compiler-in-3-minutes-should-that-worry-me.html"><![CDATA[<p>I spent weeks building <a href="https://github.com/geeksilva97/brainjuck">BrainJuck</a>. A Brainfuck-to-JVM compiler, written by hand in Node.js, with zero dependencies. Weeks reading hex dumps, fighting the StackMapTable, miscalculating jump offsets at two in the morning. I <a href="/programacao/2026/03/09/eu-criei-um-compilador-para-jvm-so-para-provar-um-ponto">already wrote about it</a> (in Portuguese).</p>

<p>Yesterday I opened Claude Code and sent a prompt. A single prompt. No back and forth, no corrections, no guiding. I pasted it and went to make coffee:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>create a compiler from Brainfuck to JVM in Node.js with no dependencies. Full Node.js.

the interface should be ./compiler somefile.bf SomeFile

i should be able to run SomeFile just by running "java SomeFile"

every brainfuck command should be accepted and the produced .class must not have a warning.
You must test everything, from the parser to the generation of the .class.
</code></pre></div></div>

<p>When I came back, three minutes later, the compiler was done. 51 tests passing, a valid <code class="language-plaintext highlighter-rouge">.class</code>, Hello World running with no warnings on stderr. One prompt. Three minutes.</p>

<p>My first reaction was to laugh. The second was to go quiet.</p>

<h2 id="what-it-generated">What it generated</h2>

<p>Claude’s compiler is a single file, ~300 lines. Parser, constant pool, bytecode generation, StackMapTable, <code class="language-plaintext highlighter-rouge">.class</code> assembly - all in one place. It works. All eight Brainfuck commands compile correctly to JVM bytecode. The loops generate <code class="language-plaintext highlighter-rouge">ifeq</code>/<code class="language-plaintext highlighter-rouge">goto</code> with correct offsets. The <code class="language-plaintext highlighter-rouge">append_frame</code> in the StackMapTable declares the right local variables. <code class="language-plaintext highlighter-rouge">javap -v</code> shows a valid class.</p>

<p>The test suite is decent: it tests the parser in isolation, the constant pool, the bytecode generation, the StackMapTable, the structure of the <code class="language-plaintext highlighter-rouge">.class</code>, and runs 19 integration tests that compile Brainfuck, execute it with <code class="language-plaintext highlighter-rouge">java</code>, and check the output. Hello World, nested loops, input, cell wrapping, all 8 commands together.</p>

<p>It works. No tricks, no hacks. It even got the <code class="language-plaintext highlighter-rouge">append_frame</code> right on the first try - fine, on the second. On the first it used <code class="language-plaintext highlighter-rouge">same_frame</code> and got a <code class="language-plaintext highlighter-rouge">VerifyError</code>, exactly like I did. But it fixed it by itself in seconds.</p>

<h2 id="where-mine-is-better">Where mine is better</h2>

<p>BrainJuck is not just a compiler that works. It’s a compiler that <strong>thinks</strong>.</p>

<p>Claude’s parser takes <code class="language-plaintext highlighter-rouge">+++</code> and generates three separate <code class="language-plaintext highlighter-rouge">iadd</code>s. My parser combines them into an <code class="language-plaintext highlighter-rouge">increment(3)</code> and emits a <code class="language-plaintext highlighter-rouge">sipush 3</code> + <code class="language-plaintext highlighter-rouge">iadd</code>. Less bytecode, less work for the JVM.</p>

<p>BrainJuck has an IR layer between parsing and code generation. That means I can add optimizations without touching the bytecode generator. Claude went straight from parsing to bytecode - it works, but it’s rigid.</p>

<p>Mine tracks the pointer position at compile time. <code class="language-plaintext highlighter-rouge">&gt;&gt;&gt;&lt;&lt;</code> becomes a <code class="language-plaintext highlighter-rouge">move_head(1)</code> with an absolute position. Claude’s generates five separate <code class="language-plaintext highlighter-rouge">iinc</code>s. Again - it works, but it’s naive.</p>

<p>The architecture has real separation: <code class="language-plaintext highlighter-rouge">index.js</code> for the parser, <code class="language-plaintext highlighter-rouge">class_generator.js</code> for <code class="language-plaintext highlighter-rouge">.class</code> assembly, <code class="language-plaintext highlighter-rouge">helpers/jvm.js</code> for the opcodes. Claude stuffed everything into one file. For 300 lines, that’s fine. For evolving the thing, it’s not.</p>

<h2 id="where-claudes-is-better">Where Claude’s is better</h2>

<p>The test coverage. I have to admit it.</p>

<p>I have 6 unit tests and 1 integration test. Claude generated 51 tests covering each layer individually. It tests edge cases I hadn’t even thought of - the empty loop <code class="language-plaintext highlighter-rouge">[]</code>, consecutive loops <code class="language-plaintext highlighter-rouge">[][]</code>, cell wrapping, an empty program.</p>

<p>My integration test is solid - it compiles Hello World, runs it with <code class="language-plaintext highlighter-rouge">java</code>, checks stdout and stderr. But it’s one scenario. Claude tested 19 different integration scenarios.</p>

<h2 id="what-actually-matters">What actually matters</h2>

<p>This is where things get honest.</p>

<p>Claude’s compiler works. If someone came to me with “I need a Brainfuck-to-JVM compiler by tomorrow” and I used Claude, the job would be delivered. Nobody would look at the generated <code class="language-plaintext highlighter-rouge">.class</code> and know it was made in 3 minutes.</p>

<p>But I would know nothing.</p>

<p>I wouldn’t know that the <code class="language-plaintext highlighter-rouge">constant pool</code> is 1-indexed and that entry 0 doesn’t exist. I wouldn’t know that <code class="language-plaintext highlighter-rouge">baload</code> sign-extends to int. I wouldn’t know that slot 0 of the local variables is reserved for the method’s arguments. I wouldn’t know that the StackMapTable’s <code class="language-plaintext highlighter-rouge">offset_delta</code> is calculated relative to the previous frame, not to the start of the method. I wouldn’t know the difference between <code class="language-plaintext highlighter-rouge">same_frame</code> and <code class="language-plaintext highlighter-rouge">append_frame</code>, or why the first frame of a method with branches has to be an <code class="language-plaintext highlighter-rouge">append_frame</code> if you declared local variables beyond the signature.</p>

<p>None of it. My head would be empty.</p>

<p>When I was debugging BrainJuck at two in the morning, comparing hex dumps against the JVM spec, getting the offset math wrong for the tenth time - that was knowledge going in. Every <code class="language-plaintext highlighter-rouge">VerifyError</code> was a lesson. Every wrong byte I found in the hex dump was a new connection in my brain.</p>

<p>Claude didn’t have to debug any of that. It already knew. It was trained on the JVM spec, on thousands of similar implementations, on decades of accumulated knowledge. For Claude, generating a correct StackMapTable is interpolation. For me, it was the final boss of the project.</p>

<h2 id="its-not-about-the-ai-being-bad">It’s not about the AI being bad</h2>

<p>I use AI to write code. I use Claude Code practically every day. I’m not against it, I don’t think it will destroy the profession, I’m not afraid of losing my job. Let that be clear.</p>

<p>But there is a difference between using AI to speed up work you understand and using AI to do work you don’t understand. In the first case, you gain time. In the second, you gain an illusion.</p>

<p>BrainJuck took weeks. Claude’s compiler took 3 minutes. The end result is similar - both generate a valid <code class="language-plaintext highlighter-rouge">.class</code>, both compile Hello World, both pass the JVM verifier. But after those weeks, I know how the JVM works on the inside. I can read bytecode, I know what a <code class="language-plaintext highlighter-rouge">VerifyError</code> means, I can dissect a <code class="language-plaintext highlighter-rouge">.class</code> with <code class="language-plaintext highlighter-rouge">xxd</code>. Those weeks gave me something no prompt does.</p>

<p>Claude’s compiler is better tested than mine. It’s faster to produce. If I wanted, I could take its code and improve it - add the optimizations mine has, split it into modules, evolve the architecture.</p>

<p>But I would never have the foundation to do that if I hadn’t gone through those weeks first.</p>

<h2 id="the-real-test">The real test</h2>

<p>If I hand you Claude’s compiler and ask you to add an optimization - collapsing <code class="language-plaintext highlighter-rouge">[-]</code> into a direct <code class="language-plaintext highlighter-rouge">clear cell</code> in the bytecode - could you do it? If a new <code class="language-plaintext highlighter-rouge">VerifyError</code> shows up, would you know where to start?</p>

<p>If you built your own, the answer is yes. If AI built it for you, the honest answer is probably no.</p>

<p>That’s the point. It’s not that AI writes bad code. The code is good. It’s that good code you don’t understand is exactly as useful as bad code you don’t understand. Either way, when it breaks, you’re lost.</p>

<h2 id="if-youre-learning">If you’re learning</h2>

<p>If you’re a developer and you want to understand compilers, virtual machines, bytecode - build one by hand. At least once. It doesn’t have to be Brainfuck, it doesn’t have to be the JVM. Pick any simple language and compile it to any target. What matters is going through the process.</p>

<p>Once you understand it, then yes - use AI to move faster, to test more scenarios, to explore variations. AI is an absurd tool when you know what you’re doing. When you don’t, it’s just a generator of false confidence.</p>

<p><a href="https://github.com/geeksilva97/brainjuck">BrainJuck</a> is on GitHub. Read the code, play with it, break it. And if you feel like it, build your own.</p>

<p>Thanks for reading!</p>]]></content><author><name>CodeSilva</name></author><category term="compilers" /><category term="jvm" /><category term="brainfuck" /><category term="ai" /><category term="claude" /><summary type="html"><![CDATA[I spent weeks building BrainJuck. A Brainfuck-to-JVM compiler, written by hand in Node.js, with zero dependencies. Weeks reading hex dumps, fighting the StackMapTable, miscalculating jump offsets at two in the morning. I already wrote about it (in Portuguese).]]></summary></entry><entry xml:lang="en-US"><title type="html">I Dissected OpenCode to Prove You Understand NOTHING About Skills</title><link href="https://codesilva.com/ia/2026/03/06/i-dissected-opencode-to-prove-you-understand-nothing-about-skills.html" rel="alternate" type="text/html" title="I Dissected OpenCode to Prove You Understand NOTHING About Skills" /><published>2026-03-06T00:00:00+00:00</published><updated>2026-03-06T00:00:00+00:00</updated><id>https://codesilva.com/ia/2026/03/06/i-dissected-opencode-to-prove-you-understand-nothing-about-skills</id><content type="html" xml:base="https://codesilva.com/ia/2026/03/06/i-dissected-opencode-to-prove-you-understand-nothing-about-skills.html"><![CDATA[<p>The other day I saw someone in a group asking “how do I create a skill for Claude Code?”, and the answers were things like “use this template”, “install this package”, “follow this 20-step guide”. People talk about skills as if they were something magical, an advanced feature that demands special knowledge.</p>

<p>It doesn’t. And to convince you of that, I went into the source code of <a href="https://github.com/sst/opencode">OpenCode</a> to see what actually happens under the hood.</p>

<p>But first, I need to explain how LLMs really work. Without that, skills make no sense.</p>

<h2 id="llms-do-nothing">LLMs do nothing</h2>

<p>An LLM is a function. Text goes in, text comes out. It doesn’t access the internet, doesn’t read files, doesn’t execute code. It predicts the next token based on what it received.</p>

<p>That is literally all.</p>

<p>When you send a message to Claude or GPT and it “reads a file” or “searches the web”, it’s not the model doing that. It’s the system around it. The model only generates text. The one that acts is the program orchestrating the conversation.</p>

<h2 id="tools-giving-the-model-hands">Tools: giving the model hands</h2>

<p>For an LLM to interact with the real world, we use <strong>tools</strong> (or <code class="language-plaintext highlighter-rouge">function calling</code>). The flow is:</p>

<ol>
  <li>You send a message along with a <strong>list of available tools</strong> - each with a name, a description, and parameters</li>
  <li>The model analyzes the message and decides whether it needs to use a tool</li>
  <li>If so, it responds asking for the execution: <code class="language-plaintext highlighter-rouge">{ "tool": "read_file", "args": { "path": "src/main.ts" } }</code></li>
  <li>The <strong>host system</strong> (not the model) executes the tool and returns the result</li>
  <li>The model receives the result and continues generating the response</li>
</ol>

<p>The model never executes anything. It only <strong>asks</strong> for execution. The agent is the one that runs it.</p>

<h2 id="agents-the-loop-that-connects-everything">Agents: the loop that connects everything</h2>

<p>An agent is that loop. Simplified to the extreme:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>while not done:
    response = llm.generate(messages, tools)
    if response has tool_call:
        result = execute(tool_call)
        messages.append(result)
    else:
        return response
</code></pre></div></div>

<p>The agent keeps the history, injects the tool definitions, executes the calls, and feeds the model with the results. OpenCode does exactly that in <a href="https://github.com/sst/opencode/blob/dev/packages/opencode/src/session/prompt.ts"><code class="language-plaintext highlighter-rouge">packages/opencode/src/session/prompt.ts</code></a>.</p>

<blockquote>
  <p>If you want to truly understand agents, study this loop. Everything else is implementation detail.</p>
</blockquote>

<h2 id="how-opencode-registers-tools">How OpenCode registers tools</h2>

<p>In OpenCode, every tool implements a <code class="language-plaintext highlighter-rouge">Tool.Info</code> interface defined in <a href="https://github.com/sst/opencode/blob/dev/packages/opencode/src/tool/tool.ts"><code class="language-plaintext highlighter-rouge">packages/opencode/src/tool/tool.ts</code></a>:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kr">interface</span> <span class="nx">Info</span><span class="o">&lt;</span><span class="nb">Parameters</span><span class="p">,</span> <span class="nx">M</span><span class="o">&gt;</span> <span class="p">{</span>
  <span class="na">id</span><span class="p">:</span> <span class="kr">string</span>
  <span class="na">init</span><span class="p">:</span> <span class="p">(</span><span class="nx">ctx</span><span class="p">?)</span> <span class="o">=&gt;</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="p">{</span>
    <span class="na">description</span><span class="p">:</span> <span class="kr">string</span>
    <span class="na">parameters</span><span class="p">:</span> <span class="nb">Parameters</span>
    <span class="nf">execute</span><span class="p">(</span><span class="nx">args</span><span class="p">,</span> <span class="nx">ctx</span><span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="p">{</span> <span class="nx">title</span><span class="p">,</span> <span class="nx">metadata</span><span class="p">,</span> <span class="nx">output</span> <span class="p">}</span><span class="o">&gt;</span>
  <span class="p">}</span><span class="o">&gt;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Every tool has an <code class="language-plaintext highlighter-rouge">id</code>, a <code class="language-plaintext highlighter-rouge">description</code>, the <code class="language-plaintext highlighter-rouge">parameters</code> it accepts, and an <code class="language-plaintext highlighter-rouge">execute</code> function. The <code class="language-plaintext highlighter-rouge">ToolRegistry</code> in <a href="https://github.com/sst/opencode/blob/dev/packages/opencode/src/tool/registry.ts"><code class="language-plaintext highlighter-rouge">packages/opencode/src/tool/registry.ts</code></a> gathers all of them - built-in, custom, and from plugins - and hands them to the model on every interaction.</p>

<p>The built-in tools are registered like this:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">return</span> <span class="p">[</span>
  <span class="nx">ReadTool</span><span class="p">,</span> <span class="nx">GlobTool</span><span class="p">,</span> <span class="nx">GrepTool</span><span class="p">,</span> <span class="nx">EditTool</span><span class="p">,</span> <span class="nx">WriteTool</span><span class="p">,</span>
  <span class="nx">BashTool</span><span class="p">,</span> <span class="nx">TaskTool</span><span class="p">,</span> <span class="nx">WebFetchTool</span><span class="p">,</span> <span class="nx">SkillTool</span><span class="p">,</span>
  <span class="c1">// ...</span>
<span class="p">]</span>
</code></pre></div></div>

<p>Notice that <code class="language-plaintext highlighter-rouge">SkillTool</code> in the middle. Remember that name.</p>

<h2 id="now-we-can-talk-what-is-a-skill">Now we can talk: what is a skill?</h2>

<p>A skill in OpenCode is a markdown file named <code class="language-plaintext highlighter-rouge">SKILL.md</code> with a YAML frontmatter:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">agents-sdk</span>
<span class="na">description</span><span class="pi">:</span> <span class="s">Build AI agents on Cloudflare Workers using the Agents SDK</span>
<span class="nn">---</span>

<span class="c1"># Cloudflare Agents SDK</span>

<span class="s">Here go detailed instructions, code examples,</span>
<span class="s">references, best practices...</span>
</code></pre></div></div>

<p>That’s it. A <code class="language-plaintext highlighter-rouge">.md</code> file with a name and a description.</p>

<p>The code that discovers these files lives in <a href="https://github.com/sst/opencode/blob/dev/packages/opencode/src/skill/skill.ts"><code class="language-plaintext highlighter-rouge">packages/opencode/src/skill/skill.ts</code></a>. It scans for <code class="language-plaintext highlighter-rouge">SKILL.md</code> in global directories (<code class="language-plaintext highlighter-rouge">~/.claude/skills/</code>, <code class="language-plaintext highlighter-rouge">~/.agents/skills/</code>), project directories (<code class="language-plaintext highlighter-rouge">.opencode/skills/</code>), custom paths, and even remote URLs.</p>

<h2 id="the-trick-skilltool-is-just-a-tool">The trick: SkillTool is just a tool</h2>

<p>The <code class="language-plaintext highlighter-rouge">SkillTool</code> in <a href="https://github.com/sst/opencode/blob/dev/packages/opencode/src/tool/skill.ts"><code class="language-plaintext highlighter-rouge">packages/opencode/src/tool/skill.ts</code></a> is a <strong>tool like any other</strong>. In its <code class="language-plaintext highlighter-rouge">init()</code> function, it:</p>

<ol>
  <li>Scans all the available <code class="language-plaintext highlighter-rouge">SKILL.md</code> files</li>
  <li>Builds its own description listing what it found:</li>
</ol>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;available_skills&gt;</span>
  <span class="nt">&lt;skill&gt;</span>
    <span class="nt">&lt;name&gt;</span>agents-sdk<span class="nt">&lt;/name&gt;</span>
    <span class="nt">&lt;description&gt;</span>Build AI agents on Cloudflare Workers...<span class="nt">&lt;/description&gt;</span>
  <span class="nt">&lt;/skill&gt;</span>
<span class="nt">&lt;/available_skills&gt;</span>
</code></pre></div></div>

<p>That description goes to the model along with the other tools. When the model decides it needs a skill, it makes a regular tool call:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w"> </span><span class="nl">"tool"</span><span class="p">:</span><span class="w"> </span><span class="s2">"skill"</span><span class="p">,</span><span class="w"> </span><span class="nl">"args"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"agents-sdk"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">SkillTool</code> receives that call, reads the corresponding <code class="language-plaintext highlighter-rouge">SKILL.md</code>, and returns its content. The model uses those instructions to continue the work.</p>

<p>Read that again. It’s the same flow as any tool. The model asks, the system reads a file, the content goes back into the context.</p>

<p>Think of it this way: if the model can call <code class="language-plaintext highlighter-rouge">read_file</code> to read a code file, why can’t it call a tool to read an instructions file? That’s exactly what <code class="language-plaintext highlighter-rouge">SkillTool</code> does. The only difference is the convention - a standardized place to put reusable instructions that the model pulls on demand.</p>

<h2 id="how-to-create-a-skill-for-real">How to create a skill, for real</h2>

<ol>
  <li>Create a folder inside <code class="language-plaintext highlighter-rouge">.opencode/skills/</code> (or <code class="language-plaintext highlighter-rouge">.claude/skills/</code>, depending on the agent)</li>
  <li>Put a <code class="language-plaintext highlighter-rouge">SKILL.md</code> inside it with <code class="language-plaintext highlighter-rouge">name</code> and <code class="language-plaintext highlighter-rouge">description</code> in the frontmatter</li>
  <li>Write the instructions in markdown</li>
</ol>

<p>Done. There is no step 4.</p>

<p>The model will see your skill’s short description in the list of available tools. If it finds it relevant to what it’s doing, it will call the tool and read the full content. If it doesn’t, it ignores it. You don’t force anything.</p>

<p>The full content only enters the context when the model asks for it. The descriptions are lightweight; the heavy markdown stays out until it’s needed.</p>

<p>One tip on what to put in a skill: Sean Goedecke wrote about <a href="https://www.seangoedecke.com/generate-skills-afterwards">generating skills after solving the problem</a>, not before. The idea is that the LLM writes better skills after it has already iterated on the solution, because then it distills what it learned. It makes sense - you don’t write good documentation before understanding the problem.</p>

<h2 id="conclusion">Conclusion</h2>

<p>A skill is a markdown file that a tool reads when the model asks for it. The same <code class="language-plaintext highlighter-rouge">tool calling</code> that lets the model read files or run commands is what lets it load a skill. There is no framework, no runtime, no magic.</p>

<p>If you know how to write markdown, you know how to create skills.</p>

<p>Thanks for reading!</p>]]></content><author><name>CodeSilva</name></author><category term="ai" /><category term="agents" /><category term="llm" /><category term="skills" /><category term="opencode" /><summary type="html"><![CDATA[Skills aren't magic. They're markdown files a tool reads when the model asks. To understand that, all you need is to understand how LLMs and tools work.]]></summary></entry></feed>
