<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://lattice-substrate.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://lattice-substrate.github.io/" rel="alternate" type="text/html" /><updated>2026-03-05T15:50:38+00:00</updated><id>https://lattice-substrate.github.io/feed.xml</id><title type="html">Lattice Substrate</title><subtitle>Engineering articles on deterministic data processing, standards-grade conformance, and the practices that produce evidence of correctness.</subtitle><author><name>Mark Lenhardt</name></author><entry><title type="html">From Multiprecision to Fixed-Width: Replacing Burger-Dybvig with Schubfach</title><link href="https://lattice-substrate.github.io/blog/2026/03/05/multiprecision-to-fixed-width-schubfach/" rel="alternate" type="text/html" title="From Multiprecision to Fixed-Width: Replacing Burger-Dybvig with Schubfach" /><published>2026-03-05T00:00:00+00:00</published><updated>2026-03-05T00:00:00+00:00</updated><id>https://lattice-substrate.github.io/blog/2026/03/05/multiprecision-to-fixed-width-schubfach</id><content type="html" xml:base="https://lattice-substrate.github.io/blog/2026/03/05/multiprecision-to-fixed-width-schubfach/"><![CDATA[<p><a href="/blog/2026/02/27/shortest-roundtrip-ieee754-burger-dybvig/">Part 1</a> of this series implemented the Burger-Dybvig algorithm for IEEE 754 to decimal conversion. The implementation uses <code class="language-plaintext highlighter-rouge">math/big.Int</code> for exact multiprecision arithmetic, produces the shortest round-trip decimal string, and applies ECMA-262 even-digit tie-breaking. It is correct. It allocates on every call. This article describes what happens when you replace it with the <a href="https://drive.google.com/file/d/1IEeATSVnEE6TkrHlCYNY2GjaraBjOT4f/view">Schubfach algorithm</a> (Giulietti, 2022), implemented from scratch in Go with the same ECMA-262 conformance contract, validated against the same 286,362 oracle test vectors.</p>

<p>The result: 31.3% geometric mean throughput improvement across all API workloads (n=6, p=0.002 for every number-dense comparison), with zero conformance regressions. The performance gain concentrates where the algorithm change predicts it should (number-dense payloads), and disappears where it should (string-dominant payloads).</p>

<h2 id="what-burger-dybvig-actually-costs">What Burger-Dybvig Actually Costs</h2>

<p>The Burger-Dybvig implementation from Part 1 does four things per number. It initializes four <code class="language-plaintext highlighter-rouge">big.Int</code> values (R, S, M+, M-). It scales them by a power of 10 from a 700-entry precomputed cache. It extracts digits one at a time via <code class="language-plaintext highlighter-rouge">big.Int</code> division. It terminates when boundary conditions indicate the shortest representation. Every operation in that pipeline touches the heap. <code class="language-plaintext highlighter-rouge">big.Int</code> arithmetic allocates on multiply, on division, on comparison when the operand exceeds one machine word.</p>

<p>The CPU profile for the Burger-Dybvig path shows where <code class="language-plaintext highlighter-rouge">math/big</code> time goes:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>2.70s  1.32%  math/big.mulAddVWW
2.33s  1.14%  math/big.nat.mulAddWW
2.28s  1.12%  math/big.(*Int).mul
1.80s  0.88%  math/big.nat.norm
1.71s  0.84%  math/big.nat.mul
</code></pre></div></div>

<p>That is 10.82s of CPU time in <code class="language-plaintext highlighter-rouge">math/big</code> during a benchmark run covering 19 workload categories (total sample time 204.18s). The memory profile adds detail. <code class="language-plaintext highlighter-rouge">jcsfloat.FormatDouble</code> accounts for 2.91% of cumulative heap allocation. That breaks down through the call chain: <code class="language-plaintext highlighter-rouge">generateDigits</code> (2.01% cumulative), which calls <code class="language-plaintext highlighter-rouge">scaleByPower10</code> → <code class="language-plaintext highlighter-rouge">pow10Big</code> (1.29% flat). The <code class="language-plaintext highlighter-rouge">pow10Big</code> function returns a defensive <code class="language-plaintext highlighter-rouge">new(big.Int).Set(cached)</code> copy on every call to prevent callers from corrupting the cache. That defensive copy is correct. It also means every <code class="language-plaintext highlighter-rouge">FormatDouble</code> invocation allocates at least one <code class="language-plaintext highlighter-rouge">big.Int</code> just to look up a power of 10, before any digit extraction begins.</p>

<p>The per-call allocation profile from the standalone <code class="language-plaintext highlighter-rouge">jcsfloat</code> benchmarks in Part 1:</p>

<table>
  <thead>
    <tr>
      <th>Workload</th>
      <th style="text-align: right">ns/op</th>
      <th style="text-align: right">B/op</th>
      <th style="text-align: right">allocs/op</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>integer (42)</td>
      <td style="text-align: right">803</td>
      <td style="text-align: right">64</td>
      <td style="text-align: right">5</td>
    </tr>
    <tr>
      <td>fraction (3.14159…)</td>
      <td style="text-align: right">2,898</td>
      <td style="text-align: right">96</td>
      <td style="text-align: right">6</td>
    </tr>
    <tr>
      <td>subnormal (5e-324)</td>
      <td style="text-align: right">1,406</td>
      <td style="text-align: right">224</td>
      <td style="text-align: right">4</td>
    </tr>
    <tr>
      <td>max safe integer</td>
      <td style="text-align: right">2,938</td>
      <td style="text-align: right">88</td>
      <td style="text-align: right">5</td>
    </tr>
  </tbody>
</table>

<p>Four to six allocations per number. For a JSON document with 2,048 numbers in an array, that is 8,000 to 12,000 allocations spent solely on converting numbers to strings.</p>

<h2 id="schubfach-the-same-problem-different-arithmetic">Schubfach: The Same Problem, Different Arithmetic</h2>

<p>Schubfach solves the same problem as Burger-Dybvig: given an IEEE 754 binary64 value, find the shortest decimal string that round-trips. The difference is how it computes the answer.</p>

<p>Where Burger-Dybvig represents the value and its boundaries as ratios of arbitrary-precision integers and iterates to extract digits, Schubfach works entirely in fixed-width integer arithmetic. The core operation is a 64x64-to-128-bit multiplication of the float’s significand against a precomputed power-of-10 constant, followed by a shift and comparison against interval bounds that are themselves fixed-width. No heap allocation in the digit generation path. No <code class="language-plaintext highlighter-rouge">math/big</code>. No defensive copies of cached values.</p>

<p>Go does not have a native <code class="language-plaintext highlighter-rouge">uint128</code> type. The 128-bit multiplication uses <code class="language-plaintext highlighter-rouge">math/bits.Mul64</code>, which returns a <code class="language-plaintext highlighter-rouge">(hi, lo)</code> pair:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">hi</span><span class="p">,</span> <span class="n">lo</span> <span class="o">:=</span> <span class="n">bits</span><span class="o">.</span><span class="n">Mul64</span><span class="p">(</span><span class="n">significand</span><span class="p">,</span> <span class="n">pow10TableHi</span><span class="p">[</span><span class="n">index</span><span class="p">])</span>
</code></pre></div></div>

<p>The upper 64 bits contain the information needed to determine the digit string. The lower 64 bits determine rounding. Carry propagation through the 128-bit product must be exact. A single bit error in the table or the multiplication produces wrong output for specific IEEE 754 values that may not surface during random testing. The oracle vectors catch these errors; random fuzzing alone does not reliably reach the affected bit patterns.</p>

<p>The precomputed table replaces Burger-Dybvig’s 700-entry <code class="language-plaintext highlighter-rouge">*big.Int</code> cache with a fixed-size array of <code class="language-plaintext highlighter-rouge">[2]uint64</code> pairs (696 entries, covering exponents -348 to 347). Where <code class="language-plaintext highlighter-rouge">pow10Big</code> returns a heap-allocated defensive copy on every call, the Schubfach table is read-only after initialization and accessed by index with no allocation.</p>

<p>The ECMA-262 formatting stage (<code class="language-plaintext highlighter-rouge">formatECMA</code> and its four branch helpers from Part 1) is shared between implementations. That code takes <code class="language-plaintext highlighter-rouge">(negative bool, digits string, n int)</code> and produces the final string. It is algorithm-independent and unchanged.</p>

<h2 id="the-tie-breaking-problem">The Tie-Breaking Problem</h2>

<p>Part 1 described ECMA-262 even-digit tie-breaking as “the most subtle part of the algorithm.” That description applies to any shortest-round-trip algorithm, not just Burger-Dybvig.</p>

