<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="pt-BR"><generator uri="https://jekyllrb.com/" version="4.3.4">Jekyll</generator><link href="https://codesilva.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://codesilva.com/" rel="alternate" type="text/html" hreflang="pt-BR" /><updated>2026-08-11T13:18:39+00:00</updated><id>https://codesilva.com/feed.xml</id><title type="html">CodeSilva</title><subtitle>Bem-vindo ao meu blog pessoal, CodeSilva! Como um aspirante a Engenheiro de Software, estou aqui para compartilhar minhas experiências, percepções e ocasionais desventuras de programação.</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’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></name></author><category term="programacao" /><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="pt-BR"><title type="html">Lei de Amdahl: TypeScript 7 é 10x mais rápido. Sua CI continua lenta</title><link href="https://codesilva.com/programacao/2026/08/07/lei-de-amdahl-typescript-7-e-10x-mais-rapido-sua-ci-continua-lenta.html" rel="alternate" type="text/html" title="Lei de Amdahl: TypeScript 7 é 10x mais rápido. Sua CI continua lenta" /><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/lei-de-amdahl-typescript-7-e-10x-mais-rapido-sua-ci-continua-lenta</id><content type="html" xml:base="https://codesilva.com/programacao/2026/08/07/lei-de-amdahl-typescript-7-e-10x-mais-rapido-sua-ci-continua-lenta.html"><![CDATA[<p>A CI da sua aplicação vai ficando mais lenta com o tempo. Um pouco a cada mês, até o dia em que ela deixa de ser um incômodo e vira um impedimento.</p>

<p>Qual é o seu primeiro passo?</p>

<p>Vi isso acontecer semana passada.</p>

<p>A CI de um serviço Node + TypeScript levava 15 minutos. Testes de unidade com coverage, uns 1.200 testes.</p>

<p>E o raciocínio que aparece na hora é sempre o mesmo: se está lento, tem algo errado, deve dar pra otimizar. Atualiza o TypeScript pra versão em Go, que faz typecheck muito mais rápido. Troca o eslint e o prettier pelo ox, que são muito mais rápidos.</p>

<p>Nada disso é falso. O TypeScript em Go <strong>é</strong> absurdamente mais rápido. O oxlint <strong>é</strong> mais rápido que o eslint.</p>

<p>E mesmo assim não ia consertar nada.</p>

<h2 id="o-typescript-7-é-tudo-isso-mesmo">O TypeScript 7 é tudo isso mesmo</h2>

<p>O <code class="language-plaintext highlighter-rouge">tsc</code> era um compilador de TypeScript escrito em TypeScript, rodando em Node. A 7 é uma porta fiel dele para Go, em binário nativo, com type-checking paralelo em 4 threads.</p>

<p>Os números da Microsoft não são modestos: VS Code caiu de 125,7s pra 10,6s, Sentry de 139,8s pra 15,7s. No editor, abrir um arquivo com erros saiu de ~17,5s pra menos de 1,3s.</p>

<p>O JavaScript emitido é o mesmo, o sistema de tipos é o mesmo. Isso é uma história de compile-time, ponto final.</p>

<p>Um detalhe pra quem for migrar: <strong>a 7.0 não tem API programática</strong>, ela só chega na 7.1. Qualquer ferramenta que embute o compilador - typescript-eslint, Volar, e por tabela Vue, Svelte, Astro, Angular - continua no TypeScript 6.</p>

<h2 id="a-faca-japonesa-e-o-feijão">A faca japonesa e o feijão</h2>

<p>A <a href="https://pt.wikipedia.org/wiki/Lei_de_Amdahl">lei de Amdahl</a> explica por que trocar o compilador não ia mudar nada:</p>

<figure class="post-figure">
  <img src="/assets/images/amdahl/lei-de-amdahl-pt.png" alt="A lei de Amdahl: S igual a 1 dividido por ((1 - p) + p / s). Duas barras de mesmo comprimento comparam antes e depois: no ANTES, uma fatia estreita p e uma fatia larga 1 - p; no DEPOIS, a fatia p virou um risco fininho e a fatia 1 - p continua idêntica. Quando s cresce, S tende a 1 / (1 - p)." />
  <figcaption>A fatia que você otimizou encolhe até virar um risco. A outra não se move - e é ela que define o total.</figcaption>
</figure>

<p><code class="language-plaintext highlighter-rouge">p</code> é a fração do trabalho que você melhorou, <code class="language-plaintext highlighter-rouge">s</code> é o quanto melhorou. A parte que dói é o limite: se <code class="language-plaintext highlighter-rouge">s</code> tender ao infinito, o speedup máximo do sistema inteiro vira <code class="language-plaintext highlighter-rouge">1 / (1 - p)</code>.</p>

<p>Ou seja: <strong>o pedaço que você não otimizou é o seu teto.</strong> E ele não se move.</p>

<p>Pensa no almoço de domingo. Você leva 40 minutos. Compra uma faca japonesa cara que corta cebola 10x mais rápido, e a propaganda não mentiu. Só que cortar cebola levava 2 minutos, e agora leva 12 segundos.</p>

<p>O almoço continua levando 38 minutos e pouco.</p>

<p>Porque o que demora é o feijão na panela. E feijão não é problema de corte, é problema de espera. Faca nenhuma resolve espera.</p>

<h2 id="fizeram-a-atualização">Fizeram a atualização</h2>

<p>Subiram tudo junto: 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>passo</th>
      <th>antes</th>
      <th>depois</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> dos testes</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>As engines novas entregaram os 6x a 10x prometidos. O <code class="language-plaintext highlighter-rouge">tsc</code> 7 compila o projeto inteiro em 1,22s. O que come a diferença é o overhead fixo de cada invocação: subir o processo e carregar a ferramenta custa mais que o trabalho em si.</p>

<p>Resultado líquido: <strong>5,91 segundos.</strong> De 900.</p>

<p>E olha que esses quatro passos somados eram 21s numa pipeline de 900s, ou seja <code class="language-plaintext highlighter-rouge">p = 2,3%</code>. Se as ferramentas fossem infinitamente rápidas, custo zero:</p>

<figure class="post-figure">
  <img src="/assets/images/amdahl/caso-concreto-pt.png" alt="S igual a 1 dividido por (1 - 0,023) igual a 1,024x. Uma barra longa de 900s de pipeline com um risco vermelho fininho na ponta esquerda, marcado como 21s de lint, format e typecheck (p = 2,3%). Abaixo, uma barra quase do mesmo tamanho: 879s no melhor caso possível. Ganho líquido: 21 segundos." />
  <figcaption>Com ferramenta infinitamente rápida e custo zero, a pipeline de 900s cai pra 879s. Esse é o teto.</figcaption>
</figure>

<p><strong>No cenário impossível, a CI cairia de 15min00 pra 14min39.</strong> Esse era o teto, e ele estava disponível antes de qualquer linha de código, com uma divisão de trinta segundos.</p>

<p>Isso não faz do benchmark da Microsoft mentira. O Slack cortou type-checking na CI de 7,5 minutos pra 1,25 minuto, só que lá é um passo dedicado de <code class="language-plaintext highlighter-rouge">tsc</code>, onde o compilador é 100% do trabalho. Mesmo compilador, mesmo ganho de engine, resultado oposto: <strong>a diferença não está na ferramenta, está no denominador.</strong></p>

<h2 id="medir-custa-cinco-minutos">Medir custa cinco minutos</h2>

<p>O GitHub Actions já mostra a duração de cada step na UI, de graça. Abra a última run e leia antes de qualquer teoria. Depois divida o tempo do passo pelo tempo total: aquilo é o máximo que você pode ganhar atacando ele. Se der 2%, você acabou de economizar uma semana.</p>

<p>Se precisar cavar mais fundo, as ferramentas já existem e são subutilizadas de um jeito criminoso:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">jest --verbose</code> te dá a duração por arquivo. Quase sempre 3 arquivos respondem por metade do tempo.</li>
  <li><code class="language-plaintext highlighter-rouge">jest --detectOpenHandles</code> acha promise que não resolve e timer que não limpa, que deixam o runner sentado esperando o event loop drenar depois que os testes já acabaram. Existe faz anos.</li>
</ul>

<p>O Knuth escreveu isso em 1974, na mesma página do <a href="https://pic.plover.com/knuth-GOTO.pdf"><em>Structured Programming with go to Statements</em></a> de onde saiu o “premature optimization is the root of all evil” que todo mundo cita pela metade:</p>

<blockquote>
  <p>“É frequentemente um erro fazer julgamentos a priori sobre quais partes de um programa são realmente críticas, já que a experiência universal dos programadores que usam ferramentas de medição é que os palpites intuitivos deles falham.”</p>
</blockquote>

<h2 id="o-problema-é-o-pulo">O problema é o pulo</h2>

<p>Nada disso quer dizer “não atualize”. Atualize - o TypeScript 7 é um feito de engenharia impressionante e eu vou migrar tudo que der. Aqueles 6 segundos não mudam a CI, mas mudam o loop do agente, que roda <code class="language-plaintext highlighter-rouge">typecheck</code> e <code class="language-plaintext highlighter-rouge">lint</code> dezenas de vezes por hora. É ganho real, só que em outro número.</p>

<p>O que está errado é outra coisa: ir de “está lento” direto pra “troca por algo mais rápido”, sem a divisão de trinta segundos no meio. <strong>Velocidade de ferramenta não é velocidade de sistema.</strong></p>

<p>E trocar ferramenta é confortável porque parece produtivo: tem PR, tem changelog, tem benchmark bonito pra mostrar. Medir primeiro parece burocracia.</p>

<p>Mas medir é o que separa engenharia de torcida.</p>

<p>Por hoje é só.</p>

<hr />

<ul>
  <li><a href="https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/">Announcing TypeScript 7.0</a> - o anúncio oficial, com os benchmarks e a lista completa de 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> - boa leitura sobre o que a migração não muda.</li>
  <li><em>Systems Performance</em>, do Brendan Gregg. Se você só for ler um capítulo, leia o 2.</li>