<p>In Burger-Dybvig, tie-breaking is explicit. The <code class="language-plaintext highlighter-rouge">midpointDigit</code> function compares <code class="language-plaintext highlighter-rouge">2R</code> against <code class="language-plaintext highlighter-rouge">S</code> using exact <code class="language-plaintext highlighter-rouge">big.Int</code> arithmetic. When <code class="language-plaintext highlighter-rouge">2R == S</code>, the digit <code class="language-plaintext highlighter-rouge">d</code> is checked: if even, keep it; if odd, round up. The invariants are visible in the code because the arithmetic is exact and the comparison is direct.</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// Burger-Dybvig: exact midpoint detection</span>
<span class="k">func</span> <span class="n">midpointDigit</span><span class="p">(</span><span class="n">d</span> <span class="kt">int</span><span class="p">,</span> <span class="n">state</span> <span class="o">*</span><span class="n">digitState</span><span class="p">)</span> <span class="kt">byte</span> <span class="p">{</span>
    <span class="n">state</span><span class="o">.</span><span class="n">scratch3</span><span class="o">.</span><span class="n">Lsh</span><span class="p">(</span><span class="o">&amp;</span><span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">,</span> <span class="m">1</span><span class="p">)</span>
    <span class="n">cmp</span> <span class="o">:=</span> <span class="n">state</span><span class="o">.</span><span class="n">scratch3</span><span class="o">.</span><span class="n">Cmp</span><span class="p">(</span><span class="o">&amp;</span><span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">cmp</span> <span class="o">&lt;</span> <span class="m">0</span> <span class="p">{</span>
        <span class="k">return</span> <span class="kt">byte</span><span class="p">(</span><span class="sc">'0'</span> <span class="o">+</span> <span class="n">d</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="n">cmp</span> <span class="o">&gt;</span> <span class="m">0</span> <span class="p">{</span>
        <span class="k">return</span> <span class="kt">byte</span><span class="p">(</span><span class="sc">'0'</span> <span class="o">+</span> <span class="n">d</span> <span class="o">+</span> <span class="m">1</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="n">d</span><span class="o">%</span><span class="m">2</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span>
        <span class="k">return</span> <span class="kt">byte</span><span class="p">(</span><span class="sc">'0'</span> <span class="o">+</span> <span class="n">d</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="kt">byte</span><span class="p">(</span><span class="sc">'0'</span> <span class="o">+</span> <span class="n">d</span> <span class="o">+</span> <span class="m">1</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>In Schubfach, the midpoint condition is encoded in the interval bounds. The algorithm computes whether the value falls strictly inside, at the boundary of, or outside the interval of valid shortest representations. Modifying the boundary comparison to prefer even digits requires understanding where in the interval arithmetic the rounding decision is made, and adjusting the comparison predicates accordingly.</p>

<p>The adjustment is small in code. A few comparison operators change from strict to inclusive (or vice versa) depending on the parity of the significand’s least significant bit. But the reasoning behind <em>which</em> operators to change requires following the proof in <a href="https://drive.google.com/file/d/1IEeATSVnEE6TkrHlCYNY2GjaraBjOT4f/view">Giulietti’s paper</a> to understand which invariant each comparison maintains.</p>

<p>The even-digit preference also influences the boundary comparisons themselves. This is the same pattern described in Part 1 for Burger-Dybvig’s <code class="language-plaintext highlighter-rouge">cmpRoundDown</code> and <code class="language-plaintext highlighter-rouge">cmpHigh</code> functions, where <code class="language-plaintext highlighter-rouge">isEven</code> switches between inclusive and strict comparisons:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// Burger-Dybvig boundary comparison (from Part 1)</span>
<span class="k">func</span> <span class="n">cmpRoundDown</span><span class="p">(</span><span class="n">lhs</span><span class="p">,</span> <span class="n">rhs</span> <span class="o">*</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">,</span> <span class="n">isEven</span> <span class="kt">bool</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">isEven</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">lhs</span><span class="o">.</span><span class="n">Cmp</span><span class="p">(</span><span class="n">rhs</span><span class="p">)</span> <span class="o">&lt;=</span> <span class="m">0</span>   <span class="c">// Inclusive at boundary</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">lhs</span><span class="o">.</span><span class="n">Cmp</span><span class="p">(</span><span class="n">rhs</span><span class="p">)</span> <span class="o">&lt;</span> <span class="m">0</span>        <span class="c">// Strict</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Schubfach has an analogous structure, but the comparison operates on 64-bit values derived from the 128-bit product rather than on <code class="language-plaintext highlighter-rouge">big.Int</code> ratios. The oracle vectors serve as the correctness proof. Any tie-breaking error produces a different digit string for at least one value in the 286,362-vector dataset. The oracle test fails with the exact bit pattern that triggered the divergence.</p>

<h2 id="benchmark-methodology">Benchmark Methodology</h2>

<p>Comparing two implementations of the same specification requires more than <code class="language-plaintext highlighter-rouge">go test -bench</code>. Both binaries must produce byte-identical output for every input. The performance comparison must separate the number formatting cost from everything else in the pipeline: parsing, string escaping, UTF-16 key sorting, memory allocation for the value tree.</p>

<p>The benchmark lab runs both implementations against identical workloads at three levels:</p>

<ol>
  <li><strong>API benchmarks</strong> via Go’s <code class="language-plaintext highlighter-rouge">testing.B</code> with <code class="language-plaintext highlighter-rouge">-count=6</code>, measuring <code class="language-plaintext highlighter-rouge">Canonicalize()</code> and <code class="language-plaintext highlighter-rouge">Verify()</code> calls directly. benchstat requires a minimum of 6 samples per comparison to compute confidence intervals at the 0.95 level.</li>
  <li><strong>CLI end-to-end timings</strong> with process startup, file I/O, and argument parsing included (9 runs per workload per implementation, 2 warmup runs discarded)</li>
  <li><strong>Conformance verification</strong> with SHA-256 digest comparison on every output, differential fuzzing (1,000 random cases), and determinism checks (identical output across repeated runs)</li>
</ol>

<p>The 19 workload categories span the space intentionally. Some are dominated by number formatting (<code class="language-plaintext highlighter-rouge">number-heavy</code>, <code class="language-plaintext highlighter-rouge">numeric-boundary</code>). Some have zero numbers (<code class="language-plaintext highlighter-rouge">long-string</code>, <code class="language-plaintext highlighter-rouge">control-escapes</code>, <code class="language-plaintext highlighter-rouge">surrogate-pair</code>). Some mix both (<code class="language-plaintext highlighter-rouge">small</code>, <code class="language-plaintext highlighter-rouge">medium</code>, <code class="language-plaintext highlighter-rouge">mixed-prod</code>, <code class="language-plaintext highlighter-rouge">nested-mixed</code>). The zero-number workloads serve as a control group: any performance change on those workloads would indicate the algorithm swap affected code paths it should not have touched.</p>

<p>The environment: Linux 6.8, Go 1.25.6, 12th Gen Intel i7-12700K (20 logical cores), CPU governor <code class="language-plaintext highlighter-rouge">performance</code>. Both implementations run under the same conditions on the same machine in the same benchmark invocation.</p>

<h2 id="conformance-evidence">Conformance Evidence</h2>

<p>Before any performance discussion, the gates:</p>

<table>
  <thead>
    <tr>
      <th>Suite</th>
      <th style="text-align: right">Cases</th>
      <th style="text-align: right">Passed</th>
      <th style="text-align: right">Failed</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>cyberphone (official)</td>
      <td style="text-align: right">36</td>
      <td style="text-align: right">36</td>
      <td style="text-align: right">0</td>
    </tr>
    <tr>
      <td>lab workloads</td>
      <td style="text-align: right">116</td>
      <td style="text-align: right">116</td>
      <td style="text-align: right">0</td>
    </tr>
    <tr>
      <td>RFC 8785 (official)</td>
      <td style="text-align: right">2</td>
      <td style="text-align: right">2</td>
      <td style="text-align: right">0</td>
    </tr>
    <tr>
      <td><strong>Total</strong></td>
      <td style="text-align: right"><strong>154</strong></td>
      <td style="text-align: right"><strong>154</strong></td>
      <td style="text-align: right"><strong>0</strong></td>
    </tr>
  </tbody>
</table>

<p>Additional evidence:</p>

<ul>
  <li>SHA-256 digests of canonical output are byte-identical between implementations for every valid workload. Not equivalent. Identical.</li>
  <li>1,000 differential fuzz cases with randomized seeds. Zero divergences.</li>
  <li>Zero oracle mismatches. Zero determinism failures. Zero invalid-input parity issues (both implementations reject the same inputs with the same failure classes).</li>
</ul>

<p>The Schubfach implementation produces the same bytes as Burger-Dybvig for every input tested. The remainder of this article is about cost, not correctness.</p>

<h2 id="api-benchmarks-number-dense-workloads">API Benchmarks: Number-Dense Workloads</h2>

<p>These are workloads where <code class="language-plaintext highlighter-rouge">FormatDouble</code> is invoked frequently enough to dominate the profile. All results are means of 6 samples:</p>

<table>
  <thead>
    <tr>
      <th>Workload</th>
      <th style="text-align: right">Schubfach (ns/op)</th>
      <th style="text-align: right">Burger-Dybvig (ns/op)</th>
      <th style="text-align: right">Ratio</th>
      <th>p</th>
      <th style="text-align: right">allocs/op (S)</th>
      <th style="text-align: right">allocs/op (BD)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>number-heavy</td>
      <td style="text-align: right">13,530</td>
      <td style="text-align: right">20,855</td>
      <td style="text-align: right">1.54x</td>
      <td>0.002</td>
      <td style="text-align: right">60</td>
      <td style="text-align: right">80</td>
    </tr>
    <tr>
      <td>numeric-boundary</td>
      <td style="text-align: right">12,396</td>
      <td style="text-align: right">18,294</td>
      <td style="text-align: right">1.48x</td>
      <td>0.002</td>
      <td style="text-align: right">38</td>
      <td style="text-align: right">52</td>
    </tr>
    <tr>
      <td>nested-mixed</td>
      <td style="text-align: right">2,182</td>
      <td style="text-align: right">3,066</td>
      <td style="text-align: right">1.41x</td>
      <td>0.002</td>
      <td style="text-align: right">45</td>
      <td style="text-align: right">49</td>
    </tr>
    <tr>
      <td>small</td>
      <td style="text-align: right">1,243</td>
      <td style="text-align: right">1,667</td>
      <td style="text-align: right">1.34x</td>
      <td>0.002</td>
      <td style="text-align: right">22</td>
      <td style="text-align: right">24</td>
    </tr>
    <tr>
      <td>medium</td>
      <td style="text-align: right">340,270</td>
      <td style="text-align: right">581,178</td>
      <td style="text-align: right">1.71x</td>
      <td>0.002</td>
      <td style="text-align: right">6,380</td>
      <td style="text-align: right">7,394</td>
    </tr>
    <tr>
      <td>mixed-prod</td>
      <td style="text-align: right">136,311</td>
      <td style="text-align: right">178,287</td>
      <td style="text-align: right">1.31x</td>
      <td>0.002</td>
      <td style="text-align: right">2,340</td>
      <td style="text-align: right">2,595</td>
    </tr>
    <tr>
      <td>array-256</td>
      <td style="text-align: right">303,348</td>
      <td style="text-align: right">474,369</td>
      <td style="text-align: right">1.56x</td>
      <td>0.002</td>
      <td style="text-align: right">6,131</td>
      <td style="text-align: right">7,151</td>
    </tr>
    <tr>
      <td>array-2048</td>
      <td style="text-align: right">2,549,000</td>
      <td style="text-align: right">4,048,000</td>
      <td style="text-align: right">1.59x</td>
      <td>0.002</td>
      <td style="text-align: right">49,135</td>
      <td style="text-align: right">57,341</td>
    </tr>
  </tbody>
</table>

<p>Every comparison reaches statistical significance at p=0.002. The speedups range from 1.31x (<code class="language-plaintext highlighter-rouge">mixed-prod</code>) to 1.71x (<code class="language-plaintext highlighter-rouge">medium</code>).</p>

<p>The allocation column is the structural signature. <code class="language-plaintext highlighter-rouge">number-heavy</code> drops from 80 to 60 allocations (25% reduction). <code class="language-plaintext highlighter-rouge">numeric-boundary</code> drops from 52 to 38 (27% reduction). <code class="language-plaintext highlighter-rouge">array-2048</code> drops from 57,341 to 49,135 (14% reduction). Each eliminated allocation corresponds to a <code class="language-plaintext highlighter-rouge">math/big</code> operation that Schubfach replaces with fixed-width arithmetic.</p>

<h2 id="api-benchmarks-string-dominant-workloads-control-group">API Benchmarks: String-Dominant Workloads (Control Group)</h2>

<p>These workloads contain few or no numbers. The number formatter is not the bottleneck:</p>

<table>
  <thead>
    <tr>
      <th>Workload</th>
      <th style="text-align: right">Schubfach (ns/op)</th>
      <th style="text-align: right">Burger-Dybvig (ns/op)</th>
      <th style="text-align: right">Ratio</th>
      <th>p</th>
      <th style="text-align: right">allocs/op (S)</th>
      <th style="text-align: right">allocs/op (BD)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>control-escapes</td>
      <td style="text-align: right">1,335</td>
      <td style="text-align: right">1,251</td>
      <td style="text-align: right">0.94x</td>
      <td>0.002</td>
      <td style="text-align: right">21</td>
      <td style="text-align: right">21</td>
    </tr>
    <tr>
      <td>long-string</td>
      <td style="text-align: right">74,802</td>
      <td style="text-align: right">73,588</td>
      <td style="text-align: right">0.98x</td>
      <td>0.026</td>
      <td style="text-align: right">14</td>
      <td style="text-align: right">14</td>
    </tr>
    <tr>
      <td>rfc-key-sorting</td>
      <td style="text-align: right">3,018</td>
      <td style="text-align: right">2,968</td>
      <td style="text-align: right">0.98x</td>
      <td>0.009</td>
      <td style="text-align: right">47</td>
      <td style="text-align: right">47</td>
    </tr>
    <tr>
      <td>surrogate-pair</td>
      <td style="text-align: right">647</td>
      <td style="text-align: right">640</td>
      <td style="text-align: right">0.99x</td>
      <td>0.002</td>
      <td style="text-align: right">14</td>
      <td style="text-align: right">14</td>
    </tr>
    <tr>
      <td>unicode</td>
      <td style="text-align: right">1,549</td>
      <td style="text-align: right">1,559</td>
      <td style="text-align: right">1.01x</td>
      <td>0.108</td>
      <td style="text-align: right">19</td>
      <td style="text-align: right">19</td>
    </tr>
    <tr>
      <td>deep</td>
      <td style="text-align: right">36,651</td>
      <td style="text-align: right">36,689</td>
      <td style="text-align: right">1.00x</td>
      <td>0.784</td>
      <td style="text-align: right">843</td>
      <td style="text-align: right">843</td>
    </tr>
    <tr>
      <td>deep-64</td>
      <td style="text-align: right">16,237</td>
      <td style="text-align: right">16,240</td>
      <td style="text-align: right">1.00x</td>
      <td>0.589</td>
      <td style="text-align: right">387</td>
      <td style="text-align: right">387</td>
    </tr>
  </tbody>
</table>

<p>Allocation counts are identical across all seven workloads. The number formatter was not invoked (or invoked for a trivial count of values). Burger-Dybvig wins on <code class="language-plaintext highlighter-rouge">control-escapes</code> (6%), <code class="language-plaintext highlighter-rouge">long-string</code> (2%), <code class="language-plaintext highlighter-rouge">rfc-key-sorting</code> (2%), and <code class="language-plaintext highlighter-rouge">surrogate-pair</code> (1%). These are small effects. <code class="language-plaintext highlighter-rouge">deep</code>, <code class="language-plaintext highlighter-rouge">deep-64</code>, and <code class="language-plaintext highlighter-rouge">unicode</code> show no statistically significant difference.</p>

<p>This is the control group. Identical allocation counts confirm that the Schubfach change is isolated to the number formatting path. The small Burger-Dybvig advantages on string workloads are consistent with binary layout, code alignment, or instruction cache effects from the different compilation units.</p>

<h2 id="api-benchmarks-large-payloads">API Benchmarks: Large Payloads</h2>

<p>The <code class="language-plaintext highlighter-rouge">large</code> workload (approximately 460KB of JSON) shows the largest absolute time savings:</p>

<table>
  <thead>
    <tr>
      <th>Workload</th>
      <th style="text-align: right">Schubfach (ns/op)</th>
      <th style="text-align: right">Burger-Dybvig (ns/op)</th>
      <th style="text-align: right">Ratio</th>
      <th>p</th>
      <th style="text-align: right">allocs/op (S)</th>
      <th style="text-align: right">allocs/op (BD)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>canonicalize/large</td>
      <td style="text-align: right">11,928,619</td>
      <td style="text-align: right">20,545,684</td>
      <td style="text-align: right">1.72x</td>
      <td>0.002</td>
      <td style="text-align: right">204,776</td>
      <td style="text-align: right">237,568</td>
    </tr>
    <tr>
      <td>verify/canonical/large</td>
      <td style="text-align: right">11,506,131</td>
      <td style="text-align: right">20,018,040</td>
      <td style="text-align: right">1.74x</td>
      <td>0.002</td>
      <td style="text-align: right">204,775</td>
      <td style="text-align: right">237,559</td>
    </tr>
    <tr>
      <td>verify/noncanonical/large</td>
      <td style="text-align: right">11,908,081</td>
      <td style="text-align: right">20,607,667</td>
      <td style="text-align: right">1.73x</td>
      <td>0.002</td>
      <td style="text-align: right">204,776</td>
      <td style="text-align: right">237,570</td>
    </tr>
  </tbody>
</table>

<p>Schubfach is 72-74% faster across all three modes. The allocation reduction (204,776 vs 237,568, a 14% drop) understates the effect: the eliminated allocations are <code class="language-plaintext highlighter-rouge">math/big</code> operations that carry CPU cost beyond their memory footprint. At this payload size the <code class="language-plaintext highlighter-rouge">math/big</code> overhead accumulates to approximately 8.6ms of additional wall-clock time per canonicalization.</p>

<h2 id="memory-profile-comparison">Memory Profile Comparison</h2>

<p>The memory profile is where the implementation difference is most directly visible.</p>

<p><strong>Burger-Dybvig, jcsfloat allocation footprint (cumulative, showing call chain):</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>jcsfloat.FormatDouble             2.91% cum
  └─ jcsfloat.generateDigits     2.01% cum
       └─ jcsfloat.pow10Big      1.29% flat
</code></pre></div></div>

<p>The cumulative values overlap: <code class="language-plaintext highlighter-rouge">FormatDouble</code>’s 2.91% <em>includes</em> <code class="language-plaintext highlighter-rouge">generateDigits</code>’s 2.01%, which <em>includes</em> <code class="language-plaintext highlighter-rouge">pow10Big</code>’s 1.29%. The flat allocation in <code class="language-plaintext highlighter-rouge">pow10Big</code> represents the defensive <code class="language-plaintext highlighter-rouge">new(big.Int).Set()</code> copies.</p>

<p><strong>Schubfach, jcsfloat allocation footprint:</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>jcsfloat.FormatDouble             1.16% cum
  └─ jcsfloat.appendIntegerFixed  0.57% flat
  └─ jcsfloat.formatECMA         0.20% flat
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">FormatDouble</code> cumulative drops from 2.91% (Burger-Dybvig) to 1.16% (Schubfach). The remaining allocations are in <code class="language-plaintext highlighter-rouge">appendIntegerFixed</code> (byte slice growth for trailing zeros) and <code class="language-plaintext highlighter-rouge">formatECMA</code> (string building). Both are shared formatting concerns, not digit generation.</p>

<p>The <code class="language-plaintext highlighter-rouge">math/big</code> entries are gone entirely. No <code class="language-plaintext highlighter-rouge">pow10Big</code>. No <code class="language-plaintext highlighter-rouge">nat.make</code>. No <code class="language-plaintext highlighter-rouge">nat.set</code>. No <code class="language-plaintext highlighter-rouge">generateDigits</code> (which in Burger-Dybvig performs <code class="language-plaintext highlighter-rouge">big.Int</code> division in a loop). The 700-entry <code class="language-plaintext highlighter-rouge">*big.Int</code> power-of-10 cache is eliminated. The <code class="language-plaintext highlighter-rouge">digitState</code> pool with its nine <code class="language-plaintext highlighter-rouge">big.Int</code> fields (<code class="language-plaintext highlighter-rouge">r</code>, <code class="language-plaintext highlighter-rouge">s</code>, <code class="language-plaintext highlighter-rouge">mPlus</code>, <code class="language-plaintext highlighter-rouge">mMinus</code>, <code class="language-plaintext highlighter-rouge">scratch1</code>, <code class="language-plaintext highlighter-rouge">scratch2</code>, <code class="language-plaintext highlighter-rouge">scratch3</code>, <code class="language-plaintext highlighter-rouge">quot</code>, <code class="language-plaintext highlighter-rouge">rem</code>) is eliminated.</p>

<p>The CPU profile shows the same pattern from the time axis. The <code class="language-plaintext highlighter-rouge">math/big</code> functions that appear in the Burger-Dybvig profile do not appear anywhere in the Schubfach profile:</p>

<table>
  <thead>
    <tr>
      <th>Function</th>
      <th>Burger-Dybvig CPU</th>
      <th>Schubfach CPU</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">math/big.mulAddVWW</code></td>
      <td>2.70s (1.32%)</td>
      <td>absent</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">math/big.nat.mulAddWW</code></td>
      <td>2.33s (1.14%)</td>
      <td>absent</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">math/big.(*Int).mul</code></td>
      <td>2.28s (1.12%)</td>
      <td>absent</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">math/big.nat.norm</code></td>
      <td>1.80s (0.88%)</td>
      <td>absent</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">math/big.nat.mul</code></td>
      <td>1.71s (0.84%)</td>
      <td>absent</td>
    </tr>
  </tbody>
</table>

<p>They are replaced by <code class="language-plaintext highlighter-rouge">math/bits.Mul64</code>, which the Go compiler inlines. It does not appear as a named function in the profile because it executes as a single <code class="language-plaintext highlighter-rouge">MULQ</code> instruction on x86-64.</p>

<h2 id="what-the-profiles-reveal-about-the-pipeline">What the Profiles Reveal About the Pipeline</h2>

<p>The top application-level functions in both CPU profiles are structurally identical:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Schubfach:
  jcs.serializeString             7.60s   3.73%
  jcstoken.(*parser).parseString  6.30s   3.09%
  jcs.validateValueTree           2.73s   1.34%
  jcs.serializeObject             2.60s   1.28%

Burger-Dybvig:
  jcs.serializeString             6.97s   3.41%
  jcstoken.(*parser).parseString  5.25s   2.57%
  jcs.validateValueTree           2.64s   1.29%
  jcs.serializeObject             2.48s   1.21%
</code></pre></div></div>

<p>(Both profiles are dominated by runtime functions above these: <code class="language-plaintext highlighter-rouge">strconv.leftShift</code>, <code class="language-plaintext highlighter-rouge">runtime.nextFreeFast</code>, <code class="language-plaintext highlighter-rouge">runtime.memclrNoHeapPointers</code>, and <code class="language-plaintext highlighter-rouge">aeshashbody</code> occupy the top positions in both builds. The application-level functions listed here appear below them.)</p>

<p>String serialization, string parsing, value tree validation, and object serialization (which includes UTF-16 key sorting via <code class="language-plaintext highlighter-rouge">sort.Slice</code>) dominate the application-level profile in both builds. These are the same code in both builds. Replacing the number formatter moved it off the critical path for number-dense payloads, but the pipeline bottleneck for general workloads was never number formatting. It was always parsing and serialization.</p>

<p>The Schubfach profile shows higher absolute times for these functions (7.60s vs 6.97s for <code class="language-plaintext highlighter-rouge">serializeString</code>) because the benchmark ran more iterations in the same wall-clock budget. Schubfach is faster per-call, so Go’s benchmark framework runs more iterations, and the cumulative CPU time in the shared code paths increases accordingly.</p>

<p>The next meaningful performance improvement for json-canon is not in number formatting. It is in the parser (which builds a full value tree with heap-allocated strings for every key and value) and the serializer (which sorts keys via <code class="language-plaintext highlighter-rouge">sort.Slice</code> with a closure). Those are architectural decisions that would require API changes to address, not algorithm swaps within an existing interface.</p>

<h2 id="geometric-means">Geometric Means</h2>

<p>benchstat reports the following geometric means across all 51 API benchmark comparisons (n=6):</p>

<table>
  <thead>
    <tr>
      <th>Metric</th>
      <th>Schubfach</th>
      <th>Burger-Dybvig</th>
      <th>Change</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>sec/op</td>
      <td>12.32us</td>
      <td>16.18us</td>
      <td>+31.33% faster</td>
    </tr>
    <tr>
      <td>B/s</td>
      <td>31.20 MiB/s</td>
      <td>23.76 MiB/s</td>
      <td>+31.33% higher throughput</td>
    </tr>
    <tr>
      <td>B/op</td>
      <td>15.70 KiB</td>
      <td>16.30 KiB</td>
      <td>-3.81% less memory</td>
    </tr>
    <tr>
      <td>allocs/op</td>
      <td>157.7</td>
      <td>174.3</td>
      <td>-10.54% fewer allocations</td>
    </tr>
  </tbody>
</table>

<p>The B/op reduction (3.81%) is smaller than the allocs/op reduction (10.54%) because the eliminated allocations are small (<code class="language-plaintext highlighter-rouge">big.Int</code> internal slices, typically 8-64 bytes each). The throughput improvement exceeds the allocation reduction because the saved allocations are not just memory operations. They are also CPU operations: <code class="language-plaintext highlighter-rouge">big.Int</code> multiply involves digit-by-digit multiplication with carry propagation, normalization, and bounds checking. Eliminating the allocations eliminates the arithmetic they supported.</p>

<h2 id="statistical-evidence">Statistical Evidence</h2>

<p>The API benchmarks were run with <code class="language-plaintext highlighter-rouge">-count=6</code>. benchstat computes valid confidence intervals for all 51 comparisons.</p>

<p>Of the 51 comparisons, 40 reach statistical significance at p &lt; 0.05. The 11 that do not are workloads where the number formatter is irrelevant: <code class="language-plaintext highlighter-rouge">deep</code> (p=0.784), <code class="language-plaintext highlighter-rouge">deep-64</code> (p=0.589), <code class="language-plaintext highlighter-rouge">unicode</code> (p=0.108 to 0.781 across modes), <code class="language-plaintext highlighter-rouge">surrogate-pair/noncanonical</code> (p=0.058), <code class="language-plaintext highlighter-rouge">long-string/canonical</code> (p=0.818), and <code class="language-plaintext highlighter-rouge">rfc-key-sorting/canonical</code> (p=0.240). These are the exact workloads where no performance difference is expected.</p>

<p>Every number-dense workload reaches p=0.002 (the minimum achievable p-value with n=6 using a permutation test). The Burger-Dybvig wins on string-dominant workloads also reach significance (<code class="language-plaintext highlighter-rouge">control-escapes</code> at p=0.002, 6% slower for Schubfach), confirming these are real effects from binary layout or instruction cache differences, not measurement noise.</p>

<p>The CLI end-to-end results (9 runs per comparison, permutation test with 3,000 resamples) provide independent corroboration. Five of 28 CLI comparisons reach significance at p &lt; 0.05: <code class="language-plaintext highlighter-rouge">canonicalize/array-2048</code> at p=0.0007 (1.32x, d=-3.14), <code class="language-plaintext highlighter-rouge">canonicalize/nested-mixed</code> at p=0.0017 (1.36x, d=-2.01), <code class="language-plaintext highlighter-rouge">canonicalize/numeric-boundary</code> at p=0.036 (1.22x, d=-1.08), <code class="language-plaintext highlighter-rouge">verify/array-2048</code> at p=0.0007 (1.30x, d=-3.97), and <code class="language-plaintext highlighter-rouge">verify/array-256</code> at p=0.038 (1.22x, d=-1.09). All five favor Schubfach. The remaining 23 comparisons do not reach significance because process startup noise dominates at the 1-7ms timescales of CLI execution.</p>

<h2 id="the-decision">The Decision</h2>

<p>The conformance gate is clean. The performance improvement is statistically significant across every number-dense workload. The implementation is a drop-in replacement: same <code class="language-plaintext highlighter-rouge">FormatDouble</code> signature, same error types, same output bytes for every tested input.</p>

<p>Burger-Dybvig was the right choice for the initial implementation. It was straightforward to verify against the <a href="https://dl.acm.org/doi/10.1145/249069.231397">original paper</a> (Burger and Dybvig, PLDI 1996). It was straightforward to debug when the oracle vectors caught errors during initial development. It was straightforward to reason about when modifying the tie-breaking policy for ECMA-262 compliance. It served its purpose: it proved the approach was viable and produced correct output while the rest of the pipeline was built around it.</p>

<p>Schubfach replaces Burger-Dybvig in json-canon. The change ships as a minor version. The API does not change. The output does not change. The canonical bytes for every input remain identical. What changes is the cost of producing them: fewer allocations, less memory, higher throughput on number-dense workloads, and the elimination of <code class="language-plaintext highlighter-rouge">math/big</code> from the formatting path’s runtime dependency graph.</p>

<h2 id="revision-history">Revision History</h2>

<table>
  <thead>
    <tr>
      <th>Date</th>
      <th>Change</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2026-03-05</td>
      <td>Initial publication.</td>
    </tr>
  </tbody>
</table>

<hr />

<p><em>The implementation lives in the <a href="https://github.com/lattice-substrate/json-canon/tree/main/jcsfloat">jcsfloat</a> package of <a href="https://github.com/lattice-substrate/json-canon">json-canon</a>, an RFC 8785 JSON canonicalization library written in Go.</em></p>]]></content><author><name>Mark Lenhardt</name></author><category term="go" /><category term="algorithms" /><category term="ieee754" /><category term="performance" /><summary type="html"><![CDATA[Replacing the Burger-Dybvig multiprecision float formatter with the Schubfach fixed-width algorithm: same ECMA-262 conformance contract, same output bytes, fewer allocations, higher throughput on number-dense workloads. Validated against 286,362 oracle test vectors.]]></summary></entry><entry><title type="html">IEEE 754 Compliance Does Not Mean Platform Independence</title><link href="https://lattice-substrate.github.io/blog/2026/03/04/fma-go-floating-point-determinism/" rel="alternate" type="text/html" title="IEEE 754 Compliance Does Not Mean Platform Independence" /><published>2026-03-04T00:00:00+00:00</published><updated>2026-03-04T00:00:00+00:00</updated><id>https://lattice-substrate.github.io/blog/2026/03/04/fma-go-floating-point-determinism</id><content type="html" xml:base="https://lattice-substrate.github.io/blog/2026/03/04/fma-go-floating-point-determinism/"><![CDATA[<p>In December 2025, the DoltHub team found that their Go database was producing different query plans on ARM Macs and x86 Windows machines. The trace led to a single expression in the query planner’s cost model:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">return</span> <span class="n">lBest</span><span class="o">*</span><span class="n">seqIOCostFactor</span> <span class="o">+</span> <span class="n">selfJoinCard</span><span class="o">*</span><span class="p">(</span><span class="n">randIOCostFactor</span><span class="o">+</span><span class="n">seqIOCostFactor</span><span class="p">),</span> <span class="no">nil</span>
</code></pre></div></div>

<p>On ARM, the Go compiler emitted a Fused-Multiply-Add instruction for this expression. On x86, it did not. The FMA rounded once instead of twice, producing a result that differed by one unit in the last place. That was enough to flip a less-than comparison between two nearly identical plan costs and select a different join order.</p>

<p>The values: <code class="language-plaintext highlighter-rouge">3.09928472e+06</code> on one platform, <code class="language-plaintext highlighter-rouge">3.0992847200000007e+06</code> on the other. Both IEEE 754 compliant. Both correct. Different bits.</p>

<p>The DoltHub team <a href="https://www.dolthub.com/blog/2025-12-19-golang-ieee-strictness/">documented the discovery and the fix</a> on their blog. The fix is interesting because it reveals a deliberate design choice in the Go compiler, and that design choice has implications for any Go program whose correctness depends on identical floating-point output across platforms.</p>

<h2 id="fma-at-the-hardware-level">FMA at the Hardware Level</h2>

<p>A Fused-Multiply-Add computes <code class="language-plaintext highlighter-rouge">a * b + c</code> as a single operation with a single rounding step. Without FMA, the CPU computes <code class="language-plaintext highlighter-rouge">a * b</code>, rounds the result to the destination precision, adds <code class="language-plaintext highlighter-rouge">c</code>, and rounds again. Two operations, two roundings. FMA eliminates the intermediate rounding. The result is generally <em>more</em> accurate, closer to the true mathematical value, because only one rounding error is introduced instead of two. But when the intermediate rounding would have rounded in a different direction than the final rounding, the FMA and non-FMA paths produce results that differ by one ULP.</p>

<p>IEEE 754 Section 5.4.1 defines <code class="language-plaintext highlighter-rouge">fusedMultiplyAdd</code> as a sanctioned operation. As Doug Priest noted in his appendix to Goldberg’s “What Every Computer Scientist Should Know About Floating-Point Arithmetic,” the standard requires correct rounding to the destination precision but does not require that intermediate precision be determined by the programmer’s source code.</p>

<p>The hardware landscape: ARMv8 chips universally support FMA (<code class="language-plaintext highlighter-rouge">FMADD</code>/<code class="language-plaintext highlighter-rouge">FMSUB</code>). On x86, FMA requires the FMA3 extension, available since Haswell (Intel, 2013) and Piledriver (AMD, 2012), but absent on older and some low-power chips. A compiler targeting ARM can emit FMA for any multiply-add expression. The same compiler targeting older x86 cannot. The same source code, the same compiler version, the same optimization level. Different instructions, different rounding, different bits.</p>

<h2 id="gos-compiler-policy">Go’s Compiler Policy</h2>

<p>Go addressed FMA in <a href="https://github.com/golang/go/issues/17895">issue #17895</a>, accepted for Go 1.9. The consensus:</p>

<blockquote>
  <p>A <code class="language-plaintext highlighter-rouge">float64</code> conversion should be an explicit signal that a rounded <code class="language-plaintext highlighter-rouge">float64</code> should be materialized.</p>
</blockquote>

<p>The Go compiler is free to fuse <code class="language-plaintext highlighter-rouge">a*b + c</code> into a single FMA instruction whenever the target hardware supports it. The opt-out mechanism is an explicit <code class="language-plaintext highlighter-rouge">float64()</code> cast, which forces the compiler to materialize a rounded intermediate result:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// FMA allowed -- compiler may fuse on ARM</span>
<span class="n">result</span> <span class="o">:=</span> <span class="n">a</span><span class="o">*</span><span class="n">b</span> <span class="o">+</span> <span class="n">c</span>

<span class="c">// FMA prevented -- explicit rounding after multiply</span>
<span class="n">result</span> <span class="o">:=</span> <span class="kt">float64</span><span class="p">(</span><span class="n">a</span><span class="o">*</span><span class="n">b</span><span class="p">)</span> <span class="o">+</span> <span class="n">c</span>
</code></pre></div></div>

<p>The DoltHub fix applied this directly:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">return</span> <span class="kt">float64</span><span class="p">(</span><span class="n">lBest</span><span class="o">*</span><span class="n">seqIOCostFactor</span><span class="p">)</span> <span class="o">+</span>
    <span class="kt">float64</span><span class="p">(</span><span class="n">selfJoinCard</span><span class="o">*</span><span class="p">(</span><span class="n">randIOCostFactor</span><span class="o">+</span><span class="n">seqIOCostFactor</span><span class="p">)),</span> <span class="no">nil</span>
</code></pre></div></div>

<p>This is a clean solution for expressions where you can identify and annotate the vulnerable multiply-add patterns. For a query planner, that may be a handful of cost-calculation expressions. For a digit-generation algorithm that involves extended sequences of floating-point arithmetic, the question becomes harder: how do you ensure that <em>every</em> expression in the pipeline is either not fusible or explicitly guarded?</p>

<h2 id="standard-library-formatters-as-implementation-details">Standard Library Formatters as Implementation Details</h2>

<p>In January 2019, Anders Rundgren, the author of RFC 8785, filed <a href="https://github.com/golang/go/issues/29491">Go issue #29491</a> against <code class="language-plaintext highlighter-rouge">strconv.FormatFloat</code>. His reference JCS implementation was producing incorrect rounding on Windows/amd64 for specific values:</p>

<table>
  <thead>
    <tr>
      <th>IEEE 754 Hex</th>
      <th><code class="language-plaintext highlighter-rouge">FormatFloat</code> Returned</th>
      <th>Correct Result</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">439babe4b56e8a39</code></td>
      <td><code class="language-plaintext highlighter-rouge">498484681984085560</code></td>
      <td><code class="language-plaintext highlighter-rouge">498484681984085570</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">c4dee27bdef22651</code></td>
      <td><code class="language-plaintext highlighter-rouge">-5.8339553793802236e+23</code></td>
      <td><code class="language-plaintext highlighter-rouge">-5.8339553793802237e+23</code></td>
    </tr>
  </tbody>
</table>

<p>The root cause was a bug in <code class="language-plaintext highlighter-rouge">strconv</code>’s <code class="language-plaintext highlighter-rouge">roundShortest</code> function. The fix shipped in Go 1.13. The RFC author’s own reference implementation, broken by the standard library it delegated to.</p>

<p>This is not an indictment of <code class="language-plaintext highlighter-rouge">strconv.FormatFloat</code>. It is a high-quality implementation. But it is a general-purpose formatting function whose underlying algorithm has changed over Go’s history: Grisu3 with exact-arithmetic fallback through Go 1.16, Ryu from <a href="https://go.dev/doc/go1.17">Go 1.17</a>, and <a href="https://go.googlesource.com/go/+/refs/tags/go1.26.0/src/internal/strconv/ftoadbox.go">Dragonbox from Go 1.26</a>. Each transition preserved round-trip correctness. None contractually guaranteed digit-sequence stability. The <code class="language-plaintext highlighter-rouge">strconv</code> documentation guarantees a shortest round-trip representation. It does not guarantee which valid shortest representation it will choose when two are equally correct, and it does not guarantee that the choice will remain stable across releases.</p>

<p>For a general-purpose formatting function, this is fine. Any shortest round-trip string is equally useful. For a canonicalization scheme, “valid but different” is a conformance failure, because the specification requires one specific digit sequence for each IEEE 754 bit pattern.</p>

<h2 id="a-common-pattern-in-jcs-implementations">A Common Pattern in JCS Implementations</h2>

<p><a href="https://github.com/gowebpki/jcs">gowebpki/jcs</a>, the maintained Go fork of Anders Rundgren’s RFC 8785 reference implementation, takes the obvious approach to number formatting: delegate to the standard library.</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">NumberToJSON</span><span class="p">(</span><span class="n">ieeeF64</span> <span class="kt">float64</span><span class="p">)</span> <span class="p">(</span><span class="n">res</span> <span class="kt">string</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">ieeeU64</span> <span class="o">:=</span> <span class="n">math</span><span class="o">.</span><span class="n">Float64bits</span><span class="p">(</span><span class="n">ieeeF64</span><span class="p">)</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">ieeeU64</span> <span class="o">&amp;</span> <span class="n">invalidPattern</span><span class="p">)</span> <span class="o">==</span> <span class="n">invalidPattern</span> <span class="p">{</span>
        <span class="k">return</span> <span class="s">"null"</span><span class="p">,</span> <span class="n">errors</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="s">"Invalid JSON number: "</span> <span class="o">+</span>
            <span class="n">strconv</span><span class="o">.</span><span class="n">FormatUint</span><span class="p">(</span><span class="n">ieeeU64</span><span class="p">,</span> <span class="m">16</span><span class="p">))</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="n">ieeeF64</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span>
        <span class="k">return</span> <span class="s">"0"</span><span class="p">,</span> <span class="no">nil</span>
    <span class="p">}</span>

    <span class="k">var</span> <span class="n">sign</span> <span class="kt">string</span> <span class="o">=</span> <span class="s">""</span>
    <span class="k">if</span> <span class="n">ieeeF64</span> <span class="o">&lt;</span> <span class="m">0</span> <span class="p">{</span>
        <span class="n">ieeeF64</span> <span class="o">=</span> <span class="o">-</span><span class="n">ieeeF64</span>
        <span class="n">sign</span> <span class="o">=</span> <span class="s">"-"</span>
    <span class="p">}</span>

    <span class="k">var</span> <span class="n">format</span> <span class="kt">byte</span> <span class="o">=</span> <span class="sc">'e'</span>
    <span class="k">if</span> <span class="n">ieeeF64</span> <span class="o">&lt;</span> <span class="m">1e+21</span> <span class="o">&amp;&amp;</span> <span class="n">ieeeF64</span> <span class="o">&gt;=</span> <span class="m">1e-6</span> <span class="p">{</span>
        <span class="n">format</span> <span class="o">=</span> <span class="sc">'f'</span>
    <span class="p">}</span>

    <span class="c">// The following should (in "theory") do the trick:</span>
    <span class="n">es6Formatted</span> <span class="o">:=</span> <span class="n">strconv</span><span class="o">.</span><span class="n">FormatFloat</span><span class="p">(</span><span class="n">ieeeF64</span><span class="p">,</span> <span class="n">format</span><span class="p">,</span> <span class="o">-</span><span class="m">1</span><span class="p">,</span> <span class="m">64</span><span class="p">)</span>

    <span class="n">exponent</span> <span class="o">:=</span> <span class="n">strings</span><span class="o">.</span><span class="n">IndexByte</span><span class="p">(</span><span class="n">es6Formatted</span><span class="p">,</span> <span class="sc">'e'</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">exponent</span> <span class="o">&gt;</span> <span class="m">0</span> <span class="p">{</span>
        <span class="k">if</span> <span class="n">es6Formatted</span><span class="p">[</span><span class="n">exponent</span><span class="o">+</span><span class="m">2</span><span class="p">]</span> <span class="o">==</span> <span class="sc">'0'</span> <span class="p">{</span>
            <span class="n">es6Formatted</span> <span class="o">=</span> <span class="n">es6Formatted</span><span class="p">[</span><span class="o">:</span><span class="n">exponent</span><span class="o">+</span><span class="m">2</span><span class="p">]</span> <span class="o">+</span>
                <span class="n">es6Formatted</span><span class="p">[</span><span class="n">exponent</span><span class="o">+</span><span class="m">3</span><span class="o">:</span><span class="p">]</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">sign</span> <span class="o">+</span> <span class="n">es6Formatted</span><span class="p">,</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This is a reasonable design. The function handles the special cases (NaN, infinity, negative zero, sign extraction, ECMA-262 format selection, exponent normalization) and delegates the hard part, digit generation, to <code class="language-plaintext highlighter-rouge">strconv.FormatFloat</code>. This is the pattern most JCS implementations follow, in Go and in other languages, because digit generation is genuinely difficult and the standard library already does it.</p>

<p>The coupling this creates is straightforward: if <code class="language-plaintext highlighter-rouge">strconv.FormatFloat</code> changes its output for a given input, the canonical output changes. Whether that matters depends on requirements. For applications where JCS output is compared within a single Go version on a single architecture, it may not matter at all. For applications where canonical output must be identical across Go versions, across platforms, or across language implementations, the coupling is the mechanism by which platform differences propagate into the canonical output.</p>

<h2 id="approaches-to-platform-independent-digit-generation">Approaches to Platform-Independent Digit Generation</h2>

<p>Two approaches eliminate platform dependence in the digit-generation pipeline. They make different tradeoffs.</p>

<h3 id="arbitrary-precision-integer-arithmetic">Arbitrary-Precision Integer Arithmetic</h3>

<p>The Burger-Dybvig algorithm represents the float value and its rounding boundaries as ratios of arbitrary-precision integers (<code class="language-plaintext highlighter-rouge">math/big.Int</code> in Go). After the initial <code class="language-plaintext highlighter-rouge">math.Float64bits</code> bit-cast extracts the raw IEEE 754 pattern, every subsequent operation is exact integer arithmetic:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// Digit extraction: multiply R by 10, divide by S</span>
<span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="o">.</span><span class="n">Mul</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">,</span> <span class="n">bigTen</span><span class="p">)</span>
<span class="n">d</span> <span class="o">:=</span> <span class="nb">new</span><span class="p">(</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">)</span>
<span class="n">d</span><span class="o">.</span><span class="n">DivMod</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">,</span> <span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="p">,</span> <span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">big.Int.Mul</code>, <code class="language-plaintext highlighter-rouge">big.Int.DivMod</code>, <code class="language-plaintext highlighter-rouge">big.Int.Cmp</code>: these are integer operations executed on the CPU’s integer ALU. The Go compiler cannot emit FMA instructions for them because FMA is a floating-point instruction. The guarantee holds not because the code is carefully written to avoid fusible expressions, but because the types involved make fusion inapplicable.</p>

<p>The single floating-point operation in the pipeline is a <code class="language-plaintext highlighter-rouge">log10</code> estimate used for initial decimal scaling. This estimate is allowed to be wrong. Two integer fixup passes correct it using exact <code class="language-plaintext highlighter-rouge">big.Int.Cmp</code> comparisons. An FMA-affected <code class="language-plaintext highlighter-rouge">log10</code> estimate that is off by one is corrected the same way as any other off-by-one estimate.</p>

<p>The tradeoff is performance. <code class="language-plaintext highlighter-rouge">math/big</code> operations allocate heap memory and are substantially slower than fixed-width integer arithmetic. For a canonicalization library where the output contract is more important than throughput, this is an acceptable cost. For a high-throughput formatter, it may not be.</p>

<p><a href="https://lattice-substrate.github.io/blog/2026/02/27/shortest-roundtrip-ieee754-burger-dybvig/">Shortest Round-Trip: Implementing IEEE 754 to Decimal Conversion in Go</a> covers the full Burger-Dybvig implementation. The determinism claim is backed by an <a href="https://lattice-substrate.github.io/blog/2026/02/24/proving-determinism-evidence-release/">offline replay harness</a> that runs 60 independent executions across 12 Linux environments on both x86_64 and arm64, comparing SHA-256 digests of canonical output.</p>

<h3 id="fixed-width-integer-algorithms">Fixed-Width Integer Algorithms</h3>

<p>Ryu and Schubfach (the algorithm underlying Dragonbox) avoid arbitrary-precision arithmetic entirely. They use fixed-width 64-bit and 128-bit integer operations with precomputed lookup tables. Their core paths are substantially faster than Burger-Dybvig.</p>

<p>These core paths are mostly integer math. A Ryu implementation’s critical loop looks roughly like this:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// Ryu core: fixed-width 128-bit multiply against precomputed table</span>
<span class="n">vr</span> <span class="o">:=</span> <span class="n">mulShift64</span><span class="p">(</span><span class="n">m2</span><span class="p">,</span> <span class="n">table</span><span class="p">[</span><span class="n">q</span><span class="p">],</span> <span class="n">j</span><span class="p">)</span>  <span class="c">// uint64 multiply + shift</span>
<span class="n">vp</span> <span class="o">:=</span> <span class="n">mulShift64</span><span class="p">(</span><span class="n">m2</span><span class="o">+</span><span class="m">1</span><span class="p">,</span> <span class="n">table</span><span class="p">[</span><span class="n">q</span><span class="p">],</span> <span class="n">j</span><span class="p">)</span>
<span class="n">vm</span> <span class="o">:=</span> <span class="n">mulShift64</span><span class="p">(</span><span class="n">m2</span><span class="o">-</span><span class="m">1</span><span class="p">,</span> <span class="n">table</span><span class="p">[</span><span class="n">q</span><span class="p">],</span> <span class="n">j</span><span class="p">)</span>
<span class="c">// ... digit extraction from vr, vp, vm using integer division</span>
</code></pre></div></div>

<p>These are integer multiplications and shifts, not floating-point multiply-adds. A careful Go implementation of Ryu or Schubfach would likely be immune to FMA for the same structural reason as Burger-Dybvig: the critical operations use integer types that the compiler cannot fuse.</p>

<p>The risk is at the edges. A Ryu port might use a floating-point <code class="language-plaintext highlighter-rouge">log10</code> estimator, or a helper function that computes an exponent approximation through <code class="language-plaintext highlighter-rouge">float64</code> arithmetic. These expressions could be fusible. Where Burger-Dybvig with <code class="language-plaintext highlighter-rouge">math/big</code> makes FMA inapplicable by type across the entire pipeline, a fixed-width implementation needs verification that:</p>

<ul>
  <li>No floating-point helper functions (log10 estimators, exponent approximations) use expressions the compiler could fuse.</li>
  <li>No intermediate value is stored in a <code class="language-plaintext highlighter-rouge">float64</code> where FMA could change the rounding.</li>
  <li>The lookup table generation does not depend on platform-specific floating-point behavior.</li>
  <li>These properties hold across Go compiler versions, as the compiler’s fusion heuristics evolve.</li>
</ul>

<p>This is not a fundamental obstacle. It is ongoing maintenance work, analogous to the <code class="language-plaintext highlighter-rouge">float64()</code> cast discipline that the DoltHub team applied to their cost model. For a project where the performance difference between fixed-width and arbitrary-precision arithmetic matters, the audit cost is well worth paying.</p>

<h2 id="the-broader-observation">The Broader Observation</h2>

<p>IEEE 754 compliance guarantees that each result is correctly rounded to the destination precision. It does not guarantee that two IEEE 754 compliant implementations produce identical results for the same source expression, because the standard permits operations like FMA that change how many roundings occur.</p>

<p>For most software, this does not matter. A query planner that picks a different join order on ARM versus x86 is a correctness problem only because it affects deterministic testing. The alternative plan may be equally efficient. A numerical simulation that differs by one ULP across platforms is within tolerance for almost all applications. FMA is, in most contexts, a net benefit: faster and more accurate.</p>

<p>For software whose correctness property is byte-identical output across platforms (canonicalization schemes, content-addressed storage, reproducible builds), the platform independence of the digit-generation pipeline is an architectural requirement, not a detail. Delegating to <code class="language-plaintext highlighter-rouge">strconv.FormatFloat</code> binds the output to the standard library’s current algorithm, its FMA exposure on the current platform, and its stability guarantees, which are round-trip correctness for a shortest representation, not digit-sequence stability. The approaches to owning the pipeline differ in how they achieve independence and what they trade for it, but the requirement itself is a consequence of IEEE 754’s design: compliance governs accuracy, not identity.</p>

<p>The Burger-Dybvig approach discussed above is the one used in <a href="https://github.com/lattice-substrate/json-canon">json-canon</a>, an RFC 8785 JSON canonicalization library written in Go.</p>

<h2 id="revision-history">Revision History</h2>

<table>
  <thead>
    <tr>
      <th>Date</th>
      <th>Change</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2026-03-04</td>
      <td>Restored Dragonbox/Go 1.26 claim after verification against source tree at <code class="language-plaintext highlighter-rouge">go1.26.0</code> tag</td>
    </tr>
    <tr>
      <td>2026-03-04</td>
      <td>Tightened FMA hardware section; restructured stdlib section to lead with bug report; corrected Ryu version (Go 1.17, not 1.14); removed Dragonbox/Go 1.26 claim (later found to be incorrect, see above); added Ryu code sketch to balance approaches section; added cross-architecture evidence link</td>
    </tr>
    <tr>
      <td>2026-03-04</td>
      <td>Shortened title; removed series references (standalone article); corrected x86 FMA/AVX2 distinction; consolidated redundant links</td>
    </tr>
    <tr>
      <td>2026-03-04</td>
      <td>Complete rewrite: restructured as subject-oriented technical article</td>
    </tr>
  </tbody>
</table>]]></content><author><name>Mark Lenhardt</name></author><category term="go" /><category term="ieee754" /><category term="determinism" /><category term="floating-point" /><summary type="html"><![CDATA[Fused-Multiply-Add instructions produce IEEE 754 compliant results that differ across platforms. What this means for Go programs that depend on identical floating-point output, and what the available approaches to platform-independent digit generation look like.]]></summary></entry><entry><title type="html">Shortest Round-Trip: Implementing IEEE 754 to Decimal Conversion in Go</title><link href="https://lattice-substrate.github.io/blog/2026/02/27/shortest-roundtrip-ieee754-burger-dybvig/" rel="alternate" type="text/html" title="Shortest Round-Trip: Implementing IEEE 754 to Decimal Conversion in Go" /><published>2026-02-27T00:00:00+00:00</published><updated>2026-02-27T00:00:00+00:00</updated><id>https://lattice-substrate.github.io/blog/2026/02/27/shortest-roundtrip-ieee754-burger-dybvig</id><content type="html" xml:base="https://lattice-substrate.github.io/blog/2026/02/27/shortest-roundtrip-ieee754-burger-dybvig/"><![CDATA[<p>Every programmer has seen this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0.1 + 0.2 = 0.30000000000000004
</code></pre></div></div>

<p>The joke is that floating-point arithmetic is broken. It isn’t. IEEE 754 is doing exactly what it specifies. The problem surfaces when you need to <em>serialize</em> these values to text, and when two different systems need to produce <em>exactly the same text</em> for the same value.</p>

<p>This is what <a href="https://www.rfc-editor.org/rfc/rfc8785">RFC 8785</a> (JSON Canonicalization Scheme) requires: byte-deterministic JSON output. And the hardest part of that requirement is number formatting. You need the <em>shortest</em> decimal string that, when parsed back, recovers the original IEEE 754 bits. You need to agree on tie-breaking when two representations are equally short. And you need to match the exact output format specified by <a href="https://tc39.es/ecma262/#sec-numeric-types-number-tostring">ECMA-262 Number serialization</a>, because that’s what RFC 8785 mandates.</p>

<p>Go’s <code class="language-plaintext highlighter-rouge">strconv.FormatFloat</code> is a high-quality shortest-round-trip formatter, but it is not an ECMA-262 conformance contract. So I implemented the Burger-Dybvig algorithm from scratch in Go, validated against pinned oracle test vectors. This article walks through the entire implementation.</p>

<h2 id="ieee-754-anatomy-64-bits-of-structure">IEEE 754 Anatomy: 64 Bits of Structure</h2>

<p>Before generating digits, you need to understand the raw material. An IEEE 754 double-precision float is 64 bits:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[1 bit: sign] [11 bits: biased exponent] [52 bits: mantissa]
</code></pre></div></div>

<p>The value it represents (for normal numbers) is:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>(-1)^sign × 1.mantissa × 2^(exponent - 1023)
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">1.mantissa</code> is key: normal numbers have an implicit leading 1 bit, giving you 53 bits of precision total. Subnormal numbers (biased exponent = 0) lose the implicit bit, giving <code class="language-plaintext highlighter-rouge">0.mantissa × 2^(-1022)</code>.</p>

<p>Here’s how the implementation extracts these parts:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">decodeFloatParts</span><span class="p">(</span><span class="n">f</span> <span class="kt">float64</span><span class="p">)</span> <span class="n">floatParts</span> <span class="p">{</span>
    <span class="n">bits</span> <span class="o">:=</span> <span class="n">math</span><span class="o">.</span><span class="n">Float64bits</span><span class="p">(</span><span class="n">f</span><span class="p">)</span>
    <span class="n">mantissa</span> <span class="o">:=</span> <span class="n">bits</span> <span class="o">&amp;</span> <span class="p">((</span><span class="kt">uint64</span><span class="p">(</span><span class="m">1</span><span class="p">)</span> <span class="o">&lt;&lt;</span> <span class="m">52</span><span class="p">)</span> <span class="o">-</span> <span class="m">1</span><span class="p">)</span>
    <span class="n">expBits</span> <span class="o">:=</span> <span class="n">exponentBits</span><span class="p">(</span><span class="n">bits</span><span class="p">)</span>
    <span class="n">biasedExp</span> <span class="o">:=</span> <span class="kt">int</span><span class="p">(</span><span class="n">expBits</span><span class="p">)</span>

    <span class="n">fMant</span> <span class="o">:=</span> <span class="n">mantissa</span>
    <span class="n">fExp</span> <span class="o">:=</span> <span class="m">1</span> <span class="o">-</span> <span class="m">1023</span> <span class="o">-</span> <span class="m">52</span>
    <span class="k">if</span> <span class="n">biasedExp</span> <span class="o">!=</span> <span class="m">0</span> <span class="p">{</span>
        <span class="n">fMant</span> <span class="o">=</span> <span class="p">(</span><span class="kt">uint64</span><span class="p">(</span><span class="m">1</span><span class="p">)</span> <span class="o">&lt;&lt;</span> <span class="m">52</span><span class="p">)</span> <span class="o">|</span> <span class="n">mantissa</span>
        <span class="n">fExp</span> <span class="o">=</span> <span class="n">biasedExp</span> <span class="o">-</span> <span class="m">1023</span> <span class="o">-</span> <span class="m">52</span>
    <span class="p">}</span>

    <span class="n">lowerBoundary</span> <span class="o">:=</span> <span class="n">biasedExp</span> <span class="o">&gt;</span> <span class="m">1</span> <span class="o">&amp;&amp;</span> <span class="n">mantissa</span> <span class="o">==</span> <span class="m">0</span>

    <span class="k">return</span> <span class="n">floatParts</span><span class="p">{</span>
        <span class="n">mantissa</span><span class="o">:</span>      <span class="n">mantissa</span><span class="p">,</span>
        <span class="n">biasedExp</span><span class="o">:</span>     <span class="n">biasedExp</span><span class="p">,</span>
        <span class="n">fMant</span><span class="o">:</span>         <span class="n">fMant</span><span class="p">,</span>
        <span class="n">fExp</span><span class="o">:</span>          <span class="n">fExp</span><span class="p">,</span>
        <span class="n">lowerBoundary</span><span class="o">:</span> <span class="n">lowerBoundary</span><span class="p">,</span>
        <span class="n">isEven</span><span class="o">:</span>        <span class="n">fMant</span><span class="o">%</span><span class="m">2</span> <span class="o">==</span> <span class="m">0</span><span class="p">,</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Two things to note here. First, the subnormal branch: when <code class="language-plaintext highlighter-rouge">biasedExp == 0</code>, there’s no implicit leading 1, and the effective exponent is fixed at <code class="language-plaintext highlighter-rouge">2^(-1074)</code> (the smallest representable power). Second, the <code class="language-plaintext highlighter-rouge">lowerBoundary</code> flag: when the mantissa is zero and the exponent is above the minimum normal range, the float sits at a power-of-two boundary where the gap to the <em>lower</em> adjacent representable is half the gap to the <em>upper</em> adjacent. This asymmetry matters for the algorithm.</p>

<p>The exponent extraction itself works on the raw bit pattern:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">exponentBits</span><span class="p">(</span><span class="n">bits</span> <span class="kt">uint64</span><span class="p">)</span> <span class="kt">uint16</span> <span class="p">{</span>
    <span class="n">hi</span> <span class="o">:=</span> <span class="kt">byte</span><span class="p">((</span><span class="n">bits</span> <span class="o">&gt;&gt;</span> <span class="m">56</span><span class="p">)</span> <span class="o">&amp;</span> <span class="m">0xFF</span><span class="p">)</span>
    <span class="n">lo</span> <span class="o">:=</span> <span class="kt">byte</span><span class="p">((</span><span class="n">bits</span> <span class="o">&gt;&gt;</span> <span class="m">48</span><span class="p">)</span> <span class="o">&amp;</span> <span class="m">0xFF</span><span class="p">)</span>
    <span class="k">return</span> <span class="p">(</span><span class="kt">uint16</span><span class="p">(</span><span class="n">hi</span><span class="o">&amp;</span><span class="m">0x7F</span><span class="p">)</span> <span class="o">&lt;&lt;</span> <span class="m">4</span><span class="p">)</span> <span class="o">|</span> <span class="kt">uint16</span><span class="p">(</span><span class="n">lo</span><span class="o">&gt;&gt;</span><span class="m">4</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This reconstructs the 11-bit biased exponent by extracting the relevant bytes and masking away the sign bit.</p>

<h2 id="the-core-insight-why-you-need-multiprecision-arithmetic">The Core Insight: Why You Need Multiprecision Arithmetic</h2>

<p>The Burger-Dybvig algorithm answers this question: what is the shortest decimal string <code class="language-plaintext highlighter-rouge">d</code> such that <code class="language-plaintext highlighter-rouge">parse(d) == f</code> for our original float <code class="language-plaintext highlighter-rouge">f</code>?</p>

<p>To answer it, you need to compute <em>exact</em> boundaries. Every float <code class="language-plaintext highlighter-rouge">f</code> has two adjacent representable values. The “shortest round-trip” string must map back to <code class="language-plaintext highlighter-rouge">f</code> and not to either neighbor. This means computing the midpoints between <code class="language-plaintext highlighter-rouge">f</code> and its neighbors with <em>exact</em> arithmetic, not floating-point arithmetic, which would introduce the very imprecision you’re trying to eliminate.</p>

<p>The algorithm represents the value and its boundaries as ratios of big integers. For a float with fractional mantissa <code class="language-plaintext highlighter-rouge">fMant</code> and exponent <code class="language-plaintext highlighter-rouge">fExp</code>, the value is <code class="language-plaintext highlighter-rouge">fMant × 2^fExp</code>. The boundaries M- and M+ define the interval within which any decimal representation will round back to <code class="language-plaintext highlighter-rouge">f</code>.</p>

<h2 id="state-initialization-r-s-m-m-">State Initialization: R, S, M+, M-</h2>

<p>The algorithm works with four multiprecision integers:</p>

<ul>
  <li><strong>R</strong> (remainder): represents the current value, scaled into the digit-extraction space</li>
  <li><strong>S</strong> (scale): the denominator; digits are extracted by dividing R by S</li>
  <li><strong>M+</strong> (upper margin): how far R can grow before rounding to the next float</li>
  <li><strong>M-</strong> (lower margin): how far R can shrink before rounding to the previous float</li>
</ul>

<p>Initialization depends on the exponent sign:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">initScaledPositiveExp</span><span class="p">(</span><span class="n">state</span> <span class="o">*</span><span class="n">digitState</span><span class="p">,</span> <span class="n">parts</span> <span class="n">floatParts</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="o">!</span><span class="n">parts</span><span class="o">.</span><span class="n">lowerBoundary</span> <span class="p">{</span>
        <span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="o">.</span><span class="n">SetUint64</span><span class="p">(</span><span class="n">parts</span><span class="o">.</span><span class="n">fMant</span><span class="p">)</span>
        <span class="n">lshByInt</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">,</span> <span class="n">parts</span><span class="o">.</span><span class="n">fExp</span><span class="o">+</span><span class="m">1</span><span class="p">)</span>  <span class="c">// r = fMant × 2^(fExp+1)</span>
        <span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="o">.</span><span class="n">SetInt64</span><span class="p">(</span><span class="m">2</span><span class="p">)</span>               <span class="c">// s = 2</span>
        <span class="n">state</span><span class="o">.</span><span class="n">mPlus</span><span class="o">.</span><span class="n">SetInt64</span><span class="p">(</span><span class="m">1</span><span class="p">)</span>
        <span class="n">lshByInt</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">mPlus</span><span class="p">,</span> <span class="n">parts</span><span class="o">.</span><span class="n">fExp</span><span class="p">)</span> <span class="c">// m+ = 2^fExp</span>
        <span class="n">state</span><span class="o">.</span><span class="n">mMinus</span><span class="o">.</span><span class="n">Set</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">mPlus</span><span class="p">)</span>     <span class="c">// m- = m+</span>
        <span class="k">return</span>
    <span class="p">}</span>

    <span class="c">// Lower boundary: asymmetric margins</span>
    <span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="o">.</span><span class="n">SetUint64</span><span class="p">(</span><span class="n">parts</span><span class="o">.</span><span class="n">fMant</span><span class="p">)</span>
    <span class="n">lshByInt</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">,</span> <span class="n">parts</span><span class="o">.</span><span class="n">fExp</span><span class="o">+</span><span class="m">2</span><span class="p">)</span>       <span class="c">// r = fMant × 2^(fExp+2)</span>
    <span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="o">.</span><span class="n">SetInt64</span><span class="p">(</span><span class="m">4</span><span class="p">)</span>                    <span class="c">// s = 4</span>
    <span class="n">state</span><span class="o">.</span><span class="n">mPlus</span><span class="o">.</span><span class="n">SetInt64</span><span class="p">(</span><span class="m">1</span><span class="p">)</span>
    <span class="n">lshByInt</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">mPlus</span><span class="p">,</span> <span class="n">parts</span><span class="o">.</span><span class="n">fExp</span><span class="o">+</span><span class="m">1</span><span class="p">)</span>   <span class="c">// m+ = 2^(fExp+1)</span>
    <span class="n">state</span><span class="o">.</span><span class="n">mMinus</span><span class="o">.</span><span class="n">SetInt64</span><span class="p">(</span><span class="m">1</span><span class="p">)</span>
    <span class="n">lshByInt</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">mMinus</span><span class="p">,</span> <span class="n">parts</span><span class="o">.</span><span class="n">fExp</span><span class="p">)</span>    <span class="c">// m- = 2^fExp (half of m+)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">lowerBoundary</code> case (mantissa is zero, exponent above minimum) requires special handling. At a power-of-two boundary, the float sits at the <em>top</em> of one binade and the <em>bottom</em> of the next. The gap to the lower neighbor is half the gap to the upper neighbor. The algorithm accounts for this by doubling all values (multiplying R and S by 2) and setting <code class="language-plaintext highlighter-rouge">m+ = 2 × m-</code>.</p>

<p>For negative exponents, the roles are similar but the scaling direction reverses. Instead of left-shifting R, we left-shift S:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">initScaledNegativeExp</span><span class="p">(</span><span class="n">state</span> <span class="o">*</span><span class="n">digitState</span><span class="p">,</span> <span class="n">parts</span> <span class="n">floatParts</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="o">!</span><span class="n">parts</span><span class="o">.</span><span class="n">lowerBoundary</span> <span class="p">{</span>
        <span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="o">.</span><span class="n">SetUint64</span><span class="p">(</span><span class="n">parts</span><span class="o">.</span><span class="n">fMant</span><span class="p">)</span>
        <span class="n">lshByInt</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">,</span> <span class="m">1</span><span class="p">)</span>               <span class="c">// r = fMant × 2</span>
        <span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="o">.</span><span class="n">SetInt64</span><span class="p">(</span><span class="m">1</span><span class="p">)</span>
        <span class="n">lshByInt</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="p">,</span> <span class="o">-</span><span class="n">parts</span><span class="o">.</span><span class="n">fExp</span><span class="o">+</span><span class="m">1</span><span class="p">)</span>   <span class="c">// s = 2^(-fExp+1)</span>
        <span class="n">state</span><span class="o">.</span><span class="n">mPlus</span><span class="o">.</span><span class="n">SetInt64</span><span class="p">(</span><span class="m">1</span><span class="p">)</span>            <span class="c">// m+ = 1</span>
        <span class="n">state</span><span class="o">.</span><span class="n">mMinus</span><span class="o">.</span><span class="n">SetInt64</span><span class="p">(</span><span class="m">1</span><span class="p">)</span>           <span class="c">// m- = 1</span>
        <span class="k">return</span>
    <span class="p">}</span>

    <span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="o">.</span><span class="n">SetUint64</span><span class="p">(</span><span class="n">parts</span><span class="o">.</span><span class="n">fMant</span><span class="p">)</span>
    <span class="n">lshByInt</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">,</span> <span class="m">2</span><span class="p">)</span>                   <span class="c">// r = fMant × 4</span>
    <span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="o">.</span><span class="n">SetInt64</span><span class="p">(</span><span class="m">1</span><span class="p">)</span>
    <span class="n">lshByInt</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="p">,</span> <span class="o">-</span><span class="n">parts</span><span class="o">.</span><span class="n">fExp</span><span class="o">+</span><span class="m">2</span><span class="p">)</span>       <span class="c">// s = 2^(-fExp+2)</span>
    <span class="n">state</span><span class="o">.</span><span class="n">mPlus</span><span class="o">.</span><span class="n">SetInt64</span><span class="p">(</span><span class="m">2</span><span class="p">)</span>                <span class="c">// m+ = 2</span>
    <span class="n">state</span><span class="o">.</span><span class="n">mMinus</span><span class="o">.</span><span class="n">SetInt64</span><span class="p">(</span><span class="m">1</span><span class="p">)</span>               <span class="c">// m- = 1</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="power-of-10-scaling">Power-of-10 Scaling</h2>

<p>After initialization, R, S, M+, and M- are all in base-2 space. To extract decimal digits, we need to scale them so that the first digit comes from <code class="language-plaintext highlighter-rouge">floor(R/S)</code>. This requires estimating <code class="language-plaintext highlighter-rouge">k ≈ ceil(log10(f))</code> and scaling accordingly:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">scaleByPower10</span><span class="p">(</span><span class="n">state</span> <span class="o">*</span><span class="n">digitState</span><span class="p">,</span> <span class="n">k</span> <span class="kt">int</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">switch</span> <span class="p">{</span>
    <span class="k">case</span> <span class="n">k</span> <span class="o">&gt;</span> <span class="m">0</span><span class="o">:</span>
        <span class="n">p</span> <span class="o">:=</span> <span class="n">pow10Big</span><span class="p">(</span><span class="n">k</span><span class="p">)</span>
        <span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="o">.</span><span class="n">Mul</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="p">,</span> <span class="n">p</span><span class="p">)</span>           <span class="c">// Scale denominator up</span>
    <span class="k">case</span> <span class="n">k</span> <span class="o">&lt;</span> <span class="m">0</span><span class="o">:</span>
        <span class="n">p</span> <span class="o">:=</span> <span class="n">pow10Big</span><span class="p">(</span><span class="o">-</span><span class="n">k</span><span class="p">)</span>
        <span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="o">.</span><span class="n">Mul</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">,</span> <span class="n">p</span><span class="p">)</span>           <span class="c">// Scale numerator up</span>
        <span class="n">state</span><span class="o">.</span><span class="n">mPlus</span><span class="o">.</span><span class="n">Mul</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">mPlus</span><span class="p">,</span> <span class="n">p</span><span class="p">)</span>   <span class="c">// Scale margins too</span>
        <span class="n">state</span><span class="o">.</span><span class="n">mMinus</span><span class="o">.</span><span class="n">Mul</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">mMinus</span><span class="p">,</span> <span class="n">p</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">k</code> estimate uses floating-point logarithms and is allowed to be off by one. Two fixup passes correct any estimation error:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">applyHighFixup</span><span class="p">(</span><span class="n">state</span> <span class="o">*</span><span class="n">digitState</span><span class="p">,</span> <span class="n">isEven</span> <span class="kt">bool</span><span class="p">,</span> <span class="n">n</span> <span class="kt">int</span><span class="p">)</span> <span class="kt">int</span> <span class="p">{</span>
    <span class="n">high</span> <span class="o">:=</span> <span class="nb">new</span><span class="p">(</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">)</span><span class="o">.</span><span class="n">Add</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">,</span> <span class="n">state</span><span class="o">.</span><span class="n">mPlus</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">cmpHigh</span><span class="p">(</span><span class="n">high</span><span class="p">,</span> <span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="p">,</span> <span class="n">isEven</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="o">.</span><span class="n">Mul</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="p">,</span> <span class="n">bigTen</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">n</span> <span class="o">+</span> <span class="m">1</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">n</span>
<span class="p">}</span>
</code></pre></div></div>

<p>If <code class="language-plaintext highlighter-rouge">R + M+</code> already exceeds <code class="language-plaintext highlighter-rouge">S</code> after initial scaling, we need one more decimal position; multiply S by 10 and increment the exponent count. The low fixup works in the opposite direction, looping while <code class="language-plaintext highlighter-rouge">10R</code> and <code class="language-plaintext highlighter-rouge">10(R + M+)</code> are both less than S.</p>

<p>Computing <code class="language-plaintext highlighter-rouge">pow10Big</code> efficiently matters because these are big integer multiplications. The implementation pre-computes 700 powers of 10 at init time:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="n">pow10Cache</span> <span class="p">[</span><span class="m">700</span><span class="p">]</span><span class="o">*</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span>

<span class="k">func</span> <span class="n">init</span><span class="p">()</span> <span class="p">{</span>
    <span class="n">pow10Cache</span><span class="p">[</span><span class="m">0</span><span class="p">]</span> <span class="o">=</span> <span class="n">big</span><span class="o">.</span><span class="n">NewInt</span><span class="p">(</span><span class="m">1</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">i</span> <span class="o">:=</span> <span class="m">1</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">pow10Cache</span><span class="p">);</span> <span class="n">i</span><span class="o">++</span> <span class="p">{</span>
        <span class="n">pow10Cache</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">=</span> <span class="nb">new</span><span class="p">(</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">)</span><span class="o">.</span><span class="n">Mul</span><span class="p">(</span><span class="n">pow10Cache</span><span class="p">[</span><span class="n">i</span><span class="o">-</span><span class="m">1</span><span class="p">],</span> <span class="n">bigTen</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">pow10Big</span><span class="p">(</span><span class="n">n</span> <span class="kt">int</span><span class="p">)</span> <span class="o">*</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">n</span> <span class="o">&gt;=</span> <span class="m">0</span> <span class="o">&amp;&amp;</span> <span class="n">n</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">pow10Cache</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span> <span class="nb">new</span><span class="p">(</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">)</span><span class="o">.</span><span class="n">Set</span><span class="p">(</span><span class="n">pow10Cache</span><span class="p">[</span><span class="n">n</span><span class="p">])</span>  <span class="c">// Defensive copy</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="nb">new</span><span class="p">(</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">)</span><span class="o">.</span><span class="n">Exp</span><span class="p">(</span><span class="n">bigTen</span><span class="p">,</span> <span class="n">big</span><span class="o">.</span><span class="n">NewInt</span><span class="p">(</span><span class="kt">int64</span><span class="p">(</span><span class="n">n</span><span class="p">)),</span> <span class="no">nil</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The defensive copy is critical. Without it, callers that mutate the returned <code class="language-plaintext highlighter-rouge">*big.Int</code> would corrupt the cache. IEEE 754 binary64 ranges from approximately 10^-324 to 10^308, so the 700-entry cache covers all practical <code class="language-plaintext highlighter-rouge">k</code> values.</p>

<h2 id="digit-generation-the-main-loop">Digit Generation: The Main Loop</h2>

<p>With scaling complete, digit extraction is a simple loop. Multiply R, M+, and M- by 10, then divide R by S to get one decimal digit. Repeat until termination:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">extractDigits</span><span class="p">(</span><span class="n">state</span> <span class="o">*</span><span class="n">digitState</span><span class="p">,</span> <span class="n">isEven</span> <span class="kt">bool</span><span class="p">,</span> <span class="n">n</span> <span class="kt">int</span><span class="p">)</span> <span class="p">(</span><span class="kt">string</span><span class="p">,</span> <span class="kt">int</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">var</span> <span class="n">digitBuf</span> <span class="p">[</span><span class="m">30</span><span class="p">]</span><span class="kt">byte</span>
    <span class="n">dIdx</span> <span class="o">:=</span> <span class="m">0</span>
    <span class="n">quot</span> <span class="o">:=</span> <span class="nb">new</span><span class="p">(</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">)</span>
    <span class="n">rem</span> <span class="o">:=</span> <span class="nb">new</span><span class="p">(</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">)</span>

    <span class="k">for</span> <span class="p">{</span>
        <span class="n">scaleDigitState</span><span class="p">(</span><span class="n">state</span><span class="p">)</span>                              <span class="c">// R, M+, M- *= 10</span>
        <span class="n">d</span> <span class="o">:=</span> <span class="n">divideAndRemainder</span><span class="p">(</span><span class="n">state</span><span class="p">,</span> <span class="n">quot</span><span class="p">,</span> <span class="n">rem</span><span class="p">)</span>           <span class="c">// d = R / S; R = R mod S</span>

        <span class="n">tc1</span><span class="p">,</span> <span class="n">tc2</span> <span class="o">:=</span> <span class="n">terminationConditions</span><span class="p">(</span><span class="n">state</span><span class="p">,</span> <span class="n">isEven</span><span class="p">)</span>
        <span class="k">if</span> <span class="o">!</span><span class="n">tc1</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">tc2</span> <span class="p">{</span>
            <span class="n">digitBuf</span><span class="p">[</span><span class="n">dIdx</span><span class="p">]</span> <span class="o">=</span> <span class="kt">byte</span><span class="p">(</span><span class="sc">'0'</span> <span class="o">+</span> <span class="n">d</span><span class="p">)</span>
            <span class="n">dIdx</span><span class="o">++</span>
            <span class="k">continue</span>
        <span class="p">}</span>

        <span class="n">digitBuf</span><span class="p">[</span><span class="n">dIdx</span><span class="p">]</span> <span class="o">=</span> <span class="n">finalDigit</span><span class="p">(</span><span class="n">d</span><span class="p">,</span> <span class="n">tc1</span><span class="p">,</span> <span class="n">tc2</span><span class="p">,</span> <span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">,</span> <span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="p">)</span>
        <span class="n">dIdx</span><span class="o">++</span>
        <span class="k">break</span>
    <span class="p">}</span>

    <span class="n">n</span> <span class="o">=</span> <span class="n">normalizeDigitBuffer</span><span class="p">(</span><span class="n">digitBuf</span><span class="p">[</span><span class="o">:</span><span class="p">],</span> <span class="n">dIdx</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">dIdx</span><span class="p">,</span> <span class="n">n</span><span class="p">)</span>
    <span class="k">return</span> <span class="kt">string</span><span class="p">(</span><span class="n">digitBuf</span><span class="p">[</span><span class="o">:</span><span class="n">dIdx</span><span class="p">]),</span> <span class="n">n</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The 30-byte buffer is generous. IEEE 754 binary64 produces at most 17 significant digits in shortest form, with carry propagation adding at most one more.</p>

<p>The two termination conditions test whether we’ve reached a boundary:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">terminationConditions</span><span class="p">(</span><span class="n">state</span> <span class="o">*</span><span class="n">digitState</span><span class="p">,</span> <span class="n">isEven</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">(</span><span class="kt">bool</span><span class="p">,</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">tc1</span> <span class="o">:=</span> <span class="n">cmpRoundDown</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">,</span> <span class="n">state</span><span class="o">.</span><span class="n">mMinus</span><span class="p">,</span> <span class="n">isEven</span><span class="p">)</span>
    <span class="n">high</span> <span class="o">:=</span> <span class="nb">new</span><span class="p">(</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">)</span><span class="o">.</span><span class="n">Add</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">,</span> <span class="n">state</span><span class="o">.</span><span class="n">mPlus</span><span class="p">)</span>
    <span class="n">tc2</span> <span class="o">:=</span> <span class="n">cmpHigh</span><span class="p">(</span><span class="n">high</span><span class="p">,</span> <span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="p">,</span> <span class="n">isEven</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">tc1</span><span class="p">,</span> <span class="n">tc2</span>
<span class="p">}</span>
</code></pre></div></div>

<ul>
  <li><strong>tc1</strong> (round down): the remainder R is small enough that rounding down to digit <code class="language-plaintext highlighter-rouge">d</code> would still land in the correct interval</li>
  <li><strong>tc2</strong> (round up): the remainder R plus the upper margin M+ meets or exceeds S, meaning rounding up to <code class="language-plaintext highlighter-rouge">d+1</code> is within range</li>
</ul>

<p>When neither condition fires, we aren’t yet at the shortest representation; emit the digit and continue. When at least one fires, this is the last digit.</p>

<h2 id="even-digit-tie-breaking">Even-Digit Tie-Breaking</h2>

<p>The most subtle part of the algorithm is what happens when both termination conditions fire simultaneously: the value sits at the exact midpoint between two representations. ECMA-262 Note 2 mandates <em>even-digit</em> tie-breaking (banker’s rounding):</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">finalDigit</span><span class="p">(</span><span class="n">d</span> <span class="kt">int</span><span class="p">,</span> <span class="n">tc1</span><span class="p">,</span> <span class="n">tc2</span> <span class="kt">bool</span><span class="p">,</span> <span class="n">r</span><span class="p">,</span> <span class="n">s</span> <span class="o">*</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">)</span> <span class="kt">byte</span> <span class="p">{</span>
    <span class="k">switch</span> <span class="p">{</span>
    <span class="k">case</span> <span class="n">tc1</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">tc2</span><span class="o">:</span>
        <span class="k">return</span> <span class="kt">byte</span><span class="p">(</span><span class="sc">'0'</span> <span class="o">+</span> <span class="n">d</span><span class="p">)</span>      <span class="c">// Only round-down applies: use d</span>
    <span class="k">case</span> <span class="o">!</span><span class="n">tc1</span> <span class="o">&amp;&amp;</span> <span class="n">tc2</span><span class="o">:</span>
        <span class="k">return</span> <span class="kt">byte</span><span class="p">(</span><span class="sc">'0'</span> <span class="o">+</span> <span class="n">d</span> <span class="o">+</span> <span class="m">1</span><span class="p">)</span>  <span class="c">// Only round-up applies: use d+1</span>
    <span class="k">default</span><span class="o">:</span>
        <span class="k">return</span> <span class="n">midpointDigit</span><span class="p">(</span><span class="n">d</span><span class="p">,</span> <span class="n">r</span><span class="p">,</span> <span class="n">s</span><span class="p">)</span>  <span class="c">// Both apply: tie-break</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">midpointDigit</span><span class="p">(</span><span class="n">d</span> <span class="kt">int</span><span class="p">,</span> <span class="n">r</span><span class="p">,</span> <span class="n">s</span> <span class="o">*</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">)</span> <span class="kt">byte</span> <span class="p">{</span>
    <span class="n">twoR</span> <span class="o">:=</span> <span class="nb">new</span><span class="p">(</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">)</span><span class="o">.</span><span class="n">Lsh</span><span class="p">(</span><span class="n">r</span><span class="p">,</span> <span class="m">1</span><span class="p">)</span>
    <span class="n">cmp</span> <span class="o">:=</span> <span class="n">twoR</span><span class="o">.</span><span class="n">Cmp</span><span class="p">(</span><span class="n">s</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">cmp</span> <span class="o">&lt;</span> <span class="m">0</span> <span class="p">{</span>
        <span class="k">return</span> <span class="kt">byte</span><span class="p">(</span><span class="sc">'0'</span> <span class="o">+</span> <span class="n">d</span><span class="p">)</span>      <span class="c">// Closer to lower: round down</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="n">cmp</span> <span class="o">&gt;</span> <span class="m">0</span> <span class="p">{</span>
        <span class="k">return</span> <span class="kt">byte</span><span class="p">(</span><span class="sc">'0'</span> <span class="o">+</span> <span class="n">d</span> <span class="o">+</span> <span class="m">1</span><span class="p">)</span>  <span class="c">// Closer to upper: round up</span>
    <span class="p">}</span>
    <span class="c">// Exactly at midpoint: even-digit tie-breaking</span>
    <span class="k">if</span> <span class="n">d</span><span class="o">%</span><span class="m">2</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span>
        <span class="k">return</span> <span class="kt">byte</span><span class="p">(</span><span class="sc">'0'</span> <span class="o">+</span> <span class="n">d</span><span class="p">)</span>      <span class="c">// d is even: keep it</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="kt">byte</span><span class="p">(</span><span class="sc">'0'</span> <span class="o">+</span> <span class="n">d</span> <span class="o">+</span> <span class="m">1</span><span class="p">)</span>      <span class="c">// d is odd: round up to even</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The midpoint test compares <code class="language-plaintext highlighter-rouge">2R</code> against <code class="language-plaintext highlighter-rouge">S</code>. If <code class="language-plaintext highlighter-rouge">2R &lt; S</code>, the remainder is less than half the scale, so round down. If <code class="language-plaintext highlighter-rouge">2R &gt; S</code>, round up. When <code class="language-plaintext highlighter-rouge">2R == S</code> exactly, the algorithm breaks the tie by choosing the even digit.</p>

<p>This even-digit preference also influences the boundary comparisons themselves. The <code class="language-plaintext highlighter-rouge">cmpRoundDown</code>, <code class="language-plaintext highlighter-rouge">cmpHigh</code>, and <code class="language-plaintext highlighter-rouge">cmpLow</code> functions use different comparison operators depending on whether the mantissa is even or odd:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">cmpRoundDown</span><span class="p">(</span><span class="n">lhs</span><span class="p">,</span> <span class="n">rhs</span> <span class="o">*</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">,</span> <span class="n">isEven</span> <span class="kt">bool</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">isEven</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">lhs</span><span class="o">.</span><span class="n">Cmp</span><span class="p">(</span><span class="n">rhs</span><span class="p">)</span> <span class="o">&lt;=</span> <span class="m">0</span>   <span class="c">// Inclusive: allow rounding to even at boundary</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">lhs</span><span class="o">.</span><span class="n">Cmp</span><span class="p">(</span><span class="n">rhs</span><span class="p">)</span> <span class="o">&lt;</span> <span class="m">0</span>        <span class="c">// Strict: odd mantissa</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">cmpHigh</span><span class="p">(</span><span class="n">lhs</span><span class="p">,</span> <span class="n">rhs</span> <span class="o">*</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">,</span> <span class="n">isEven</span> <span class="kt">bool</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">isEven</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">lhs</span><span class="o">.</span><span class="n">Cmp</span><span class="p">(</span><span class="n">rhs</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="m">0</span>   <span class="c">// Inclusive</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">lhs</span><span class="o">.</span><span class="n">Cmp</span><span class="p">(</span><span class="n">rhs</span><span class="p">)</span> <span class="o">&gt;</span> <span class="m">0</span>        <span class="c">// Strict</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This asymmetry is the difference between ECMA-262 compliance and “close enough.” When the mantissa is even, the boundary comparisons are inclusive, allowing termination at exact boundary values and creating the conditions for even-digit tie-breaking. When odd, strict comparisons ensure the algorithm doesn’t terminate prematurely.</p>

<h2 id="carry-propagation">Carry Propagation</h2>

<p>When <code class="language-plaintext highlighter-rouge">finalDigit</code> returns <code class="language-plaintext highlighter-rouge">d+1</code>, it may produce a 10 (the character after ‘9’). This overflow must propagate:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">normalizeDigitBuffer</span><span class="p">(</span><span class="n">digitBuf</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">,</span> <span class="n">dIdx</span> <span class="kt">int</span><span class="p">,</span> <span class="n">dIdxPtr</span> <span class="o">*</span><span class="kt">int</span><span class="p">,</span> <span class="n">n</span> <span class="kt">int</span><span class="p">)</span> <span class="kt">int</span> <span class="p">{</span>
    <span class="c">// Propagate carries</span>
    <span class="k">for</span> <span class="n">i</span> <span class="o">:=</span> <span class="n">dIdx</span> <span class="o">-</span> <span class="m">1</span><span class="p">;</span> <span class="n">i</span> <span class="o">&gt;</span> <span class="m">0</span><span class="p">;</span> <span class="n">i</span><span class="o">--</span> <span class="p">{</span>
        <span class="k">if</span> <span class="n">digitBuf</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">&gt;</span> <span class="sc">'9'</span> <span class="p">{</span>
            <span class="n">digitBuf</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">=</span> <span class="sc">'0'</span>
            <span class="n">digitBuf</span><span class="p">[</span><span class="n">i</span><span class="o">-</span><span class="m">1</span><span class="p">]</span><span class="o">++</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="c">// Handle carry out of the first digit</span>
    <span class="k">if</span> <span class="n">dIdx</span> <span class="o">&gt;</span> <span class="m">0</span> <span class="o">&amp;&amp;</span> <span class="n">digitBuf</span><span class="p">[</span><span class="m">0</span><span class="p">]</span> <span class="o">&gt;</span> <span class="sc">'9'</span> <span class="p">{</span>
        <span class="nb">copy</span><span class="p">(</span><span class="n">digitBuf</span><span class="p">[</span><span class="m">1</span><span class="o">:</span><span class="n">dIdx</span><span class="o">+</span><span class="m">1</span><span class="p">],</span> <span class="n">digitBuf</span><span class="p">[</span><span class="m">0</span><span class="o">:</span><span class="n">dIdx</span><span class="p">])</span>
        <span class="n">digitBuf</span><span class="p">[</span><span class="m">0</span><span class="p">]</span> <span class="o">=</span> <span class="sc">'1'</span>
        <span class="n">digitBuf</span><span class="p">[</span><span class="m">1</span><span class="p">]</span> <span class="o">=</span> <span class="sc">'0'</span>
        <span class="n">dIdx</span><span class="o">++</span>
        <span class="n">n</span><span class="o">++</span>    <span class="c">// One more integer digit</span>
    <span class="p">}</span>

    <span class="c">// Strip trailing zeros</span>
    <span class="k">for</span> <span class="n">dIdx</span> <span class="o">&gt;</span> <span class="m">1</span> <span class="o">&amp;&amp;</span> <span class="n">digitBuf</span><span class="p">[</span><span class="n">dIdx</span><span class="o">-</span><span class="m">1</span><span class="p">]</span> <span class="o">==</span> <span class="sc">'0'</span> <span class="p">{</span>
        <span class="n">dIdx</span><span class="o">--</span>
    <span class="p">}</span>
    <span class="o">*</span><span class="n">dIdxPtr</span> <span class="o">=</span> <span class="n">dIdx</span>
    <span class="k">return</span> <span class="n">n</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Consider a carry cascade: digits <code class="language-plaintext highlighter-rouge">[9, 9, 10]</code> become <code class="language-plaintext highlighter-rouge">[9, 10, 0]</code> then <code class="language-plaintext highlighter-rouge">[10, 0, 0]</code>, and finally the carry-out case shifts everything right to produce <code class="language-plaintext highlighter-rouge">[1, 0, 0, 0]</code> with an incremented exponent. Trailing zeros are then stripped since the algorithm produces shortest representations.</p>

<h2 id="ecma-262-output-formatting">ECMA-262 Output Formatting</h2>

<p>The Burger-Dybvig algorithm produces a digit string and an exponent <code class="language-plaintext highlighter-rouge">n</code>. The ECMA-262 specification (§6.1.6.1.20) defines four formatting branches based on <code class="language-plaintext highlighter-rouge">n</code>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">formatECMA</span><span class="p">(</span><span class="n">negative</span> <span class="kt">bool</span><span class="p">,</span> <span class="n">digits</span> <span class="kt">string</span><span class="p">,</span> <span class="n">n</span> <span class="kt">int</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
    <span class="n">k</span> <span class="o">:=</span> <span class="nb">len</span><span class="p">(</span><span class="n">digits</span><span class="p">)</span>

    <span class="k">var</span> <span class="n">buf</span> <span class="p">[]</span><span class="kt">byte</span>
    <span class="k">if</span> <span class="n">negative</span> <span class="p">{</span>
        <span class="n">buf</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="sc">'-'</span><span class="p">)</span>
    <span class="p">}</span>

    <span class="k">switch</span> <span class="p">{</span>
    <span class="k">case</span> <span class="n">k</span> <span class="o">&lt;=</span> <span class="n">n</span> <span class="o">&amp;&amp;</span> <span class="n">n</span> <span class="o">&lt;=</span> <span class="m">21</span><span class="o">:</span>
        <span class="c">// Integer fixed: "12300" (digits + trailing zeros)</span>
        <span class="n">buf</span> <span class="o">=</span> <span class="n">appendIntegerFixed</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="n">digits</span><span class="p">,</span> <span class="n">k</span><span class="p">,</span> <span class="n">n</span><span class="p">)</span>
    <span class="k">case</span> <span class="m">0</span> <span class="o">&lt;</span> <span class="n">n</span> <span class="o">&amp;&amp;</span> <span class="n">n</span> <span class="o">&lt;=</span> <span class="m">21</span><span class="o">:</span>
        <span class="c">// Fraction fixed: "12.345" (decimal point within digits)</span>
        <span class="n">buf</span> <span class="o">=</span> <span class="n">appendFractionFixed</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="n">digits</span><span class="p">,</span> <span class="n">n</span><span class="p">)</span>
    <span class="k">case</span> <span class="o">-</span><span class="m">6</span> <span class="o">&lt;</span> <span class="n">n</span> <span class="o">&amp;&amp;</span> <span class="n">n</span> <span class="o">&lt;=</span> <span class="m">0</span><span class="o">:</span>
        <span class="c">// Small fraction: "0.00123" (leading zeros after decimal point)</span>
        <span class="n">buf</span> <span class="o">=</span> <span class="n">appendSmallFraction</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="n">digits</span><span class="p">,</span> <span class="n">n</span><span class="p">)</span>
    <span class="k">default</span><span class="o">:</span>
        <span class="c">// Exponential: "1.23e+20" or "1e-7"</span>
        <span class="n">buf</span> <span class="o">=</span> <span class="n">appendExponential</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="n">digits</span><span class="p">,</span> <span class="n">k</span><span class="p">,</span> <span class="n">n</span><span class="p">)</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="kt">string</span><span class="p">(</span><span class="n">buf</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The boundary constants come directly from the ECMA-262 specification:</p>

<table>
  <thead>
    <tr>
      <th>Branch</th>
      <th>Condition</th>
      <th>Example Input</th>
      <th>Output</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Integer fixed</td>
      <td><code class="language-plaintext highlighter-rouge">k ≤ n ≤ 21</code></td>
      <td><code class="language-plaintext highlighter-rouge">1e20</code></td>
      <td><code class="language-plaintext highlighter-rouge">100000000000000000000</code></td>
    </tr>
    <tr>
      <td>Fraction fixed</td>
      <td><code class="language-plaintext highlighter-rouge">0 &lt; n ≤ 21, n &lt; k</code></td>
      <td><code class="language-plaintext highlighter-rouge">1.5</code></td>
      <td><code class="language-plaintext highlighter-rouge">1.5</code></td>
    </tr>
    <tr>
      <td>Small fraction</td>
      <td><code class="language-plaintext highlighter-rouge">-6 &lt; n ≤ 0</code></td>
      <td><code class="language-plaintext highlighter-rouge">1e-6</code></td>
      <td><code class="language-plaintext highlighter-rouge">0.000001</code></td>
    </tr>
    <tr>
      <td>Exponential</td>
      <td>otherwise</td>
      <td><code class="language-plaintext highlighter-rouge">1e21</code></td>
      <td><code class="language-plaintext highlighter-rouge">1e+21</code></td>
    </tr>
  </tbody>
</table>

<p>The boundaries are exact. A value of exactly 10^21 uses exponential notation (<code class="language-plaintext highlighter-rouge">1e+21</code>), while 999999999999999900000 (just below 10^21) uses integer fixed notation. A value of exactly 10^-6 uses small fraction notation (<code class="language-plaintext highlighter-rouge">0.000001</code>), while anything below that switches to exponential.</p>

<p>These boundary behaviors can be verified against specific bit patterns:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0x444b1ae4d6e2ef50 → "1e+21"                        (exponential)
0x444b1ae4d6e2ef4f → "999999999999999900000"         (integer fixed)
0x3eb0c6f7a0b5ed8d → "0.000001"                      (small fraction)
0x3eb0c6f7a0b5ed8c → "9.999999999999997e-7"          (exponential)
</code></pre></div></div>

<h2 id="why-strconvformatfloat-is-insufficient">Why strconv.FormatFloat Is Insufficient</h2>

<p>Go’s <code class="language-plaintext highlighter-rouge">strconv.FormatFloat</code> is the standard library’s number-to-string conversion. With format <code class="language-plaintext highlighter-rouge">'e'</code>, <code class="language-plaintext highlighter-rouge">'f'</code>, or <code class="language-plaintext highlighter-rouge">'g'</code> and precision <code class="language-plaintext highlighter-rouge">-1</code>, it produces shortest-round-trip representations. The issue for JCS is not quality; the issue is contract mismatch. RFC 8785 requires ECMAScript-compatible rendering rules, and <code class="language-plaintext highlighter-rouge">FormatFloat</code> does not expose that contract.</p>

<p>The gaps are concrete.</p>

<p><strong>Format-policy divergence.</strong> ECMA-262 and <code class="language-plaintext highlighter-rouge">FormatFloat</code> make different notation choices at key boundaries. Examples:</p>

<table>
  <thead>
    <tr>
      <th>Value</th>
      <th>ECMA-262 / RFC 8785 expected</th>
      <th><code class="language-plaintext highlighter-rouge">FormatFloat(v, 'g', -1, 64)</code></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">1e20</code></td>
      <td><code class="language-plaintext highlighter-rouge">100000000000000000000</code></td>
      <td><code class="language-plaintext highlighter-rouge">1e+20</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">1e-6</code></td>
      <td><code class="language-plaintext highlighter-rouge">0.000001</code></td>
      <td><code class="language-plaintext highlighter-rouge">1e-06</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">1000000</code></td>
      <td><code class="language-plaintext highlighter-rouge">1000000</code></td>
      <td><code class="language-plaintext highlighter-rouge">1e+06</code></td>
    </tr>
  </tbody>
</table>

<p>These are all valid shortest representations, but only one side matches the JCS contract (see RFC 8785’s ECMAScript-compatible serialization examples in Appendix B).</p>

<p>These three examples are sufficient to establish the core point: ECMA-262 rendering switches between multiple notation branches (fixed integer, fixed fraction, small fraction, exponential) at exact exponent thresholds. No single <code class="language-plaintext highlighter-rouge">FormatFloat</code> mode reproduces all of them. The <code class="language-plaintext highlighter-rouge">'e'</code> and <code class="language-plaintext highlighter-rouge">'f'</code> modes each use one fixed notation. The <code class="language-plaintext highlighter-rouge">'g'</code> mode switches between <code class="language-plaintext highlighter-rouge">'e'</code> and <code class="language-plaintext highlighter-rouge">'f'</code> based on exponent magnitude, but its switching thresholds and formatting details do not match ECMA-262’s. Once boundary cases disagree, conformance requires a dedicated ECMA-262 formatting layer.</p>

<p><strong>Upstream algorithm drift risk.</strong> Go’s shortest-mode internals are not static over time. Through Go 1.16, <code class="language-plaintext highlighter-rouge">strconv</code> used Grisu3 with an exact-arithmetic fallback. <a href="https://go.dev/doc/go1.17">Go 1.17</a> replaced this with Ryu. <a href="https://go.googlesource.com/go/+/refs/tags/go1.26.0/src/internal/strconv/ftoadbox.go">Go 1.26</a> replaced Ryu with Dragonbox for shortest-mode formatting. Each transition preserved round-trip correctness and improved performance, but the <code class="language-plaintext highlighter-rouge">strconv</code> documentation does not guarantee that digit sequences will remain stable across releases. Three algorithm replacements in ten releases is normal for a standard library, but it is exactly why canonicalization code should not outsource its normative behavior to implementation details of an external runtime.</p>

<p>Building from scratch eliminates these issues. The Burger-Dybvig path always uses exact multiprecision arithmetic, applies explicit midpoint handling, and formats according to ECMA-262 branch rules. The cost is performance, but for canonicalization, correctness is the constraint.</p>

<h2 id="special-values">Special Values</h2>

<p>Three special cases are handled before the algorithm runs:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">FormatDouble</span><span class="p">(</span><span class="n">f</span> <span class="kt">float64</span><span class="p">)</span> <span class="p">(</span><span class="kt">string</span><span class="p">,</span> <span class="o">*</span><span class="n">jcserr</span><span class="o">.</span><span class="n">Error</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">math</span><span class="o">.</span><span class="n">IsNaN</span><span class="p">(</span><span class="n">f</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span> <span class="s">""</span><span class="p">,</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">InvalidGrammar</span><span class="p">,</span> <span class="o">-</span><span class="m">1</span><span class="p">,</span>
            <span class="s">"NaN is not representable in JSON"</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="n">f</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span>
        <span class="k">return</span> <span class="s">"0"</span><span class="p">,</span> <span class="no">nil</span>    <span class="c">// Both +0 and -0 produce "0"</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="n">math</span><span class="o">.</span><span class="n">IsInf</span><span class="p">(</span><span class="n">f</span><span class="p">,</span> <span class="m">0</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span> <span class="s">""</span><span class="p">,</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">InvalidGrammar</span><span class="p">,</span> <span class="o">-</span><span class="m">1</span><span class="p">,</span>
            <span class="s">"Infinity is not representable in JSON"</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="c">// ... proceed with Burger-Dybvig</span>
<span class="p">}</span>
</code></pre></div></div>

<p>NaN and Infinity are errors; JSON has no representation for them. Negative zero is normalized to <code class="language-plaintext highlighter-rouge">"0"</code> because IEEE 754’s -0 and +0 are <em>mathematically</em> equal, and canonical JSON should not distinguish them. (Note: lexical <code class="language-plaintext highlighter-rouge">-0</code> in JSON <em>input</em> is a separate concern, rejected at parse time as a policy violation; the two requirements are independent.)</p>

<h2 id="validation-pinned-oracle-vectors">Validation: Pinned Oracle Vectors</h2>

<p>The implementation is validated against two pinned oracle datasets:</p>

<ul>
  <li><strong>golden_vectors.csv</strong>: boundary values, powers of 10, subnormals, and edge cases</li>
  <li><strong>golden_stress_vectors.csv</strong>: tie-breaking and carry propagation scenarios</li>
</ul>

<p>Each CSV row contains a 16-character hex encoding of the IEEE 754 bits and the expected output string:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0000000000000000,0
0000000000000001,5e-324
3ff0000000000000,1
3eb0c6f7a0b5ed8d,0.000001
</code></pre></div></div>

<p>The test function validates three properties per dataset:</p>

<ol>
  <li><strong>Semantic correctness</strong>: <code class="language-plaintext highlighter-rouge">FormatDouble(bits) == expected</code> for every row</li>
  <li><strong>Cardinality</strong>: The exact row count matches expectations (catches truncation)</li>
  <li><strong>Integrity</strong>: SHA-256 of the entire file matches a pinned hash (catches corruption)</li>
</ol>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">TestGoldenOracle</span><span class="p">(</span><span class="n">t</span> <span class="o">*</span><span class="n">testing</span><span class="o">.</span><span class="n">T</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">verifyOracle</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="s">"testdata/golden_vectors.csv"</span><span class="p">,</span> <span class="m">54445</span><span class="p">,</span>
        <span class="s">"593bdecbe0dccbc182bc3baf570b716887db25739fc61b7808764ecb966d5636"</span><span class="p">)</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">TestStressOracle</span><span class="p">(</span><span class="n">t</span> <span class="o">*</span><span class="n">testing</span><span class="o">.</span><span class="n">T</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">verifyOracle</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="s">"testdata/golden_stress_vectors.csv"</span><span class="p">,</span> <span class="m">231917</span><span class="p">,</span>
        <span class="s">"287d21ac87e5665550f1baf86038302a0afc67a74a020dffb872f1a93b26d410"</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The SHA-256 checksums pin the test data against silent modification. If someone edits a single byte in either file, the test fails.</p>

<h2 id="round-trip-and-fuzz-testing">Round-Trip and Fuzz Testing</h2>

<p>Beyond oracle vectors, the implementation uses two additional validation strategies.</p>

<p><strong>Round-trip testing</strong> verifies the shortest representation property directly: format a value, parse it back with <code class="language-plaintext highlighter-rouge">strconv.ParseFloat</code>, and confirm the original bits are recovered:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">cases</span> <span class="o">:=</span> <span class="p">[]</span><span class="kt">float64</span><span class="p">{</span><span class="m">5e-324</span><span class="p">,</span> <span class="m">1e-7</span><span class="p">,</span> <span class="m">1e-6</span><span class="p">,</span> <span class="m">0.1</span><span class="p">,</span> <span class="m">0.2</span><span class="p">,</span> <span class="m">1.1</span><span class="p">,</span> <span class="m">1</span><span class="p">,</span> <span class="m">2</span><span class="p">,</span> <span class="m">1e20</span><span class="p">,</span> <span class="m">1e21</span><span class="p">,</span> <span class="n">math</span><span class="o">.</span><span class="n">MaxFloat64</span><span class="p">}</span>
<span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">c</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">cases</span> <span class="p">{</span>
    <span class="n">formatted</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">jcsfloat</span><span class="o">.</span><span class="n">FormatDouble</span><span class="p">(</span><span class="n">c</span><span class="p">)</span>
    <span class="n">parsed</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">strconv</span><span class="o">.</span><span class="n">ParseFloat</span><span class="p">(</span><span class="n">formatted</span><span class="p">,</span> <span class="m">64</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">parsed</span> <span class="o">!=</span> <span class="n">c</span> <span class="p">{</span>
        <span class="n">t</span><span class="o">.</span><span class="n">Fatalf</span><span class="p">(</span><span class="s">"round-trip failed for %.17g: formatted %q, parsed back as %.17g"</span><span class="p">,</span>
            <span class="n">c</span><span class="p">,</span> <span class="n">formatted</span><span class="p">,</span> <span class="n">parsed</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>Fuzz testing</strong> generates random 64-bit patterns, interprets them as IEEE 754 doubles, and verifies round-trip stability:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">FuzzFormatDoubleRoundTrip</span><span class="p">(</span><span class="n">f</span> <span class="o">*</span><span class="n">testing</span><span class="o">.</span><span class="n">F</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">f</span><span class="o">.</span><span class="n">Fuzz</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">testing</span><span class="o">.</span><span class="n">T</span><span class="p">,</span> <span class="n">data</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">data</span><span class="p">)</span> <span class="o">&lt;</span> <span class="m">8</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
        <span class="n">bits</span> <span class="o">:=</span> <span class="n">binary</span><span class="o">.</span><span class="n">BigEndian</span><span class="o">.</span><span class="n">Uint64</span><span class="p">(</span><span class="n">data</span><span class="p">[</span><span class="o">:</span><span class="m">8</span><span class="p">])</span>
        <span class="n">fval</span> <span class="o">:=</span> <span class="n">math</span><span class="o">.</span><span class="n">Float64frombits</span><span class="p">(</span><span class="n">bits</span><span class="p">)</span>

        <span class="k">if</span> <span class="n">math</span><span class="o">.</span><span class="n">IsNaN</span><span class="p">(</span><span class="n">fval</span><span class="p">)</span> <span class="o">||</span> <span class="n">math</span><span class="o">.</span><span class="n">IsInf</span><span class="p">(</span><span class="n">fval</span><span class="p">,</span> <span class="m">0</span><span class="p">)</span> <span class="p">{</span>
            <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">jcsfloat</span><span class="o">.</span><span class="n">FormatDouble</span><span class="p">(</span><span class="n">fval</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">t</span><span class="o">.</span><span class="n">Fatal</span><span class="p">(</span><span class="s">"expected error for non-finite"</span><span class="p">)</span> <span class="p">}</span>
            <span class="k">return</span>
        <span class="p">}</span>

        <span class="n">s</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">jcsfloat</span><span class="o">.</span><span class="n">FormatDouble</span><span class="p">(</span><span class="n">fval</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">t</span><span class="o">.</span><span class="n">Fatalf</span><span class="p">(</span><span class="s">"unexpected error: %v"</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span> <span class="p">}</span>

        <span class="n">parsed</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">strconv</span><span class="o">.</span><span class="n">ParseFloat</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="m">64</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">fval</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span>
            <span class="k">if</span> <span class="n">parsed</span> <span class="o">!=</span> <span class="m">0</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">"zero round-trip failed"</span><span class="p">)</span> <span class="p">}</span>
            <span class="k">return</span>
        <span class="p">}</span>
        <span class="k">if</span> <span class="n">math</span><span class="o">.</span><span class="n">Float64bits</span><span class="p">(</span><span class="n">parsed</span><span class="p">)</span> <span class="o">!=</span> <span class="n">math</span><span class="o">.</span><span class="n">Float64bits</span><span class="p">(</span><span class="n">fval</span><span class="p">)</span> <span class="p">{</span>
            <span class="n">t</span><span class="o">.</span><span class="n">Fatalf</span><span class="p">(</span><span class="s">"round-trip failed: bits=%016x → %q → bits=%016x"</span><span class="p">,</span>
                <span class="n">bits</span><span class="p">,</span> <span class="n">s</span><span class="p">,</span> <span class="n">math</span><span class="o">.</span><span class="n">Float64bits</span><span class="p">(</span><span class="n">parsed</span><span class="p">))</span>
        <span class="p">}</span>
    <span class="p">})</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Note the special case for zero: IEEE 754 -0 formats as <code class="language-plaintext highlighter-rouge">"0"</code>, which parses back as +0. The bit patterns differ (0x8000000000000000 vs 0x0000000000000000), but mathematically the values are equal, so the round-trip comparison uses <code class="language-plaintext highlighter-rouge">== 0</code> instead of bit equality.</p>

<p><strong>Idempotency testing</strong> verifies that format → parse → format produces the same string, which is a consequence of correct tie-breaking:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="n">i</span> <span class="o">:=</span> <span class="kt">uint64</span><span class="p">(</span><span class="m">1</span><span class="p">);</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="m">5000</span><span class="p">;</span> <span class="n">i</span> <span class="o">+=</span> <span class="m">97</span> <span class="p">{</span>
    <span class="n">v</span> <span class="o">:=</span> <span class="n">math</span><span class="o">.</span><span class="n">Float64frombits</span><span class="p">(</span><span class="n">i</span> <span class="o">*</span> <span class="m">0x9e3779b97f4a7c15</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">math</span><span class="o">.</span><span class="n">IsNaN</span><span class="p">(</span><span class="n">v</span><span class="p">)</span> <span class="o">||</span> <span class="n">math</span><span class="o">.</span><span class="n">IsInf</span><span class="p">(</span><span class="n">v</span><span class="p">,</span> <span class="m">0</span><span class="p">)</span> <span class="p">{</span> <span class="k">continue</span> <span class="p">}</span>

    <span class="n">f1</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">jcsfloat</span><span class="o">.</span><span class="n">FormatDouble</span><span class="p">(</span><span class="n">v</span><span class="p">)</span>
    <span class="n">parsed</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">strconv</span><span class="o">.</span><span class="n">ParseFloat</span><span class="p">(</span><span class="n">f1</span><span class="p">,</span> <span class="m">64</span><span class="p">)</span>
    <span class="n">f2</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">jcsfloat</span><span class="o">.</span><span class="n">FormatDouble</span><span class="p">(</span><span class="n">parsed</span><span class="p">)</span>

    <span class="k">if</span> <span class="n">f1</span> <span class="o">!=</span> <span class="n">f2</span> <span class="p">{</span>
        <span class="n">t</span><span class="o">.</span><span class="n">Fatalf</span><span class="p">(</span><span class="s">"idempotency failed for bits=%016x: %s != %s"</span><span class="p">,</span>
            <span class="n">math</span><span class="o">.</span><span class="n">Float64bits</span><span class="p">(</span><span class="n">v</span><span class="p">),</span> <span class="n">f1</span><span class="p">,</span> <span class="n">f2</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>If tie-breaking were inconsistent, formatting a value at the exact midpoint could oscillate between two representations. Even-digit tie-breaking prevents this by always choosing the same direction at midpoints.</p>

<h2 id="the-complete-pipeline">The Complete Pipeline</h2>

<p>Putting it all together, the conversion from IEEE 754 bits to canonical decimal string follows this path:</p>

<ol>
  <li><strong>Decode</strong> the 64-bit pattern into mantissa, exponent, and boundary flags</li>
  <li><strong>Initialize</strong> R, S, M+, M- as big integers with appropriate scaling</li>
  <li><strong>Estimate</strong> the decimal exponent <code class="language-plaintext highlighter-rouge">k</code> using floating-point logarithms</li>
  <li><strong>Scale</strong> by 10^k using pre-computed powers from the 700-entry cache</li>
  <li><strong>Fix up</strong> the scaling if the estimate was off by one (high or low)</li>
  <li><strong>Extract digits</strong> one at a time by multiplying by 10 and dividing</li>
  <li><strong>Terminate</strong> when boundary conditions indicate the shortest representation</li>
  <li><strong>Tie-break</strong> using even-digit preference when at the exact midpoint</li>
  <li><strong>Propagate carries</strong> if rounding up causes overflow</li>
  <li><strong>Format</strong> using the appropriate ECMA-262 branch based on the exponent</li>
</ol>

<p>The entire implementation has zero external dependencies (only <code class="language-plaintext highlighter-rouge">math</code>, <code class="language-plaintext highlighter-rouge">math/big</code>, and the project’s own error package). It is deterministic, locale-independent, and produces identical output regardless of platform or Go runtime version.</p>

<p>The implementation lives in the <code class="language-plaintext highlighter-rouge">jcsfloat</code> package of <a href="https://github.com/lattice-substrate/json-canon">json-canon</a>, an RFC 8785 JSON canonicalization library.</p>

<h2 id="revision-history">Revision History</h2>

<table>
  <thead>
    <tr>
      <th>Date</th>
      <th>Change</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2026-03-04</td>
      <td>Removed oracle vector counts from description, introduction, and validation section heading</td>
    </tr>
    <tr>
      <td>2026-03-04</td>
      <td>Restored Dragonbox/Go 1.26 claim after verification against source tree at <code class="language-plaintext highlighter-rouge">go1.26.0</code> tag (PR #75195 shows “Closed” on GitHub because Go merges through Gerrit; the change landed); previous removal was incorrect</td>
    </tr>
    <tr>
      <td>2026-03-04</td>
      <td>Removed Dragonbox/Go 1.26 claim based on GitHub PR status and release notes (later found to be incorrect, see above); corrected FormatFloat ‘g’ mode description; removed stale source line reference</td>
    </tr>
    <tr>
      <td>2026-03-03</td>
      <td>Removed line-count claims from introduction and conclusion</td>
    </tr>
    <tr>
      <td>2026-02-27</td>
      <td>Initial publication</td>
    </tr>
  </tbody>
</table>]]></content><author><name>Mark Lenhardt</name></author><category term="go" /><category term="algorithms" /><category term="ieee754" /><summary type="html"><![CDATA[The Burger-Dybvig algorithm in Go: finding the shortest decimal string that round-trips to the original IEEE 754 binary value, with ECMA-262 tie-breaking rules, validated against pinned oracle test vectors.]]></summary></entry><entry><title type="html">What Your JSON Parser Doesn’t Reject: Building a Strict RFC 8259 Parser in Go</title><link href="https://lattice-substrate.github.io/blog/2026/02/26/strict-rfc8259-json-parser/" rel="alternate" type="text/html" title="What Your JSON Parser Doesn’t Reject: Building a Strict RFC 8259 Parser in Go" /><published>2026-02-26T00:00:00+00:00</published><updated>2026-02-26T00:00:00+00:00</updated><id>https://lattice-substrate.github.io/blog/2026/02/26/strict-rfc8259-json-parser</id><content type="html" xml:base="https://lattice-substrate.github.io/blog/2026/02/26/strict-rfc8259-json-parser/"><![CDATA[<p>Most JSON parsers are lenient by design. They accept input that RFC 8259 forbids, because lenient parsing is convenient and rarely causes visible problems. But when your downstream depends on deterministic processing (canonical signatures, content-addressed storage, reproducible builds), leniency is a defect. A value silently accepted by one parser and rejected by another breaks your contract.</p>

<p>I built a strict JSON parser in Go. It enforces every constraint in <a href="https://www.rfc-editor.org/rfc/rfc8259">RFC 8259</a> and adds the <a href="https://www.rfc-editor.org/rfc/rfc7493">RFC 7493</a> (I-JSON) restrictions that <a href="https://www.rfc-editor.org/rfc/rfc8785">RFC 8785</a> requires. This article walks through the design and implementation, focusing on the parts where strictness requires real work: surrogate pairs, noncharacter detection, duplicate keys after escape decoding, and resource bounds.</p>

<h2 id="the-gap-what-canonicalization-cannot-accept">The Gap: What Canonicalization Cannot Accept</h2>

<p>Go’s <code class="language-plaintext highlighter-rouge">encoding/json</code> is a solid general-purpose parser. For canonicalization, some of its documented behaviors are intentionally too permissive. On Go 1.25.6, representative examples look like this:</p>

<table>
  <thead>
    <tr>
      <th>Input</th>
      <th><code class="language-plaintext highlighter-rouge">encoding/json</code> result</th>
      <th>Strict parser</th>
      <th>Source</th>
      <th>Why it matters</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">"\uD800\u0041"</code></td>
      <td>Decodes as <code class="language-plaintext highlighter-rouge">"�A"</code> (invalid surrogate replaced)</td>
      <td>Rejected: lone surrogate</td>
      <td>RFC 7493 §2.1</td>
      <td>I-JSON forbids surrogates</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">"\uFDD0"</code></td>
      <td>Accepts noncharacter</td>
      <td>Rejected: noncharacter</td>
      <td>RFC 7493 §2.1</td>
      <td>I-JSON forbids noncharacters</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">{"a":1,"a":2}</code></td>
      <td>Later key wins (<code class="language-plaintext highlighter-rouge">a=2</code>)</td>
      <td>Rejected: duplicate key</td>
      <td>RFC 7493 §2.3</td>
      <td>I-JSON requires unique names</td>
    </tr>
    <tr>
      <td>raw bytes <code class="language-plaintext highlighter-rouge">22 ff 22</code></td>
      <td>Decodes as <code class="language-plaintext highlighter-rouge">"�"</code></td>
      <td>Rejected: invalid UTF-8</td>
      <td>project policy</td>
      <td>silent byte substitution changes meaning</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">-0</code></td>
      <td>Accepts lexical <code class="language-plaintext highlighter-rouge">-0</code></td>
      <td>Rejected: negative zero token</td>
      <td>project policy</td>
      <td>lexical ambiguity at acceptance boundary</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">1e-400</code></td>
      <td>Parses to <code class="language-plaintext highlighter-rouge">0</code> with <code class="language-plaintext highlighter-rouge">err == nil</code> (Go 1.25.6)</td>
      <td>Rejected: underflow</td>
      <td>project policy</td>
      <td>non-zero token collapses to zero</td>
    </tr>
  </tbody>
</table>

<p>Rows labeled RFC 7493 are normative I-JSON requirements. Rows labeled project policy are stricter acceptance rules chosen to keep canonicalization fail-closed and deterministic.</p>

<p>These are not bugs in <code class="language-plaintext highlighter-rouge">encoding/json</code>; they are compatibility choices. The <a href="https://pkg.go.dev/encoding/json#Unmarshal">Go package documentation for <code class="language-plaintext highlighter-rouge">Unmarshal</code></a> explicitly states that invalid UTF-8 / invalid UTF-16 surrogates are replaced by U+FFFD, and duplicate object keys are processed in order with later values replacing earlier ones.</p>

<p>Minimal reproduction (Go 1.25.6), with explicit error checks:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="n">v</span> <span class="n">any</span>
<span class="n">err</span> <span class="o">:=</span> <span class="n">json</span><span class="o">.</span><span class="n">Unmarshal</span><span class="p">([]</span><span class="kt">byte</span><span class="p">(</span><span class="s">`"\uD800\u0041"`</span><span class="p">),</span> <span class="o">&amp;</span><span class="n">v</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">"case surrogate err=%v v=%#v</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">err</span><span class="p">,</span> <span class="n">v</span><span class="p">)</span> <span class="c">// err=&lt;nil&gt;, v="�A"</span>

<span class="n">err</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">Unmarshal</span><span class="p">([]</span><span class="kt">byte</span><span class="p">(</span><span class="s">`{"a":1,"a":2}`</span><span class="p">),</span> <span class="o">&amp;</span><span class="n">v</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">"case dup-key err=%v v=%#v</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">err</span><span class="p">,</span> <span class="n">v</span><span class="p">)</span> <span class="c">// err=&lt;nil&gt;, later duplicate replaces earlier value</span>

<span class="n">err</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">Unmarshal</span><span class="p">([]</span><span class="kt">byte</span><span class="p">(</span><span class="s">"1e-400"</span><span class="p">),</span> <span class="o">&amp;</span><span class="n">v</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">"case underflow err=%v v=%#v</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">err</span><span class="p">,</span> <span class="n">v</span><span class="p">)</span> <span class="c">// err=&lt;nil&gt;, v=0</span>
</code></pre></div></div>

<p>For application code, replacement and merge behavior is pragmatic. A canonicalization engine has a different contract: reject ambiguous or lossy inputs so every accepted value has a single deterministic canonical form. If two parsers disagree on interpretation, canonical output is undefined. Rejection is the safe outcome.</p>

<h2 id="parser-architecture">Parser Architecture</h2>

<p>The parser uses single-pass recursive descent with byte-level dispatch. The core structure is a <code class="language-plaintext highlighter-rouge">parser</code> struct that holds the input bytes, a cursor position, depth tracking, and configurable bounds:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">parser</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">data</span>             <span class="p">[]</span><span class="kt">byte</span>
    <span class="n">pos</span>              <span class="kt">int</span>
    <span class="n">depth</span>            <span class="kt">int</span>
    <span class="n">valueCount</span>       <span class="kt">int</span>
    <span class="n">maxDepth</span>         <span class="kt">int</span>
    <span class="n">maxValues</span>        <span class="kt">int</span>
    <span class="n">maxObjectMembers</span> <span class="kt">int</span>
    <span class="n">maxArrayElements</span> <span class="kt">int</span>
    <span class="n">maxStringBytes</span>   <span class="kt">int</span>
    <span class="n">maxNumberChars</span>   <span class="kt">int</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Value dispatch is a single <code class="language-plaintext highlighter-rouge">switch</code> on the first byte:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">p</span> <span class="o">*</span><span class="n">parser</span><span class="p">)</span> <span class="n">parseValue</span><span class="p">()</span> <span class="p">(</span><span class="o">*</span><span class="n">Value</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">p</span><span class="o">.</span><span class="n">valueCount</span><span class="o">++</span>
    <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">valueCount</span> <span class="o">&gt;</span> <span class="n">p</span><span class="o">.</span><span class="n">maxValues</span> <span class="p">{</span>
        <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">newErrorf</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">BoundExceeded</span><span class="p">,</span>
            <span class="s">"value count %d exceeds maximum %d"</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">valueCount</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">maxValues</span><span class="p">)</span>
    <span class="p">}</span>

    <span class="n">c</span><span class="p">,</span> <span class="n">ok</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">peek</span><span class="p">()</span>
    <span class="k">if</span> <span class="o">!</span><span class="n">ok</span> <span class="p">{</span>
        <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">newError</span><span class="p">(</span><span class="s">"unexpected end of input"</span><span class="p">)</span>
    <span class="p">}</span>

    <span class="k">switch</span> <span class="n">c</span> <span class="p">{</span>
    <span class="k">case</span> <span class="sc">'{'</span><span class="o">:</span>  <span class="k">return</span> <span class="n">p</span><span class="o">.</span><span class="n">parseObject</span><span class="p">()</span>
    <span class="k">case</span> <span class="sc">'['</span><span class="o">:</span>  <span class="k">return</span> <span class="n">p</span><span class="o">.</span><span class="n">parseArray</span><span class="p">()</span>
    <span class="k">case</span> <span class="sc">'"'</span><span class="o">:</span>  <span class="k">return</span> <span class="n">p</span><span class="o">.</span><span class="n">parseString</span><span class="p">()</span>
    <span class="k">case</span> <span class="sc">'t'</span><span class="p">,</span> <span class="sc">'f'</span><span class="o">:</span>  <span class="k">return</span> <span class="n">p</span><span class="o">.</span><span class="n">parseBool</span><span class="p">()</span>
    <span class="k">case</span> <span class="sc">'n'</span><span class="o">:</span>  <span class="k">return</span> <span class="n">p</span><span class="o">.</span><span class="n">parseNull</span><span class="p">()</span>
    <span class="k">default</span><span class="o">:</span>   <span class="k">return</span> <span class="n">p</span><span class="o">.</span><span class="n">parseNumber</span><span class="p">()</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>No lookahead beyond the first byte is needed for type discrimination. Every call to <code class="language-plaintext highlighter-rouge">parseValue</code> checks the bound counter first.</p>

<h2 id="utf-8-validation-upfront-and-incremental">UTF-8 Validation: Upfront and Incremental</h2>

<p>The parser validates UTF-8 twice, for different reasons.</p>

<p><strong>Upfront bulk validation</strong> runs before any parsing begins:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="o">!</span><span class="n">utf8</span><span class="o">.</span><span class="n">Valid</span><span class="p">(</span><span class="n">data</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">InvalidUTF8</span><span class="p">,</span> <span class="n">firstInvalidUTF8Offset</span><span class="p">(</span><span class="n">data</span><span class="p">),</span>
        <span class="s">"input is not valid UTF-8"</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This catches structural UTF-8 violations: continuation bytes without start bytes, truncated multibyte sequences, overlong encodings, and raw UTF-8 encodings of surrogate code points. The <code class="language-plaintext highlighter-rouge">firstInvalidUTF8Offset</code> function locates the exact byte offset of the first violation by scanning rune-by-rune until <code class="language-plaintext highlighter-rouge">utf8.DecodeRune</code> returns a replacement character:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">firstInvalidUTF8Offset</span><span class="p">(</span><span class="n">data</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="kt">int</span> <span class="p">{</span>
    <span class="k">for</span> <span class="n">i</span> <span class="o">:=</span> <span class="m">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">data</span><span class="p">);</span> <span class="p">{</span>
        <span class="n">_</span><span class="p">,</span> <span class="n">size</span> <span class="o">:=</span> <span class="n">utf8</span><span class="o">.</span><span class="n">DecodeRune</span><span class="p">(</span><span class="n">data</span><span class="p">[</span><span class="n">i</span><span class="o">:</span><span class="p">])</span>
        <span class="k">if</span> <span class="n">size</span> <span class="o">==</span> <span class="m">1</span> <span class="o">&amp;&amp;</span> <span class="n">data</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">&gt;=</span> <span class="m">0x80</span> <span class="p">{</span>
            <span class="k">return</span> <span class="n">i</span>
        <span class="p">}</span>
        <span class="n">i</span> <span class="o">+=</span> <span class="n">size</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="m">0</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>Incremental validation</strong> happens during string parsing. After verifying the input is well-formed UTF-8, the string parser still decodes each rune individually to validate <em>semantic</em> constraints: noncharacter rejection and surrogate detection that apply to decoded values, not raw bytes.</p>

<p>Why both? The upfront check is fast (Go’s <code class="language-plaintext highlighter-rouge">utf8.Valid</code> is optimized) and gives clear error reporting for malformed input before the parser’s position tracking complicates offset calculation. The incremental check applies rules that operate on decoded code points, not byte patterns.</p>

<h2 id="number-grammar-four-layers-of-enforcement">Number Grammar: Four Layers of Enforcement</h2>

<p>RFC 8259 §6 defines a specific number grammar. The parser enforces it in four phases.</p>

<h3 id="leading-zero-rejection">Leading Zero Rejection</h3>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">p</span> <span class="o">*</span><span class="n">parser</span><span class="p">)</span> <span class="n">scanZeroIntegerPart</span><span class="p">()</span> <span class="o">*</span><span class="n">jcserr</span><span class="o">.</span><span class="n">Error</span> <span class="p">{</span>
    <span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">++</span>
    <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="n">isDigit</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">[</span><span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="p">])</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">p</span><span class="o">.</span><span class="n">newError</span><span class="p">(</span><span class="s">"leading zero in number"</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">01</code>, <code class="language-plaintext highlighter-rouge">007</code>, <code class="language-plaintext highlighter-rouge">00.5</code>: all rejected. A zero integer part must be exactly <code class="language-plaintext highlighter-rouge">0</code>, followed by a non-digit (or end of number). RFC 8259 §6 specifies: “Leading zeros are not allowed.”</p>

<h3 id="missing-fraction-digits">Missing Fraction Digits</h3>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">p</span> <span class="o">*</span><span class="n">parser</span><span class="p">)</span> <span class="n">scanFractionPart</span><span class="p">(</span><span class="n">numStart</span> <span class="kt">int</span><span class="p">)</span> <span class="o">*</span><span class="n">jcserr</span><span class="o">.</span><span class="n">Error</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span> <span class="o">&gt;=</span> <span class="nb">len</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">)</span> <span class="o">||</span> <span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">[</span><span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="p">]</span> <span class="o">!=</span> <span class="sc">'.'</span> <span class="p">{</span>
        <span class="k">return</span> <span class="no">nil</span>
    <span class="p">}</span>
    <span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">++</span>

    <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span> <span class="o">&gt;=</span> <span class="nb">len</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">)</span> <span class="o">||</span> <span class="o">!</span><span class="n">isDigit</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">[</span><span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="p">])</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">p</span><span class="o">.</span><span class="n">newError</span><span class="p">(</span><span class="s">"expected digit after decimal point"</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="c">// ... scan remaining digits</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">1.</code> and <code class="language-plaintext highlighter-rouge">1.e5</code> are rejected; the grammar requires at least one digit after the decimal point.</p>

<h3 id="lexical-negative-zero">Lexical Negative Zero</h3>

<p>This is a project policy enforcement, not a grammar rule. The value <code class="language-plaintext highlighter-rouge">-0</code> is mathematically zero, but the <em>lexical</em> token <code class="language-plaintext highlighter-rouge">-0</code> is ambiguous:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// PROF-NEGZ-001: lexical negative zero</span>
<span class="k">if</span> <span class="n">strings</span><span class="o">.</span><span class="n">HasPrefix</span><span class="p">(</span><span class="n">raw</span><span class="p">,</span> <span class="s">"-"</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="n">tokenRepresentsZero</span><span class="p">(</span><span class="n">raw</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">NumberNegZero</span><span class="p">,</span> <span class="n">start</span><span class="p">,</span>
        <span class="s">"negative zero token is not allowed"</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This rejects <code class="language-plaintext highlighter-rouge">-0</code>, <code class="language-plaintext highlighter-rouge">-0.0</code>, <code class="language-plaintext highlighter-rouge">-0e0</code>, <code class="language-plaintext highlighter-rouge">-0.0e+0</code>, and every other way to spell negative zero in JSON number syntax. The <code class="language-plaintext highlighter-rouge">tokenRepresentsZero</code> function checks whether the mantissa portion of the number token contains any non-zero digit, ignoring the exponent entirely.</p>

<p>Note: this is a <em>different</em> requirement from serialization. At serialization time, the IEEE 754 bit pattern for -0 outputs as <code class="language-plaintext highlighter-rouge">"0"</code>. At parse time, the lexical token <code class="language-plaintext highlighter-rouge">-0</code> is rejected. The two requirements are independent: one governs input, the other governs output.</p>

<h3 id="overflow-and-underflow">Overflow and Underflow</h3>

<p>After grammar validation, the number string is parsed with <code class="language-plaintext highlighter-rouge">strconv.ParseFloat</code>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">f</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">strconv</span><span class="o">.</span><span class="n">ParseFloat</span><span class="p">(</span><span class="n">raw</span><span class="p">,</span> <span class="m">64</span><span class="p">)</span>

<span class="c">// Overflow: value exceeds IEEE 754 range</span>
<span class="k">if</span> <span class="n">math</span><span class="o">.</span><span class="n">IsNaN</span><span class="p">(</span><span class="n">f</span><span class="p">)</span> <span class="o">||</span> <span class="n">math</span><span class="o">.</span><span class="n">IsInf</span><span class="p">(</span><span class="n">f</span><span class="p">,</span> <span class="m">0</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">NumberOverflow</span><span class="p">,</span> <span class="n">start</span><span class="p">,</span>
        <span class="s">"number overflows IEEE 754 double"</span><span class="p">)</span>
<span class="p">}</span>

<span class="c">// Underflow: non-zero token rounds to zero</span>
<span class="k">if</span> <span class="n">f</span> <span class="o">==</span> <span class="m">0</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">tokenRepresentsZero</span><span class="p">(</span><span class="n">raw</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">NumberUnderflow</span><span class="p">,</span> <span class="n">start</span><span class="p">,</span>
        <span class="s">"non-zero number underflows to IEEE 754 zero"</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">1e999999</code> overflows to infinity; rejected. <code class="language-plaintext highlighter-rouge">1e-400</code> is a non-zero number that underflows to zero in IEEE 754; also rejected. The underflow check uses the same <code class="language-plaintext highlighter-rouge">tokenRepresentsZero</code> function: if the <em>token</em> has non-zero digits but <code class="language-plaintext highlighter-rouge">ParseFloat</code> returns zero, the precision was lost.</p>

<h2 id="string-parsing-two-paths">String Parsing: Two Paths</h2>

<p>The string parser is a linear scan with two code paths: escape sequences and raw UTF-8 bytes.</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">p</span> <span class="o">*</span><span class="n">parser</span><span class="p">)</span> <span class="n">parseString</span><span class="p">()</span> <span class="p">(</span><span class="o">*</span><span class="n">Value</span><span class="p">,</span> <span class="kt">error</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="n">p</span><span class="o">.</span><span class="n">expect</span><span class="p">(</span><span class="sc">'"'</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="no">nil</span><span class="p">,</span> <span class="n">err</span>
    <span class="p">}</span>

    <span class="k">var</span> <span class="n">buf</span> <span class="p">[]</span><span class="kt">byte</span>
    <span class="k">for</span> <span class="p">{</span>
        <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span> <span class="o">&gt;=</span> <span class="nb">len</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">newError</span><span class="p">(</span><span class="s">"unterminated string"</span><span class="p">)</span>
        <span class="p">}</span>
        <span class="n">b</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">[</span><span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="p">]</span>
        <span class="k">if</span> <span class="n">b</span> <span class="o">==</span> <span class="sc">'"'</span> <span class="p">{</span>
            <span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">++</span>
            <span class="k">return</span> <span class="o">&amp;</span><span class="n">Value</span><span class="p">{</span><span class="n">Kind</span><span class="o">:</span> <span class="n">KindString</span><span class="p">,</span> <span class="n">Str</span><span class="o">:</span> <span class="kt">string</span><span class="p">(</span><span class="n">buf</span><span class="p">)},</span> <span class="no">nil</span>
        <span class="p">}</span>
        <span class="k">if</span> <span class="n">b</span> <span class="o">==</span> <span class="sc">'\\'</span> <span class="p">{</span>
            <span class="c">// Escape sequence path</span>
            <span class="n">escapeStart</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span>
            <span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">++</span>
            <span class="n">r</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">parseEscape</span><span class="p">(</span><span class="n">escapeStart</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="no">nil</span><span class="p">,</span> <span class="n">err</span> <span class="p">}</span>
            <span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">validateStringRune</span><span class="p">(</span><span class="n">r</span><span class="p">,</span> <span class="n">escapeStart</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="no">nil</span><span class="p">,</span> <span class="n">err</span> <span class="p">}</span>
            <span class="k">var</span> <span class="n">tmp</span> <span class="p">[</span><span class="m">4</span><span class="p">]</span><span class="kt">byte</span>
            <span class="n">n</span> <span class="o">:=</span> <span class="n">utf8</span><span class="o">.</span><span class="n">EncodeRune</span><span class="p">(</span><span class="n">tmp</span><span class="p">[</span><span class="o">:</span><span class="p">],</span> <span class="n">r</span><span class="p">)</span>
            <span class="n">buf</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="n">tmp</span><span class="p">[</span><span class="o">:</span><span class="n">n</span><span class="p">]</span><span class="o">...</span><span class="p">)</span>
            <span class="k">continue</span>
        <span class="p">}</span>
        <span class="k">if</span> <span class="n">b</span> <span class="o">&lt;</span> <span class="m">0x20</span> <span class="p">{</span>
            <span class="c">// PARSE-GRAM-004: reject unescaped control characters</span>
            <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">newErrorf</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">InvalidGrammar</span><span class="p">,</span>
                <span class="s">"unescaped control character 0x%02X in string"</span><span class="p">,</span> <span class="n">b</span><span class="p">)</span>
        <span class="p">}</span>
        <span class="c">// Raw UTF-8 path</span>
        <span class="n">sourceOffset</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span>
        <span class="n">r</span><span class="p">,</span> <span class="n">size</span> <span class="o">:=</span> <span class="n">utf8</span><span class="o">.</span><span class="n">DecodeRune</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">[</span><span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">:</span><span class="p">])</span>
        <span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">validateStringRune</span><span class="p">(</span><span class="n">r</span><span class="p">,</span> <span class="n">sourceOffset</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="no">nil</span><span class="p">,</span> <span class="n">err</span> <span class="p">}</span>
        <span class="n">buf</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">[</span><span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">:</span><span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">+</span><span class="n">size</span><span class="p">]</span><span class="o">...</span><span class="p">)</span>
        <span class="n">p</span><span class="o">.</span><span class="n">pos</span> <span class="o">+=</span> <span class="n">size</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Both paths call <code class="language-plaintext highlighter-rouge">validateStringRune</code> to check for noncharacters and surrogates. The escape path first decodes the escape to a rune, then validates the decoded value. The raw path decodes the rune from UTF-8 bytes, then validates. The decoded results are identical: <code class="language-plaintext highlighter-rouge">\u0041</code> and <code class="language-plaintext highlighter-rouge">A</code> both produce rune 0x41. This is critical for duplicate key detection later.</p>

<p>The escape dispatch is a straightforward table:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">escapedRune</span><span class="p">(</span><span class="n">b</span> <span class="kt">byte</span><span class="p">)</span> <span class="p">(</span><span class="kt">rune</span><span class="p">,</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">switch</span> <span class="n">b</span> <span class="p">{</span>
    <span class="k">case</span> <span class="sc">'"'</span><span class="o">:</span>  <span class="k">return</span> <span class="sc">'"'</span><span class="p">,</span> <span class="no">true</span>
    <span class="k">case</span> <span class="sc">'\\'</span><span class="o">:</span> <span class="k">return</span> <span class="sc">'\\'</span><span class="p">,</span> <span class="no">true</span>
    <span class="k">case</span> <span class="sc">'/'</span><span class="o">:</span>  <span class="k">return</span> <span class="sc">'/'</span><span class="p">,</span> <span class="no">true</span>
    <span class="k">case</span> <span class="sc">'b'</span><span class="o">:</span>  <span class="k">return</span> <span class="sc">'\b'</span><span class="p">,</span> <span class="no">true</span>
    <span class="k">case</span> <span class="sc">'f'</span><span class="o">:</span>  <span class="k">return</span> <span class="sc">'\f'</span><span class="p">,</span> <span class="no">true</span>
    <span class="k">case</span> <span class="sc">'n'</span><span class="o">:</span>  <span class="k">return</span> <span class="sc">'\n'</span><span class="p">,</span> <span class="no">true</span>
    <span class="k">case</span> <span class="sc">'r'</span><span class="o">:</span>  <span class="k">return</span> <span class="sc">'\r'</span><span class="p">,</span> <span class="no">true</span>
    <span class="k">case</span> <span class="sc">'t'</span><span class="o">:</span>  <span class="k">return</span> <span class="sc">'\t'</span><span class="p">,</span> <span class="no">true</span>
    <span class="k">default</span><span class="o">:</span>   <span class="k">return</span> <span class="m">0</span><span class="p">,</span> <span class="no">false</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>RFC 8259 §7 defines exactly these eight simple escapes plus <code class="language-plaintext highlighter-rouge">\uXXXX</code>. Anything else (<code class="language-plaintext highlighter-rouge">\x41</code>, <code class="language-plaintext highlighter-rouge">\U0041</code>, <code class="language-plaintext highlighter-rouge">\a</code>) is rejected with <code class="language-plaintext highlighter-rouge">InvalidGrammar</code>.</p>

<p>Control characters below U+0020 that appear unescaped are also rejected per §7: “All Unicode characters may be placed within the quotation marks, except for the characters that MUST be escaped: quotation mark, reverse solidus, and the control characters (U+0000 through U+001F).”</p>

<h2 id="surrogate-pairs-2-character-lookahead">Surrogate Pairs: 2-Character Lookahead</h2>

<p>UTF-16 surrogate pairs in JSON appear as <code class="language-plaintext highlighter-rouge">\uD800\uDC00</code>: two consecutive <code class="language-plaintext highlighter-rouge">\uXXXX</code> escapes where the first is a high surrogate (U+D800-U+DBFF) and the second is a low surrogate (U+DC00-U+DFFF). Together they encode a supplementary-plane character (U+10000 and above).</p>

<p>The parser must handle five cases:</p>

<ol>
  <li><strong>Not a surrogate</strong>: Return the rune as-is</li>
  <li><strong>Lone low surrogate</strong> (U+DC00-U+DFFF appearing first): Error</li>
  <li><strong>High surrogate with no following <code class="language-plaintext highlighter-rouge">\u</code></strong>: Error</li>
  <li><strong>High surrogate followed by non-low-surrogate</strong>: Error</li>
  <li><strong>Valid pair</strong>: Decode to supplementary-plane scalar</li>
</ol>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">p</span> <span class="o">*</span><span class="n">parser</span><span class="p">)</span> <span class="n">parseUnicodeEscape</span><span class="p">(</span><span class="n">sourceOffset</span> <span class="kt">int</span><span class="p">)</span> <span class="p">(</span><span class="kt">rune</span><span class="p">,</span> <span class="o">*</span><span class="n">jcserr</span><span class="o">.</span><span class="n">Error</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">r1</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">readHex4</span><span class="p">(</span><span class="n">sourceOffset</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="m">0</span><span class="p">,</span> <span class="n">err</span>
    <span class="p">}</span>

    <span class="k">if</span> <span class="o">!</span><span class="n">utf16</span><span class="o">.</span><span class="n">IsSurrogate</span><span class="p">(</span><span class="n">r1</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">r1</span><span class="p">,</span> <span class="no">nil</span>                              <span class="c">// Case 1: not a surrogate</span>
    <span class="p">}</span>

    <span class="k">if</span> <span class="n">r1</span> <span class="o">&gt;=</span> <span class="m">0xDC00</span> <span class="p">{</span>
        <span class="k">return</span> <span class="m">0</span><span class="p">,</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">LoneSurrogate</span><span class="p">,</span>  <span class="c">// Case 2: lone low surrogate</span>
            <span class="n">sourceOffset</span><span class="p">,</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"lone low surrogate U+%04X"</span><span class="p">,</span> <span class="n">r1</span><span class="p">))</span>
    <span class="p">}</span>

    <span class="c">// Case 3: high surrogate must be followed by \uXXXX</span>
    <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">+</span><span class="m">1</span> <span class="o">&gt;=</span> <span class="nb">len</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">)</span> <span class="o">||</span> <span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">[</span><span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="p">]</span> <span class="o">!=</span> <span class="sc">'\\'</span> <span class="o">||</span> <span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">[</span><span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">+</span><span class="m">1</span><span class="p">]</span> <span class="o">!=</span> <span class="sc">'u'</span> <span class="p">{</span>
        <span class="k">return</span> <span class="m">0</span><span class="p">,</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">LoneSurrogate</span><span class="p">,</span> <span class="n">sourceOffset</span><span class="p">,</span>
            <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"lone high surrogate U+%04X (no following </span><span class="se">\\</span><span class="s">u)"</span><span class="p">,</span> <span class="n">r1</span><span class="p">))</span>
    <span class="p">}</span>
    <span class="n">secondEscapeOffset</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span>
    <span class="n">p</span><span class="o">.</span><span class="n">pos</span> <span class="o">+=</span> <span class="m">2</span>                                      <span class="c">// Skip past '\u'</span>

    <span class="n">r2</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">readHex4</span><span class="p">(</span><span class="n">secondEscapeOffset</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="m">0</span><span class="p">,</span> <span class="n">err</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="n">r2</span> <span class="o">&lt;</span> <span class="m">0xDC00</span> <span class="o">||</span> <span class="n">r2</span> <span class="o">&gt;</span> <span class="m">0xDFFF</span> <span class="p">{</span>
        <span class="k">return</span> <span class="m">0</span><span class="p">,</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">LoneSurrogate</span><span class="p">,</span>  <span class="c">// Case 4: not a low surrogate</span>
            <span class="n">secondEscapeOffset</span><span class="p">,</span>
            <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"high surrogate U+%04X followed by non-low-surrogate U+%04X"</span><span class="p">,</span> <span class="n">r1</span><span class="p">,</span> <span class="n">r2</span><span class="p">))</span>
    <span class="p">}</span>

    <span class="c">// Case 5: valid pair</span>
    <span class="n">decoded</span> <span class="o">:=</span> <span class="n">utf16</span><span class="o">.</span><span class="n">DecodeRune</span><span class="p">(</span><span class="n">r1</span><span class="p">,</span> <span class="n">r2</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">decoded</span> <span class="o">==</span> <span class="n">unicode</span><span class="o">.</span><span class="n">ReplacementChar</span> <span class="p">{</span>
        <span class="k">return</span> <span class="m">0</span><span class="p">,</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">LoneSurrogate</span><span class="p">,</span> <span class="n">sourceOffset</span><span class="p">,</span>
            <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"invalid surrogate pair U+%04X U+%04X"</span><span class="p">,</span> <span class="n">r1</span><span class="p">,</span> <span class="n">r2</span><span class="p">))</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">decoded</span><span class="p">,</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The 2-character lookahead checks for <code class="language-plaintext highlighter-rouge">\u</code> without consuming input. This is the only lookahead in the parser beyond single-byte dispatch. If the two bytes aren’t <code class="language-plaintext highlighter-rouge">\u</code>, the high surrogate is lone and the error points to the <em>first</em> escape’s offset. If the second escape exists but decodes to a non-low surrogate (like <code class="language-plaintext highlighter-rouge">\uD800\u0041</code>), the error points to the <em>second</em> escape’s offset, because that’s where the violation occurs.</p>