</ul>]]></content><author><name></name></author><category term="programacao" /><category term="typescript" /><category term="performance" /><category term="ci" /><category term="testes" /><category term="engenharia de software" /><summary type="html"><![CDATA[A CI da sua aplicação vai ficando mais lenta com o tempo. Um pouco a cada mês, até o dia em que ela deixa de ser um incômodo e vira um impedimento.]]></summary></entry><entry xml:lang="pt-BR"><title type="html">Em Java, usar equals sempre foi remédio. Ninguém te contou a doença</title><link href="https://codesilva.com/programacao/2026/08/02/em-java-usar-equals-sempre-foi-remedio-ninguem-te-contou-a-doenca.html" rel="alternate" type="text/html" title="Em Java, usar equals sempre foi remédio. Ninguém te contou a doença" /><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/em-java-usar-equals-sempre-foi-remedio-ninguem-te-contou-a-doenca</id><content type="html" xml:base="https://codesilva.com/programacao/2026/08/02/em-java-usar-equals-sempre-foi-remedio-ninguem-te-contou-a-doenca.html"><![CDATA[<p>Em algum momento da sua vida com Java você escreveu isso e levou um susto:</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>Aí você fez o que todo mundo faz: jogou no Google.</p>

<p>E achou rápido, porque <a href="https://stackoverflow.com/questions/1700081/why-is-128-128-false-but-127-127-is-true-when-comparing-integer-wrappers-in-ja">a pergunta está no Stack Overflow desde novembro de 2009</a>, com duzentos e poucos votos. A resposta é sempre a mesma: <code class="language-plaintext highlighter-rouge">Integer x = 1996</code> não chama <code class="language-plaintext highlighter-rouge">new Integer(1996)</code>, chama <code class="language-plaintext highlighter-rouge">Integer.valueOf(1996)</code>. E o <code class="language-plaintext highlighter-rouge">valueOf</code> tem um cache de instâncias prontas, de -128 até 127. Dentro dessa faixa você recebe sempre o mesmo objeto de volta. Fora dela, um objeto novo a cada chamada.</p>

<p>Fez sentido. Você anotou mentalmente que wrapper se compara com <code class="language-plaintext highlighter-rouge">equals</code>, fechou a aba e seguiu a vida.</p>

<p>E é fácil descartar esse caso. Boxing é escolha sua: usa <code class="language-plaintext highlighter-rouge">int</code>, para de comparar wrapper com <code class="language-plaintext highlighter-rouge">==</code>, e o problema evapora. Pegadinha de entrevista, curiosidade de laboratório.</p>

<p>Mas e quando não dá pra usar primitivo?</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> não tem literal. Não tem versão primitiva. Se você precisa de uma data, você é obrigado a usar objeto.</p>

<p>E aqui não tem cache nenhum na história pra culpar. <code class="language-plaintext highlighter-rouge">d1</code> e <code class="language-plaintext highlighter-rouge">d3</code> são a mesma data, com o mesmo ano, o mesmo mês e o mesmo dia. O <code class="language-plaintext highlighter-rouge">equals</code> concorda. O <code class="language-plaintext highlighter-rouge">==</code> diz que não.</p>

<p>Então “usa <code class="language-plaintext highlighter-rouge">equals</code>” é remédio, não diagnóstico. A pergunta que ninguém respondeu é por que <code class="language-plaintext highlighter-rouge">==</code> compara endereço de memória em primeiro lugar.</p>

<p>E ela tem resposta. No dia 31 de julho de 2026 um commit de <strong>208 mil linhas</strong> entrou no OpenJDK e mudou essa resposta pela primeira vez desde o Java 1.0.</p>

<h2 id="identidade-é-conseguir-distinguir-duas-coisas-idênticas">Identidade é conseguir distinguir duas coisas idênticas</h2>

<p>Antes do Java, pensa em duas coisas do mundo físico.</p>

<p>Ninguém liga pra qual cédula de cinquenta reais você recebeu de troco. Se eu trocar a sua por outra igual enquanto você não olha, não aconteceu nada. Duas notas de cinquenta são intercambiáveis.</p>

<p>Agora o seu carro. Você empresta pro vizinho e quer AQUELE carro de volta, não um igualzinho.</p>

<p>A diferença entre os dois casos é o que a gente chama de <strong>identidade</strong>: a capacidade de distinguir duas coisas que têm exatamente o mesmo conteúdo.</p>

<p>E repara numa coisa. Identidade só serve pra coisa que muda.</p>

<p>O carro importa individualmente porque ele acumula história: quilometragem, amassado, tanque vazio. Se dois carros fossem congelados e nunca mudassem, tanto faria qual você recebe de volta. Vira nota de cinquenta.</p>

<p>Daí sai a frase de onde vem todo o resto: <strong>identidade é uma capacidade que só dado mutável usa.</strong></p>

<h2 id="você-já-conhece-isso-por-outro-nome">Você já conhece isso por outro nome</h2>

<p>Se você faz DDD, essa distinção não é novidade nenhuma. É <strong>Entity versus Value Object</strong>, uma ideia que o Ward Cunningham já descrevia em 1994 e que o <a href="https://martinfowler.com/eaaCatalog/valueObject.html">Fowler catalogou</a> antes do Evans colocar ela no centro da modelagem.</p>

<p>Entity é o carro. O pedido <code class="language-plaintext highlighter-rouge">#4712</code> continua sendo o mesmo pedido depois de mudar de status três vezes.</p>

<p>Value Object é a nota de cinquenta. <code class="language-plaintext highlighter-rouge">R$ 50,00</code> é <code class="language-plaintext highlighter-rouge">R$ 50,00</code>, um <code class="language-plaintext highlighter-rouge">CPF</code> é definido pelos onze dígitos.</p>

<p>Só que em DDD isso sempre foi disciplina de design, e nada mais. Você escrevia <code class="language-plaintext highlighter-rouge">Money</code> imutável, sem setter, com <code class="language-plaintext highlighter-rouge">equals</code> na mão, e documentava que aquilo era um value object. O time entendia. O compilador não. A JVM muito menos.</p>

<p>Em runtime, o seu value object era uma entity igual a todas as outras: endereço próprio, header, identidade, e <code class="language-plaintext highlighter-rouge">==</code> mentindo pra você. Você desenhava a distinção no diagrama de domínio e pagava o preço cheio na memória.</p>

<p><strong>É essa distância que o JEP 401 fecha.</strong> Pela primeira vez <code class="language-plaintext highlighter-rouge">value</code> é uma palavra que o compilador lê, e não um comentário na documentação.</p>

<h2 id="o-java-achou-que-tudo-era-carro">O Java achou que tudo era carro</h2>

<p>E aqui está a decisão que o Java tomou: <strong>todo objeto tem identidade</strong>. Sem exceção, desde 1995.</p>

<p>Foi uma escolha de design, e na época ela era razoável, porque objeto em Java nasceu mutável por padrão.</p>

<p>O problema é que ela virou uma lei da física da linguagem. E lei da física tem consequências que caem por gravidade, querendo você ou não.</p>

<p><strong><code class="language-plaintext highlighter-rouge">==</code> compara endereço</strong> porque o endereço <em>é</em> a identidade. Dois objetos distintos precisam morar em lugares distintos, senão não dá pra distinguir.</p>

<p><strong>Todo objeto carrega um header</strong>, e ele existe porque identidade precisa morar em algum lugar. É nele que ficam o estado de lock e o identity hash.</p>

<p>Não precisa acreditar em mim. O <a href="https://github.com/openjdk/jol">JOL</a>, de <em>Java Object Layout</em>, é uma ferramenta do OpenJDK que lê o layout que a JVM de verdade escolheu pro objeto, campo por campo, em vez de estimar. Passei ela num <code class="language-plaintext highlighter-rouge">LocalDate</code> do JDK 28:</p>

<p><img src="https://codesilva.com/assets/images/java-object-header-anatomy.png" alt="Anatomia de um objeto LocalDate na memória, com as faixas desenhadas em escala. No topo, uma faixa escura e alta rotulada header, de 8 bytes, com a anotação lock state mais identity hash. Abaixo, três faixas azuis agrupadas por uma chave rotulada your data: y igual a 1996 com 4 bytes, m igual a 1 com 1 byte e d igual a 23 com 1 byte. Por último, uma faixa hachurada de padding com 2 bytes. Embaixo, o total: 16 bytes." /></p>

<p>Dezesseis bytes de objeto pra carregar seis bytes de data. O header sozinho é maior que o dado, e ainda sobram dois bytes de padding.</p>

<p>E essa já é a versão magra. No JDK 28 o HotSpot liga os <em>compact object headers</em> por padrão, que dobram o ponteiro de classe pra dentro da mark word. Rodando com <code class="language-plaintext highlighter-rouge">-XX:-UseCompactObjectHeaders</code>, o mesmo <code class="language-plaintext highlighter-rouge">LocalDate</code> volta a ocupar 24 bytes. A JVM já vinha brigando com esse custo por outro caminho, e mesmo depois de encolher o header ele continua sendo o maior pedaço do objeto.</p>

<p>Guarda essa figura, porque o header volta no fim do post. É nele que mora o lock, e é por isso que <code class="language-plaintext highlighter-rouge">synchronized</code> vai deixar de funcionar.</p>

<p><strong>Array de objeto é array de ponteiro.</strong> Se cada elemento precisa de endereço próprio, o array não consegue guardar os dados: ele guarda o caminho até eles.</p>

<p>Compara um <code class="language-plaintext highlighter-rouge">int[5]</code> com um <code class="language-plaintext highlighter-rouge">LocalDate[5]</code>, que é o exemplo que o próprio JEP usa:</p>

<p><img src="https://codesilva.com/assets/images/java-int-array-vs-localdate-array.png" alt="Comparação de layout de memória. À esquerda, int de cinco posições: um único bloco contíguo com os valores 1996, 2006, 1996, 1 e 23, marcado como contiguous. À direita, LocalDate de cinco posições: um bloco de células onde cada uma guarda uma seta apontando pra fora, e as setas se cruzam até objetos soltos e espalhados, cada um com uma faixa escura de header no topo e os campos y, m e d embaixo. Marcado como pointers, scattered." /></p>

<p>O array de <code class="language-plaintext highlighter-rouge">int</code> é um bloco só. O de <code class="language-plaintext highlighter-rouge">LocalDate</code> não guarda datas, guarda ponteiros, e cada objeto foi parar onde o alocador achou espaço, carregando o próprio header junto. Percorrer isso é uma sequência de saltos de memória com cache miss em cada um.</p>

<p>Medindo os dois com o JOL: 32 bytes contra 80. Duas vezes e meia mais memória pra representar a mesma coisa.</p>

<p>Tudo pra carregar um <code class="language-plaintext highlighter-rouge">int</code> e dois <code class="language-plaintext highlighter-rouge">byte</code>s de informação útil por data.</p>

<h2 id="o-cache-do-integer-é-uma-gambiarra-pra-não-pagar-identidade">O cache do Integer é uma gambiarra pra não pagar identidade</h2>

<p>Agora dá pra entender de onde veio o susto do começo.</p>

<p>Identidade custa. Cada <code class="language-plaintext highlighter-rouge">new</code> é uma alocação, um header, um endereço, e mais um objeto pro GC visitar depois. Como boxing de <code class="language-plaintext highlighter-rouge">int</code> acontece o tempo todo, alguém decidiu que valia a pena reaproveitar as instâncias mais comuns em vez de criar objeto novo toda vez.</p>

<p>Daí o cache de -128 a 127.</p>

<p>O detalhe é que reaproveitar instância significa reaproveitar identidade. E identidade é observável por <code class="language-plaintext highlighter-rouge">==</code>.</p>

<p>Ou seja: <strong>a pegadinha do <code class="language-plaintext highlighter-rouge">Integer</code> é o modelo aparecendo.</strong> Uma otimização de alocação vazou pra semântica da linguagem, e só pôde vazar porque <code class="language-plaintext highlighter-rouge">==</code> fala de identidade em vez de valor.</p>

<p>Você não estava confuso. O modelo é que estava estranho. Eu já escrevi sobre <a href="/carreira/2025/05/05/perguntaram-me-porque-java-e-dificil.html">por que Java parece difícil</a>, e boa parte da resposta é essa: a linguagem cobra que você entenda decisões antigas que ninguém te conta.</p>

<h2 id="mas-dado-imutável-nunca-precisou-disso">Mas dado imutável nunca precisou disso</h2>

<p><code class="language-plaintext highlighter-rouge">LocalDate</code> é imutável. <code class="language-plaintext highlighter-rouge">Integer</code> é imutável. <code class="language-plaintext highlighter-rouge">Optional</code>, <code class="language-plaintext highlighter-rouge">Duration</code>, <code class="language-plaintext highlighter-rouge">BigDecimal</code>, o <code class="language-plaintext highlighter-rouge">Money</code> que você escreveu semana passada.</p>

<p>Nenhum deles é carro. São todos nota de cinquenta.</p>

<p>Você nunca, em nenhum código que já escreveu, precisou saber <em>qual</em> instância de 23 de janeiro de 1996 você tem na mão. Só que a JVM não tinha como saber disso, então ela cobrava identidade de todo mundo, no preço cheio, por garantia.</p>

<p>É isso que o JEP 401 conserta. Ele te dá uma saída.</p>

<h2 id="o-jep-401-deixa-você-abrir-mão-da-identidade">O JEP 401 deixa você abrir mão da identidade</h2>

<p>O commit <a href="https://github.com/openjdk/jdk/commit/cc278dbb8a1ca0754d5842708b9029441055d361"><code class="language-plaintext highlighter-rouge">cc278dbb</code></a> implementa dois JEPs de uma vez, os dois como preview no JDK 28: o <a href="https://openjdk.org/jeps/401">JEP 401 (Value Objects)</a> e o <a href="https://openjdk.org/jeps/539">JEP 539 (Strict Field Initialization)</a>. São 208.011 linhas adicionadas, 13.161 removidas, 300 arquivos, 64 co-autores e 14 revisores. É a maior entrega do Project Valhalla até hoje.</p>

<p>E a API disso é uma palavra:</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>O modificador <code class="language-plaintext highlighter-rouge">value</code> traz três coisas de graça. Os campos viram <code class="language-plaintext highlighter-rouge">final</code>, a classe vira <code class="language-plaintext highlighter-rouge">final</code>, e <code class="language-plaintext highlighter-rouge">==</code> passa a comparar campo a campo.</p>

<p>Os termos são parecidos e significam coisas diferentes:</p>

<ul>
  <li><strong>value class</strong> é o que você declara, com o modificador</li>
  <li><strong>value object</strong> é a instância dela, o objeto sem identidade</li>
  <li><strong>Value Objects</strong> é o nome da feature, e <strong>Project Valhalla</strong> é o guarda-chuva</li>
</ul>

<p>E a plataforma já migrou 30 classes:</p>

<table>
  <thead>
    <tr>
      <th>Pacote</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>Que é o que responde o susto do começo. Output de verdade, rodado num build com o JEP 401 ligado:</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>Segura essa última linha, ela volta no fim do post.</p>

<h2 id="escrevendo-as-suas-próprias">Escrevendo as suas próprias</h2>

<p>Se o seu dado já é um record, é uma palavra:</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>Record é <strong>transparente</strong>: os campos são exatamente os componentes do construtor. Quando você guarda o estado de um jeito e expõe de outro, tipo dinheiro como um <code class="language-plaintext highlighter-rouge">long</code> de centavos, aí é <code class="language-plaintext highlighter-rouge">value class</code> normal:</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">// implicitamente 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>O <code class="language-plaintext highlighter-rouge">value</code> fecha portas, e o compilador é direto sobre quais:</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>Campo vira <code class="language-plaintext highlighter-rouge">final</code>, classe vira <code class="language-plaintext highlighter-rouge">final</code>, e herdar de uma classe com identidade seria herdar identidade junto. Hierarquia você ainda tem: dá pra implementar interface, e dá pra estender uma <code class="language-plaintext highlighter-rouge">abstract value class</code>, que é como <code class="language-plaintext highlighter-rouge">Integer</code> e <code class="language-plaintext highlighter-rouge">BigInteger</code> convivem hoje debaixo de <code class="language-plaintext highlighter-rouge">Number</code>.</p>

<p>A regra que mais pega gente é a do construtor. Value object precisa estar completo antes de qualquer um ver ele, então o corpo inteiro roda antes do <code class="language-plaintext highlighter-rouge">super()</code>, e ali <code class="language-plaintext highlighter-rouge">this</code> não existe:</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>A saída é tornar o método <code class="language-plaintext highlighter-rouge">static</code>, ou chamar <code class="language-plaintext highlighter-rouge">super()</code> na mão depois de setar todos os campos. E com preview ligado isso vale pra <strong>todos</strong> os records, value ou não, então record que usa <code class="language-plaintext highlighter-rouge">this</code> no construtor canônico para de compilar.</p>

<p>Pra saber o que marcar, a regra é curta: estado imutável e você nunca precisa distinguir duas instâncias com o mesmo conteúdo. Se você modela por DDD, <strong>a sua pasta de value objects é o primeiro lugar pra olhar</strong>. Fica fora o que é mutável, o que serve de lock e o que guarda dado sensível, já que <code class="language-plaintext highlighter-rouge">==</code> compara campo privado.</p>

<h2 id="sem-identidade-a-jvm-não-precisa-mais-dar-endereço">Sem identidade, a JVM não precisa mais dar endereço</h2>

<p>Volta na lista de consequências lá de cima e inverte cada uma.</p>

<p>Se o objeto não tem identidade, ele não precisa ser distinguível. Se não precisa ser distinguível, <strong>não precisa de endereço próprio</strong>. E aí a JVM ganha duas liberdades.</p>

<p><strong>Flattening</strong> é jogar os campos direto pra dentro do array ou do campo que referencia o objeto:</p>

<p><img src="https://codesilva.com/assets/images/java-value-objects-flattening.png" alt="Diagrama de antes e depois. À esquerda, BEFORE: um array cujas células apontam com setas pra objetos soltos e espalhados, cada um com faixa escura de header e os campos y, m e d. Uma seta grande aponta pra direita. À direita, AFTER: um único bloco contíguo de cinco linhas, cada linha com os valores 1, 1996, 01 e 23 escritos direto dentro dela, sem seta nenhuma e sem header. Marcado como flattened." /></p>

<p>Zero ponteiro, zero header, tudo contíguo, com o primeiro bit dizendo se a referência é <code class="language-plaintext highlighter-rouge">null</code>. O JEP diz que esse array pode passar a ter características de performance parecidas com as de um <code class="language-plaintext highlighter-rouge">int[]</code>.</p>

<p>Isso é o que o JEP descreve. Eu quis ver acontecendo.</p>

<p>Aloquei um <code class="language-plaintext highlighter-rouge">LocalDate[]</code> de dois milhões de posições, todas com datas diferentes, e medi a heap. Mesmo programa, mesmo JDK, mesma máquina, mudando só o <code class="language-plaintext highlighter-rouge">--enable-preview</code>:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>bytes por elemento</th>
      <th>total</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>sem preview, <code class="language-plaintext highlighter-rouge">LocalDate</code> é identity</td>
      <td>28,5</td>
      <td>56,9 MB</td>
    </tr>
    <tr>
      <td>com preview, <code class="language-plaintext highlighter-rouge">LocalDate</code> é value</td>
      <td><strong>8,4</strong></td>
      <td><strong>16,8 MB</strong></td>
    </tr>
  </tbody>
</table>

<p>Oito bytes por elemento, que é exatamente a palavra de 64 bits que o JEP previu.</p>

<p>E não precisa confiar na minha medição de heap pra aceitar isso, porque a aritmética fecha sozinha: se cada elemento ainda fosse um ponteiro pra um objeto separado, só os objetos já ocupariam dois milhões vezes 16 bytes, que é o tamanho mínimo de um objeto na heap. Dá 32 MB. Não cabe em 16,8.</p>

<p>Os objetos não estão lá. Só os valores.</p>

<p>Agora, uma ressalva que vale mais que a medição: <strong>isso não está na spec.</strong> O JEP 401 não tem sequer uma seção de Specification, e diz com todas as letras que flattening e scalarization são “optimizations, not language features”, feitas a critério da JVM. Garantir layout de memória é não-objetivo declarado.</p>

<p>O que o JEP garante é semântica. Value object não tem identidade, <code class="language-plaintext highlighter-rouge">==</code> compara campos, sincronizar lança exceção. Isso é contrato.</p>

<p>O array contíguo é permissão, não promessa. O JEP tira o que impedia a JVM de achatar, e cada implementação decide se acha. Eu mostrei uma que achatou.</p>

<p><strong>Scalarization</strong> é o passo seguinte, dentro do JIT. Quando o objeto está numa variável local ou num parâmetro, ele é decomposto em valores soltos. O <code class="language-plaintext highlighter-rouge">plusYears</code> compilado para de receber um ponteiro e passa a receber <code class="language-plaintext highlighter-rouge">(boolean isNull, int year, byte month, byte day)</code>, devolvendo outra tupla igual.</p>

<p>O objeto simplesmente nunca existe na memória.</p>

<p>Escape analysis já fazia algo parecido com objeto comum, mas basta um caminho de código comparar identidade pra otimização evaporar. Com value class a garantia é estática, e ela atravessa fronteira de método.</p>

<h2 id="menos-alocação-é-menos-gc">Menos alocação é menos GC</h2>

<p>Aqui é onde isso encosta no seu Grafana.</p>

<p>Cada objeto que a JVM não aloca é um objeto que o GC não precisa marcar, varrer nem mover. Um <code class="language-plaintext highlighter-rouge">LocalDate[]</code> de um milhão de posições deixa de ser um milhão de objetos vivos na heap e passa a ser um bloco de memória.</p>

<p>O laço que você escreveu sem pensar, criando um <code class="language-plaintext highlighter-rouge">LocalDate</code> por iteração pra jogar fora logo em seguida, para de gerar lixo. Não é que o GC ficou mais rápido: é que não tem mais o que coletar. E dado contíguo ainda é dado que o CPU busca com menos cache miss, o que costuma valer mais que o tempo de alocação em si.</p>

<p>E como não é promessa, tem jeito de não acontecer. Três coisas atrapalham na prática:</p>

<ul>
  <li><strong>Campo mutável tem teto de 64 bits</strong>, porque leitura e escrita precisam ser atômicas. Um <code class="language-plaintext highlighter-rouge">LocalDateTime</code> não cabe e volta a ser ponteiro.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">Object</code> mata o flattening.</strong> <code class="language-plaintext highlighter-rouge">Integer[]</code> é achatável, <code class="language-plaintext highlighter-rouge">Object[]</code> não é, e genérico apagado cai no mesmo caso. Não muda semântica, só layout.</li>
  <li><strong>Código antigo precisa recompilar</strong>, porque a JVM depende de um atributo novo no class file pra saber a tempo que a classe é value class.</li>
</ul>

<h2 id="o-que-você-perde-é-exatamente-o-que-dependia-de-identidade">O que você perde é exatamente o que dependia de identidade</h2>

<p>E o preço tem uma lógica por trás: é a mesma decisão de design cobrando na saída o que ela cobrava na entrada. Tudo que quebra é coisa que precisava distinguir instância. Rodei cada um pra pegar a mensagem real:</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>Lock mora no header, naquela mark word lá do começo, então <code class="language-plaintext highlighter-rouge">synchronized</code> deixa de funcionar. O <em>Java Concurrency in Practice</em> inteiro parte do princípio de que qualquer objeto serve de lock, e agora não serve mais. Junto com ele caem <code class="language-plaintext highlighter-rouge">wait</code> e <code class="language-plaintext highlighter-rouge">notify</code>, que dependem desse mesmo lock, e também o <code class="language-plaintext highlighter-rouge">WeakHashMap</code> e o <code class="language-plaintext highlighter-rouge">java.lang.ref</code> inteiro, porque referência fraca precisa apontar pra uma instância específica.</p>

<p>Fora isso, tem o que continua funcionando mas diferente do que você espera.</p>

<p>O <code class="language-plaintext highlighter-rouge">==</code> agora compara os campos internos, então ele pode divergir do seu <code class="language-plaintext highlighter-rouge">equals</code>, que talvez olhe outra coisa. Virou também uma operação com custo, porque a comparação é recursiva e árvore profunda de value objects chega a estourar <code class="language-plaintext highlighter-rouge">StackOverflowError</code>. E como ele lê campo privado, virou um canal de inferência. O JEP avisa na lata: value object não foi feito pra proteger dado sensível.</p>

<p>E até o <code class="language-plaintext highlighter-rouge">==</code> de identity object ficou um tiquinho mais caro, porque o bytecode <code class="language-plaintext highlighter-rouge">if_acmpeq</code> agora precisa de um teste extra pra detectar value object. O caminho de identidade virou fast path, mas ele existe, e é cobrado do código que não usa nada disso.</p>

<h2 id="a-peça-que-faltava-jep-539">A peça que faltava: JEP 539</h2>

<p>Ainda tinha um buraco. Value object promete que o valor nunca muda, mas em Java um campo pode ser lido <strong>antes</strong> de ser inicializado, valendo <code class="language-plaintext highlighter-rouge">0</code> ou <code class="language-plaintext highlighter-rouge">null</code>.</p>

<p>O exemplo do JEP é uma dependência circular:</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>Rodando, sai isso:</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>Duas leituras do mesmo campo <code class="language-plaintext highlighter-rouge">final</code>, dois valores diferentes. <code class="language-plaintext highlighter-rouge">Log</code> é inicializada no meio da inicialização de <code class="language-plaintext highlighter-rouge">App</code>, lê <code class="language-plaintext highlighter-rouge">appID</code> valendo o default <code class="language-plaintext highlighter-rouge">0</code>, e cola o zero no <code class="language-plaintext highlighter-rouge">prefix</code>. E repara na sacanagem: se <code class="language-plaintext highlighter-rouge">Log</code> fosse inicializada primeiro, o bug sumia. É o tipo de bug que desaparece quando você vai investigar.</p>

<p>Um campo <code class="language-plaintext highlighter-rouge">final</code> que dá dois valores diferentes destrói a premissa inteira de value object. Por isso o JEP 539 cria o flag <code class="language-plaintext highlighter-rouge">ACC_STRICT_INIT</code>: campo marcado assim não tem valor default e precisa ser escrito antes de qualquer leitura. O <code class="language-plaintext highlighter-rouge">javac</code> marca <strong>todos</strong> os campos de value class com ele, e é por isso que os dois JEPs entraram no mesmo commit.</p>

<p>Se for rodar o exemplo, não espere ele consertar sozinho: eu liguei o <code class="language-plaintext highlighter-rouge">--enable-preview</code> e o <code class="language-plaintext highlighter-rouge">App[0]</code> continua lá. Impor inicialização estrita ao código que já existe é não-objetivo declarado do JEP 539, então só campo de value class recebe o flag.</p>

<h2 id="testando-hoje">Testando hoje</h2>

<p>Aqui tem uma pegadinha de logística, e eu só descobri porque fui rodar.</p>

<p>O caminho óbvio é pegar o early-access do JDK 28 em <a href="https://jdk.java.net/28/">jdk.java.net/28</a>. <strong>Não funciona ainda.</strong> O build 9 saiu em 31/07/2026, mesmo dia da integração, e foi cortado antes dela entrar: <code class="language-plaintext highlighter-rouge">value record</code> dá erro de sintaxe, <code class="language-plaintext highlighter-rouge">Objects.hasIdentity</code> não existe, e o <code class="language-plaintext highlighter-rouge">Integer 1996 == 1996</code> continua <code class="language-plaintext highlighter-rouge">false</code>.</p>

<p>O que roda hoje é o early-access do próprio Valhalla, em <a href="https://jdk.java.net/valhalla/">jdk.java.net/valhalla</a>. O build <code class="language-plaintext highlighter-rouge">27-jep401ea3+1-1</code> implementa o JEP 401, e foi nele que eu rodei tudo que tem output neste post.</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 precisa estar ligado dos dois lados, e não dá pra escolher a versão identity do <code class="language-plaintext highlighter-rouge">LocalDate</code> nesse modo: ou é tudo, ou é nada.</p>

<h2 id="o-que-não-mudou">O que não mudou</h2>

<p>Duas coisas ficaram para trás, e as duas são engraçadas.</p>

<p><strong>O cache do <code class="language-plaintext highlighter-rouge">Integer</code> continua existindo.</strong> O <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> que veio no commit conta que ele foi mantido de propósito, por performance, e que agora não tem mais impacto semântico nenhum. A gambiarra que criou a pegadinha continua rodando embaixo do capô. Você é que não consegue mais enxergar ela.</p>

<p><strong>E <code class="language-plaintext highlighter-rouge">String</code> não migrou.</strong> A classe tem dependências de identidade na API e na implementação, então <code class="language-plaintext highlighter-rouge">Objects.hasIdentity("abcd")</code> continua devolvendo <code class="language-plaintext highlighter-rouge">true</code>. A pegadinha mais famosa do Java, <code class="language-plaintext highlighter-rouge">==</code> em <code class="language-plaintext highlighter-rouge">String</code>, segue de pé.</p>

<p>Fora isso, o resto é fundação. O JEP 402 vai melhorar o boxing de primitivos em cima disso, e o JEP 218 vai deixar genérico especializar layout quando parametrizado com value class, que é o <code class="language-plaintext highlighter-rouge">List&lt;int&gt;</code> sem boxing.</p>

<p>Mas a mudança grande já aconteceu, e ela é conceitual antes de ser técnica.</p>

<p><code class="language-plaintext highlighter-rouge">==</code> deixou de perguntar “vocês dois moram no mesmo endereço?” e passou a perguntar “dá pra distinguir vocês dois?”. Pra carro, a resposta continua sendo o endereço. Pra nota de cinquenta, agora é o valor.</p>

<p>Aquele susto que você levou lá no começo nunca teve como resposta “usa <code class="language-plaintext highlighter-rouge">equals</code>”, e muito menos “usa primitivo”. Pro <code class="language-plaintext highlighter-rouge">LocalDate</code> nunca existiu primitivo pra usar.</p>

<p>A resposta era que aquela data nunca precisou de identidade, e que você vinha pagando por ela desde sempre.</p>

<p>Por hoje é só.</p>

<hr />

<h2 id="referências">Referências</h2>

<ul>
  <li><a href="https://openjdk.org/jeps/401">JEP 401: Value Objects</a> e <a href="https://openjdk.org/jeps/539">JEP 539: Strict Field Initialization</a>, os dois Integrated no JDK 28</li>
  <li><a href="https://github.com/openjdk/jdk/commit/cc278dbb8a1ca0754d5842708b9029441055d361">Commit <code class="language-plaintext highlighter-rouge">cc278dbb</code></a>, de 31/07/2026, e o <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> que veio junto</li>
  <li><a href="https://jdk.java.net/valhalla/">EA do Valhalla</a>, build <code class="language-plaintext highlighter-rouge">27-jep401ea3+1-1</code>, usado nos testes deste 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">A pergunta original no Stack Overflow</a>, de 2009</li>
</ul>]]></content><author><name></name></author><category term="programacao" /><category term="java" /><category term="jvm" /><category term="value objects" /><category term="valhalla" /><category term="performance" /><category term="low-level" /><summary type="html"><![CDATA[Em algum momento da sua vida com Java você escreveu isso e levou um susto:]]></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></name></author><category term="programacao" /><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="pt-BR"><title type="html">OpenTelemetry é o SDK do Langfuse pra Go</title><link href="https://codesilva.com/ai/2026/07/30/opentelemetry-e-o-sdk-do-langfuse-pra-go.html" rel="alternate" type="text/html" title="OpenTelemetry é o SDK do Langfuse pra 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-e-o-sdk-do-langfuse-pra-go</id><content type="html" xml:base="https://codesilva.com/ai/2026/07/30/opentelemetry-e-o-sdk-do-langfuse-pra-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>Fiquei uma semana olhando pra essa linha me achando o cara. Daí eu mudei um prompt, rodei de novo, deu <code class="language-plaintext highlighter-rouge">94,6%</code>, e veio a pergunta óbvia: isso é pior que ontem, ou é ruído?</p>