<p>Compare with <code class="language-plaintext highlighter-rouge">encoding/json</code>: it replaces lone surrogates with U+FFFD (documented behavior in the <code class="language-plaintext highlighter-rouge">Unmarshal</code> docs). This is convenient for display pipelines, but it silently changes semantic content. A canonicalization engine cannot do this. Changing input bytes means the canonical output no longer represents the original data.</p>

<h2 id="noncharacter-rejection-66-forbidden-code-points">Noncharacter Rejection: 66 Forbidden Code Points</h2>

<p>RFC 7493 §2.1 forbids Unicode noncharacters in I-JSON text. There are exactly 66:</p>

<ul>
  <li>32 in the range U+FDD0 to U+FDEF</li>
  <li>34 at U+xFFFE and U+xFFFF for each of the 17 Unicode planes (0-16)</li>
</ul>

<p>The implementation uses a compact two-branch check:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">IsNoncharacter</span><span class="p">(</span><span class="n">r</span> <span class="kt">rune</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">r</span> <span class="o">&gt;=</span> <span class="m">0xFDD0</span> <span class="o">&amp;&amp;</span> <span class="n">r</span> <span class="o">&lt;=</span> <span class="m">0xFDEF</span> <span class="p">{</span>
        <span class="k">return</span> <span class="no">true</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="n">r</span><span class="o">&amp;</span><span class="m">0xFFFE</span> <span class="o">==</span> <span class="m">0xFFFE</span> <span class="o">&amp;&amp;</span> <span class="n">r</span> <span class="o">&lt;=</span> <span class="m">0x10FFFF</span> <span class="p">{</span>
        <span class="k">return</span> <span class="no">true</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="no">false</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The bitwise trick on the second branch is worth explaining. The mask <code class="language-plaintext highlighter-rouge">r &amp; 0xFFFE</code> zeros the lowest bit, so both U+xFFFE and U+xFFFF match the pattern <code class="language-plaintext highlighter-rouge">0xFFFE</code>. The bound <code class="language-plaintext highlighter-rouge">r &lt;= 0x10FFFF</code> limits this to planes 0 through 16. This catches U+FFFE, U+FFFF, U+1FFFE, U+1FFFF, all the way up to U+10FFFE and U+10FFFF, for 2 per plane times 17 planes = 34 code points.</p>

<p>The validation runs on <em>decoded</em> runes, not raw bytes. This matters because supplementary-plane noncharacters like U+1FFFE can appear as either raw UTF-8 bytes or as surrogate pair escapes (<code class="language-plaintext highlighter-rouge">\uD83F\uDFFE</code>). Both paths decode to the same rune and hit the same check.</p>

<h2 id="duplicate-key-detection-after-escape-decoding">Duplicate Key Detection: After Escape Decoding</h2>

<p>RFC 7493 §2.3 requires that JSON objects not contain duplicate member names. The subtle requirement is that <code class="language-plaintext highlighter-rouge">"\u0061"</code> and <code class="language-plaintext highlighter-rouge">"a"</code> are the <em>same key</em>; both decode to the string “a”.</p>

<p>The parser handles this by comparing <em>decoded</em> strings, not raw lexemes:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">p</span> <span class="o">*</span><span class="n">parser</span><span class="p">)</span> <span class="n">parseObject</span><span class="p">()</span> <span class="p">(</span><span class="o">*</span><span class="n">Value</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="c">// ...</span>
    <span class="n">v</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="n">Value</span><span class="p">{</span><span class="n">Kind</span><span class="o">:</span> <span class="n">KindObject</span><span class="p">}</span>
    <span class="n">seen</span> <span class="o">:=</span> <span class="nb">make</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">int</span><span class="p">)</span>

    <span class="k">for</span> <span class="p">{</span>
        <span class="n">keyStart</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span>

        <span class="n">keyVal</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">parseString</span><span class="p">()</span>   <span class="c">// Decodes all escapes</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="no">nil</span><span class="p">,</span> <span class="n">err</span> <span class="p">}</span>
        <span class="n">key</span> <span class="o">:=</span> <span class="n">keyVal</span><span class="o">.</span><span class="n">Str</span>                <span class="c">// Decoded Unicode string</span>

        <span class="k">if</span> <span class="n">firstOff</span><span class="p">,</span> <span class="n">exists</span> <span class="o">:=</span> <span class="n">seen</span><span class="p">[</span><span class="n">key</span><span class="p">];</span> <span class="n">exists</span> <span class="p">{</span>
            <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">DuplicateKey</span><span class="p">,</span> <span class="n">keyStart</span><span class="p">,</span>
                <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"duplicate object key %q (first at byte %d)"</span><span class="p">,</span> <span class="n">key</span><span class="p">,</span> <span class="n">firstOff</span><span class="p">))</span>
        <span class="p">}</span>
        <span class="n">seen</span><span class="p">[</span><span class="n">key</span><span class="p">]</span> <span class="o">=</span> <span class="n">keyStart</span>

        <span class="c">// ... parse colon and value</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">seen</code> map uses the decoded string as the key. When the parser encounters <code class="language-plaintext highlighter-rouge">"\u0061"</code>, it decodes the escape to produce “a”, and the map lookup finds a match with a previously seen raw <code class="language-plaintext highlighter-rouge">"a"</code>. The error message includes the byte offset of <em>both</em> occurrences, enabling precise diagnostics.</p>

<p>This is a design choice that some parsers skip entirely (<code class="language-plaintext highlighter-rouge">encoding/json</code> processes duplicate keys in input order, with later values replacing or merging earlier ones) or implement on raw bytes (which misses escape-decoded equivalence). For canonicalization, decoded comparison is the only correct approach, because canonical output normalizes escape sequences.</p>

<h2 id="resource-bounds-seven-independent-limits">Resource Bounds: Seven Independent Limits</h2>

<p>The parser enforces seven configurable bounds, each checked at a different point in the parse:</p>

<table>
  <thead>
    <tr>
      <th>Bound</th>
      <th>Default</th>
      <th>Checked At</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Input size</td>
      <td>64 MB</td>
      <td>Before parsing begins</td>
    </tr>
    <tr>
      <td>Nesting depth</td>
      <td>1,000</td>
      <td>On each <code class="language-plaintext highlighter-rouge">{</code> or <code class="language-plaintext highlighter-rouge">[</code></td>
    </tr>
    <tr>
      <td>Total values</td>
      <td>1,000,000</td>
      <td>On each <code class="language-plaintext highlighter-rouge">parseValue</code> call</td>
    </tr>
    <tr>
      <td>Object members</td>
      <td>250,000</td>
      <td>Before adding each member</td>
    </tr>
    <tr>
      <td>Array elements</td>
      <td>250,000</td>
      <td>Before adding each element</td>
    </tr>
    <tr>
      <td>String bytes</td>
      <td>8 MB</td>
      <td>During string decode (per-string)</td>
    </tr>
    <tr>
      <td>Number chars</td>
      <td>4,096</td>
      <td>During number scan (per-token)</td>
    </tr>
  </tbody>
</table>

<p>All bounds are fail-fast: the parser stops at the first violation rather than continuing to consume input. The number character bound is checked <em>during</em> digit scanning, not just after:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">p</span> <span class="o">*</span><span class="n">parser</span><span class="p">)</span> <span class="n">scanNonZeroIntegerDigits</span><span class="p">(</span><span class="n">numStart</span> <span class="kt">int</span><span class="p">)</span> <span class="o">*</span><span class="n">jcserr</span><span class="o">.</span><span class="n">Error</span> <span class="p">{</span>
    <span class="k">for</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="n">isDigit</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">[</span><span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="p">])</span> <span class="p">{</span>
        <span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">++</span>
        <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">-</span><span class="n">numStart</span> <span class="o">&gt;</span> <span class="n">p</span><span class="o">.</span><span class="n">maxNumberChars</span> <span class="p">{</span>
            <span class="k">return</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">BoundExceeded</span><span class="p">,</span> <span class="n">numStart</span><span class="p">,</span>
                <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"number token length %d exceeds maximum %d"</span><span class="p">,</span>
                    <span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">-</span><span class="n">numStart</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">maxNumberChars</span><span class="p">))</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This prevents a 100 MB number token from being fully scanned before the bound check fires.</p>

<p>Every bound violation returns a <code class="language-plaintext highlighter-rouge">BoundExceeded</code> failure class, regardless of which bound was hit. This is a deliberate classification choice: the <em>cause</em> is “resource policy violation,” not “invalid grammar” or “I/O error.” Machines consuming the exit code can distinguish policy rejections from syntax errors.</p>

<h2 id="error-offset-tracking">Error Offset Tracking</h2>

<p>Every parser error includes a byte offset pointing to the source position of the violation:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">p</span> <span class="o">*</span><span class="n">parser</span><span class="p">)</span> <span class="n">newError</span><span class="p">(</span><span class="n">msg</span> <span class="kt">string</span><span class="p">)</span> <span class="o">*</span><span class="n">jcserr</span><span class="o">.</span><span class="n">Error</span> <span class="p">{</span>
    <span class="k">return</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">InvalidGrammar</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="p">,</span> <span class="n">msg</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>For most errors, the offset is the parser’s current position, the byte where parsing failed. But for escape sequences and surrogate pairs, the offset points to the <em>start</em> of the escape that caused the problem, not the byte where the violation was detected:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">b</span> <span class="o">==</span> <span class="sc">'\\'</span> <span class="p">{</span>
    <span class="n">escapeStart</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span>   <span class="c">// Record position of the backslash</span>
    <span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">++</span>
    <span class="n">r</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">parseEscape</span><span class="p">(</span><span class="n">escapeStart</span><span class="p">)</span>  <span class="c">// Pass source position</span>
    <span class="c">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>For a lone high surrogate in <code class="language-plaintext highlighter-rouge">"\uD800"</code>, the offset is 1 (the backslash). For a high surrogate followed by a non-low surrogate in <code class="language-plaintext highlighter-rouge">"\uD800\u0041"</code>, the offset is 7 (the second backslash). This matters for tooling: an editor or diagnostic tool can highlight the exact source token that caused the rejection.</p>

<p>The offset semantics are stable across the failure taxonomy. Parse errors always report byte positions in the original input. CLI errors use offset -1 (not applicable). Bound violations report the start of the bounded element (e.g., the first byte of a too-long number token).</p>

<h2 id="what-strictness-buys-you">What Strictness Buys You</h2>

<p>A lenient parser makes these implicit decisions:</p>
<ul>
  <li>“Leading zeros are fine” → Different parsers may interpret <code class="language-plaintext highlighter-rouge">012</code> as octal 10 or decimal 12</li>
  <li>“Lone surrogates get replaced” → The canonical output of <code class="language-plaintext highlighter-rouge">\uD800</code> is undefined</li>
  <li>“Duplicate keys use last value” → Or first value, depending on implementation</li>
  <li>“Trailing content is ignored” → <code class="language-plaintext highlighter-rouge">{"a":1}extra</code> silently becomes <code class="language-plaintext highlighter-rouge">{"a":1}</code></li>
</ul>

<p>A strict parser converts these implicit decisions into explicit rejections. The downstream consumer never has to wonder whether the input was ambiguous. If it parsed, it has exactly one interpretation. If it didn’t parse, the failure class and byte offset tell the consumer exactly what went wrong and where.</p>

<p>For infrastructure that depends on deterministic processing, this is the difference between “it works in my tests” and “it works because ambiguous input is structurally excluded.”</p>

<h3 id="strictness-as-error-budget">Strictness as Error Budget</h3>

<p>There’s a useful way to think about parser strictness: it’s an error budget. A lenient parser spends its error budget on user convenience, accepting malformed input so users don’t have to fix their data. A strict parser spends its error budget on correctness guarantees, ensuring every accepted input has exactly one interpretation.</p>

<p>Neither is wrong. They serve different purposes. But when you build infrastructure that sits between systems, processing input from one machine and producing output consumed by another, spending the error budget on convenience is spending it on the wrong consumer. The machine downstream doesn’t benefit from leniency. It benefits from guarantees.</p>

<p>The parser code in the <code class="language-plaintext highlighter-rouge">jcstoken</code> package exists to provide one guarantee: if the parser returns a value, that value has a single, unambiguous, deterministic canonical representation. Every rejection (leading zeros, lone surrogates, duplicate keys, noncharacters, overflow, underflow, negative zero) eliminates a case where two consumers might disagree.</p>

<p>The implementation lives in the <code class="language-plaintext highlighter-rouge">jcstoken</code> package of <a href="https://github.com/lattice-substrate/json-canon">json-canon</a>, an RFC 8785 JSON canonicalization library. For callers who want a single parse-and-serialize call, <code class="language-plaintext highlighter-rouge">jcs.Canonicalize(input)</code> wraps this parser with the RFC 8785 serializer.</p>

<h2 id="revision-history">Revision History</h2>

<table>
  <thead>
    <tr>
      <th>Date</th>
      <th>Change</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2026-03-04</td>
      <td>Corrected leading-zero quote attribution (RFC 8259, not ECMA-404 phrasing); fixed -0 policy attribution (project policy, not RFC 7493); removed stale source line reference; corrected bounds check timing (before add, not after)</td>
    </tr>
    <tr>
      <td>2026-03-03</td>
      <td>Removed line-count claims; added <code class="language-plaintext highlighter-rouge">jcs.Canonicalize</code> API reference</td>
    </tr>
    <tr>
      <td>2026-02-26</td>
      <td>Initial publication</td>
    </tr>
  </tbody>
</table>]]></content><author><name>Mark Lenhardt</name></author><category term="go" /><category term="parsing" /><category term="json" /><summary type="html"><![CDATA[A strict Go parser that enforces every constraint in RFC 8259. Surrogate pair validation, noncharacter detection, duplicate key rejection after escape decoding, and seven independent resource bounds, all things encoding/json silently accepts.]]></summary></entry><entry><title type="html">The Small Decisions That Infrastructure Depends On</title><link href="https://lattice-substrate.github.io/blog/2026/02/25/small-decisions-infrastructure-primitive/" rel="alternate" type="text/html" title="The Small Decisions That Infrastructure Depends On" /><published>2026-02-25T00:00:00+00:00</published><updated>2026-02-25T00:00:00+00:00</updated><id>https://lattice-substrate.github.io/blog/2026/02/25/small-decisions-infrastructure-primitive</id><content type="html" xml:base="https://lattice-substrate.github.io/blog/2026/02/25/small-decisions-infrastructure-primitive/"><![CDATA[<p>Infrastructure software isn’t defined by one big architectural choice. It’s defined by getting dozens of small decisions right. Decisions that most projects skip because they seem unimportant until they aren’t.</p>