<p>Subi o scroll pra conferir. O meu terminal tinha comido o ontem.</p>

<p>É isso. Esse é o motivo inteiro de eu ter colocado o Langfuse na frente de um agente em Go. Não porque eu queria um dashboard, mas porque <strong>um número que você não consegue comparar com o da semana passada não é medição, é achismo.</strong></p>

<p>O agente é a Ava, uma PoC de pesquisa em Go: um voice agent que aplica pesquisas de opinião em voz alta e sabe a hora de desligar. Ela existe porque um time aqui tinha um voice agent que não sabia quando a conversa tinha acabado, então eu construí <a href="/ia/2026/07/24/claude-code-a-eficacia-irracional-da-simplicidade">a PoC mais burra que funcionava em vez de escrever uma spec</a>. Uma tarde, zero documento.</p>

<p>Este post é o que aconteceu depois daquela tarde, quando “funciona” parou de ser suficiente e eu quis saber se aquilo <em>continuava</em> funcionando.</p>

<p>E é em Go que aparece a primeira parede, porque o Langfuse tem SDK pra Python e pra JS e nada pra gente. Acontece que tudo bem. O que vem a seguir é o caminho inteiro: o que é um eval, o eval porco que eu escrevi primeiro, e como ele chegou no Langfuse com nada além do SDK padrão do OpenTelemetry e do <code class="language-plaintext highlighter-rouge">net/http</code>.</p>

<h2 id="primeiro-como-você-sabe-se-uma-aplicação-de-llm-presta">Primeiro: como você sabe se uma aplicação de LLM presta?</h2>

<p>Se você escreve uma função que soma dois números, o teste é óbvio:</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>Agora escreve esse teste pra um modelo. Mesma entrada, roda duas vezes, saem duas frases diferentes. As duas certas. O <code class="language-plaintext highlighter-rouge">!=</code> não te serve pra nada.</p>

<p>Essa é a parte que enrola quem vem de software normal: <strong>o modelo não é a unidade sob teste, o comportamento é.</strong> Você não está afirmando que a saída é uma string específica. Você está afirmando que a saída tem uma propriedade que te interessa. Então o trabalho todo vira: escolher a propriedade e achar um jeito de dar nota pra ela.</p>

<p>A Chip Huyen cataloga as formas de fazer isso em <em>AI Engineering</em>. Eu acabei usando três delas, e são essas três que eu aprenderia antes de encostar em qualquer plataforma.</p>

<h3 id="1-corretude-funcional">1. Corretude funcional</h3>

<p>O sistema fez a coisa? Não “o texto ficou bonitinho” - ele <em>funcionou</em>?</p>

<p>Esse é o tipo mais forte de eval e sempre o primeiro a buscar, porque não tem interpretação envolvida. Se você pede pro modelo escrever <code class="language-plaintext highlighter-rouge">gcd(a, b)</code>, você não dá nota pro código, você roda e checa que <code class="language-plaintext highlighter-rouge">gcd(15, 20)</code> devolve 5. É assim que o LeetCode te avalia e é assim que o HumanEval avalia modelos.</p>

<p>A versão da Ava: a cada resposta, um classificador decide o que a conversa faz em seguida - avançar, reler a pergunta, pedir esclarecimento ou desligar. Essa decisão tem uma resposta certa, então o eval é uma comparação:</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>Chato. <code class="language-plaintext highlighter-rouge">==</code> num label. <strong>Isso é uma feature.</strong> A saída do classificador é restrita o bastante pra ser checada exatamente, e tudo que vem depois dessa decisão é uma máquina de estados que eu testo como qualquer outro código Go. Quando dá pra empurrar uma coisa difusa pra um conjunto pequeno de labels, empurre - você recupera o seu <code class="language-plaintext highlighter-rouge">==</code>.</p>

<h3 id="2-similaridade-contra-dados-de-referência">2. Similaridade contra dados de referência</h3>

<p>Tem coisa que não tem uma resposta certa só. A frase de encerramento da Ava é um retorno personalizado ao que a pessoa falou. Existem mil boas.</p>

<p>Aqui você compara a saída contra dados de referência: um corpus rotulado de pares <code class="language-plaintext highlighter-rouge">(entrada, esperado)</code>, onde <code class="language-plaintext highlighter-rouge">esperado</code> é uma <em>resposta de referência</em> em vez de um label. E “comparar” se divide em dois.</p>

<p><strong>Similaridade léxica</strong> trabalha nas palavras em si. Sobreposição, distância de edição, BLEU, ROUGE. Barato, determinístico, sem modelo no meio, e não faz a menor ideia de que <em>“caro”</em> e <em>“salgado”</em> significam a mesma coisa.</p>

<p><strong>Similaridade semântica</strong> trabalha no significado. Você joga os dois textos em vetores e mede o ângulo entre eles, então paráfrase pontua alto. Custa uma chamada de embedding por comparação, e devolve um float pro qual você agora tem que escolher um limiar.</p>

<p>Em Go as duas são funções do mesmo formato:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// Léxica: palavras em comum, sem modelo envolvido.</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">// Semântica: gera os embeddings e mede o ângulo entre os vetores.</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>Daí você dá nota pro caso com a que couber, contra um limiar que é seu:</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>Eu fui de léxica, e não foi porque eu benchmarkei nada. Eu precisava de uma propriedade só: <strong>o agente falou de coisas que a pessoa realmente disse?</strong> Essa pergunta é sobre quais palavras apareceram, então significado nunca foi o eixo.</p>

<p>Uma dúzia de linhas de <code class="language-plaintext highlighter-rouge">strings.Fields</code> e um <code class="language-plaintext highlighter-rouge">map[string]bool</code> responderam. O <code class="language-plaintext highlighter-rouge">UnsupportedWords(line, answers)</code> me dá as palavras do encerramento que não aparecem nem nas respostas da pessoa nem no repertório do próprio agente. Uma checagem barata de groundedness: ela não pergunta se a frase está certa, só se tem alguma coisa ali que foi inventada.</p>

<p>E pegou uma de verdade. Eu coloquei um exemplo bonitinho de frase de encerramento no prompt, e o modelo de 3B copiou ele literalmente nos 13 casos, contando animadamente sobre lavanda pra uma pessoa que só tinha dito <em>“Baunilha.”</em>. Todo score de match exato continuava marcando 100%, porque nenhum deles estava checando se as palavras tinham sido <strong>conquistadas</strong>.</p>