<p>Most software fails at the center: the core algorithm is wrong, the architecture doesn’t scale, the data model can’t represent the domain. Infrastructure software fails at the margins: the sort order is subtly wrong for one class of inputs, the error code changes in a patch release and breaks a CI pipeline, the upgrade removes a flag that a deployment script depends on.</p>

<p>These marginal failures are harder to prevent because they’re harder to see. They don’t cause test failures during development. They cause production incidents three months after a routine upgrade.</p>

<p>This article examines three categories of these decisions through the lens of a JSON canonicalization library: a correctness detail that affects sort order, a failure classification system that enables machine automation, and an ABI stability discipline that prevents upgrade emergencies. None of these individually is difficult. Together, they define whether downstream systems can depend on you without reservations.</p>

<h2 id="correctness-detail-utf-16-vs-utf-8-key-sorting">Correctness Detail: UTF-16 vs UTF-8 Key Sorting</h2>

<p><a href="https://www.rfc-editor.org/rfc/rfc8785">RFC 8785</a> (JSON Canonicalization Scheme) requires that object keys be sorted by lexicographic order of UTF-16 code units. That is not UTF-8 byte order, and it is not Unicode scalar-value order.</p>

<p>For most strings, these orderings are identical. They diverge when you have characters above U+FFFF (the upper boundary of the Basic Multilingual Plane). Here’s why.</p>