<p>Tem um preço, e ele é especificamente o preço da léxica: uma paráfrase legítima (“aroma” no lugar de “cheiro”) cai naquela lista como violação. A semântica perdoaria isso, ao custo de uma chamada de embedding e de um limiar que eu teria que defender. De um jeito ou de outro eu reporto o número e nunca travo nada nele. Ele me diz onde ir olhar, e nada além disso.</p>

<h3 id="3-ia-como-juiz">3. IA como juiz</h3>

<p>E aí tem o tipo de coisa que comparação de string nenhuma vai alcançar. A Ava fala um reconhecimento curto antes da próxima pergunta pra não parecer um formulário. <em>“Lavanda, boa escolha”</em> é um ack bom? Você sabe na hora. O seu código não faz ideia.</p>

<p>Então você pergunta pra um modelo, restringe ele a <code class="language-plaintext highlighter-rouge">{"good": bool, "reason": string}</code> e faz o parse do JSON. Em Go isso é uma chamada de API e um <code class="language-plaintext highlighter-rouge">json.Unmarshal</code>, não tem nada de esperto aí.</p>

<p>Duas regras que eu passaria pra qualquer um fazendo isso. <strong>Fixe um único modelo juiz</strong> pra todos os modelos que você avalia, ou os seus scores param de ser comparáveis entre si. E <strong>nunca deixe o juiz quebrar o seu build</strong> - ele é uma dependência paga e não determinística, e queda de juiz não pode deixar o CI vermelho.</p>

<p>Custo e alcance sobem conforme você desce essa lista. Confiança vai no sentido contrário. Então trave o build no primeiro tipo, e apenas acompanhe os outros dois.</p>

<h2 id="o-eval-porco-um-slice-um-loop-e-um-exit-code">O eval porco: um slice, um loop e um exit code</h2>

<p>Essa é a parte que eu queria que alguém tivesse me falado antes: <strong>um eval não é uma plataforma.</strong> São três coisas.</p>

<p>Um 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 desses, rotulados na mão</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Um scorer - o loop lá de cima, num worker pool. E um limiar:</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>, e um exit code diferente de zero quando o comportamento do agente regride. Isso é um eval de verdade. Zero dependências, e pegou bug de verdade.</p>

<blockquote>
  <p><strong>NOTA:</strong> os dois limiares são deliberadamente diferentes. Ler errado uma resposta e classificar como outra coisa perde uma resposta real, então isso trava forte. Um reconhecimento sem graça é cosmético, então é reportado e nunca bloqueia. Trave no que perde dado.</p>
</blockquote>

<p>Eu não estou te mostrando a versão porca porque ela é charmosa. Estou mostrando porque tudo que o Langfuse me deu depois é uma <em>visão</em> em cima exatamente dessas três peças. <strong>O scorer é descartável. O dataset é o ativo.</strong></p>

<p>Quando você enxerga isso, a plataforma passa a ser armazenamento em vez de mágica. Que é tudo que eu sempre quis dela, já que o meu problema real era que a run de ontem não existia mais.</p>

<h2 id="duas-portas-pro-langfuse-e-nenhuma-delas-é-um-sdk">Duas portas pro Langfuse, e nenhuma delas é um SDK</h2>

<p>O Langfuse não tem SDK pra Go. O que ele tem são duas superfícies HTTP, e juntas elas cobrem tudo:</p>