<p>Characters above U+FFFF require four bytes in UTF-8 but are encoded as <em>surrogate pairs</em> in UTF-16, as two 16-bit code units. The high surrogate falls in the range U+D800-U+DBFF. Consider two characters:</p>

<ul>
  <li><strong>U+10000</strong> (LINEAR B SYLLABLE B008 A): UTF-16 encoding is <code class="language-plaintext highlighter-rouge">D800 DC00</code> (surrogate pair). UTF-8 encoding is <code class="language-plaintext highlighter-rouge">F0 90 80 80</code>.</li>
  <li><strong>U+E000</strong> (first Private Use Area character): UTF-16 encoding is <code class="language-plaintext highlighter-rouge">E000</code> (single code unit). UTF-8 encoding is <code class="language-plaintext highlighter-rouge">EE 80 80</code>.</li>
</ul>

<p>In UTF-16 code-unit order: U+10000 (<code class="language-plaintext highlighter-rouge">D800</code>) &lt; U+E000 (<code class="language-plaintext highlighter-rouge">E000</code>). The high surrogate <code class="language-plaintext highlighter-rouge">D800</code> is numerically less than <code class="language-plaintext highlighter-rouge">E000</code>.</p>

<p>In UTF-8 byte order: U+E000 (<code class="language-plaintext highlighter-rouge">EE</code>) &lt; U+10000 (<code class="language-plaintext highlighter-rouge">F0</code>). The three-byte UTF-8 prefix <code class="language-plaintext highlighter-rouge">EE</code> is numerically less than the four-byte prefix <code class="language-plaintext highlighter-rouge">F0</code>.</p>

<p>The two orderings disagree. RFC 8785 mandates UTF-16 code-unit order (see §3.2.3). Most canonicalization implementations get this wrong because they use byte-order comparison, which works for UTF-8 strings but produces a different order than the specification requires.</p>

<p>Why does RFC 8785 use UTF-16 code-unit order instead of the more natural (for modern systems) Unicode code-point order? Because JCS is defined for interoperability with ECMAScript string serialization rules, and ECMAScript strings are sequences of 16-bit code units. The required sort is a pure lexicographic code-unit comparison, not locale-aware collation (<code class="language-plaintext highlighter-rouge">localeCompare</code> is locale-sensitive and is not the JCS rule).</p>

<p>This means every implementation in a language with UTF-8 native strings (Go, Rust, Python 3) must explicitly convert to UTF-16 for comparison. Implementations that skip this conversion will produce correct output for the BMP (U+0000 to U+FFFF) and incorrect output for supplementary-plane characters. Since supplementary-plane characters are rare in practice (emoji, historic scripts, mathematical symbols), the bug is unlikely to surface in casual testing. It will surface when a production system encounters a key containing an emoji or a CJK Extension B character.</p>

<p>The implementation is compact:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">serializeObject</span><span class="p">(</span><span class="n">buf</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">,</span> <span class="n">v</span> <span class="o">*</span><span class="n">jcstoken</span><span class="o">.</span><span class="n">Value</span><span class="p">)</span> <span class="p">([]</span><span class="kt">byte</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">sorted</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="n">sortableMember</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">v</span><span class="o">.</span><span class="n">Members</span><span class="p">))</span>
    <span class="k">for</span> <span class="n">i</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">v</span><span class="o">.</span><span class="n">Members</span> <span class="p">{</span>
        <span class="n">sorted</span><span class="p">[</span><span class="n">i</span><span class="p">]</span><span class="o">.</span><span class="n">member</span> <span class="o">=</span> <span class="n">v</span><span class="o">.</span><span class="n">Members</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
        <span class="c">// Fast path: ASCII keys need no UTF-16 encoding since byte order</span>
        <span class="c">// equals UTF-16 code-unit order for U+0000..U+007F.</span>
        <span class="k">if</span> <span class="o">!</span><span class="n">isASCII</span><span class="p">(</span><span class="n">v</span><span class="o">.</span><span class="n">Members</span><span class="p">[</span><span class="n">i</span><span class="p">]</span><span class="o">.</span><span class="n">Key</span><span class="p">)</span> <span class="p">{</span>
            <span class="n">sorted</span><span class="p">[</span><span class="n">i</span><span class="p">]</span><span class="o">.</span><span class="n">key16</span> <span class="o">=</span> <span class="n">utf16</span><span class="o">.</span><span class="n">Encode</span><span class="p">([]</span><span class="kt">rune</span><span class="p">(</span><span class="n">v</span><span class="o">.</span><span class="n">Members</span><span class="p">[</span><span class="n">i</span><span class="p">]</span><span class="o">.</span><span class="n">Key</span><span class="p">))</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="n">sort</span><span class="o">.</span><span class="n">Slice</span><span class="p">(</span><span class="n">sorted</span><span class="p">,</span> <span class="k">func</span><span class="p">(</span><span class="n">i</span><span class="p">,</span> <span class="n">j</span> <span class="kt">int</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">compareSortKeys</span><span class="p">(</span><span class="o">&amp;</span><span class="n">sorted</span><span class="p">[</span><span class="n">i</span><span class="p">],</span> <span class="o">&amp;</span><span class="n">sorted</span><span class="p">[</span><span class="n">j</span><span class="p">])</span> <span class="o">&lt;</span> <span class="m">0</span>
    <span class="p">})</span>
    <span class="c">// ... serialize sorted members</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Non-ASCII keys are converted from Go’s native UTF-8 string to a <code class="language-plaintext highlighter-rouge">[]uint16</code> via <code class="language-plaintext highlighter-rouge">utf16.Encode([]rune(key))</code>. ASCII-only keys skip this conversion; byte order and UTF-16 code-unit order are identical for U+0000..U+007F. The <code class="language-plaintext highlighter-rouge">compareSortKeys</code> function handles both cases, falling back to <code class="language-plaintext highlighter-rouge">compareUTF16Units</code> when either key contains non-ASCII characters:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">compareSortKeys</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span> <span class="o">*</span><span class="n">sortableMember</span><span class="p">)</span> <span class="kt">int</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">a</span><span class="o">.</span><span class="n">key16</span> <span class="o">==</span> <span class="no">nil</span> <span class="o">&amp;&amp;</span> <span class="n">b</span><span class="o">.</span><span class="n">key16</span> <span class="o">==</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="k">if</span> <span class="n">a</span><span class="o">.</span><span class="n">member</span><span class="o">.</span><span class="n">Key</span> <span class="o">&lt;</span> <span class="n">b</span><span class="o">.</span><span class="n">member</span><span class="o">.</span><span class="n">Key</span> <span class="p">{</span>
            <span class="k">return</span> <span class="o">-</span><span class="m">1</span>
        <span class="p">}</span>
        <span class="k">if</span> <span class="n">a</span><span class="o">.</span><span class="n">member</span><span class="o">.</span><span class="n">Key</span> <span class="o">&gt;</span> <span class="n">b</span><span class="o">.</span><span class="n">member</span><span class="o">.</span><span class="n">Key</span> <span class="p">{</span>
            <span class="k">return</span> <span class="m">1</span>
        <span class="p">}</span>
        <span class="k">return</span> <span class="m">0</span>
    <span class="p">}</span>
    <span class="n">ak</span> <span class="o">:=</span> <span class="n">a</span><span class="o">.</span><span class="n">key16</span>
    <span class="k">if</span> <span class="n">ak</span> <span class="o">==</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="n">ak</span> <span class="o">=</span> <span class="n">utf16</span><span class="o">.</span><span class="n">Encode</span><span class="p">([]</span><span class="kt">rune</span><span class="p">(</span><span class="n">a</span><span class="o">.</span><span class="n">member</span><span class="o">.</span><span class="n">Key</span><span class="p">))</span>
    <span class="p">}</span>
    <span class="n">bk</span> <span class="o">:=</span> <span class="n">b</span><span class="o">.</span><span class="n">key16</span>
    <span class="k">if</span> <span class="n">bk</span> <span class="o">==</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="n">bk</span> <span class="o">=</span> <span class="n">utf16</span><span class="o">.</span><span class="n">Encode</span><span class="p">([]</span><span class="kt">rune</span><span class="p">(</span><span class="n">b</span><span class="o">.</span><span class="n">member</span><span class="o">.</span><span class="n">Key</span><span class="p">))</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">compareUTF16Units</span><span class="p">(</span><span class="n">ak</span><span class="p">,</span> <span class="n">bk</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>When both keys are ASCII (<code class="language-plaintext highlighter-rouge">key16 == nil</code>), direct string comparison produces identical ordering, with no allocation needed. When either key contains non-ASCII characters, the function falls back to full UTF-16 code-unit comparison via <code class="language-plaintext highlighter-rouge">compareUTF16Units</code>. This is the only correct approach for RFC 8785 compliance. The canonical output for an object with keys U+10000 and U+E000 places U+10000 <em>first</em>, the opposite of what a naive byte-order sort would produce.</p>