<ol>
  <li><strong>Um endpoint OTLP</strong> em <code class="language-plaintext highlighter-rouge">/api/public/otel/v1/traces</code>. Esse é o caminho oficialmente suportado pra qualquer linguagem sem SDK - a <a href="https://langfuse.com/integrations/native/opentelemetry">documentação de OpenTelemetry deles</a> fala isso na lata: <em>“For other languages, use the native OpenTelemetry API for your language and export spans to Langfuse.”</em> Você aponta o SDK padrão de OpenTelemetry pra Go pra lá. Isso carrega os traces.</li>
  <li><strong>Uma API REST</strong> em <code class="language-plaintext highlighter-rouge">/api/public/*</code> pras coisas que o OpenTelemetry não tem conceito: datasets, runs de experimento e scores. <code class="language-plaintext highlighter-rouge">net/http</code> puro. A <a href="https://langfuse.com/docs/api-and-data-platform/features/public-api">documentação da Public API</a> cobre auth e convenções; a <a href="https://api.reference.langfuse.com/">referência completa da API</a> é a página que você vai deixar aberta de verdade.</li>
</ol>

<p>Essa é a arquitetura inteira. O <code class="language-plaintext highlighter-rouge">internal/obs</code> no meu projeto são dois arquivos, um por porta.</p>

<h3 id="porta-1-traces-via-otlp">Porta 1: traces via OTLP</h3>

<p>A configuração inteira é um 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">// sem credencial: tracing desligado, tudo vira 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>Três coisas ali valem mais que o resto.</p>

<p><strong>A auth é Basic auth com o seu par de chaves.</strong> Chave pública como usuário, chave secreta como senha, base64, pronto. Sem dança de token.</p>

<p><strong>O <code class="language-plaintext highlighter-rouge">x-langfuse-ingestion-version: 4</code></strong> é o contrato de ingestão atual, e a documentação deles é seca sobre isso: mande ele <em>“so that new data appears in real time.”</em> Mande. Sem ele o endpoint cai num mapeamento antigo e os seus spans chegam sutilmente errados.</p>

<p><strong><code class="language-plaintext highlighter-rouge">NewSchemaless</code>, e não <code class="language-plaintext highlighter-rouge">NewWithAttributes</code>.</strong> Essa me custou uma tarde. Se você fixa a sua própria URL de schema semconv no resource, ela conflita com a que o resource padrão do SDK já carrega, e o <code class="language-plaintext highlighter-rouge">Merge</code> quebra o seu init inteiro num schema mismatch. Atributos schemaless fazem merge limpo e sobrevivem a upgrade de SDK.</p>

<p>E repare no que acontece sem credencial: o tracer global continua um noop, então toda chamada instrumentada no código custa zero. A PoC continua rodando 100% offline, o que não é pouca coisa quando o seu gate model é local.</p>

<h3 id="instrumentando-embrulhe-não-edite">Instrumentando: embrulhe, não edite</h3>

<p>Agora, o que é que entra no trace? Eu não saí espalhando span pelo agente. Todo chamador de LLM no projeto é uma interface, então tracing é um 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>Uma linha no call site liga tudo: <code class="language-plaintext highlighter-rouge">cl = obs.TraceClassifier(cl, name)</code>. O wrapper é pass-through puro, e nunca encosta no <code class="language-plaintext highlighter-rouge">Turn</code> nem no erro. Um wrapper que consegue alterar um resultado é um bug que você vai acabar culpando o modelo por.</p>

<p>O prefixo de atributo <code class="language-plaintext highlighter-rouge">langfuse.*</code> é a parte que você não adivinha pela documentação do OTel. Aquilo são convenções do próprio Langfuse: o <code class="language-plaintext highlighter-rouge">observation.type</code> faz o span renderizar como uma generation, com painel de input e output em vez de uma barra de tempo pelada, e qualquer chave <code class="language-plaintext highlighter-rouge">langfuse.trace.metadata.*</code> vira um campo filtrável na UI.</p>

<p>Eu carimbo o <code class="language-plaintext highlighter-rouge">prompt_version</code> em toda chamada, sem exceção. Sem ele uma edição de prompt fica invisível, porque saída velha e saída nova caem na mesma pilha sem eixo nenhum pra separar.</p>

<p>É assim que esses atributos ficam quando chegam lá. Um trace de <code class="language-plaintext highlighter-rouge">classify_turn</code>, 0,31s, saindo de um programa Go sem SDK nenhum:</p>

<p><img src="/assets/images/langfuse-go-trace.png" alt="Tela de detalhe de trace do Langfuse pra um span chamado classify_turn. O input mostra a pergunta 'What would make you buy our candles again?' e a resposta 'Price might be a bit steep but if they had a loyalty program or discounts I'd buy again.'. O output mostra intent 'answer', sufficient true, clarity 'clear' e ack vazio. O bloco de metadata lista prompt_version 79bec7b42725 junto dos atributos crus do span: gen_ai.request.model, langfuse.observation.type 'generation', langfuse.trace.name, langfuse.session.id e os campos classify.intent / classify.clarity / classify.sufficient. Legenda: tudo nesse painel veio de atributos de span setados em Go." /></p>

<p>Todo campo daquele screenshot veio de uma chamada de <code class="language-plaintext highlighter-rouge">SetAttributes</code>. Nada foi configurado na UI. E já que você está aí, repare na barra lateral esquerda, porque ela resolve a confusão que eu vou contar mais pra frente: <strong>Evaluation</strong> e <strong>Prompt Management</strong> são duas seções separadas. Nenhuma depende da outra.</p>

<h2 id="por-que-traces-e-não-só-números">Por que traces, e não só números</h2>

<p>Aqui está o que eu subestimei. Eu fui pro Langfuse atrás de histórico de runs e ganhei uma coisa que eu não tinha pedido.</p>

<p>Uma conversa por voz não é uma chamada de modelo. É transcrever, classificar, escrever o reconhecimento, sintetizar, e de novo, vinte turnos adentro. Quando uma conversa sai estranha, o número agregado de acurácia não serve pra nada. Ele me diz com que frequência alguma coisa quebrou e nunca o que aconteceu.</p>

<p>Um trace é o turno inteiro como um objeto aninhado: entradas, saídas, latência por etapa, em ordem, marcados com o id da sessão. <strong>Dar nota pra uma conversa acaba sendo muito mais fácil do que dar nota pra uma frase</strong>, porque a frase finalmente tem contexto em volta. A resposta anterior da pessoa está ali em cima, logo acima da decisão que leu ela errado.</p>

<p>E a latência para de se esconder. Numa ligação, o tempo de TTS e de transcrição <em>é silêncio morto na linha</em>, e ele nunca aparece nos spans de LLM. Então esses também entram no trace, com um helper minúsculo pras etapas que não são chamada de LLM:</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>Esses spans carregam o id da sessão, que é o que transforma vinte traces separados de volta numa conversa legível:</p>

<p><img src="/assets/images/langfuse-go-session.png" alt="Visão de sessão do Langfuse pra uma conversa de pesquisa por voz, mostrando 'Total traces: 24'. A coluna da esquerda lista entradas e saídas em ordem: 'Hi!', 'I'm Ava.', 'How's everything going this morning?', e então a resposta da pessoa 'Hey, Ava, just getting ready for a busy day ahead. How about you?'. A coluna da direita mostra os spans de tts e stt que produziram cada uma, com timestamps separados por segundos. Legenda: 24 traces, uma conversa, na ordem em que ela realmente aconteceu." /></p>

<p>24 traces pra uma pesquisa só. É esse número que me convenceu de que um dashboard de médias nunca ia ser suficiente.</p>

<h2 id="o-que-eu-entendi-errado-evals-vivem-em-cima-dos-traces">O que eu entendi errado: evals vivem em cima dos traces</h2>

<p>Agora a confusão que me impediu de começar, que é o motivo real de eu ter querido escrever este post.</p>

<p>Eu entrei nessa achando que, pra rodar evals no Langfuse, eu teria que mover os meus prompts pra dentro do Langfuse antes. Adotar o prompt management deles, versionar prompt na UI deles, deixar o runner deles executar. Isso é uma migração de verdade, e é o tipo de preço que faz você fechar a aba e decidir que o terminal está ótimo, obrigado.</p>

<p><strong>Não é verdade.</strong> Avaliação no Langfuse pendura nos <em>traces</em>. A unidade é um <strong>Score</strong>, e o <a href="https://langfuse.com/docs/evaluation/scores/data-model">modelo de dados de score</a> é a página que resolveu isso pra mim: um nome, um valor, um tipo de dado e exatamente um sujeito - um trace, uma observation, uma sessão ou uma run de dataset. Trace está listado como o caso comum. Nada num score se importa com onde o seu prompt mora. Você emite o trace do Go, pendura scores nele, e pronto, você está fazendo eval.</p>

<p>Você não entrega a receita pro crítico de restaurante. Ele come o prato.</p>

<p>O prompt management só vira pré-requisito pra uma coisa específica: pedir pro Langfuse <em>executar um prompt ele mesmo</em> contra um dataset. Eu nunca quis isso. O meu prompt mora em Go, do lado do código que depende dele, e é lá que ele fica.</p>

<h3 id="porta-2-scores-via-rest">Porta 2: scores via REST</h3>

<p>Um score é um POST pequeno. O cliente inteiro é <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 é um julgamento. Exatamente UM sujeito precisa estar setado.</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 ou 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">// informe e a escrita vira idempotente</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Pendurando um veredito por caso no trace que produziu ele:</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>Essa chamada sozinha é o que transformou o meu terminal com scroll em algo usável. Na UI eu filtro por <code class="language-plaintext highlighter-rouge">intent_correct = 0</code> e leio só os erros, cada um clicável direto pra conversa inteira que produziu ele.</p>

<p>Pra capturar o id do trace, o wrapper de tracing joga ele numa ref carregada no contexto:</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>E o lado offline - o meu dataset escrito na mão - mapeia nos <a href="https://langfuse.com/docs/evaluation/experiments/data-model">dataset experiments</a> do Langfuse do mesmo jeito. O corpus vira um dataset, a passada de cada modelo vira uma run, cada caso vira um run item que <strong>liga o item do dataset ao trace que ele produziu</strong>. Métricas agregadas penduram na run em vez de num 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>O que deixa o ponto concreto: até uma run offline de eval passa <em>por dentro</em> do tracing, porque um run item exige um id de trace. Não existe caminho pra dentro do Langfuse que pule os traces.</p>

<p>E esse é o retorno do exercício inteiro, a coisa que o meu terminal nunca ia conseguir me dar:</p>

<p><img src="/assets/images/langfuse-go-experiments.png" alt="Aba de Experiments do Langfuse pra um dataset chamado closing-line, listando quatro runs dos mesmos 13 casos contra o qwen2.5:3b, cada uma marcada com o hash do prompt que a produziu. As colunas mostram run items, latência média caindo de 1,24s na run de baseline pra 0,84s, e scores no nível da run, onde clean_opener vai de 0,6923 no baseline pra 1,0000 depois da correção, enquanto model_clean_opener fica em 0,8462. Legenda: quatro runs do mesmo dataset, comparáveis porque a versão do prompt veio junto com cada uma." /></p>

<p>Esse é o eval de encerramento lá de cima, o mesmo do <code class="language-plaintext highlighter-rouge">UnsupportedWords</code>. Quatro runs dos mesmos 13 casos, cada linha marcada com o hash do prompt que a produziu, enfileiradas de um jeito que uma mudança é uma coisa que eu leio em vez de uma coisa que eu lembro. A linha do baseline e a linha depois da correção continuam as duas ali semanas depois, que é exatamente o que o meu terminal falhou em fazer.</p>

<p>E é isso que finalmente responde a pergunta que eu abri, coisa que armazenamento sozinho nunca ia responder. <em>94,6% é pior que ontem, ou é ruído?</em> são na verdade duas perguntas, e a que importa é se o <strong>prompt</strong> mexeu.</p>

<p>Duas linhas carregando o mesmo fingerprint e scores diferentes: isso é ruído, e a resposta honesta é aumentar o dataset ou parar de ler aquela casa decimal. Duas linhas com fingerprints diferentes e scores diferentes: isso é a sua mudança, e agora dá pra discutir.</p>

<p>O histórico é o que me deixou fazer a pergunta na semana seguinte. O fingerprint é o que tornou ela respondível.</p>

<h3 id="quatro-pegadinhas-que-vão-te-pegar">Quatro pegadinhas que vão te pegar</h3>

<p><strong>Dê flush antes de ligar as coisas.</strong> O exporter OTLP em batch é assíncrono, e o endpoint de run item rejeita um trace que ele ainda não ingeriu. Então force um flush antes, e trate 404 como retentável:</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>Faça retry do 404, e só do 404.</strong> Um 404 ali significa que nada foi criado, então retentar é seguro. Fora isso aquele endpoint não é idempotente - o servidor é quem cunha o id - então nenhum outro status vale um retry.</p>

<p><strong>Aprenda quais endpoints fazem upsert.</strong> Dataset faz upsert por nome, item de dataset faz upsert por id, score faz upsert pelo id que você informa. Então eu derivo os ids de um hash de conteúdo e reenvio o corpus inteiro a cada run sem criar uma única duplicata:</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>Edite o <code class="language-plaintext highlighter-rouge">dataset.go</code> e só os casos que realmente mudaram mudam lá em cima. Run items são a exceção, então eles são postados exatamente uma vez e nunca retentados às cegas.</p>

<p><strong>Agora o versionamento de prompt é seu.</strong> Essa é a conta por manter os prompts em Go, e ela chega quietinha. O prompt management do Langfuse teria versionado eles pra mim. Os meus prompts são constantes de string em Go, então ninguém versiona eles além de mim, e um score que eu não consigo atribuir a um prompt volta a ser achismo.</p>

<p>O meu primeiro instinto foi uma constante pra incrementar na mão. Ideia terrível: eu ia editar o prompt, esquecer de incrementar, e atribuir silenciosamente a saída nova à versão velha. Pior que não ter versão nenhuma, porque parece confiável.</p>

<p>Então a versão é derivada do prompt:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// ClassifyPromptVersion é um fingerprint curto e estável das instruções do</span>
<span class="c">// classificador: o system prompt mais as âncoras de few-shot. Endereçado por</span>
<span class="c">// conteúdo, então não tem como sair de sincronia como um número incrementado na mão.</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>Doze caracteres hex, e é esse o <code class="language-plaintext highlighter-rouge">79bec7b42725</code> do screenshot de trace lá em cima. Mude uma única palavra do prompt e o fingerprint muda junto, tendo eu lembrado de pensar nisso ou não.</p>

<p>Dois detalhes que valem roubar. <strong>Inclua os exemplos de few-shot no hash</strong>, não só o system prompt, porque um exemplo editado muda comportamento tanto quanto uma instrução editada. E o teste afirma só que o valor é estável entre chamadas e tem 12 caracteres, nunca <em>qual</em> é o valor - um teste fixando o hash falharia a cada edição legítima de prompt, que é a forma mais rápida de você se ensinar a ignorar teste vermelho.</p>

<p>A troca é real e eu faria de novo: um <code class="language-plaintext highlighter-rouge">79bec7b42725</code> opaco é menos legível que um <code class="language-plaintext highlighter-rouge">v3</code>, e ele não consegue mentir pra mim.</p>

<h2 id="o-que-eu-diria-pra-um-amigo-começando-isso-em-go">O que eu diria pra um amigo começando isso em Go</h2>

<p>A ausência de um SDK parecia um bloqueio e era um incômodo de dois arquivos. <code class="language-plaintext highlighter-rouge">internal/obs</code>: um arquivo pros traces OTLP, um pro cliente REST. Essa é a integração inteira.</p>

<p>Então, na ordem:</p>

<ol>
  <li><strong>Escreva o dataset primeiro.</strong> Vinte casos rotulados num <code class="language-plaintext highlighter-rouge">[]struct{}</code> ganham de qualquer ferramenta que você ainda não escolheu. Os casos são o ativo; todo o resto vem depois deles.</li>
  <li><strong>Pontue exatamente onde der.</strong> Empurre saída difusa pra conjuntos pequenos de label e recupere o seu <code class="language-plaintext highlighter-rouge">==</code>. Vá de similaridade e de juiz só pro que resiste de verdade a isso, e não trave o build neles.</li>
  <li><strong>Aponte o SDK padrão do OTel pra <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>. Você terminou em 30 linhas.</li>
  <li><strong>Instrumente embrulhando interfaces</strong>, nunca editando call site. E mantenha o wrapper pass-through puro.</li>
  <li><strong>Os seus prompts podem continuar em Go.</strong> Scores penduram em traces. Gere você mesmo o fingerprint do prompt e carimbe ele em todo span, e você fica com o prompt e com o histórico dele onde o código está.</li>
</ol>

<p>Os números da Ava continuam saindo no terminal, exatamente como antes. A diferença é que eles não somem mais quando eu fecho ele.</p>

<p>Por hoje é só.</p>

<blockquote>
  <p><strong>Recomendação de livro</strong>: <em>AI Engineering</em>, da Chip Huyen - os capítulos de avaliação são o tratamento mais claro disso que eu já encontrei, e a taxonomia da primeira metade deste post vem de lá.</p>
</blockquote>]]></content><author><name></name></author><category term="ai" /><category term="go" /><category term="langfuse" /><category term="opentelemetry" /><category term="evals" /><category term="llm" /><category term="observabilidade" /><summary type="html"><![CDATA[O Langfuse tem SDK pra Python e pra JS, e nada pra Go. E não precisa: o SDK padrão do OpenTelemetry carrega os traces, o net/http carrega os scores, e a integração inteira são dois arquivos. No caminho, o que é um eval de verdade e por que os seus prompts podem continuar em Go.]]></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></name></author><category term="ai" /><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="pt-BR"><title type="html">Como SIMD transformou 146 segundos de tokenização em menos de 1 segundo</title><link href="https://codesilva.com/low-level/2026/07/27/como-simd-transformou-146-segundos-de-tokenizacao-em-menos-de-1-segundo.html" rel="alternate" type="text/html" title="Como SIMD transformou 146 segundos de tokenização em menos de 1 segundo" /><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/como-simd-transformou-146-segundos-de-tokenizacao-em-menos-de-1-segundo</id><content type="html" xml:base="https://codesilva.com/low-level/2026/07/27/como-simd-transformou-146-segundos-de-tokenizacao-em-menos-de-1-segundo.html"><![CDATA[<p>Quando você manda um prompt pra uma LLM, o modelo não lê o seu texto. Antes de qualquer coisa, um tokenizador quebra a sua frase em pedaços e troca cada pedaço por um número. O que o modelo recebe é isso: uma lista de inteiros. Nunca as letras.</p>

<svg viewBox="0 0 750 190" role="img" aria-label="Fluxo: o seu prompt em texto passa pelo tokenizador e vira uma lista de tokens antes do modelo receber" 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)">seu prompt</text>
  <text x="90" y="99" text-anchor="middle" font-size="12" fill="var(--secondary-text,#6a737d)">"me explica SIMD"</text>
  <text x="280" y="86" text-anchor="middle" font-size="15" font-weight="700" fill="var(--link-color,#0969da)">tokenizador</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)">modelo</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)">o passo que ninguém cronometra</text>
</svg>

<p>Pra um prompt só, esse passo é instantâneo. Você nem percebe que aconteceu.</p>

<p>Só que você quase nunca tokeniza uma coisa só. Pensa num RAG: você pega os documentos da sua empresa e, antes de indexar, tokeniza cada um. Ou um fine-tune: tokeniza o dataset inteiro antes de treinar. Ou uma avaliação rodando sobre milhões de exemplos. Aí aquele passo invisível vira hora de máquina.</p>

<p>E ninguém cronometra ele. A gente aceita a lentidão da tokenização como se fosse lei da física - “é o preço de trabalhar com LLM em escala”. Não é. Quando eu fui medir, tinha <strong>200x</strong> parados ali, esperando alguém olhar.</p>

<p>Esse 200x mora num lugar específico: pipeline que tokeniza em massa e grava os ids no disco. Se o seu caso é o prompt de um usuário em produção, a conta muda. E entre as duas pontas tem o <code class="language-plaintext highlighter-rouge">TTFT</code>, o tempo até o primeiro token, onde a tokenização reaparece. A gente chega em todas.</p>

<h2 id="primeiro-quanto-do-tempo-é-tokenização">Primeiro: quanto do tempo é tokenização?</h2>

<p>Antes de vender solução, vale medir o problema. Porque otimizar o que não pesa é perda de tempo - a gente volta nisso lá no final.</p>

<p>Montei o cenário mais simples possível: 281.664 documentos de texto real, 863 MB do dataset fineweb. Ler do disco, passar pelo tokenizador do Qwen3-8B, e escrever os token ids. Só isso, nenhum modelo rodando no meio.</p>

<p>O <a href="https://github.com/karpathy/nanoGPT/blob/master/data/openwebtext/prepare.py"><code class="language-plaintext highlighter-rouge">prepare.py</code> do nanoGPT</a> faz exatamente isso: tokeniza o corpus uma vez e grava os ids num <code class="language-plaintext highlighter-rouge">.bin</code> que o treino lê depois. Tokenizar de novo a cada época seria desperdício.</p>

<p>Depois cronometrei cada etapa. O resultado:</p>

<table>
  <thead>
    <tr>
      <th>etapa</th>
      <th style="text-align: right">tempo</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ler 863 MB do disco</td>
      <td style="text-align: right">0,8 s</td>
    </tr>
    <tr>
      <td><strong>tokenizar</strong></td>
      <td style="text-align: right"><strong>146 s</strong></td>
    </tr>
    <tr>
      <td>escrever 760 MB de ids</td>
      <td style="text-align: right">0,1 s</td>
    </tr>
  </tbody>
</table>

<p>A tokenização era <strong>99,4% do tempo.</strong> Não era <em>um</em> gargalo no meio de outros. Era a pipeline inteira. Ler e escrever quase um gigabyte cada era ruído perto dela.</p>

<p>Isso muda o jogo. Quando um passo come 99% do tempo, otimizar ele deixa de ser refino e vira a prioridade número um.</p>

<h2 id="por-isso-matar-a-tokenização-acelera-a-pipeline-inteira">POR ISSO: matar a tokenização acelera a pipeline inteira</h2>

<p>Aqui entra o <a href="https://github.com/marcelroed/gigatoken">Gigatoken</a>, um tokenizador que o Marcel Roed escreveu em Rust. A promessa dele é agressiva: até <strong>~1000x mais rápido</strong> que o <code class="language-plaintext highlighter-rouge">tokenizers</code> da HuggingFace.</p>

<p>Número redondo demais, desconfiei. Então rodei o meu próprio benchmark - mesmo corpus, mesmo tokenizador, na minha máquina (um MacBook M4 Max). Só a tokenização:</p>

<table>
  <thead>
    <tr>
      <th>ferramenta</th>
      <th style="text-align: right">tempo</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>Cerca de 200x</strong> na minha máquina. Não é o 1000x do README deles, mas o 1000x foi num servidor de 144 núcleos - quanto mais núcleo, mais a diferença abre. No meu laptop, 200x já basta pra doer.</p>

<p>E a pipeline inteira, com I/O e tudo? Caiu de 147 segundos pra menos de 2. <strong>88x mais rápido no total.</strong> Dois minutos e meio de espera viraram menos de dois segundos, no mesmo corpus e com o mesmo resultado.</p>

<blockquote>
  <p>NOTA: o número mais importante dessas tabelas não é o tempo. É que os dois produziram <strong>exatamente os mesmos 190.429.497 tokens.</strong> Byte por byte. Um é 200x mais rápido fazendo o trabalho idêntico. Isso não é atalho, é engenharia.</p>
</blockquote>

<h2 id="isso-não-é-otimização-prematura">Isso não é otimização prematura</h2>

<p>Uma objeção apareceu na minha cabeça antes de aparecer na sua: trocar de tokenizador pra raspar segundos não é o retrato da otimização prematura?</p>

<p>Era o que eu pensava, e é por isso que nunca tinha cronometrado esse passo. O <code class="language-plaintext highlighter-rouge">tokenizers</code> da HuggingFace ainda ajuda a não olhar: é escrito em Rust, é multithreaded, mantido por gente séria. Qualquer pessoa razoável bate o olho e diz “isso está otimizado”. E ainda tinha 200x na mesa.</p>

<p>Otimização prematura seria eu ter ido mexer no I/O que gastava 0,8 segundo. Atacar o passo que come 99% do tempo é o óbvio, e só parece ousado porque ninguém olhou o relógio antes.</p>

<p>O que existe, e ninguém batizou, é o problema inverso: <strong>otimização tardia</strong>, quando você aceita um gargalo como intocável sem nunca ter cronometrado ele. “Tokenização é lenta” era isso. Uma parede que todo mundo via, até alguém enxergar uma porta.</p>

<h2 id="mas-você-não-escolheu-o-tokenizador-mais-lento-de-propósito">“Mas você não escolheu o tokenizador mais lento de propósito?”</h2>

<p>Boa pergunta. Se eu testasse só o Qwen, você teria todo o direito de desconfiar. Então testei mais.</p>

<p>Com o <strong>GPT-2</strong>, o clássico: a HuggingFace levou 129 segundos, o Gigatoken levou 0,22. Os 193.502.159 tokens, de novo, idênticos. Trocar de tokenizador dentro da HuggingFace não fecha o abismo - abre.</p>

<p>Mas o teste que importa mesmo é contra o <strong>tiktoken</strong>, da OpenAI. É o tokenizador com fama de rápido, o que roda por trás do GPT-4. E ele <em>é</em> rápido: fez o mesmo corpus em 12 segundos, uns 10x mais veloz que a HuggingFace.</p>

<p>Só que o Gigatoken fez em 0,22 segundo. <strong>57x mais rápido que o tokenizador rápido</strong>, com resultado idêntico token a token.</p>

<p>E o mais interessante: <strong>a lentidão da HuggingFace não é “tokenizar é lento”.</strong> O tiktoken faz a mesma tokenização 10x mais rápido só trocando um mecanismo interno. O gargalo tem nome específico, e não é o BPE.</p>

<h2 id="como-simd-na-parte-que-ninguém-olhava">Como? SIMD na parte que ninguém olhava</h2>

<p>O nome do gargalo é <code class="language-plaintext highlighter-rouge">pretokenization</code>.</p>

<p>Antes do algoritmo de tokenização de verdade (o BPE), tem uma etapa boba: quebrar o texto em pré-pedaços. Quase todo mundo faz isso com <strong>expressão regular</strong>. E regex varrendo gigabytes de texto é lento - ela lê meio que caractere por caractere, procurando o padrão.</p>

<p>O que o Marcel fez foi trocar o regex por uma implementação que faz a mesma coisa com <strong>SIMD</strong>. E é aqui que a mágica desmonta.</p>

<p>Imagina que você precisa achar todas as vírgulas num texto de mil páginas. Você pode ler palavra por palavra, procurando. Ou pode abrir o texto em 64 colunas paralelas e perguntar de uma vez: “tem vírgula em algum destes 64 caracteres agora?”. Mesmo resultado. Tempo bem diferente.</p>

<p><strong>SIMD é isso.</strong> Single Instruction, Multiple Data. Uma instrução, vários dados. O processador pega 16, 32, 64 bytes e faz a mesma operação em todos num único ciclo.</p>

<blockquote>
  <p>NOTA: se você quiser puxar esse fio, o SIMD é um eixo de concorrência inteiro que a maioria de nós ignora - não é thread nem processo, é a própria ALU do processador trabalhando em lote. O Paul Butcher dedica um capítulo a isso no <em>Seven Concurrency Models in Seven Weeks</em> (o data parallelism), e o Mitchell Hashimoto tem um <a href="https://mitchellh.com/writing/everyone-should-know-simd">ensaio ótimo</a> defendendo que todo dev deveria conhecer. Mas pro que interessa aqui, basta saber: é essa técnica que transforma 146 segundos em 0,7.</p>
</blockquote>

<h2 id="a-parte-honesta-200x-no-passo-não-é-200x-na-sua-vida">A parte honesta: 200x no passo não é 200x na sua vida</h2>

<p>Agora o aviso que separa este post de um panfleto de vendas.</p>

<p>Eu disse que a tokenização era 99% do tempo. Repara: 99% do tempo DAQUELE teste. Isso vale pra qualquer pipeline onde <strong>nenhum modelo roda junto</strong>: você lê texto, tokeniza e grava os ids. Foi só isso que eu medi, e ali a tokenização domina sozinha.</p>

<p>No momento em que um modelo entra na mesma pipeline, a conta muda. Num RAG você tokeniza e depois joga cada pedaço num modelo de embedding - esse forward pass é trabalho de verdade, e a fatia da tokenização encolhe.</p>

<p>E se você tá tokenizando o prompt de um usuário antes de chamar um modelo em produção, o cenário vira de vez. Na geração em si, a tokenização é uma migalha perto do forward pass - 200x ali não muda quase nada, porque ela nunca foi o problema.</p>

<p>A exceção fina fica no tempo até o primeiro token, o <code class="language-plaintext highlighter-rouge">TTFT</code>. O próprio autor do Gigatoken mostrou, na <a href="https://news.ycombinator.com/item?id=49010167">discussão no Hacker News</a>, que em modelos menores dá pra raspar 5-10% do TTFT. É real - mas é outro universo perto do 88x do pré-processamento. O tamanho do prêmio depende de onde a tokenização entra na sua conta. Isso é a <strong>Lei de Amdahl.</strong></p>

<p>O ganho total de otimizar uma parte é limitado por quanto aquela parte pesava no todo. Otimizar 99% do tempo transforma tudo. Otimizar 1% não muda nada, por mais espetacular que seja o 200x.</p>

<p>Eu aprendi isso na marra. Esse ano fiz a rinha de backend e, pela primeira vez na vida, escrevi um kernel com SIMD na mão - a aplicação fazia muita conta repetitiva, o candidato perfeito. A matemática ficou bem mais rápida. E o ganho final foi decepcionante.</p>

<p>Porque a matemática nunca foi o meu gargalo. O tempo estava em tudo que rodava em volta dela. Eu tinha acelerado brilhantemente um pedaço que não pesava. A mesma lei que faz o Gigatoken parecer mágica me deu uma rasteira na rinha - só que na direção contrária.</p>

<h2 id="o-que-fica">O que fica</h2>

<p>O trabalho de verdade não é “instalar o Gigatoken”. É <strong>medir onde o seu tempo mora</strong> antes de sair otimizando.</p>

<p>Se você faz pré-processamento de dados pra LLM e nunca cronometrou a tokenização, cronometre. Se o seu caminho for parecido com o do teste - ler texto, tokenizar, gravar os ids - ela pode estar comendo quase tudo. E se estiver, tem um 200x ali:</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>A API dele imita a dos tokenizadores que já existem, então a troca em si é uma linha. Quanto desse ganho aparece na sua ponta é outra conversa, e depende de quanto do seu tempo estava na tokenização. Só o cronômetro responde isso.</p>

<p>Mas se a tokenização for migalha na sua pipeline, guarde o Gigatoken pro dia certo e vá caçar o gargalo real. Porque no fim é sempre a mesma história: o tempo mora em algum lugar específico, e quase nunca é onde a gente acha.</p>

<p>Por hoje é só.</p>]]></content><author><name></name></author><category term="low-level" /><category term="ai" /><category term="llm" /><category term="performance" /><category term="tokenização" /><category term="simd" /><summary type="html"><![CDATA[Cronometrei ler, tokenizar e gravar 863 MB de texto: a tokenização era 99,4% do tempo. Um tokenizador com SIMD fez 146 segundos virarem 0,74.]]></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></name></author><category term="low-level" /><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="pt-BR"><title type="html">Claude Code: a eficácia irracional da simplicidade</title><link href="https://codesilva.com/ia/2026/07/24/claude-code-a-eficacia-irracional-da-simplicidade.html" rel="alternate" type="text/html" title="Claude Code: a eficácia irracional da simplicidade" /><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-a-eficacia-irracional-da-simplicidade</id><content type="html" xml:base="https://codesilva.com/ia/2026/07/24/claude-code-a-eficacia-irracional-da-simplicidade.html"><![CDATA[<p>Um time aqui do trabalho estava com um problema bem específico: a agente de voz deles não sabia a hora de encerrar a conversa.</p>

<p>O produto é bacana. A pessoa abre um link, uma IA faz uma sequência de perguntas por voz, e no fim aquilo vira dado estruturado. Uma enquete conversacional. O tipo de coisa que você manda pro cliente e ele responde falando, sem formulário.</p>

<p>O ponto que faltava era o encerramento. A agente fazia as perguntas, recebia as respostas, e ficava ali. Boiando. Um humano <em>sente</em> a hora de encerrar um papo. A máquina, não.</p>

<p>Ultimamente eu tenho pego esse tipo de problema e montado um PoC pra deixar de exemplo - um caso rodando que os colegas podem abrir, entender e adaptar pro contexto deles. Foi o que fiz aqui.</p>

<p>Eu podia ter feito o que virou moda: sentar e escrever um documento gigante antes de tocar em qualquer código. Um spec. Requisitos, diagrama de estados, os quinze jeitos acadêmicos de detectar fim de conversa, critério de aceitação pra cada um. Spec-driven development, que estão vendendo como o jeito adulto de trabalhar com IA.</p>

<p>Não fiz. E é sobre isso que é esse post: por que a coisa mais simples possível resolveu, e por que o documentão teria me atrasado.</p>

<h2 id="o-único-documento-que-valeu-a-pena-veio-antes-de-tudo---e-não-era-um-spec">O único documento que valeu a pena veio antes de tudo - e não era um spec</h2>

<p>A primeira coisa que fiz foi pedir pesquisa. Um prompt, escrito na pressa, com os typos e tudo:</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>Repara no que esse documento é. Ele não descreve o que eu ia construir. Ele mapeia o que o mundo já sabe sobre o problema: como a LiveKit, a Pipecat, a Vapi encerram uma chamada, que modelos de <em>endpointing</em> existem, o que os outros já tentaram e onde quebraram.</p>

<p>Essa é a diferença que quase ninguém separa direito.</p>

<p>Pesquisa reduz uma incógnita que você <strong>não tem como adivinhar sentado na cadeira.</strong> É conhecimento externo, que já existe fora da sua cabeça, esperando você ir buscar.</p>

<p>Spec tenta adivinhar uma incógnita que só o sistema rodando vai te responder. É chute interno com cara de certeza.</p>

<p>Um você colhe. O outro você inventa. E adivinha qual dos dois todo mundo passa o primeiro dia produzindo.</p>

<p>Salvei o resultado:</p>

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

<p>E só <em>depois</em> de ter o mapa na mão é que pedi o protótipo:</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>A primeira linha de código de verdade nasceu 37 minutos depois do primeiro prompt. Sem PRD. Sem reunião de alinhamento sobre o schema. Pesquisa, um plano de meia página, e mão na massa.</p>

<h2 id="aquele-prompt-não-foi-uma-busca-no-google">Aquele prompt não foi uma busca no Google</h2>

<p>Quando eu peço uma pesquisa dessas, o Claude Code não abre o Google e cola o primeiro resultado. Aquele meu prompt lá de cima virou um <a href="https://code.claude.com/docs/en/workflows"><code class="language-plaintext highlighter-rouge">/deep-research</code></a>, um comando que já vem embutido na ferramenta. Eu descrevi o problema em português torto; o Claude escolheu a ferramenta e rodou.</p>

<p>Por baixo, o <code class="language-plaintext highlighter-rouge">/deep-research</code> é um <em>workflow</em>: um script que orquestra vários subagentes, cada um numa fatia da tarefa, guardando os resultados fora da janela de contexto principal pra ela não entupir. Ele roda cinco fases - a lógica que qualquer pesquisador humano bom seguiria, só que em paralelo e sem preguiça:</p>

<ol>
  <li><strong>Scope</strong> - pega a minha pergunta e quebra em uns 5 ângulos diferentes. Um busca o estado da arte, outro os papers acadêmicos, outro a visão cética/contrarian, outro a implementação prática. Ângulos que não se repetem.</li>
  <li><strong>Search</strong> - dispara 5 agentes de busca <strong>em paralelo</strong>, um por ângulo. Cinco pesquisas simultâneas, não uma.</li>
  <li><strong>Fetch</strong> - junta tudo, remove URL repetida, e vai buscar de fato as ~15 melhores fontes. Não o resuminho da SERP: a página inteira. De cada uma, extrai afirmações <em>falsificáveis</em>, com a citação direta que sustenta cada uma.</li>
  <li><strong>Verify</strong> - e aqui está o pulo do gato. Cada afirmação passa por 3 revisores céticos, cada um com uma ordem explícita: <strong>tente refutar isso.</strong> Se 2 dos 3 derrubam, a afirmação morre. Marketing, benchmark cereja, fórum, paper velho de área que anda rápido: tudo isso é filtrado antes de chegar em mim.</li>
  <li><strong>Synthesize</strong> - funde as duplicatas semânticas, ranqueia por confiança, e cospe um relatório com as fontes citadas.</li>
</ol>

<p>Agora compara isso com o que você faz no Google.</p>

<p>Você digita uma query, recebe dez links azuis, abre oito abas, lê metade de três, esquece duas, e no fim salva um bookmark que nunca mais vai revisitar. Uma query. Um ângulo. Zero verificação. <strong>Você é o loop</strong> - o cansado, o que pula a sétima aba porque já deu.</p>

<p>No Claude Code o loop é a máquina, que não cansa na sétima aba nem na quinquagésima. E tem uma diferença mais funda que velocidade: no Google você busca <em>páginas</em>; no workflow você busca <em>afirmações verificadas</em>. Uma coisa te devolve uma pilha de abas. A outra te devolve um relatório onde cada frase já apanhou de três céticos.</p>

<p>O resultado daquela tarde está aberto no <a href="https://github.com/geeksilva97/voice-survey-agent/blob/main/docs/RESEARCH.md">RESEARCH.md do projeto</a>. São <strong>45 fontes distintas</strong> - arXiv, GitHub, HuggingFace, docs da LiveKit e da Vapi - organizadas por tema, com os modelos de turn-taking open source separados dos datasets acadêmicos separados das ferramentas de vendor. Isso saiu de <em>um</em> prompt.</p>

<p>Senta e tenta montar essa mesma bibliografia na mão, no Google, numa tarde. Boa sorte. Você chega na décima aba e desiste - e as 35 fontes que faltavam eram justamente as que iam te poupar de reinventar o <em>endpointing</em> do zero.</p>

<h2 id="o-design-de-verdade-foi-escrito-pelos-bugs">O design de verdade foi escrito pelos bugs</h2>

<p>Aqui está a parte que nenhum spec teria pego, porque é impossível ele pegar.</p>

<p>O produto de verdade nasceu de eu <strong>conversar com a coisa</strong> e reclamar do que estava ruim. Prompt por prompt, cada um disparado logo depois de eu falar no microfone e ouvir a agente errar:</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>Timeout do VAD curto demais. Ela me cortava no meio da frase pra perguntar se eu ainda estava ali. Isso não estava no spec porque não <em>podia</em> estar. Você só descobre com um microfone na mão e uma frase pela metade na boca.</p>

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

<p>A despedida cortada no meio da própria despedida.</p>

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

<p>Ela reperguntava algo que eu já tinha respondido, porque um classificador rotulou minha resposta errado.</p>

<p>E a mais subjetiva de todas, a que dá o nó:</p>

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

<p>Como é que você escreve num documento a regra “a abertura tem que soar humana”? Não escreve. O objetivo do produto era exatamente esse - fluido, não robótico - e isso é <strong>inespecificável em prosa.</strong> Você não sente um PRD. Você sente uma conversa.</p>

<p>Cada um desses defeitos foi descoberto boiando na frente do protótipo, não previsto num documento. E não tinha como ser diferente. Um agente de voz é feito do que acontece no meio-tempo entre a fala e a resposta, e esse meio-tempo não cabe em bullet point.</p>

<h2 id="o-eval-virou-o-spec---só-que-crescendo-de-trás-pra-frente">O eval virou o spec - só que crescendo de trás pra frente</h2>

<p>Toda vez que eu achava um comportamento novo, o pedido era o mesmo:</p>

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

<p>Foi assim que o “spec” foi nascendo. Ele não previu os comportamentos. Ele os <strong>acumulou</strong> conforme apareciam. O documento cresceu de trás pra frente, a partir da realidade que rodou, e não da minha imaginação de sexta à tarde.</p>

<p>Repara na inversão. No spec-driven, o documento vem primeiro e a realidade tenta alcançar. No que eu fiz, a realidade veio primeiro e o documento correu atrás pra registrar o que já tinha se provado verdade.</p>

<p>O mesmo valeu pra escrever as coisas:</p>

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

<p>Doc <em>depois</em> da validação, descrevendo o que uma execução comprovou. Não antes, descrevendo o que eu torcia pra dar certo. O CLAUDE.md do projeto só apareceu lá no fim, quando já havia o que descrever.</p>

<h2 id="o-que-sobrou-no-fim">O que sobrou no fim</h2>

<p>Um PoC bom o suficiente pra resolver o problema interno do time.</p>

<p>Roda no navegador. Gera as perguntas a partir de um preset, a agente conduz a enquete com voz humana, reage ao que a pessoa fala, e - o que importava desde o começo - <strong>sabe a hora de encerrar.</strong> Inclusive quando a pessoa some no meio e ela precisa detectar o silêncio e finalizar sozinha, com dignidade.</p>

<p>Nada de documento de cem páginas. Um loop apertado, repetido até cansar: constrói, testa ao vivo, escuta o que quebra, conserta, fixa no eval. De novo. E de novo.</p>

<p>Foi rápido não <em>apesar</em> de ser simples. Foi rápido <strong>porque</strong> era simples.</p>

<h2 id="por-que-a-simplicidade-foi-eficaz-demais">Por que a simplicidade foi eficaz demais</h2>

<p>Tem um artigo famoso do Wigner sobre a eficácia irracional da matemática nas ciências naturais. A ideia é que uma ferramenta simples às vezes explica muito mais do que tinha o direito de explicar. Foi exatamente essa a sensação aqui.</p>

<p>E o loop bobo de tentativa e erro que deu conta de tudo tem um nome: é só um chat com o Claude Code aberto do lado do navegador. Prompt curto, olho no protótipo, prompt curto de novo. Nenhuma cerimônia. Um problema que o pessoal do documentão trataria como projeto de duas semanas virou uma tarde de conversa.</p>

<p>Se você chegou até aqui procurando o pulo do gato, eu te devo uma decepção: não tem. Não teve arquitetura esperta, não teve prompt mágico, não teve técnica secreta. Descrevi o problema em português torto, deixei a ferramenta pesquisar, construí a coisa mais burra que funcionava e fui consertando o que quebrava na minha frente. <strong>Eu não fiz nada mirabolante - e é esse o post inteiro.</strong> A simplicidade não foi um detalhe do caminho. Foi o caminho.</p>

<p>E o motivo é meio óbvio quando você para pra olhar.</p>

<p>O spec-driven tenta transformar as incógnitas mais difíceis - as que só existem no comportamento do sistema rodando - em prosa confiante, antes do sistema existir. Ele gasta o cartucho mais caro cedo, no exato momento em que você sabe menos sobre o problema.</p>

<p>Eu só carreguei adiantado a única incógnita que dava pra resolver adiantado: a pesquisa do domínio, o conhecimento que já existia lá fora. O resto das incógnitas - as do design - eu deixei se resolverem sozinhas, empiricamente, no atrito com o microfone.</p>

<p>Não é que documento seja inútil. É que documento é bom pra registrar o que você <strong>descobriu</strong>, e péssimo pra fingir que já <strong>sabe.</strong> A hora dele é depois do primeiro contato com a realidade, não antes.</p>

<p>E sim, eu tenho consciência de que isso é um PoC, não um produto rodando em produção com SLA e pager tocando às três da manhã. Mas a ordem não muda com o tamanho da coisa. Produto, task, protótipo de uma tarde: você começa pela pesquisa, entende o que quer fazer, e <em>daí</em> faz. Foi literalmente o arco desse post. O spec-driven não inverte essa ordem - ele só empurra a parte de <strong>entender</strong> pra antes da hora, quando ela ainda é chute.</p>

<p>O pessoal do spec-driven vai passar o dia um escrevendo uma seção primorosa sobre o timeout do barge-in - que um microfone de verdade corrige em noventa segundos.</p>

<p>Você provavelmente não precisa disso. Pesquisa o que dá pra pesquisar, constrói a coisa mais burra que funciona, e deixa a realidade escrever o resto do spec pra você. Ela escreve melhor, e nunca atrasa a entrega.</p>

<p>Por hoje é só.</p>]]></content><author><name></name></author><category term="ia" /><category term="ia" /><category term="claude-code" /><category term="deep-research" /><category term="workflows" /><category term="voice-agents" /><category term="poc" /><category term="engenharia de software" /><summary type="html"><![CDATA[O spec-driven development virou o jeito 'adulto' de trabalhar com IA: escrever um documentão antes de tocar no código. Num problema real de agente de voz eu fiz o oposto - pesquisei o que dava pra pesquisar, construí o PoC mais burro que funcionava, e deixei os bugs escreverem o spec. Resolvi numa tarde.]]></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></name></author><category term="ia" /><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></feed>