<h3 id="string-escaping-what-gets-escaped-and-why">String Escaping: What Gets Escaped and Why</h3>

<p>Canonicalization also requires deterministic string escaping. The rules are specific:</p>

<table>
  <thead>
    <tr>
      <th>Character</th>
      <th>Canonical Form</th>
      <th>Rule</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>U+0008 (backspace)</td>
      <td><code class="language-plaintext highlighter-rouge">\b</code></td>
      <td>Named escape</td>
    </tr>
    <tr>
      <td>U+0009 (tab)</td>
      <td><code class="language-plaintext highlighter-rouge">\t</code></td>
      <td>Named escape</td>
    </tr>
    <tr>
      <td>U+000A (newline)</td>
      <td><code class="language-plaintext highlighter-rouge">\n</code></td>
      <td>Named escape</td>
    </tr>
    <tr>
      <td>U+000C (form feed)</td>
      <td><code class="language-plaintext highlighter-rouge">\f</code></td>
      <td>Named escape</td>
    </tr>
    <tr>
      <td>U+000D (carriage return)</td>
      <td><code class="language-plaintext highlighter-rouge">\r</code></td>
      <td>Named escape</td>
    </tr>
    <tr>
      <td>U+0022 (quotation mark)</td>
      <td><code class="language-plaintext highlighter-rouge">\"</code></td>
      <td>Named escape</td>
    </tr>
    <tr>
      <td>U+005C (reverse solidus)</td>
      <td><code class="language-plaintext highlighter-rouge">\\</code></td>
      <td>Named escape</td>
    </tr>
    <tr>
      <td>U+0000-U+001F (other controls)</td>
      <td><code class="language-plaintext highlighter-rouge">\u00xx</code></td>
      <td>Lowercase hex, zero-padded</td>
    </tr>
    <tr>
      <td>U+002F (solidus <code class="language-plaintext highlighter-rouge">/</code>)</td>
      <td><code class="language-plaintext highlighter-rouge">/</code></td>
      <td>NOT escaped</td>
    </tr>
    <tr>
      <td>Everything else above U+001F</td>
      <td>Raw UTF-8</td>
      <td>No escaping</td>
    </tr>
  </tbody>
</table>

<p>The solidus rule is worth noting. JSON allows <code class="language-plaintext highlighter-rouge">\/</code> as a valid escape, and many serializers produce it. RFC 8785 specifies that solidus is <em>not</em> escaped in canonical output. This is a one-line decision in the implementation, but it means any test that compares canonical output byte-for-byte must agree on this point.</p>

<h2 id="failure-contracts-designing-a-taxonomy-that-machines-can-depend-on">Failure Contracts: Designing a Taxonomy That Machines Can Depend On</h2>

<p>Most error handling in CLI tools follows a pattern: print a message to stderr, exit non-zero. The message is for humans. The exit code is a vague signal, usually 1 for “something went wrong.”</p>

<p>This is insufficient for machine automation. When a CI pipeline runs a canonicalization check, it needs to distinguish between “the input was malformed JSON” (a build error) and “stdout couldn’t be written” (an infrastructure problem). The human can read the message. The script needs the exit code.</p>

<h3 id="13-classes-3-exit-codes">13 Classes, 3 Exit Codes</h3>

<p>The failure taxonomy defines 13 failure classes mapped to 3 exit codes:</p>

<table>
  <thead>
    <tr>
      <th>Exit Code</th>
      <th>Meaning</th>
      <th>Classes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0</td>
      <td>Success</td>
      <td>-</td>
    </tr>
    <tr>
      <td>2</td>
      <td>Input rejection</td>
      <td>INVALID_UTF8, INVALID_GRAMMAR, DUPLICATE_KEY, LONE_SURROGATE, NONCHARACTER, NUMBER_OVERFLOW, NUMBER_NEGZERO, NUMBER_UNDERFLOW, BOUND_EXCEEDED, NOT_CANONICAL, CLI_USAGE</td>
    </tr>
    <tr>
      <td>10</td>
      <td>Internal error</td>
      <td>INTERNAL_IO, INTERNAL_ERROR</td>
    </tr>
  </tbody>
</table>

<p>The exit code space is deliberately sparse. Codes 0, 2, and 10 leave room for future expansion without breaking existing scripts that check <code class="language-plaintext highlighter-rouge">$?</code>. Eleven classes share exit code 2 because they all represent the same decision for automation: “the input or invocation was wrong; don’t retry without changing something.” Exit code 1 is intentionally avoided; many shells and tools use 1 as a generic failure code, and conflating tool-specific failures with generic failure would reduce the signal.</p>

<p>The two exit-10 classes represent a fundamentally different situation: the tool itself encountered a problem. A CI pipeline should fail the build on exit 2 (bad input is a build error) but might alert ops on exit 10 (the build infrastructure has a problem).</p>

<p>Why 13 classes instead of 3? Because the exit code is a coarse signal for automation, while the class is a fine-grained signal for diagnostics. A script checks the exit code. A log aggregator or monitoring system can parse the class name from stderr. Having <code class="language-plaintext highlighter-rouge">DUPLICATE_KEY</code> as a distinct class from <code class="language-plaintext highlighter-rouge">INVALID_GRAMMAR</code> lets a monitoring dashboard show “we’re seeing a spike in duplicate-key rejections” without confusing it with syntax errors.</p>

<h3 id="root-cause-classification">Root-Cause Classification</h3>

<p>The classification principle is: classify by <em>root cause</em>, not by error <em>origin</em>.</p>

<p>The clearest example: a missing file path. The user runs <code class="language-plaintext highlighter-rouge">jcs-canon canonicalize /nonexistent.json</code> and the file doesn’t exist. This is an <code class="language-plaintext highlighter-rouge">os.Open</code> error. An I/O error. So it should be <code class="language-plaintext highlighter-rouge">INTERNAL_IO</code>, right?</p>

<p>No. It should be <code class="language-plaintext highlighter-rouge">CLI_USAGE</code> (exit 2).</p>

<p>The root cause is that the user supplied an invalid argument. The file path is user input, just like the JSON content. When the path cannot be opened, the problem is the same as providing an unknown flag or specifying two input files. The invocation is wrong.</p>

<p><code class="language-plaintext highlighter-rouge">INTERNAL_IO</code> is reserved for failures <em>after</em> a valid I/O channel is established. If <code class="language-plaintext highlighter-rouge">os.Open</code> succeeds but a later <code class="language-plaintext highlighter-rouge">Read</code> fails due to a broken pipe, or if writing to stdout fails because the pipe was closed. Those are infrastructure problems. The channel was valid; something broke during operation.</p>

<p>This distinction matters for automation:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>jcs-canon canonicalize input.json
<span class="k">case</span> <span class="nv">$?</span> <span class="k">in
  </span>0<span class="p">)</span>  <span class="nb">echo</span> <span class="s2">"Success"</span> <span class="p">;;</span>
  2<span class="p">)</span>  <span class="nb">echo</span> <span class="s2">"Input or usage problem - fix the invocation"</span> <span class="p">;;</span>
  10<span class="p">)</span> <span class="nb">echo</span> <span class="s2">"Infrastructure problem - investigate the environment"</span> <span class="p">;;</span>
<span class="k">esac</span>
</code></pre></div></div>

<h3 id="the-structured-error-type">The Structured Error Type</h3>

<p>Every error in the system is a <code class="language-plaintext highlighter-rouge">*jcserr.Error</code> with four fields:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">Error</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">Class</span>   <span class="n">FailureClass</span>   <span class="c">// Stable category (determines exit code)</span>
    <span class="n">Offset</span>  <span class="kt">int</span>            <span class="c">// Source-byte position, or -1</span>
    <span class="n">Message</span> <span class="kt">string</span>         <span class="c">// Human-readable diagnostic</span>
    <span class="n">Cause</span>   <span class="kt">error</span>          <span class="c">// Underlying error (for wrapping)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">Class</code> field is the stable contract. The <code class="language-plaintext highlighter-rouge">Message</code> field is <em>not</em> stable; its wording may change in minor releases. This is explicit policy: machines should switch on the class, not parse the message string. The <code class="language-plaintext highlighter-rouge">Offset</code> field gives byte-precise error positions for parse failures, enabling editors and diagnostic tools to highlight the exact location.</p>

<h3 id="offset-semantics">Offset Semantics</h3>

<p>The offset field deserves its own discussion because it’s an area where most error reporting gets lazy.</p>

<p>For a simple parse error like a leading zero in <code class="language-plaintext highlighter-rouge">{"n":01}</code>, the offset points to the <code class="language-plaintext highlighter-rouge">0</code> at the start of the number token. Straightforward. But for errors inside escape sequences, the offset must point to the <em>originating escape</em>, not the byte where the violation was detected.</p>

<p>Consider the string <code class="language-plaintext highlighter-rouge">"\uD800\u0041"</code>. This contains a high surrogate (U+D800) followed by a non-low-surrogate (U+0041). The parser detects the error when reading the second escape sequence, but the error offset points to byte 7 (the backslash of <code class="language-plaintext highlighter-rouge">\u0041</code>), not to byte 1 (the backslash of <code class="language-plaintext highlighter-rouge">\uD800</code>). This is because the <em>violation</em> is in the second escape: a high surrogate was followed by U+0041 instead of a low surrogate. Pointing to the first escape would be misleading: <code class="language-plaintext highlighter-rouge">\uD800</code> isn’t inherently wrong; it’s wrong because of what follows.</p>

<p>Conversely, for a lone low surrogate like <code class="language-plaintext highlighter-rouge">"\uDC00"</code>, the offset points to byte 1 (the backslash) because the low surrogate appearing without a preceding high surrogate is the violation.</p>

<p>These offset semantics are stable across the failure taxonomy and documented as part of the error contract. Changing where an offset points is a behavioral change, even if the class and message remain the same.</p>

<p>The error format is also deliberate:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>jcserr: INVALID_GRAMMAR at byte 15: leading zero in number
jcserr: CLI_USAGE: read file "config.json": no such file or directory
jcserr: INTERNAL_IO: writing output: write: broken pipe
</code></pre></div></div>

<p>The class name always appears after <code class="language-plaintext highlighter-rouge">jcserr:</code>. Byte offsets appear only for parse-time errors (where they’re meaningful). The cause chain preserves the underlying error for debugging without losing the classification.</p>

<h2 id="abi-stability-preventing-upgrade-emergencies">ABI Stability: Preventing Upgrade Emergencies</h2>

<p>For a CLI tool consumed by scripts and CI pipelines, the <em>interface</em> is the product. Changing a flag name, moving output between stdout and stderr, or redefining an exit code breaks every downstream consumer. These breakages are invisible in your test suite and visible only in your users’ build failures.</p>

<h3 id="defining-the-stable-surface">Defining the Stable Surface</h3>

<p>The stable ABI surface includes:</p>

<ol>
  <li><strong>Command names</strong>: <code class="language-plaintext highlighter-rouge">canonicalize</code>, <code class="language-plaintext highlighter-rouge">verify</code></li>
  <li><strong>Flag names and semantics</strong>: <code class="language-plaintext highlighter-rouge">--quiet</code>, <code class="language-plaintext highlighter-rouge">--help</code>, <code class="language-plaintext highlighter-rouge">--version</code></li>
  <li><strong>Exit code mapping</strong>: 0/2/10 with defined semantics</li>
  <li><strong>Output stream placement</strong>: canonical data to stdout, diagnostics to stderr, verify result to stderr</li>
  <li><strong>Failure class names in stderr</strong>: The class name <code class="language-plaintext highlighter-rouge">INVALID_GRAMMAR</code> is stable; the surrounding message text is not</li>
  <li><strong>Canonical output bytes</strong>: identical input must produce identical stdout bytes</li>
</ol>

<p>Notably absent from the stable surface: the exact wording of error messages and help text. These are explicitly non-stable, allowing improvements to diagnostics without a major version bump.</p>

<p>There is one subtlety here: failure class <em>names</em> that appear in stderr (e.g., <code class="language-plaintext highlighter-rouge">INVALID_GRAMMAR</code> in the error output) are stable, even though the surrounding message text is not. This lets machines extract the class name from stderr as a fallback when they can’t use the exit code alone, for instance when a script needs to distinguish between <code class="language-plaintext highlighter-rouge">INVALID_GRAMMAR</code> and <code class="language-plaintext highlighter-rouge">DUPLICATE_KEY</code>, both of which share exit code 2.</p>

<p>The stream placement policy is also part of the stable surface. The <code class="language-plaintext highlighter-rouge">canonicalize</code> command writes canonical bytes to stdout and diagnostics to stderr. The <code class="language-plaintext highlighter-rouge">verify</code> command writes nothing to stdout and writes <code class="language-plaintext highlighter-rouge">ok\n</code> to stderr on success (suppressible with <code class="language-plaintext highlighter-rouge">--quiet</code>). If a future version moved the verify result from stderr to stdout, every script that captures <code class="language-plaintext highlighter-rouge">verify</code> output would break. This is why stream placement is documented, tested, and governed by SemVer.</p>

<h3 id="the-machine-readable-contract">The Machine-Readable Contract</h3>

<p>The ABI is documented in two forms. <code class="language-plaintext highlighter-rouge">ABI.md</code> is the human-readable specification. <code class="language-plaintext highlighter-rouge">abi_manifest.json</code> is the machine-readable contract:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"abi_version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1.0.0"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"tool"</span><span class="p">:</span><span class="w"> </span><span class="s2">"jcs-canon"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"commands"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"canonicalize"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"stable"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
      </span><span class="nl">"flags"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"--quiet"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"short"</span><span class="p">:</span><span class="w"> </span><span class="s2">"-q"</span><span class="p">,</span><span class="w"> </span><span class="nl">"stable"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">},</span><span class="w">
        </span><span class="nl">"--help"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"short"</span><span class="p">:</span><span class="w"> </span><span class="s2">"-h"</span><span class="p">,</span><span class="w"> </span><span class="nl">"stable"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">}</span><span class="w">
      </span><span class="p">},</span><span class="w">
      </span><span class="nl">"stdout"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Canonical JSON bytes (on success)"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"stderr"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Error diagnostics (on failure)"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"exit_codes"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="mi">0</span><span class="p">,</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="mi">10</span><span class="p">]</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"verify"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"stable"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
      </span><span class="nl">"flags"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"--quiet"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"short"</span><span class="p">:</span><span class="w"> </span><span class="s2">"-q"</span><span class="p">,</span><span class="w"> </span><span class="nl">"stable"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">},</span><span class="w">
        </span><span class="nl">"--help"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"short"</span><span class="p">:</span><span class="w"> </span><span class="s2">"-h"</span><span class="p">,</span><span class="w"> </span><span class="nl">"stable"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">}</span><span class="w">
      </span><span class="p">},</span><span class="w">
      </span><span class="nl">"stdout"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Empty (verify never writes to stdout)"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"stderr"</span><span class="p">:</span><span class="w"> </span><span class="s2">"'ok</span><span class="se">\\</span><span class="s2">n' on success (unless --quiet), error diagnostics on failure"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"exit_codes"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="mi">0</span><span class="p">,</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="mi">10</span><span class="p">]</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"exit_codes"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"0"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"class"</span><span class="p">:</span><span class="w"> </span><span class="s2">"SUCCESS"</span><span class="p">},</span><span class="w">
    </span><span class="nl">"2"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"class"</span><span class="p">:</span><span class="w"> </span><span class="s2">"INPUT_REJECTION"</span><span class="p">},</span><span class="w">
    </span><span class="nl">"10"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"class"</span><span class="p">:</span><span class="w"> </span><span class="s2">"INTERNAL_ERROR"</span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"failure_classes"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"INVALID_UTF8"</span><span class="p">,</span><span class="w"> </span><span class="nl">"exit_code"</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"INVALID_GRAMMAR"</span><span class="p">,</span><span class="w"> </span><span class="nl">"exit_code"</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"DUPLICATE_KEY"</span><span class="p">,</span><span class="w"> </span><span class="nl">"exit_code"</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="p">},</span><span class="w">
    </span><span class="err">...</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"compatibility"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"policy"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Strict SemVer. Any change to items marked 'stable: true' requires major version bump."</span><span class="p">,</span><span class="w">
    </span><span class="nl">"stderr_wording"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Non-stable. Diagnostic message text may change in minor/patch releases."</span><span class="p">,</span><span class="w">
    </span><span class="nl">"exit_code_mapping"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Stable. Failure class → exit code mapping is frozen."</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>This file serves two purposes. First, it’s a test fixture: CI validates that the tool’s actual behavior matches the manifest’s claims. Second, it’s a communication device for downstream consumers. A script author can read the manifest to understand what’s stable, what might change, and what exit codes to expect, without parsing prose documentation.</p>

<h3 id="why-a-manifest-instead-of-just-documentation">Why a Manifest Instead of Just Documentation</h3>

<p>A reasonable question: why maintain both <code class="language-plaintext highlighter-rouge">ABI.md</code> (human-readable) and <code class="language-plaintext highlighter-rouge">abi_manifest.json</code> (machine-readable)? Isn’t the documentation sufficient?</p>

<p>Documentation drifts. When a developer adds a flag, they update the code and the tests, but they may forget to update the ABI documentation. The manifest is testable: CI can validate that the tool’s actual behavior matches the manifest’s claims. A test that parses <code class="language-plaintext highlighter-rouge">abi_manifest.json</code> and checks that <code class="language-plaintext highlighter-rouge">jcs-canon verify --help</code> exits 0, that <code class="language-plaintext highlighter-rouge">jcs-canon verify</code> produces <code class="language-plaintext highlighter-rouge">ok\n</code> on stderr for valid canonical input, and that error output goes to stderr, will catch ABI drift that documentation review would miss.</p>

<p>The manifest also serves as a communication device. A downstream consumer can <code class="language-plaintext highlighter-rouge">curl</code> the manifest from a release and programmatically determine what the stable surface looks like. This is more reliable than scraping a README.</p>

<h3 id="change-control">Change Control</h3>

<p>The SemVer rules are strict and non-negotiable:</p>

<ul>
  <li><strong>Patch releases</strong> (0.2.0 → 0.2.1): No behavior changes to stable surface items</li>
  <li><strong>Minor releases</strong> (0.2.x → 0.3.0): May add new commands or flags, but existing behavior is preserved</li>
  <li><strong>Major releases</strong> (0.x.x → 1.0.0): May change anything, with migration guidance</li>
</ul>

<p>Any ABI-impacting change must update <em>four artifacts in the same commit series</em>: the implementation, <code class="language-plaintext highlighter-rouge">abi_manifest.json</code>, test assertions, and <code class="language-plaintext highlighter-rouge">CHANGELOG.md</code>. This co-update requirement prevents the manifest from drifting out of sync with the code. A test that validates the manifest against actual behavior catches drift that documentation alone would miss.</p>

<h2 id="the-compound-effect">The Compound Effect</h2>

<p>Each of these decisions is individually minor. None of them requires novel engineering. None of them is exciting. A project that omits all three still works. It parses JSON, it serializes JSON, it runs from the command line.</p>

<p>But the project that includes all three communicates something different to its consumers. It says: “We have thought about the cases you will encounter. The sort order is correct, not just close. The exit codes mean what we say they mean, and we won’t change them without a major version. The error you get for a missing file is classified by root cause, not by which system call failed.”</p>

<p>Infrastructure trust is not earned by one big decision. It’s earned by consistent attention to the decisions that are easy to defer and hard to fix later. The sort order that’s wrong by one comparison. The exit code that changes in a patch release. The error message that scripts parse because there’s no stable classification.</p>

<p>These are the margins where infrastructure fails.</p>

<p>Consider the alternative. A project that uses UTF-8 byte-order sorting passes 99.9% of real-world key comparisons correctly; supplementary-plane characters in JSON keys are rare. But the 0.1% failure is a silent correctness bug, not a crash. The canonical output is wrong for those inputs, and the consumer has no way to detect it without an independent reference implementation. This is the kind of bug that ships, is discovered months later, and then can’t be fixed without a breaking change because existing consumers depend on the (wrong) output.</p>

<p>A project with unstructured exit codes works fine for human use. But the first time someone writes a CI pipeline that retries on exit 1, they’ll retry on parse errors (which will never succeed) and on I/O errors (which might). Distinguishing these requires parsing stderr text, which breaks when the message wording changes in a patch release.</p>

<p>A project without an ABI manifest accumulates undocumented behavioral changes. Each one seems minor: renaming a flag, changing where help text goes, adding a new exit code. But each one is a potential breaking change for a downstream consumer who discovered the behavior empirically and depended on it.</p>

<p>Getting these details right is not the interesting part of the engineering. But it’s the part that determines whether your downstream can depend on you.</p>

<p>The implementation discussed here is from <a href="https://github.com/lattice-substrate/json-canon">json-canon</a>, an RFC 8785 JSON canonicalization library written in Go.</p>

<h2 id="revision-history">Revision History</h2>

<table>
  <thead>
    <tr>
      <th>Date</th>
      <th>Change</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2026-03-04</td>
      <td>Corrected U+10000 character name (LINEAR B SYLLABLE B008 A); clarified BMP boundary phrasing</td>
    </tr>
    <tr>
      <td>2026-03-03</td>
      <td>Renamed article; revised title, opening, and series name</td>
    </tr>
    <tr>
      <td>2026-03-03</td>
      <td>Removed line-count metrics from compound-effect section; updated <code class="language-plaintext highlighter-rouge">serializeObject</code> and sort comparator snippets to reflect ASCII fast-path optimization</td>
    </tr>
    <tr>
      <td>2026-02-25</td>
      <td>Initial publication</td>
    </tr>
  </tbody>
</table>]]></content><author><name>Mark Lenhardt</name></author><category term="go" /><category term="architecture" /><category term="engineering" /><summary type="html"><![CDATA[UTF-16 code-unit key sorting, a thirteen-class failure taxonomy with stable exit codes, and machine-readable ABI contracts. The small decisions that determine whether downstream systems can depend on you.]]></summary></entry><entry><title type="html">Proving Determinism: Evidence-Based Release Engineering</title><link href="https://lattice-substrate.github.io/blog/2026/02/24/proving-determinism-evidence-release/" rel="alternate" type="text/html" title="Proving Determinism: Evidence-Based Release Engineering" /><published>2026-02-24T00:00:00+00:00</published><updated>2026-02-24T00:00:00+00:00</updated><id>https://lattice-substrate.github.io/blog/2026/02/24/proving-determinism-evidence-release</id><content type="html" xml:base="https://lattice-substrate.github.io/blog/2026/02/24/proving-determinism-evidence-release/"><![CDATA[<p>Any project can claim deterministic output. “Our tests pass” is not a determinism proof. It’s a confidence signal from one machine, one OS, one kernel, at one point in time.</p>

<p>For a JSON canonicalization library, determinism is the product. If the same input produces different bytes on Ubuntu vs Alpine, on x86_64 vs arm64, or on the third run vs the first run, the tool is broken, regardless of what the test suite says.</p>

<p>This article describes how to build an offline replay harness that produces <em>executable evidence</em> of determinism across distributions, kernels, and CPU architectures, and how to gate releases on that evidence. The approach is transferable to any project where output stability matters.</p>

<h2 id="the-problem-it-works-on-my-machine">The Problem: “It Works on My Machine”</h2>

<p>Unit tests prove that functions return correct values. Integration tests prove that components work together. Neither proves that the tool produces <em>identical output</em> across environments.</p>

<p>Consider what can differ between two Linux machines running the same Go binary:</p>

<ul>
  <li><strong>C library</strong>: glibc (Ubuntu, Debian, Fedora) vs musl (Alpine). Go is statically compiled, so this shouldn’t matter, but “shouldn’t” is not proof.</li>
  <li><strong>Kernel version</strong>: syscall behavior, filesystem semantics, memory layout. Go’s runtime abstracts these, but abstractions can leak.</li>
  <li><strong>CPU architecture</strong>: x86_64 vs arm64. Floating-point rounding, SIMD optimizations, and endianness. Go generates architecture-specific code.</li>
  <li><strong>Runtime initialization</strong>: Go’s runtime performs memory allocation, goroutine scheduling, and GC initialization. Any of these could influence program behavior if the code contains latent non-determinism.</li>
</ul>

<p>For a canonicalization tool, any of these differences producing a different output byte is a correctness failure. You can’t prove the absence of these failures by testing on one machine. You need to test on <em>many</em> machines and compare the results.</p>

<h2 id="what-evidence-looks-like">What Evidence Looks Like</h2>

<p>The harness runs the tool against a fixed set of inputs on multiple nodes: different Linux distributions, container and VM execution modes, x86_64 and arm64. Each node runs the tool multiple times. Every run records SHA-256 digests of its output. At the end, a validation function checks that <em>every digest matches</em>.</p>

<p>The output is a JSON evidence bundle:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"schema_version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"evidence.v1"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"bundle_sha256"</span><span class="p">:</span><span class="w"> </span><span class="s2">"5654748feaa65318..."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"control_binary_sha256"</span><span class="p">:</span><span class="w"> </span><span class="s2">"e0296e034d1440a4..."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"source_git_commit"</span><span class="p">:</span><span class="w"> </span><span class="s2">"da4a4ee6fcefc4f4..."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"source_git_tag"</span><span class="p">:</span><span class="w"> </span><span class="s2">"v0.2.1"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"architecture"</span><span class="p">:</span><span class="w"> </span><span class="s2">"x86_64"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"hard_release_gate"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"node_replays"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"node_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"alpine320-container"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"distro"</span><span class="p">:</span><span class="w"> </span><span class="s2">"alpine-3.20"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"replay_index"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
      </span><span class="nl">"case_count"</span><span class="p">:</span><span class="w"> </span><span class="mi">74</span><span class="p">,</span><span class="w">
      </span><span class="nl">"passed"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
      </span><span class="nl">"canonical_sha256"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2818166c21e1b445..."</span><span class="p">,</span><span class="w">
      </span><span class="nl">"verify_sha256"</span><span class="p">:</span><span class="w"> </span><span class="s2">"66d329b3bd829da5..."</span><span class="p">,</span><span class="w">
      </span><span class="nl">"failure_class_sha256"</span><span class="p">:</span><span class="w"> </span><span class="s2">"af58643f979138da..."</span><span class="p">,</span><span class="w">
      </span><span class="nl">"exit_code_sha256"</span><span class="p">:</span><span class="w"> </span><span class="s2">"73d91ef3f2fd6d70..."</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"aggregate_canonical_sha256"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2818166c21e1b445..."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"aggregate_verify_sha256"</span><span class="p">:</span><span class="w"> </span><span class="s2">"66d329b3bd829da5..."</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>When all 60 replays (12 nodes times 5 replays each) produce identical digests, the evidence shows that canonical output is byte-stable <strong>for that specific binary across the tested matrix</strong>.</p>

<h2 id="proof-boundary-what-this-evidence-proves">Proof Boundary: What This Evidence Proves</h2>

<p>This matters for rigor. The evidence bundle does <strong>not</strong> claim:</p>

<ul>
  <li>“all possible builds are deterministic,”</li>
  <li>“all Linux environments are covered,”</li>
  <li>or “future toolchains will preserve behavior automatically.”</li>
</ul>

<p>It does claim:</p>

<ul>
  <li>this exact control binary (by SHA-256),</li>
  <li>built from this exact source commit,</li>
  <li>produced identical output across this explicit replay matrix and replay count.</li>
</ul>

<p>That distinction is the boundary between engineering evidence and overclaiming. Determinism is established for a concrete artifact under a declared environment envelope.</p>

<h2 id="test-bundles-immutable-input-packages">Test Bundles: Immutable Input Packages</h2>

<p>The first requirement is <em>immutable inputs</em>. If the test data can change between runs, comparing digests is meaningless. The harness packages all inputs into a tar archive with properties that eliminate environmental variation.</p>

<h3 id="fixed-timestamps-and-ownership">Fixed Timestamps and Ownership</h3>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">writeBundleTarGz</span><span class="p">(</span><span class="n">path</span> <span class="kt">string</span><span class="p">,</span> <span class="n">entries</span> <span class="p">[]</span><span class="n">bundleEntry</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
    <span class="c">// ...</span>
    <span class="n">fixed</span> <span class="o">:=</span> <span class="n">time</span><span class="o">.</span><span class="n">Unix</span><span class="p">(</span><span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">)</span><span class="o">.</span><span class="n">UTC</span><span class="p">()</span>
    <span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">e</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">entries</span> <span class="p">{</span>
        <span class="n">hdr</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="n">tar</span><span class="o">.</span><span class="n">Header</span><span class="p">{</span>
            <span class="n">Name</span><span class="o">:</span>    <span class="n">e</span><span class="o">.</span><span class="n">path</span><span class="p">,</span>
            <span class="n">Mode</span><span class="o">:</span>    <span class="n">e</span><span class="o">.</span><span class="n">mode</span><span class="p">,</span>
            <span class="n">Size</span><span class="o">:</span>    <span class="kt">int64</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">e</span><span class="o">.</span><span class="n">data</span><span class="p">)),</span>
            <span class="n">ModTime</span><span class="o">:</span> <span class="n">fixed</span><span class="p">,</span>   <span class="c">// Unix epoch: 1970-01-01T00:00:00Z</span>
            <span class="n">Uid</span><span class="o">:</span>     <span class="m">0</span><span class="p">,</span>       <span class="c">// root</span>
            <span class="n">Gid</span><span class="o">:</span>     <span class="m">0</span><span class="p">,</span>       <span class="c">// root</span>
            <span class="n">Uname</span><span class="o">:</span>   <span class="s">"root"</span><span class="p">,</span>
            <span class="n">Gname</span><span class="o">:</span>   <span class="s">"root"</span><span class="p">,</span>
        <span class="p">}</span>
        <span class="n">tw</span><span class="o">.</span><span class="n">WriteHeader</span><span class="p">(</span><span class="n">hdr</span><span class="p">)</span>
        <span class="n">tw</span><span class="o">.</span><span class="n">Write</span><span class="p">(</span><span class="n">e</span><span class="o">.</span><span class="n">data</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Every entry in the tar archive has its modification time set to the Unix epoch, its ownership set to root:root, and its permissions set by the code rather than the filesystem. This means the archive is byte-identical regardless of when, where, or by whom it was created.</p>

<h3 id="sorted-entries">Sorted Entries</h3>

<p>Tar archives are ordered. If entries are added in filesystem order, the archive depends on the filesystem’s iteration behavior, which can vary between ext4 and xfs, between Linux kernel versions, and between NFS and local disk. The harness sorts entries lexicographically before writing:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">sort</span><span class="o">.</span><span class="n">Slice</span><span class="p">(</span><span class="n">entries</span><span class="p">,</span> <span class="k">func</span><span class="p">(</span><span class="n">i</span><span class="p">,</span> <span class="n">j</span> <span class="kt">int</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
    <span class="k">return</span> <span class="n">entries</span><span class="p">[</span><span class="n">i</span><span class="p">]</span><span class="o">.</span><span class="n">path</span> <span class="o">&lt;</span> <span class="n">entries</span><span class="p">[</span><span class="n">j</span><span class="p">]</span><span class="o">.</span><span class="n">path</span>
<span class="p">})</span>
</code></pre></div></div>

<h3 id="sha-256-binding">SHA-256 Binding</h3>

<p>The bundle manifest records SHA-256 checksums for every component:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">BundleManifest</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">Version</span>         <span class="kt">string</span>            <span class="s">`json:"version"`</span>
    <span class="n">BinaryPath</span>      <span class="kt">string</span>            <span class="s">`json:"binary_path"`</span>
    <span class="n">BinarySHA256</span>    <span class="kt">string</span>            <span class="s">`json:"binary_sha256"`</span>
    <span class="n">WorkerPath</span>      <span class="kt">string</span>            <span class="s">`json:"worker_path"`</span>
    <span class="n">WorkerSHA256</span>    <span class="kt">string</span>            <span class="s">`json:"worker_sha256"`</span>
    <span class="n">MatrixPath</span>      <span class="kt">string</span>            <span class="s">`json:"matrix_path"`</span>
    <span class="n">MatrixSHA256</span>    <span class="kt">string</span>            <span class="s">`json:"matrix_sha256"`</span>
    <span class="n">ProfilePath</span>     <span class="kt">string</span>            <span class="s">`json:"profile_path"`</span>
    <span class="n">ProfileSHA256</span>   <span class="kt">string</span>            <span class="s">`json:"profile_sha256"`</span>
    <span class="n">VectorFiles</span>     <span class="p">[]</span><span class="kt">string</span>          <span class="s">`json:"vector_files"`</span>
    <span class="n">VectorSHA256</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="s">`json:"vector_sha256"`</span>
    <span class="n">VectorSetSHA256</span> <span class="kt">string</span>            <span class="s">`json:"vector_set_sha256"`</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The binary, worker, matrix, profile, and each vector file have independent checksums. The <code class="language-plaintext highlighter-rouge">VectorSetSHA256</code> is a digest of all vector file checksums combined (sorted by path), so changing any single vector file changes the set digest.</p>

<p>The bundle archive itself also gets a SHA-256 checksum, which the evidence bundle records. This creates a chain: the evidence references the bundle by digest, the bundle references each component by digest, and the release gate validates all of these against the actual files on disk.</p>

<p>This chain has the same integrity property as certificate chains: modifying any component invalidates every layer above it. If someone edits a single vector file, its SHA-256 changes, which changes the vector set SHA-256, which changes the bundle manifest, which changes the bundle archive SHA-256, which no longer matches the evidence bundle’s recorded value. The release gate catches this at the top of the chain without needing to know <em>which</em> component was modified.</p>

<h2 id="the-replay-matrix-defining-across-environments">The Replay Matrix: Defining “Across Environments”</h2>

<p>A matrix defines the nodes that must be tested:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"architecture"</span><span class="p">:</span><span class="w"> </span><span class="s2">"x86_64"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"nodes"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"debian12-container"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"mode"</span><span class="p">:</span><span class="w"> </span><span class="s2">"container"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"distro"</span><span class="p">:</span><span class="w"> </span><span class="s2">"debian-12"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"kernel_family"</span><span class="p">:</span><span class="w"> </span><span class="s2">"host"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"replays"</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"alpine320-container"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"mode"</span><span class="p">:</span><span class="w"> </span><span class="s2">"container"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"distro"</span><span class="p">:</span><span class="w"> </span><span class="s2">"alpine-3.20"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"kernel_family"</span><span class="p">:</span><span class="w"> </span><span class="s2">"host"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"replays"</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"debian12-vm"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"mode"</span><span class="p">:</span><span class="w"> </span><span class="s2">"vm"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"distro"</span><span class="p">:</span><span class="w"> </span><span class="s2">"debian-12"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"kernel_family"</span><span class="p">:</span><span class="w"> </span><span class="s2">"distro"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"replays"</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Each node has a mode (container or VM), a distribution, a kernel family, and a replay count. Container nodes share the host kernel but differ in userspace (glibc vs musl, different library versions). VM nodes run their own kernels.</p>

<p>The distinction matters: container-mode tests prove that userspace differences don’t affect output. VM-mode tests prove that kernel differences don’t affect output. Together, they cover the two main sources of environmental variation on Linux.</p>

<p>A typical x86_64 matrix includes 12 nodes: 6 container lanes (Debian 12, Ubuntu 22.04, Fedora 40, Rocky 9, Alpine 3.20, openSUSE) and 6 VM lanes (Debian, Fedora, Rocky, Ubuntu with GA kernel, Ubuntu with HWE kernel, and a legacy LTS kernel). Each runs 5 replays, for a total of 60 independent executions.</p>

<h3 id="environment-pinning-and-reproducibility-boundaries">Environment Pinning and Reproducibility Boundaries</h3>

<p>Matrix declarations define <em>which lanes</em> must run. Reproducibility additionally depends on how those lanes are provisioned over time. If a lane references a moving base image tag, the lane identity remains the same while underlying bits may drift.</p>

<p>For peer-review-grade replay reproducibility, pin lane substrates as immutable artifacts (for example: container image digests and checksummed VM base images/snapshots) and record those identifiers alongside evidence. Without that, evidence still proves parity for the observed run, but re-running months later may exercise different substrate bits.</p>

<p>A profile defines the policy for what constitutes a valid evidence bundle:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"maximal-offline-linux-x86_64"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"required_suites"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="s2">"canonical-byte-stability"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"verify-parity"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"failure-class-parity"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"bounds-limit-parity"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"binary-identity"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"env-independence"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"evidence-completeness"</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"min_cold_replays"</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span><span class="p">,</span><span class="w">
  </span><span class="nl">"hard_release_gate"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"evidence_required"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"v1"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The profile enforces that every required node runs at least 5 times, that the evidence includes all required test suites, and that the evidence is a hard release gate (not advisory).</p>

<h2 id="evidence-capture-what-to-record">Evidence Capture: What to Record</h2>

<p>Each worker node runs the tool against every test vector and accumulates four independent digest streams:</p>

<ol>
  <li><strong>Canonical digest</strong>: SHA-256 of all canonical output bytes, concatenated with vector IDs</li>
  <li><strong>Verify digest</strong>: SHA-256 of verify mode results (exit code, stdout, stderr per vector)</li>
  <li><strong>Failure class digest</strong>: SHA-256 of failure class tokens (“OK” or the class name) per vector</li>
  <li><strong>Exit code digest</strong>: SHA-256 of numeric exit codes per vector</li>
</ol>

<p>These four streams capture different properties. The canonical digest proves byte-identical output. The verify digest proves that the verify command agrees. The failure class digest proves that error classification is stable. The exit code digest proves that the process-level interface is stable.</p>

<p>The digest accumulation works by concatenating structured records with delimiters, then computing a single SHA-256 over the entire stream. Each record includes the vector ID and the relevant output, separated by a unit separator (0x1F) and terminated by a newline. This produces a deterministic input to SHA-256 regardless of record ordering (vectors are processed in sorted order) or platform-specific line ending behavior.</p>

<p>Separating the four digest streams matters for diagnostics. If the canonical digest matches but the failure class digest doesn’t, the tool is producing the same output but classifying errors differently, which could indicate a failure taxonomy change that wasn’t intentional. If the exit code digest matches but the verify digest doesn’t, the tool exits correctly but produces different stderr text, which is acceptable if the stderr change is non-stable, but should be investigated.</p>

<h3 id="source-binding">Source Binding</h3>

<p>The evidence bundle records the exact source state:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"source_git_commit"</span><span class="p">:</span><span class="w"> </span><span class="s2">"da4a4ee6fcefc4f43777c76e5235d824d249807c"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"source_git_tag"</span><span class="p">:</span><span class="w"> </span><span class="s2">"v0.2.1"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"control_binary_sha256"</span><span class="p">:</span><span class="w"> </span><span class="s2">"e0296e034d1440a4aad2a3620e5663d749c2007f..."</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The git commit SHA pins the source code. The binary SHA-256 pins the compiled artifact. The git tag identifies the release. Together, these create an audit trail from evidence back to source code, with no ambiguity about which code produced the evidence.</p>

<h2 id="validation-logic-detecting-drift">Validation Logic: Detecting Drift</h2>

<p>The validation function implements a simple invariant: all replays must produce identical digests.</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">ValidateEvidenceBundle</span><span class="p">(</span><span class="n">e</span> <span class="o">*</span><span class="n">EvidenceBundle</span><span class="p">,</span> <span class="n">m</span> <span class="o">*</span><span class="n">Matrix</span><span class="p">,</span> <span class="n">p</span> <span class="o">*</span><span class="n">Profile</span><span class="p">,</span>
    <span class="n">opts</span> <span class="n">EvidenceValidationOptions</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>

    <span class="c">// ... schema version, profile match, SHA-256 format checks ...</span>

    <span class="k">var</span> <span class="n">baseline</span> <span class="o">*</span><span class="n">NodeRunEvidence</span>
    <span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">id</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">requiredNodes</span> <span class="p">{</span>
        <span class="n">runs</span> <span class="o">:=</span> <span class="n">byNode</span><span class="p">[</span><span class="n">id</span><span class="p">]</span>
        <span class="n">wantReplays</span> <span class="o">:=</span> <span class="n">requiredReplayCount</span><span class="p">(</span><span class="n">matrixByID</span><span class="p">[</span><span class="n">id</span><span class="p">],</span> <span class="n">p</span><span class="p">)</span>
        <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">runs</span><span class="p">)</span> <span class="o">&lt;</span> <span class="n">wantReplays</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">"node %s has %d replays, want at least %d"</span><span class="p">,</span>
                <span class="n">id</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">runs</span><span class="p">),</span> <span class="n">wantReplays</span><span class="p">)</span>
        <span class="p">}</span>

        <span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">run</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">runs</span> <span class="p">{</span>
            <span class="k">if</span> <span class="n">baseline</span> <span class="o">==</span> <span class="no">nil</span> <span class="p">{</span>
                <span class="n">r</span> <span class="o">:=</span> <span class="n">run</span>
                <span class="n">baseline</span> <span class="o">=</span> <span class="o">&amp;</span><span class="n">r</span>
                <span class="k">continue</span>
            <span class="p">}</span>
            <span class="k">if</span> <span class="n">run</span><span class="o">.</span><span class="n">CanonicalSHA256</span> <span class="o">!=</span> <span class="n">baseline</span><span class="o">.</span><span class="n">CanonicalSHA256</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">"canonical digest drift at node %s replay %d"</span><span class="p">,</span>
                    <span class="n">run</span><span class="o">.</span><span class="n">NodeID</span><span class="p">,</span> <span class="n">run</span><span class="o">.</span><span class="n">ReplayIndex</span><span class="p">)</span>
            <span class="p">}</span>
            <span class="k">if</span> <span class="n">run</span><span class="o">.</span><span class="n">VerifySHA256</span> <span class="o">!=</span> <span class="n">baseline</span><span class="o">.</span><span class="n">VerifySHA256</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">"verify digest drift at node %s replay %d"</span><span class="p">,</span>
                    <span class="n">run</span><span class="o">.</span><span class="n">NodeID</span><span class="p">,</span> <span class="n">run</span><span class="o">.</span><span class="n">ReplayIndex</span><span class="p">)</span>
            <span class="p">}</span>
            <span class="k">if</span> <span class="n">run</span><span class="o">.</span><span class="n">FailureClassSHA256</span> <span class="o">!=</span> <span class="n">baseline</span><span class="o">.</span><span class="n">FailureClassSHA256</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">"failure-class digest drift at node %s replay %d"</span><span class="p">,</span>
                    <span class="n">run</span><span class="o">.</span><span class="n">NodeID</span><span class="p">,</span> <span class="n">run</span><span class="o">.</span><span class="n">ReplayIndex</span><span class="p">)</span>
            <span class="p">}</span>
            <span class="k">if</span> <span class="n">run</span><span class="o">.</span><span class="n">ExitCodeSHA256</span> <span class="o">!=</span> <span class="n">baseline</span><span class="o">.</span><span class="n">ExitCodeSHA256</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">"exit-code digest drift at node %s replay %d"</span><span class="p">,</span>
                    <span class="n">run</span><span class="o">.</span><span class="n">NodeID</span><span class="p">,</span> <span class="n">run</span><span class="o">.</span><span class="n">ReplayIndex</span><span class="p">)</span>
            <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="c">// Aggregate digests must match baseline</span>
    <span class="k">if</span> <span class="n">e</span><span class="o">.</span><span class="n">AggregateCanonical</span> <span class="o">!=</span> <span class="n">baseline</span><span class="o">.</span><span class="n">CanonicalSHA256</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">"aggregate canonical digest mismatch"</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="c">// ... verify, failure-class, exit-code aggregates ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The first replay becomes the baseline. Every subsequent replay (across all nodes, all distributions, all execution modes) is compared against this baseline. Any divergence is an immediate failure with a message identifying the exact node and replay index where drift was detected.</p>

<p>The aggregate digests provide a summary check: four SHA-256 values that represent the behavior of the entire run. If the aggregates match the baseline’s per-node digests, all nodes agreed.</p>

<h3 id="what-the-validation-checks">What the Validation Checks</h3>

<p>In addition to digest parity, the validation function enforces:</p>

<ul>
  <li><strong>Schema version</strong>: Must be <code class="language-plaintext highlighter-rouge">evidence.v1</code> (enables future schema evolution)</li>
  <li><strong>Profile match</strong>: Evidence profile name must match the policy profile</li>
  <li><strong>SHA-256 format</strong>: All digest fields must be exactly 64 hex characters</li>
  <li><strong>Git commit format</strong>: Must be exactly 40 hex characters</li>
  <li><strong>Architecture match</strong>: Evidence architecture must match the matrix</li>
  <li><strong>Artifact binding</strong>: Bundle, binary, matrix, and profile SHA-256s must match the actual files</li>
  <li><strong>Replay coverage</strong>: Every required node must have at least the minimum replay count</li>
  <li><strong>Replay contiguity</strong>: Replay indices must be 1, 2, 3, …, N (no gaps)</li>
  <li><strong>Pass status</strong>: Every replay must be marked <code class="language-plaintext highlighter-rouge">passed: true</code></li>
  <li><strong>Suite coverage</strong>: Required test suites must match the profile exactly</li>
</ul>

<h2 id="release-gating-the-go-test-that-says-no">Release Gating: The Go Test That Says No</h2>

<p>The release gate is a standard Go test function. It loads the evidence bundle, the matrix, and the profile, then calls the validation function with expected SHA-256 values computed fresh from the actual artifacts:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">TestOfflineReplayEvidenceReleaseGate</span><span class="p">(</span><span class="n">t</span> <span class="o">*</span><span class="n">testing</span><span class="o">.</span><span class="n">T</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">evidencePath</span> <span class="o">:=</span> <span class="n">os</span><span class="o">.</span><span class="n">Getenv</span><span class="p">(</span><span class="s">"JCS_OFFLINE_EVIDENCE"</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">evidencePath</span> <span class="o">==</span> <span class="s">""</span> <span class="p">{</span>
        <span class="n">t</span><span class="o">.</span><span class="n">Skip</span><span class="p">(</span><span class="s">"set JCS_OFFLINE_EVIDENCE to validate offline evidence bundle"</span><span class="p">)</span>
    <span class="p">}</span>

    <span class="n">bundlePath</span> <span class="o">:=</span> <span class="n">os</span><span class="o">.</span><span class="n">Getenv</span><span class="p">(</span><span class="s">"JCS_OFFLINE_BUNDLE"</span><span class="p">)</span>
    <span class="n">controlBinaryPath</span> <span class="o">:=</span> <span class="n">os</span><span class="o">.</span><span class="n">Getenv</span><span class="p">(</span><span class="s">"JCS_OFFLINE_CONTROL_BINARY"</span><span class="p">)</span>
    <span class="c">// ... load matrix, profile, evidence ...</span>

    <span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">replay</span><span class="o">.</span><span class="n">ValidateEvidenceBundle</span><span class="p">(</span><span class="n">evidence</span><span class="p">,</span> <span class="n">matrix</span><span class="p">,</span> <span class="n">profile</span><span class="p">,</span>
        <span class="n">replay</span><span class="o">.</span><span class="n">EvidenceValidationOptions</span><span class="p">{</span>
            <span class="n">ExpectedBundleSHA256</span><span class="o">:</span>        <span class="n">mustFileSHA256</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">bundlePath</span><span class="p">),</span>
            <span class="n">ExpectedControlBinarySHA256</span><span class="o">:</span> <span class="n">mustFileSHA256</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">controlBinaryPath</span><span class="p">),</span>
            <span class="n">ExpectedMatrixSHA256</span><span class="o">:</span>        <span class="n">mustFileSHA256</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">matrixPath</span><span class="p">),</span>
            <span class="n">ExpectedProfileSHA256</span><span class="o">:</span>       <span class="n">mustFileSHA256</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">profilePath</span><span class="p">),</span>
            <span class="n">ExpectedArchitecture</span><span class="o">:</span>        <span class="n">matrix</span><span class="o">.</span><span class="n">Architecture</span><span class="p">,</span>
            <span class="n">ExpectedSourceGitCommit</span><span class="o">:</span>     <span class="n">os</span><span class="o">.</span><span class="n">Getenv</span><span class="p">(</span><span class="s">"JCS_OFFLINE_EXPECTED_GIT_COMMIT"</span><span class="p">),</span>
            <span class="n">ExpectedSourceGitTag</span><span class="o">:</span>        <span class="n">os</span><span class="o">.</span><span class="n">Getenv</span><span class="p">(</span><span class="s">"JCS_OFFLINE_EXPECTED_GIT_TAG"</span><span class="p">),</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="n">t</span><span class="o">.</span><span class="n">Fatalf</span><span class="p">(</span><span class="s">"offline evidence gate failed: %v"</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The test is gated by an environment variable. In normal development, it’s skipped. During release, CI sets the variable and the test becomes a hard gate. The SHA-256 values are computed fresh from the files on disk; they’re not hardcoded. This means the test validates that the evidence bundle references the <em>actual</em> artifacts being released, not some previously valid set.</p>

<h3 id="environment-variable-binding">Environment Variable Binding</h3>

<p>The release gate uses seven environment variables:</p>

<table>
  <thead>
    <tr>
      <th>Variable</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">JCS_OFFLINE_EVIDENCE</code></td>
      <td>Path to the evidence JSON file</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">JCS_OFFLINE_BUNDLE</code></td>
      <td>Path to the tar bundle</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">JCS_OFFLINE_CONTROL_BINARY</code></td>
      <td>Path to the release binary</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">JCS_OFFLINE_MATRIX</code></td>
      <td>Path to the matrix JSON file</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">JCS_OFFLINE_PROFILE</code></td>
      <td>Path to the profile JSON file</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">JCS_OFFLINE_EXPECTED_GIT_COMMIT</code></td>
      <td>Expected source commit SHA</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">JCS_OFFLINE_EXPECTED_GIT_TAG</code></td>
      <td>Expected release tag</td>
    </tr>
  </tbody>
</table>

<p>Most have sensible defaults: matrix and profile default to the repository’s standard files; bundle and control binary paths are derived from the evidence path’s directory. The evidence path has no default. It must be explicitly provided, which prevents accidental release without evidence.</p>

<p>The design as a <code class="language-plaintext highlighter-rouge">go test</code> function (rather than a standalone script) is intentional. It integrates with Go’s standard testing infrastructure: <code class="language-plaintext highlighter-rouge">go test -v</code> shows progress, <code class="language-plaintext highlighter-rouge">-run</code> selects specific gates, <code class="language-plaintext highlighter-rouge">-count=1</code> disables caching. The test is part of the same codebase as the tool it validates, which means the validation logic is versioned alongside the evidence schema. And because it’s a Go test, it can import the same <code class="language-plaintext highlighter-rouge">replay</code> package that generates the evidence, ensuring the validation code and generation code share type definitions.</p>

<p>The <code class="language-plaintext highlighter-rouge">t.Skip</code> pattern (skip when the environment variable is absent, fail when it’s present but the evidence is invalid) means the gate is silent during normal development and enforced during release. Developers running <code class="language-plaintext highlighter-rouge">go test ./...</code> don’t see the offline gate. The release pipeline, which sets the environment variables, does.</p>

<h2 id="schema-versioning">Schema Versioning</h2>

<p>The evidence schema is versioned independently from the tool version. The current schema is <code class="language-plaintext highlighter-rouge">evidence.v1</code>. The validation function checks the schema version as its first action:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">e</span><span class="o">.</span><span class="n">SchemaVersion</span> <span class="o">!=</span> <span class="n">EvidenceSchemaVersion</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">"unsupported schema_version %q"</span><span class="p">,</span> <span class="n">e</span><span class="o">.</span><span class="n">SchemaVersion</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This enables schema evolution without invalidating existing evidence. A future <code class="language-plaintext highlighter-rouge">evidence.v2</code> could add new fields (node CPU architecture, memory constraints, filesystem type) without breaking the validation of v1 evidence bundles. The validation function would branch on the schema version and apply the appropriate checks for each.</p>

<p>The schema is also defined as a JSON Schema file (<code class="language-plaintext highlighter-rouge">offline/schema/evidence.v1.json</code>), enabling validation by external tools. Any system that consumes evidence bundles can validate them against the schema independently of the Go validation code.</p>

<h2 id="cross-architecture-parity">Cross-Architecture Parity</h2>

<p>The same harness runs independently for x86_64 and arm64, each with its own matrix, profile, and evidence bundle. The CI pipeline runs both and validates both independently.</p>

<p>Cross-architecture parity is not <em>required</em> to match; the aggregate digests between x86_64 and arm64 are compared separately. This is a deliberate design choice. Go’s runtime, floating-point behavior, and standard library may produce different intermediate results on different architectures. What matters is that each architecture is <em>internally</em> consistent: all x86_64 nodes agree with each other, and all arm64 nodes agree with each other.</p>

<p>If cross-architecture aggregate digests <em>do</em> match (which they do in practice for this tool, since the implementation uses pure integer arithmetic and explicit formatting), that’s additional confidence. But the harness doesn’t make it a requirement, because mandating it would create false failures if a future Go release changed architecture-specific behavior in a standard library function.</p>

<h2 id="what-running-5-times-per-node-proves">What Running 5+ Times Per Node Proves</h2>

<p>The replay count (5 per node in the maximal profile) is not arbitrary. A single run proves the tool works. Multiple runs prove it works <em>deterministically</em>.</p>

<p>Non-determinism in software can come from several sources: uninitialized memory, map iteration order, concurrent goroutine scheduling, time-dependent behavior, or environment-sensitive code paths. A single run may happen to hit the “correct” ordering every time. Multiple cold runs (starting from a fresh process each time, with no warm caches) increase the probability of surfacing non-deterministic behavior.</p>

<p>Five cold replays is a pragmatic balance between coverage and execution time. Each replay runs the full vector suite from a fresh process invocation, exercising the tool’s startup path, parser initialization, and output formatting from scratch. If any of these paths contain non-deterministic behavior, five independent executions have a reasonable chance of producing divergent digests.</p>

<h2 id="why-this-matters-evidence-as-a-first-class-artifact">Why This Matters: Evidence as a First-Class Artifact</h2>

<p>Most release processes treat testing as a gate: tests pass, the release ships. The evidence is a test log: ephemeral, human-consumed, not structured for machine validation.</p>

<p>Evidence-based release engineering treats evidence as a <em>first-class artifact</em>: versioned, checksummed, machine-readable, and committed to the repository alongside the code it validates. The evidence for v0.2.1 is available at the same commit as the v0.2.1 source code. Anyone can re-validate the release gate by running a single <code class="language-plaintext highlighter-rouge">go test</code> command with the evidence path.</p>

<p>This approach has three practical benefits:</p>

<ol>
  <li>
    <p><strong>Auditability</strong>: The evidence bundle is a complete record of what was tested, on what environments, at what time, from what source. There’s no ambiguity about whether the tests actually ran or what they covered.</p>
  </li>
  <li>
    <p><strong>Reproducibility</strong>: The bundle contains everything needed to reproduce the test: the binary, the vectors, the matrix. Re-running the harness with the same bundle should produce the same evidence (modulo wall-clock timestamps).</p>
  </li>
  <li>
    <p><strong>Trust</strong>: The SHA-256 chain from evidence to bundle to binary to source code means each layer’s integrity is independently verifiable. Tampering with any component breaks the chain.</p>
  </li>
</ol>

<p>The cost is real: maintaining the harness, running replays across multiple environments, committing evidence bundles to the repository. The evidence bundle for a single architecture is approximately 1,000 lines of JSON. The bundle archive contains the test binary, worker binary, all vector files, the matrix, and the profile. Running the full matrix takes minutes, not seconds.</p>

<p>For most projects, this is overkill. A well-written test suite with good coverage provides sufficient confidence for application software. But “sufficient confidence” and “proof” are different claims. When your README says “byte-deterministic output,” the evidence bundle makes that claim auditable. Anyone can examine the evidence, verify the SHA-256 chain, and confirm that 60 independent executions across 12 environments produced identical output.</p>

<p>For infrastructure that downstream systems depend on for correctness (not just convenience), this is the minimum required to make the claim credible.</p>

<p>The implementation described here is from <a href="https://github.com/lattice-substrate/json-canon">json-canon</a>, an RFC 8785 JSON canonicalization library written in Go.</p>

<h2 id="revision-history">Revision History</h2>

<table>
  <thead>
    <tr>
      <th>Date</th>
      <th>Change</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2026-03-04</td>
      <td>Corrected configuration snippets from YAML to JSON (matching actual file format); fixed matrix <code class="language-plaintext highlighter-rouge">kernel_family</code> value; expanded defaults description to cover bundle and control binary derived paths</td>
    </tr>
    <tr>
      <td>2026-03-03</td>
      <td>Reviewed against documentation restructure; no substantive changes required</td>
    </tr>
    <tr>
      <td>2026-02-24</td>
      <td>Initial publication</td>
    </tr>
  </tbody>
</table>]]></content><author><name>Mark Lenhardt</name></author><category term="go" /><category term="devops" /><category term="testing" /><summary type="html"><![CDATA[Unit tests don't prove determinism. An offline replay harness that runs canonical operations across distributions and architectures, captures cryptographic evidence, and gates releases on byte-identical output.]]></summary></entry></feed>