<?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://sir-teo.github.io/blogs/feed.xml" rel="self" type="application/atom+xml" /><link href="https://sir-teo.github.io/blogs/" rel="alternate" type="text/html" /><updated>2026-06-18T23:26:54-04:00</updated><id>https://sir-teo.github.io/blogs/feed.xml</id><title type="html">Teo’s Research Notebook</title><subtitle>Notes on AI, machine learning, data systems, mathematics, statistics, and the psychology of building reliable things.</subtitle><author><name>Teo</name><email>zengwc.teo2016@outlook.com</email></author><entry><title type="html">The Cut Survives The Contractions</title><link href="https://sir-teo.github.io/blogs/computer-science/2026/06/18/the-cut-survives-the-contractions.html" rel="alternate" type="text/html" title="The Cut Survives The Contractions" /><published>2026-06-18T10:10:00-04:00</published><updated>2026-06-18T10:10:00-04:00</updated><id>https://sir-teo.github.io/blogs/computer-science/2026/06/18/the-cut-survives-the-contractions</id><content type="html" xml:base="https://sir-teo.github.io/blogs/computer-science/2026/06/18/the-cut-survives-the-contractions.html"><![CDATA[<p>Some algorithms find a certificate by building it.</p>

<p>Karger’s contraction algorithm finds one by trying not to destroy it.</p>

<p>The problem is the <strong>global</strong> minimum cut of an undirected graph: split the
vertices into two nonempty sides while minimizing the number, or total weight,
of crossing edges. There is no distinguished source or sink. This is not the
same posture as an <code class="language-plaintext highlighter-rouge">s-t</code> max-flow computation. We are asking where the whole
graph is easiest to disconnect.</p>

<p>The random contraction algorithm is almost suspiciously short:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>while more than two supernodes remain:
    choose a remaining edge uniformly at random
    contract its endpoints
    delete self-loops, keep parallel edges
return the cut between the two final supernodes
</code></pre></div></div>

<p>The trick is not that one trial is reliable. It is not. The trick is that a
particular minimum cut has a quantifiable chance to survive, and independent
repetition is cheap to reason about.</p>

<h2 id="the-event-is-survival">The Event Is Survival</h2>

<p>Fix one minimum cut \(C\) with \(\lambda\) crossing edges.</p>

<p>If the algorithm never contracts an edge of \(C\), then the two sides of \(C\)
are never merged together. Other contractions can collapse vertices inside each
side, but they cannot erase the border. When only two supernodes remain, those
supernodes must be exactly the two sides of the fixed cut, up to renaming.</p>

<p>So the success event for this fixed cut is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>no edge of C is ever selected for contraction
</code></pre></div></div>

<p>Karger’s proof prices that event one contraction at a time.<sup id="fnref:karger-mincut" role="doc-noteref"><a href="#fn:karger-mincut" class="footnote" rel="footnote">1</a></sup>
Suppose the current contracted graph has \(r\) supernodes and the original cut
has survived so far. Contracting vertices only restricts the set of cuts still
available, so the current graph’s min-cut is at least \(\lambda\). Therefore
every supernode has degree at least \(\lambda\), and the current multigraph has
at least \(r\lambda/2\) edges.</p>

<p>Only \(\lambda\) of those edges cross our fixed cut. Thus the probability that
the next random edge kills this cut is at most:</p>

\[\frac{\lambda}{r\lambda/2}=\frac{2}{r}.\]

<p>The survival probability is at least:</p>

\[\prod_{r=n}^{3}\left(1-\frac{2}{r}\right)
=
\frac{2}{n(n-1)}
=
\frac{1}{\binom{n}{2}}.\]

<p>That number is small. For \(n=100\) it is about <code class="language-plaintext highlighter-rouge">0.0202%</code> for one named cut in
one run. But it is not astronomically small, and it has no hidden dependence on
the number of edges. After \(T\) independent runs, the probability of missing
that same fixed cut is at most:</p>

\[\left(1-\frac{2}{n(n-1)}\right)^T.\]

<p>That is the entire shape of the algorithm: a bad one-shot gamble with a clean
lower bound becomes useful when repeated.</p>

<h2 id="lab-let-the-cut-try-to-survive">Lab: Let The Cut Try To Survive</h2>

<p>The lab below builds small unweighted multigraphs, computes the exact global
min cut by brute force, then runs repeated random contractions against that
oracle.</p>

<p>The default graph is two <code class="language-plaintext highlighter-rouge">K5</code> cliques joined by two parallel bridge edges. Its
minimum cut is unique: separate the cliques by cutting the two bridge copies.
The exact oracle enumerates <code class="language-plaintext highlighter-rouge">511</code> nonempty cuts, because it fixes one vertex on
the complement side to avoid counting each cut twice.</p>

<p>Default run:</p>

<table>
  <thead>
    <tr>
      <th>Quantity</th>
      <th style="text-align: right">Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Vertices</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">10</code></td>
    </tr>
    <tr>
      <td>Edge copies</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">22</code></td>
    </tr>
    <tr>
      <td>Exact min-cut value</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">2</code></td>
    </tr>
    <tr>
      <td>Canonical min cuts</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">1</code></td>
    </tr>
    <tr>
      <td>Random contraction trials</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">240</code></td>
    </tr>
    <tr>
      <td>Optimal returned cuts</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">73</code></td>
    </tr>
    <tr>
      <td>Observed optimal rate</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">30.4%</code></td>
    </tr>
    <tr>
      <td>First optimal trial</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">13</code></td>
    </tr>
    <tr>
      <td>One-fixed-cut lower bound</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">2.22%</code></td>
    </tr>
    <tr>
      <td>Repetitions for 95% fixed-cut success</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">134</code></td>
    </tr>
  </tbody>
</table>

<p>The observed rate is much better than the proof bound on this graph. That is
normal. The proof is worst-case and follows one named cut. The cycle preset is
the opposite lesson: a ten-cycle has <code class="language-plaintext highlighter-rouge">45</code> canonical minimum cuts, and every
contraction result is optimal, even though any one fixed cut still has only the
Karger lower-bound chance.</p>

<style>
  .karger-lab {
    background: #ffffff;
    border: 1px solid #d7dee5;
    border-radius: 8px;
    box-sizing: border-box;
    color: #172026;
    margin: 2rem 0;
    max-width: 100%;
    overflow: hidden;
    padding: 1rem;
  }
  .karger-lab *,
  .karger-lab *::before,
  .karger-lab *::after {
    box-sizing: border-box;
  }
  .karger-lab__controls {
    display: grid;
    gap: 0.8rem 1rem;
    grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
    margin-bottom: 1rem;
  }
  .karger-lab label {
    color: #39444e;
    display: grid;
    font-size: 0.82rem;
    font-weight: 700;
    gap: 0.35rem;
    line-height: 1.25;
    min-width: 0;
  }
  .karger-lab input,
  .karger-lab select {
    accent-color: #0f766e;
    border: 1px solid #cbd5df;
    border-radius: 6px;
    color: #172026;
    font: inherit;
    margin: 0;
    min-width: 0;
    width: 100%;
  }
  .karger-lab select {
    padding: 0.35rem 0.45rem;
  }
  .karger-lab output {
    color: #172026;
    font-variant-numeric: tabular-nums;
    font-weight: 760;
  }
  .karger-lab__metrics {
    display: grid;
    gap: 0.55rem;
    grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
    margin: 0 0 1rem;
  }
  .karger-lab__metric {
    background: #f8fafc;
    border: 1px solid #e3e9f0;
    border-radius: 7px;
    min-height: 4rem;
    padding: 0.55rem 0.65rem;
  }
  .karger-lab__metric.good {
    border-color: #9ed8c5;
  }
  .karger-lab__metric.bad {
    border-color: #f2b8c4;
  }
  .karger-lab__metric span {
    color: #5d6872;
    display: block;
    font-size: 0.72rem;
    font-weight: 760;
    letter-spacing: 0;
    text-transform: uppercase;
  }
  .karger-lab__metric strong {
    color: #172026;
    display: block;
    font-size: clamp(0.95rem, 2vw, 1.18rem);
    font-variant-numeric: tabular-nums;
    line-height: 1.25;
    margin-top: 0.2rem;
    overflow-wrap: anywhere;
  }
  .karger-lab canvas {
    aspect-ratio: 920 / 680;
    border: 1px solid #e3e9f0;
    border-radius: 8px;
    display: block;
    max-width: 100%;
    width: 100%;
  }
  .karger-lab__note {
    color: #5d6872;
    font-size: 0.88rem;
    line-height: 1.55;
    margin: 0.85rem 0 0;
  }
  @media (max-width: 620px) {
    .karger-lab {
      padding: 0.8rem;
    }
    .karger-lab canvas {
      aspect-ratio: 390 / 980;
    }
  }
</style>

<div class="karger-lab" data-karger-mincut-lab="">
  <div class="karger-lab__controls">
    <label>
      Graph <output data-value="graph">two cliques, two bridges</output>
      <select data-param="graph" aria-label="Graph preset">
        <option value="bridge" selected="">two cliques, two bridges</option>
        <option value="cycle">cycle, many min cuts</option>
        <option value="islands">three islands</option>
        <option value="ladder">ladder</option>
      </select>
    </label>
    <label>
      Trials <output data-value="trials">240</output>
      <input data-param="trials" type="range" min="20" max="800" step="20" value="240" aria-label="Random contraction trials" />
    </label>
    <label>
      Seed <output data-value="seed">37</output>
      <input data-param="seed" type="range" min="1" max="997" step="1" value="37" aria-label="Random seed" />
    </label>
    <label>
      Shown trial <output data-value="trace">7</output>
      <input data-param="trace" type="range" min="1" max="80" step="1" value="7" aria-label="Trace trial" />
    </label>
  </div>
  <div class="karger-lab__metrics" data-metrics=""></div>
  <canvas data-canvas="" aria-label="Karger random contraction min-cut lab with exact oracle, one contraction trace, outcome histogram, and repetition bound"></canvas>
  <p class="karger-lab__note" data-note="">
    Loading random contraction experiment.
  </p>
</div>

<script defer="" src="/blogs/assets/js/karger-mincut-lab.js?v=20260618c"></script>

<p>The audit exported by the script gives the randomized experiment a deterministic
receipt: 63 audit rows, backed by 365 individual invariant checks.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>node - <span class="o">&lt;&lt;</span><span class="sh">'</span><span class="no">NODE</span><span class="sh">'
const lab = require("./assets/js/karger-mincut-lab.js");
const EXPECTED_GRAPHS = 4;
const EXPECTED_NAMED_CHECKS = 11;
const EXPECTED_GRID_CASES = 48;
const EXPECTED_ROWS = 63;
const EXPECTED_CHECKS = 365;
const EXPECTED_TOTALS = {
  graphRows: EXPECTED_GRAPHS,
  gridSummaries: EXPECTED_GRAPHS,
  gridRows: EXPECTED_GRID_CASES,
  namedChecks: EXPECTED_NAMED_CHECKS,
  passed: EXPECTED_ROWS,
  total: EXPECTED_ROWS,
  passedChecks: EXPECTED_CHECKS,
  totalChecks: EXPECTED_CHECKS
};
const EXPECTED_DEFAULT_SUMMARY = {
  bestValue: 2,
  bound: "0.022",
  exactCount: 1,
  firstSuccess: 13,
  lambda: 2,
  repetitions95: 134,
  successRate: "0.304",
  successes: 73,
  trials: 240
};
const EXPECTED_GRAPH_ROWS = [
  "bridge:10/22:lambda=2:cuts=1:trials=32:optimal=11:survivals=11:checks=47/47:passed=true",
  "cycle:10/10:lambda=2:cuts=45:trials=32:optimal=32:survivals=1:checks=37/37:passed=true",
  "islands:15/36:lambda=3:cuts=2:trials=32:optimal=4:survivals=3:checks=39/39:passed=true",
  "ladder:12/18:lambda=2:cuts=7:trials=32:optimal=20:survivals=3:checks=39/39:passed=true"
];
const EXPECTED_NAMED_ROWS = [
  "bridge-lambda:1/1:true",
  "bridge-mincut-count:1/1:true",
  "bridge-finds-mincut:1/1:true",
  "bridge-best-value:1/1:true",
  "cycle-lambda:1/1:true",
  "cycle-mincut-count:1/1:true",
  "cycle-all-trials-optimal:1/1:true",
  "default-deterministic:1/1:true",
  "default-best-is-exact:1/1:true",
  "n10-repetition-count:1/1:true",
  "grid-size:1/1:true"
];
const EXPECTED_GRID_SUMMARY_ROWS = [
  "bridge:cases=12/12:checks=48/48:success=0.175..0.308",
  "cycle:cases=12/12:checks=48/48:success=1.000..1.000",
  "islands:cases=12/12:checks=48/48:success=0.125..0.300",
  "ladder:cases=12/12:checks=48/48:success=0.550..0.650"
];
const EXPECTED_GRID_CASE_ROWS = [
  "bridge:1:40:lambda=2:best=2:successes=12:rate=0.300:bins=3:checks=4/4:passed=true",
  "bridge:1:120:lambda=2:best=2:successes=37:rate=0.308:bins=3:checks=4/4:passed=true",
  "bridge:1:240:lambda=2:best=2:successes=62:rate=0.258:bins=3:checks=4/4:passed=true",
  "bridge:17:40:lambda=2:best=2:successes=9:rate=0.225:bins=3:checks=4/4:passed=true",
  "bridge:17:120:lambda=2:best=2:successes=31:rate=0.258:bins=3:checks=4/4:passed=true",
  "bridge:17:240:lambda=2:best=2:successes=64:rate=0.267:bins=3:checks=4/4:passed=true",
  "bridge:83:40:lambda=2:best=2:successes=12:rate=0.300:bins=3:checks=4/4:passed=true",
  "bridge:83:120:lambda=2:best=2:successes=27:rate=0.225:bins=3:checks=4/4:passed=true",
  "bridge:83:240:lambda=2:best=2:successes=68:rate=0.283:bins=3:checks=4/4:passed=true",
  "bridge:211:40:lambda=2:best=2:successes=7:rate=0.175:bins=3:checks=4/4:passed=true",
  "bridge:211:120:lambda=2:best=2:successes=34:rate=0.283:bins=3:checks=4/4:passed=true",
  "bridge:211:240:lambda=2:best=2:successes=70:rate=0.292:bins=3:checks=4/4:passed=true",
  "cycle:1:40:lambda=2:best=2:successes=40:rate=1.000:bins=1:checks=4/4:passed=true",
  "cycle:1:120:lambda=2:best=2:successes=120:rate=1.000:bins=1:checks=4/4:passed=true",
  "cycle:1:240:lambda=2:best=2:successes=240:rate=1.000:bins=1:checks=4/4:passed=true",
  "cycle:17:40:lambda=2:best=2:successes=40:rate=1.000:bins=1:checks=4/4:passed=true",
  "cycle:17:120:lambda=2:best=2:successes=120:rate=1.000:bins=1:checks=4/4:passed=true",
  "cycle:17:240:lambda=2:best=2:successes=240:rate=1.000:bins=1:checks=4/4:passed=true",
  "cycle:83:40:lambda=2:best=2:successes=40:rate=1.000:bins=1:checks=4/4:passed=true",
  "cycle:83:120:lambda=2:best=2:successes=120:rate=1.000:bins=1:checks=4/4:passed=true",
  "cycle:83:240:lambda=2:best=2:successes=240:rate=1.000:bins=1:checks=4/4:passed=true",
  "cycle:211:40:lambda=2:best=2:successes=40:rate=1.000:bins=1:checks=4/4:passed=true",
  "cycle:211:120:lambda=2:best=2:successes=120:rate=1.000:bins=1:checks=4/4:passed=true",
  "cycle:211:240:lambda=2:best=2:successes=240:rate=1.000:bins=1:checks=4/4:passed=true",
  "islands:1:40:lambda=3:best=3:successes=7:rate=0.175:bins=3:checks=4/4:passed=true",
  "islands:1:120:lambda=3:best=3:successes=31:rate=0.258:bins=3:checks=4/4:passed=true",
  "islands:1:240:lambda=3:best=3:successes=59:rate=0.246:bins=3:checks=4/4:passed=true",
  "islands:17:40:lambda=3:best=3:successes=12:rate=0.300:bins=3:checks=4/4:passed=true",
  "islands:17:120:lambda=3:best=3:successes=25:rate=0.208:bins=3:checks=4/4:passed=true",
  "islands:17:240:lambda=3:best=3:successes=42:rate=0.175:bins=3:checks=4/4:passed=true",
  "islands:83:40:lambda=3:best=3:successes=9:rate=0.225:bins=3:checks=4/4:passed=true",
  "islands:83:120:lambda=3:best=3:successes=25:rate=0.208:bins=3:checks=4/4:passed=true",
  "islands:83:240:lambda=3:best=3:successes=45:rate=0.188:bins=3:checks=4/4:passed=true",
  "islands:211:40:lambda=3:best=3:successes=5:rate=0.125:bins=3:checks=4/4:passed=true",
  "islands:211:120:lambda=3:best=3:successes=17:rate=0.142:bins=3:checks=4/4:passed=true",
  "islands:211:240:lambda=3:best=3:successes=41:rate=0.171:bins=3:checks=4/4:passed=true",
  "ladder:1:40:lambda=2:best=2:successes=25:rate=0.625:bins=3:checks=4/4:passed=true",
  "ladder:1:120:lambda=2:best=2:successes=78:rate=0.650:bins=4:checks=4/4:passed=true",
  "ladder:1:240:lambda=2:best=2:successes=141:rate=0.588:bins=4:checks=4/4:passed=true",
  "ladder:17:40:lambda=2:best=2:successes=25:rate=0.625:bins=4:checks=4/4:passed=true",
  "ladder:17:120:lambda=2:best=2:successes=68:rate=0.567:bins=5:checks=4/4:passed=true",
  "ladder:17:240:lambda=2:best=2:successes=140:rate=0.583:bins=5:checks=4/4:passed=true",
  "ladder:83:40:lambda=2:best=2:successes=24:rate=0.600:bins=3:checks=4/4:passed=true",
  "ladder:83:120:lambda=2:best=2:successes=67:rate=0.558:bins=4:checks=4/4:passed=true",
  "ladder:83:240:lambda=2:best=2:successes=132:rate=0.550:bins=5:checks=4/4:passed=true",
  "ladder:211:40:lambda=2:best=2:successes=26:rate=0.650:bins=4:checks=4/4:passed=true",
  "ladder:211:120:lambda=2:best=2:successes=67:rate=0.558:bins=4:checks=4/4:passed=true",
  "ladder:211:240:lambda=2:best=2:successes=137:rate=0.571:bins=4:checks=4/4:passed=true"
];

function sameJson(left, right) {
  return JSON.stringify(left) === JSON.stringify(right);
}

function rowDrifts(actual, expected) {
  const count = Math.max(actual.length, expected.length);
  const drift = [];
  for (let index = 0; index &lt; count; index += 1) {
    if (actual[index] !== expected[index]) {
      drift.push({ index, actual: actual[index], expected: expected[index] });
    }
  }
  return drift;
}

function fixed3(value) {
  return Number(value).toFixed(3);
}

const audit = lab.auditKargerMincutLab();
console.table(audit.byGraph.map((row) =&gt; ({
  graph: row.graph,
  lambda: row.lambda,
  minCuts: row.minCuts,
  trialChecks: row.trials,
  optimalTrials: row.optimalTrials,
  survivals: row.survivals,
  checks: `</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">`
})));
console.table(audit.gridSummary.map((row) =&gt; ({
  graph: row.graph,
  cases: `</span><span class="k">${</span><span class="nv">row</span><span class="p">.passed</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.cases</span><span class="k">}</span><span class="sh">`,
  checks: `</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">`,
  minSuccessRate: row.minSuccessRate.toFixed(3),
  maxSuccessRate: row.maxSuccessRate.toFixed(3)
})));
const auditShape = {
  defaultSummary: {
    bestValue: audit.defaultSummary.bestValue,
    bound: fixed3(audit.defaultSummary.bound),
    exactCount: audit.defaultSummary.exactCount,
    firstSuccess: audit.defaultSummary.firstSuccess,
    lambda: audit.defaultSummary.lambda,
    repetitions95: audit.defaultSummary.repetitions95,
    successRate: fixed3(audit.defaultSummary.successRate),
    successes: audit.defaultSummary.successes,
    trials: audit.defaultSummary.trials
  },
  graphRows: audit.byGraph.map((row) =&gt;
    `</span><span class="k">${</span><span class="nv">row</span><span class="p">.graph</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.nodes</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.edges</span><span class="k">}</span><span class="sh">:lambda=</span><span class="k">${</span><span class="nv">row</span><span class="p">.lambda</span><span class="k">}</span><span class="sh">:` +
    `cuts=</span><span class="k">${</span><span class="nv">row</span><span class="p">.minCuts</span><span class="k">}</span><span class="sh">:trials=</span><span class="k">${</span><span class="nv">row</span><span class="p">.trials</span><span class="k">}</span><span class="sh">:optimal=</span><span class="k">${</span><span class="nv">row</span><span class="p">.optimalTrials</span><span class="k">}</span><span class="sh">:` +
    `survivals=</span><span class="k">${</span><span class="nv">row</span><span class="p">.survivals</span><span class="k">}</span><span class="sh">:checks=</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">:` +
    `passed=</span><span class="k">${</span><span class="nv">row</span><span class="p">.passed</span><span class="k">}</span><span class="sh">`
  ),
  gridCaseRows: audit.cases.map((row) =&gt;
    `</span><span class="k">${</span><span class="nv">row</span><span class="p">.graph</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.seed</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.trials</span><span class="k">}</span><span class="sh">:lambda=</span><span class="k">${</span><span class="nv">row</span><span class="p">.lambda</span><span class="k">}</span><span class="sh">:` +
    `best=</span><span class="k">${</span><span class="nv">row</span><span class="p">.bestValue</span><span class="k">}</span><span class="sh">:successes=</span><span class="k">${</span><span class="nv">row</span><span class="p">.successes</span><span class="k">}</span><span class="sh">:` +
    `rate=</span><span class="k">${</span><span class="nv">fixed3</span><span class="p">(row.successRate)</span><span class="k">}</span><span class="sh">:bins=</span><span class="k">${</span><span class="nv">row</span><span class="p">.histogramBins</span><span class="k">}</span><span class="sh">:` +
    `checks=</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">:passed=</span><span class="k">${</span><span class="nv">row</span><span class="p">.passed</span><span class="k">}</span><span class="sh">`
  ),
  gridSummaryRows: audit.gridSummary.map((row) =&gt;
    `</span><span class="k">${</span><span class="nv">row</span><span class="p">.graph</span><span class="k">}</span><span class="sh">:cases=</span><span class="k">${</span><span class="nv">row</span><span class="p">.passed</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.cases</span><span class="k">}</span><span class="sh">:` +
    `checks=</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">:` +
    `success=</span><span class="k">${</span><span class="nv">fixed3</span><span class="p">(row.minSuccessRate)</span><span class="k">}</span><span class="sh">..</span><span class="k">${</span><span class="nv">fixed3</span><span class="p">(row.maxSuccessRate)</span><span class="k">}</span><span class="sh">`
  ),
  namedRows: audit.namedChecks.map((row) =&gt;
    `</span><span class="k">${</span><span class="nv">row</span><span class="p">.name</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.passed</span><span class="k">}</span><span class="sh">`
  ),
  totals: {
    graphRows: audit.byGraph.length,
    gridSummaries: audit.gridSummary.length,
    gridRows: audit.cases.length,
    namedChecks: audit.namedChecks.length,
    passed: audit.passed,
    total: audit.total,
    passedChecks: audit.passedChecks,
    totalChecks: audit.totalChecks
  }
};
const graphRowDrifts = rowDrifts(auditShape.graphRows, EXPECTED_GRAPH_ROWS);
const namedRowDrifts = rowDrifts(auditShape.namedRows, EXPECTED_NAMED_ROWS);
const gridSummaryDrifts = rowDrifts(
  auditShape.gridSummaryRows,
  EXPECTED_GRID_SUMMARY_ROWS
);
const gridCaseDrifts = rowDrifts(auditShape.gridCaseRows, EXPECTED_GRID_CASE_ROWS);
const shapeErrors = [
  sameJson(auditShape.defaultSummary, EXPECTED_DEFAULT_SUMMARY) ? null : "defaultSummary",
  sameJson(auditShape.totals, EXPECTED_TOTALS) ? null : "totals",
  graphRowDrifts.length ? "graphRows" : null,
  namedRowDrifts.length ? "namedRows" : null,
  gridSummaryDrifts.length ? "gridSummaryRows" : null,
  gridCaseDrifts.length ? "gridCaseRows" : null
].filter(Boolean);
if (shapeErrors.length) {
  throw new Error(
    `Karger audit shape drifted in </span><span class="k">${</span><span class="nv">shapeErrors</span><span class="p">.join(</span><span class="s2">", "</span><span class="p">)</span><span class="k">}</span><span class="sh">:</span><span class="se">\n</span><span class="sh">` +
    JSON.stringify({
      auditShape,
      expected: {
        defaultSummary: EXPECTED_DEFAULT_SUMMARY,
        totals: EXPECTED_TOTALS
      },
      drifts: {
        graphRows: graphRowDrifts,
        namedRows: namedRowDrifts,
        gridSummaryRows: gridSummaryDrifts,
        gridCaseRows: gridCaseDrifts
      }
    }, null, 2)
  );
}

const failedGraphRows = audit.byGraph.filter(
  (row) =&gt; !row.passed || row.passedChecks !== row.totalChecks
);
const failedNamedChecks = audit.namedChecks.filter((row) =&gt; !row.passed);
const failedGridGroups = audit.gridSummary.filter(
  (row) =&gt; row.passed !== row.cases || row.passedChecks !== row.totalChecks
);
const failedCases = audit.cases.filter(
  (row) =&gt; !row.passed || row.passedChecks !== row.totalChecks
);
if (!audit.ok || failedGraphRows.length || failedNamedChecks.length ||
    failedGridGroups.length || failedCases.length ||
    audit.passed !== audit.total || audit.passedChecks !== audit.totalChecks) {
  throw new Error(JSON.stringify({
    failures: audit.failures,
    failedGraphRows,
    failedNamedChecks,
    failedGridGroups,
    failedCases: failedCases.slice(0, 10),
    shapeErrors,
    auditShape
  }, null, 2));
}
console.log(
  `</span><span class="k">${</span><span class="nv">audit</span><span class="p">.passed</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">audit</span><span class="p">.total</span><span class="k">}</span><span class="sh"> audit rows and ` +
  `</span><span class="k">${</span><span class="nv">audit</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">audit</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh"> checks passed`
);
</span><span class="no">NODE
</span></code></pre></div></div>

<p>It verifies the brute-force oracle on every preset, checks 32 single
contractions per graph against the oracle, checks that fixed-cut survival
implies an optimal result, confirms that the ten-cycle always returns a min
cut, and runs a 48-case grid over graph presets, seeds, and trial counts. The
365 checks count each lower-bound trial, each fixed-cut survival witness, each
named regression, and four grid invariants per parameter case.</p>

<h2 id="keep-the-parallel-edges">Keep The Parallel Edges</h2>

<p>The line “delete self-loops, keep parallel edges” is not a programming detail.
It is the distribution.</p>

<p>If two supernodes have five original edges between them, there should be five
ways for the next random edge to choose that pair. Collapsing those five edges
into one unweighted edge changes the algorithm. The lab stores parallel edges
as separate edge copies for exactly this reason.</p>

<p>The same point explains weighted graphs. Karger’s original paper presents the
algorithm for weighted undirected graphs too; integer weights can be viewed as
parallel edge copies, and the paper discusses how to avoid making the runtime
depend on the numeric sum of weights.<sup id="fnref:karger-mincut:1" role="doc-noteref"><a href="#fn:karger-mincut" class="footnote" rel="footnote">1</a></sup> The browser lab stays
small and integer because it also carries a brute-force exact oracle.</p>

<h2 id="why-this-is-not-a-max-flow-story">Why This Is Not A Max-Flow Story</h2>

<p>Max-flow/min-cut proves optimality through a residual graph and a reachable set.
Random contraction proves success by conditioning on a cut not being touched.</p>

<p>Both stories end with a cut, but the evidence is different.</p>

<p>For max-flow, the certificate is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>no residual path remains, and the reachable side has capacity equal to the flow
</code></pre></div></div>

<p>For random contraction, one successful trial’s certificate is weaker unless we
also verify the cut value. The algorithm is Monte Carlo: it has a fixed running
plan, and a single run can miss. The standard way to use it is to repeat trials
and keep the smallest cut seen. In a production implementation, one would often
pair that repeated randomized search with a deterministic or independently
checked certificate when exactness matters.</p>

<p>This is why the lab includes the exact oracle even though it is exponential. It
lets the small experiment say something honest:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>the randomized algorithm returned value 2
the exhaustive oracle says no cut has value below 2
</code></pre></div></div>

<p>For larger graphs, that oracle is gone. The proof bound is what remains.</p>

<h2 id="karger-stein-as-a-recursion-hint">Karger-Stein As A Recursion Hint</h2>

<p>The basic contraction algorithm repeats all the way down to two supernodes.
Karger and Stein’s later algorithm changes the shape: contract partway, branch,
and recurse, exploiting the fact that a minimum cut is a small fraction of the
edges while avoiding some wasted full descents.<sup id="fnref:karger-stein" role="doc-noteref"><a href="#fn:karger-stein" class="footnote" rel="footnote">2</a></sup> Their JACM paper
gave a randomized strongly polynomial algorithm for weighted undirected min cut
with high probability in <code class="language-plaintext highlighter-rouge">O(n^2 log^3 n)</code> time.</p>

<p>The lab does not implement that recursive version. It stays with the tiny
algorithm because the survival event is visible. But the recursive improvement
has the same soul: contract aggressively while the fixed cut is unlikely to be
hit, then spend branching only when the graph is smaller.</p>

<h2 id="evidence-interpretation-speculation">Evidence, Interpretation, Speculation</h2>

<p><strong>Evidence.</strong> Karger’s 1992/1993 contraction paper defines the random
contraction algorithm, proves the fixed minimum-cut survival bound
<code class="language-plaintext highlighter-rouge">1 / binom(n, 2)</code>, and derives the repeated-trial high-probability result.
Karger and Stein later improved the approach with recursive contraction and a
faster high-probability algorithm for weighted undirected graphs.</p>

<p><strong>Interpretation.</strong> The algorithm is best understood as survival analysis for a
certificate that already exists. Randomness is not searching uniformly over all
cuts. It is sampling contraction histories, and a good history is one that
never touches the cut we care about.</p>

<p><strong>Speculation.</strong> Random contraction is a useful antidote to a common engineering
instinct: do not assume that a randomized algorithm is mysterious because its
steps are locally reckless. Sometimes the right proof object is the event that
the reckless step avoided one small forbidden set.</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:karger-mincut" role="doc-endnote">
      <p>David R. Karger, <a href="https://people.csail.mit.edu/karger/Papers/mincut.pdf">Global Min-cuts in RNC, and Other Ramifications of a Simple Min-Cut Algorithm</a>, October 30, 1992; SODA 1993 version listed on <a href="https://people.csail.mit.edu/karger/papers.html">Karger’s publications page</a>. Section 2 gives the contraction algorithm, the fixed min-cut survival theorem, and the <code class="language-plaintext highlighter-rouge">O(n^2 log n)</code> independent-repetition corollary. <a href="#fnref:karger-mincut" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:karger-mincut:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:karger-stein" role="doc-endnote">
      <p>David R. Karger and Clifford Stein, <a href="https://people.csail.mit.edu/karger/Papers/fastcut.pdf">A New Approach to the Minimum Cut Problem</a>, preliminary STOC 1993 version and Journal of the ACM 43(4), 1996. Karger’s publications page lists the JACM version and summarizes the <code class="language-plaintext highlighter-rouge">O(n^2 log^3 n)</code> high-probability bound for weighted undirected graphs. <a href="#fnref:karger-stein" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Teo</name><email>zengwc.teo2016@outlook.com</email></author><category term="computer-science" /><category term="karger-algorithm" /><category term="min-cut" /><category term="randomized-algorithms" /><category term="graph-algorithms" /><category term="monte-carlo" /><category term="algorithms" /><summary type="html"><![CDATA[Karger's min-cut algorithm is a small Monte Carlo argument: a minimum cut is found if none of its edges are contracted. A brute-force oracle and simulator make the survival proof visible.]]></summary></entry><entry><title type="html">The Password Hash Rents Memory</title><link href="https://sir-teo.github.io/blogs/computer-science/2026/06/18/the-password-hash-rents-memory.html" rel="alternate" type="text/html" title="The Password Hash Rents Memory" /><published>2026-06-18T09:56:00-04:00</published><updated>2026-06-18T09:56:00-04:00</updated><id>https://sir-teo.github.io/blogs/computer-science/2026/06/18/the-password-hash-rents-memory</id><content type="html" xml:base="https://sir-teo.github.io/blogs/computer-science/2026/06/18/the-password-hash-rents-memory.html"><![CDATA[<p>A password hash is easy to describe too small:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>store hash(password)
</code></pre></div></div>

<p>That line is missing the adversary. The interesting event is not a normal login.
The interesting event is a database leak. Once the password table is gone, the
attacker is no longer asking your server for permission. They are running an
offline search over candidate passwords, comparing each candidate with the stored
verifier.</p>

<p>So the stored password verifier is not a checksum. It is a recurring invoice:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>one password guess = time + memory + bandwidth + salt-specific work
</code></pre></div></div>

<p>The verifier has to be cheap enough that a real user can log in, but expensive
enough that a leaked table does not become a parallel guessing festival.</p>

<p>That is the small design tension behind memory-hard password hashing.</p>

<h2 id="the-hash-is-a-guessing-budget">The Hash Is A Guessing Budget</h2>

<p>NIST SP 800-63B says verifiers should store passwords in a form resistant to
offline attack; the current SP 800-63-4 text describes a password hashing scheme
as taking a password, salt, and cost factor, then making each password guess more
expensive for someone who has obtained the hashed password file.<sup id="fnref:nist" role="doc-noteref"><a href="#fn:nist" class="footnote" rel="footnote">1</a></sup></p>

<p>That sentence is doing more work than it first appears to do.</p>

<p>First, this is an <strong>offline</strong> threat. Online rate limits, lockouts, step-up
authentication, and anomaly detection matter, but they are not in the hot path
after the table leaks. A stolen table turns the defense into arithmetic.</p>

<p>Second, the salt is not meant to be secret. Its job is to keep work from being
reused. NIST requires at least 32 bits and says salts should be chosen to
minimize collisions among stored hashes; RFC 9106 recommends 16-byte salts for
Argon2 password hashing.<sup id="fnref:argon2-inputs" role="doc-noteref"><a href="#fn:argon2-inputs" class="footnote" rel="footnote">2</a></sup> In practice, larger random salts are
normal because storage is cheap and accidental reuse is embarrassing.</p>

<p>Third, the cost factor is not a moral gesture. It is an operational budget. Set
it too low and offline search is cheap. Set it too high and login becomes a
self-inflicted denial of service.</p>

<p>The security engineering question is therefore:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>what is the largest per-login bill we can afford to hand to honest users?
</code></pre></div></div>

<h2 id="why-memory-changes-the-attack-shape">Why Memory Changes The Attack Shape</h2>

<p>Classic iterated hashes mostly charge time. That helps, but offline search is
embarrassingly parallel: buy more hardware, run more guesses. Percival and
Josefsson’s scrypt RFC makes the hardware economics explicit: if attackers can
use custom parallel circuits, the relevant unit is closer to dollar-seconds than
to one machine’s wall-clock time.<sup id="fnref:scrypt" role="doc-noteref"><a href="#fn:scrypt" class="footnote" rel="footnote">3</a></sup></p>

<p>Memory-hard schemes try to make parallel guesses rent scarce area too. A design
that demands 64 MiB per guess cannot be packed into the same hardware shape as a
design that demands 64 bytes per guess. The attacker now has to answer:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>how many guesses can I keep resident at once?
what memory bandwidth feeds them?
what happens if I store less and recompute missing blocks?
</code></pre></div></div>

<p>Argon2 is the modern reference point. RFC 9106 describes Argon2 as a memory-hard
function, names Argon2id as the primary variant to support, and gives two
notable default recommendations: Argon2id with 2 GiB, 1 pass, 4 lanes, and a
128-bit salt as the first recommended option; and Argon2id with 64 MiB, 3 passes,
4 lanes, and a 128-bit salt when much less memory is available.<sup id="fnref:argon2-rec" role="doc-noteref"><a href="#fn:argon2-rec" class="footnote" rel="footnote">4</a></sup></p>

<p>Those recommendations are not “64 MiB is always enough” or “2 GiB is always
practical.” They are reminders that the password hash has knobs with different
physical meanings:</p>

<ul>
  <li><strong>memory</strong> limits parallel resident guesses;</li>
  <li><strong>passes</strong> add repeated work over the memory;</li>
  <li><strong>lanes</strong> expose parallelism inside one verifier call;</li>
  <li><strong>salt</strong> prevents one computation from being spent against many accounts;</li>
  <li><strong>tag and algorithm metadata</strong> let you migrate later.</li>
</ul>

<p>The hash string should carry the algorithm and cost parameters, because tomorrow
you may need to rehash at a higher cost after a successful login.</p>

<h2 id="lab-spend-the-login-budget">Lab: Spend The Login Budget</h2>

<p>The lab below is not an Argon2 implementation and not a hardware benchmark. It
is a static economics model with deliberately plain assumptions:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>defender latency ~= fixed overhead + passes * memory / effective bandwidth
attacker slots   ~= attacker memory / per-guess memory
attacker rate    ~= min(slot-limited rate, bandwidth-limited rate)
</code></pre></div></div>

<p>For the time-memory tradeoff control, the lab uses a toy penalty:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>penalty(alpha) = alpha^-1.45
</code></pre></div></div>

<p>where <code class="language-plaintext highlighter-rouge">alpha</code> is the fraction of full memory used. That is not a claim about a
specific attack on Argon2. It is a visible contract: in this model, cutting
memory buys more slots but pays for recomputation. RFC 9106’s security section
discusses real time-space tradeoff attacks in terms of time-area product, and
real deployments should rely on the scheme’s own analysis and current library
guidance rather than this toy exponent.<sup id="fnref:argon2-tradeoff" role="doc-noteref"><a href="#fn:argon2-tradeoff" class="footnote" rel="footnote">5</a></sup></p>

<p>Default model:</p>

<table>
  <thead>
    <tr>
      <th>Quantity</th>
      <th style="text-align: right">Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Memory per hash</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">64 MiB</code></td>
    </tr>
    <tr>
      <td>Passes</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">3</code></td>
    </tr>
    <tr>
      <td>Lanes</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">4</code></td>
    </tr>
    <tr>
      <td>Defender latency</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">105 ms</code></td>
    </tr>
    <tr>
      <td>Attacker memory budget</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">24 GiB</code></td>
    </tr>
    <tr>
      <td>Concurrent full-memory guesses</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">384</code></td>
    </tr>
    <tr>
      <td>Offline hash evaluations</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">729/s</code></td>
    </tr>
    <tr>
      <td>40-bit single-target median</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">23.9 y</code></td>
    </tr>
    <tr>
      <td>10,000-account breach, unique salts</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">33.1 y</code></td>
    </tr>
    <tr>
      <td>Same breach, shared salt</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">1.21 d</code></td>
    </tr>
  </tbody>
</table>

<p>The last two rows are the point of the salt panel. For one target, a salt does
not make the chosen password stronger. For a breached table, unique salts stop
one candidate word from being checked against every account with one KDF
evaluation.</p>

<style>
  .password-lab {
    background: #ffffff;
    border: 1px solid #d7dee5;
    border-radius: 8px;
    box-sizing: border-box;
    color: #172026;
    margin: 2rem 0;
    max-width: 100%;
    overflow: hidden;
    padding: 1rem;
  }
  .password-lab *,
  .password-lab *::before,
  .password-lab *::after {
    box-sizing: border-box;
  }
  .password-lab__controls {
    display: grid;
    gap: 0.75rem;
    grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
    margin-bottom: 1rem;
  }
  .password-lab label {
    color: #5d6872;
    display: grid;
    font-size: 0.82rem;
    font-weight: 700;
    gap: 0.35rem;
    min-width: 0;
  }
  .password-lab input[type="range"],
  .password-lab select {
    accent-color: #0f766e;
    border: 1px solid #cbd5df;
    border-radius: 6px;
    color: #172026;
    font: inherit;
    margin: 0;
    min-width: 0;
    width: 100%;
  }
  .password-lab select {
    padding: 0.35rem 0.45rem;
  }
  .password-lab output {
    color: #172026;
    font-variant-numeric: tabular-nums;
  }
  .password-lab__metrics {
    display: grid;
    gap: 0.6rem;
    grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
    margin: 0 0 1rem;
  }
  .password-lab__metric {
    background: #f8fafc;
    border: 1px solid #e3e9f0;
    border-radius: 7px;
    padding: 0.55rem 0.65rem;
  }
  .password-lab__metric span {
    color: #5d6872;
    display: block;
    font-size: 0.72rem;
    font-weight: 760;
    letter-spacing: 0;
    text-transform: uppercase;
  }
  .password-lab__metric strong {
    color: #172026;
    display: block;
    font-size: clamp(1rem, 2vw, 1.22rem);
    font-variant-numeric: tabular-nums;
    line-height: 1.25;
    margin-top: 0.18rem;
    overflow-wrap: anywhere;
  }
  .password-lab__metric--good strong {
    color: #0f766e;
  }
  .password-lab__metric--warn strong {
    color: #b45309;
  }
  .password-lab canvas {
    aspect-ratio: 900 / 640;
    border: 1px solid #e3e9f0;
    border-radius: 8px;
    display: block;
    max-width: 100%;
    width: 100%;
  }
  .password-lab__note {
    color: #5d6872;
    font-size: 0.88rem;
    line-height: 1.55;
    margin: 0.8rem 0 0;
  }
  @media (max-width: 600px) {
    .password-lab {
      padding: 0.8rem;
    }
    .password-lab canvas {
      aspect-ratio: 360 / 980;
    }
  }
</style>

<div class="password-lab" data-password-hash-lab="">
  <div class="password-lab__controls">
    <label>
      Memory per hash <output data-value="memory">64 MiB</output>
      <input data-param="memory" type="range" min="16" max="2048" step="16" value="64" aria-label="Memory per password hash" />
    </label>
    <label>
      Passes <output data-value="passes">3</output>
      <input data-param="passes" type="range" min="1" max="6" step="1" value="3" aria-label="Password hash passes" />
    </label>
    <label>
      Lanes <output data-value="lanes">4</output>
      <input data-param="lanes" type="range" min="1" max="8" step="1" value="4" aria-label="Parallel lanes" />
    </label>
    <label>
      Attacker memory <output data-value="attackerMemory">24 GiB</output>
      <input data-param="attackerMemory" type="range" min="4" max="512" step="4" value="24" aria-label="Attacker memory budget" />
    </label>
    <label>
      Memory tradeoff <output data-value="tradeoff">full memory</output>
      <select data-param="tradeoff" aria-label="Attacker time memory tradeoff">
        <option value="full" selected="">full memory</option>
        <option value="half">1/2 memory</option>
        <option value="quarter">1/4 memory</option>
        <option value="eighth">1/8 memory</option>
      </select>
    </label>
    <label>
      Search space <output data-value="bits">40 bits</output>
      <input data-param="bits" type="range" min="24" max="64" step="1" value="40" aria-label="Password search space in bits" />
    </label>
    <label>
      Breached accounts <output data-value="accounts">10,000</output>
      <input data-param="accounts" type="range" min="1" max="1000000" step="999" value="10000" aria-label="Breached account count" />
    </label>
    <label>
      Salt model <output data-value="salt">unique salts</output>
      <select data-param="salt" aria-label="Salt reuse model">
        <option value="unique" selected="">unique salts</option>
        <option value="shared">shared salt</option>
      </select>
    </label>
  </div>
  <div class="password-lab__metrics" data-metrics=""></div>
  <canvas data-canvas="" aria-label="Password hashing memory-hardness lab showing defender latency, attacker memory slots, crack horizon, and salt reuse economics"></canvas>
  <p class="password-lab__note" data-note="">
    Loading password hash model.
  </p>
</div>

<script defer="" src="/blogs/assets/js/password-hash-lab.js?v=20260618c"></script>

<p>The audit exported by the script runs 12 named critical checks plus 216
generated checks over a 72-row memory/pass/tradeoff/salt grid:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>node - <span class="o">&lt;&lt;</span><span class="sh">'</span><span class="no">NODE</span><span class="sh">'
const lab = require("./assets/js/password-hash-lab.js");
const EXPECTED_CRITICAL_CHECKS = 12;
const EXPECTED_ROWS = 72;
const EXPECTED_CASES = 228;
const EXPECTED_CHECKS = 228;
const EXPECTED_DEFAULT_SHAPE =
  "64:3:4:105.111:384:729.167:10000:128:1";
const EXPECTED_MEMORY_SHAPE = [
  "16:18/54:53.753:195.375:18",
  "64:18/54:113.012:48.844:18",
  "256:18/54:350.049:12.211:18",
  "1024:18/54:1298.198:3.053:0"
];
const EXPECTED_PASSES_SHAPE = [
  "1:24/72:159.926:18.316:18",
  "3:24/72:411.778:6.105:18",
  "6:24/72:789.556:3.053:18"
];
const EXPECTED_TRADEOFF_SHAPE = [
  "full:24/72:453.753:10.392:18",
  "half:24/72:453.753:7.607:18",
  "quarter:24/72:453.753:3.053:18"
];
const EXPECTED_SALT_SHAPE = [
  "shared:36/108:453.753:1.000:1585928.894:27",
  "unique:36/108:453.753:1000.000:1585928894.382:27"
];
const EXPECTED_CRITICAL_SHAPE = [
  "72-case password hash grid completed:1/1",
  "default latency is finite:1/1",
  "quarter-memory tradeoff does not improve total rate in toy model:1/1",
  "raising memory lowers attacker rate in model:1/1",
  "raising memory raises defender latency:1/1",
  "raising passes lowers attacker rate in model:1/1",
  "raising passes raises defender latency:1/1",
  "server concurrency budget is memory divided by per-login memory:1/1",
  "shared salt accelerates breach-wide search:1/1",
  "shared salt lets one KDF cover the breach word:1/1",
  "toy tradeoff penalty is memory-hard shaped:1/1",
  "unique salts force one KDF per account per word:1/1"
];
const EXPECTED_TOTALS = {
  criticalChecks: EXPECTED_CRITICAL_CHECKS,
  rows: EXPECTED_ROWS,
  passed: true,
  passedChecks: EXPECTED_CHECKS,
  total: EXPECTED_CASES,
  totalChecks: EXPECTED_CHECKS
};

function fixed3(value) {
  return Number(value).toFixed(3);
}

function sameJson(actual, expected) {
  return JSON.stringify(actual) === JSON.stringify(expected);
}

function defaultShape(row) {
  return [
    row.params.memory,
    row.params.passes,
    row.params.lanes,
    fixed3(row.latencyMs),
    row.attackerSlots,
    fixed3(row.hashRate),
    row.evalsPerDictionaryWord,
    row.maxConcurrentLogins,
    row.serverBudgetOk ? 1 : 0
  ].join(":");
}

function groupShape(row, key) {
  return [
    row[key],
    `</span><span class="k">${</span><span class="nv">row</span><span class="p">.cases</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">`,
    fixed3(row.avgLatencyMs),
    fixed3(row.minHashRate),
    row.serverBudgetOk
  ].join(":");
}

function saltShape(row) {
  return [
    row.salt,
    `</span><span class="k">${</span><span class="nv">row</span><span class="p">.cases</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">`,
    fixed3(row.avgLatencyMs),
    fixed3(row.avgEvalsPerDictionaryWord),
    fixed3(row.avgBreachMedianSeconds),
    row.serverBudgetOk
  ].join(":");
}

const audit = lab.auditPasswordHashLab();
const failed = audit.cases.filter(
  (row) =&gt; !row.passed || row.passedChecks !== row.totalChecks
);
const failedCriticalChecks = audit.criticalChecks.filter(
  (row) =&gt; !row.passed || row.passedChecks !== row.totalChecks
);
console.log(audit.defaultResult);
console.table(audit.byMemory);
console.table(audit.byPasses);
console.table(audit.byTradeoff);
console.table(audit.bySalt);
console.table(audit.criticalChecks);
const auditShape = {
  defaultResult: defaultShape(audit.defaultResult),
  totals: {
    criticalChecks: audit.criticalTotal,
    rows: audit.cases.length,
    passed: audit.passed,
    passedChecks: audit.passedChecks,
    total: audit.total,
    totalChecks: audit.totalChecks
  },
  byMemory: audit.byMemory.map((row) =&gt; groupShape(row, "memory")),
  byPasses: audit.byPasses.map((row) =&gt; groupShape(row, "passes")),
  byTradeoff: audit.byTradeoff.map((row) =&gt; groupShape(row, "tradeoff")),
  bySalt: audit.bySalt.map(saltShape),
  criticalChecks: audit.criticalChecks.map(
    (row) =&gt; `</span><span class="k">${</span><span class="nv">row</span><span class="p">.check</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">`
  ).sort()
};
const shapeErrors = [
  sameJson(auditShape.defaultResult, EXPECTED_DEFAULT_SHAPE) ? null : "defaultResult",
  sameJson(auditShape.totals, EXPECTED_TOTALS) ? null : "totals",
  sameJson(auditShape.byMemory, EXPECTED_MEMORY_SHAPE) ? null : "byMemory",
  sameJson(auditShape.byPasses, EXPECTED_PASSES_SHAPE) ? null : "byPasses",
  sameJson(auditShape.byTradeoff, EXPECTED_TRADEOFF_SHAPE) ? null : "byTradeoff",
  sameJson(auditShape.bySalt, EXPECTED_SALT_SHAPE) ? null : "bySalt",
  sameJson(auditShape.criticalChecks, EXPECTED_CRITICAL_SHAPE) ? null : "criticalChecks"
].filter(Boolean);
if (shapeErrors.length) {
  throw new Error(
    `audit grid drifted in </span><span class="k">${</span><span class="nv">shapeErrors</span><span class="p">.join(</span><span class="s2">", "</span><span class="p">)</span><span class="k">}</span><span class="sh">:</span><span class="se">\n</span><span class="sh">` +
    JSON.stringify(auditShape, null, 2)
  );
}
if (
  failed.length ||
  failedCriticalChecks.length ||
  !audit.ok ||
  !audit.passed ||
  audit.passedChecks !== audit.totalChecks
) {
  throw new Error(JSON.stringify({
    failed,
    failedCriticalChecks,
    shapeErrors,
    auditShape,
    passed: audit.passed,
    passedChecks: audit.passedChecks,
    totalChecks: audit.totalChecks
  }, null, 2));
}
console.log(`</span><span class="k">${</span><span class="nv">audit</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">audit</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh"> checks passed`);
</span><span class="no">NODE
</span></code></pre></div></div>

<p>It verifies monotone behavior for memory and passes, the salt accounting, the
time-memory tradeoff shape, the server memory budget calculation, and a 72-case
grid over memory, passes, tradeoffs, and salt modes (228 checks total).</p>

<h2 id="read-the-knobs-as-constraints">Read The Knobs As Constraints</h2>

<p>Raise <strong>Memory per hash</strong> from <code class="language-plaintext highlighter-rouge">64 MiB</code> to <code class="language-plaintext highlighter-rouge">256 MiB</code>. The lab’s defender latency
goes from about <code class="language-plaintext highlighter-rouge">105 ms</code> to about <code class="language-plaintext highlighter-rouge">318 ms</code>, and the attacker’s modeled hash
evaluation rate drops by roughly 4x. That is the trade: the honest server spends
more memory and latency so the leaked-table attacker gets fewer resident guesses
and less bandwidth headroom.</p>

<p>Now set <strong>Memory tradeoff</strong> to <code class="language-plaintext highlighter-rouge">1/4 memory</code>. The attacker gets four times as
many resident slots, but the toy recomputation penalty is larger than that slot
gain, so the total modeled evaluation rate falls. This is the intuition memory
hardness is trying to buy. It is not a proof about the real scheme.</p>

<p>Finally change <strong>Salt model</strong> to <code class="language-plaintext highlighter-rouge">shared salt</code>. The one-target line does not
move. The breach-wide line collapses, because one candidate password can be
tested against every account after one KDF evaluation. That is why salt
uniqueness is table hygiene, not only cryptographic decoration.</p>

<h2 id="what-the-lab-leaves-out">What The Lab Leaves Out</h2>

<p>The lab does not model GPUs, ASICs, cache hierarchy, library implementation
details, side channels, or actual Argon2 indexing. The constants are knobs in a
toy economy, not published benchmark numbers.</p>

<p>It also does not decide whether a password is good. A 40-bit search space in
the plot is a simplified entropy ledger, not a statement about human password
choice. Real password systems need blocklists for common and compromised
passwords, online rate limiting, phishing-resistant recovery paths, MFA where
appropriate, and careful handling of password managers and Unicode input. NIST’s
password section is explicit about blocklists, rate limiting, full-password
verification, and authenticated protected channels.<sup id="fnref:nist:1" role="doc-noteref"><a href="#fn:nist" class="footnote" rel="footnote">1</a></sup></p>

<p>The password hash is the part that keeps working after the online controls have
been bypassed by a table leak. It is necessary, not sufficient.</p>

<h2 id="evidence-interpretation-speculation">Evidence, Interpretation, Speculation</h2>

<p><strong>Evidence.</strong> NIST SP 800-63B requires salted, offline-resistant password
storage and says cost factors should be as high as practical without hurting
verifier performance. RFC 9106 describes Argon2 inputs, operation, Argon2id, and
recommended parameter sets. RFC 7914 explains scrypt’s motivation in custom
hardware economics.</p>

<p><strong>Interpretation.</strong> The right mental model is budget accounting. A verifier cost
is a production SLO for the defender and a search tax for the attacker. Memory
is useful because it changes how many guesses can be parallelized under a fixed
hardware budget.</p>

<p><strong>Speculation.</strong> Many weak deployments survive because the password hash is
reviewed like a library choice instead of a capacity plan. I would rather see a
short table with measured p50/p95 login latency, memory pressure under burst,
stored parameter strings, and rehash migration policy than a bare claim that
“we use Argon2.”</p>

<h2 id="deployment-checklist">Deployment Checklist</h2>

<p>For a production password verifier, I want these questions answered:</p>

<ol>
  <li>Which scheme and exact parameter string is stored with each password?</li>
  <li>What are p50, p95, and overload login latencies on production-shaped
hardware?</li>
  <li>How much memory is reserved for authentication under peak login and recovery
traffic?</li>
  <li>Are salts randomly generated, unique in practice, and stored per password?</li>
  <li>Is there a server-side secret pepper or keyed post-processing step, and where
is that key isolated?</li>
  <li>What triggers rehashing to newer parameters?</li>
  <li>Does account recovery meet at least the same offline-resistance standard?</li>
  <li>Are chosen passwords checked against common, expected, and compromised
password blocklists?</li>
</ol>

<p>A password hash rents memory so an attacker has to rent it too. The invoice
should be visible before the breach, not discovered afterward.</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:nist" role="doc-endnote">
      <p>NIST, <a href="https://pages.nist.gov/800-63-4/sp800-63b.html">SP 800-63B, Authentication and Lifecycle Management</a>, SP 800-63-4, August 2025. As consulted on June 18, 2026, Section 3.1.1.2 says verifiers shall store passwords in a form resistant to offline attacks, use salted password hashing schemes with a cost factor, choose the cost factor as high as practical without hurting verifier performance, use salts of at least 32 bits chosen to minimize collisions, store the salt and hash per password, and store password-hashing scheme references and cost factors to support migration. <a href="#fnref:nist" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:nist:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:argon2-inputs" role="doc-endnote">
      <p>Alex Biryukov, Daniel Dinu, Dmitry Khovratovich, and Simon Josefsson, <a href="https://datatracker.ietf.org/doc/html/rfc9106">RFC 9106, “Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications”</a>, September 2021. Section 3.1 defines Argon2 inputs, including password, salt, degree of parallelism, memory size, number of passes, optional secret value, and type; it recommends 16-byte salts for password hashing and says salts should be unique for each password. <a href="#fnref:argon2-inputs" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:scrypt" role="doc-endnote">
      <p>Colin Percival and Simon Josefsson, <a href="https://datatracker.ietf.org/doc/html/rfc7914">RFC 7914, “The scrypt Password-Based Key Derivation Function”</a>, August 2016. The introduction explains why iteration-only schemes lose ground against custom parallel hardware and frames brute-force password search in dollar-seconds; Section 2 describes scrypt parameters <code class="language-plaintext highlighter-rouge">N</code>, <code class="language-plaintext highlighter-rouge">r</code>, and <code class="language-plaintext highlighter-rouge">p</code>. <a href="#fnref:scrypt" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:argon2-rec" role="doc-endnote">
      <p>RFC 9106, Section 7.4, <a href="https://datatracker.ietf.org/doc/html/rfc9106#section-7.4">“Recommendations”</a>. It names Argon2id with <code class="language-plaintext highlighter-rouge">t=1</code>, <code class="language-plaintext highlighter-rouge">p=4</code>, <code class="language-plaintext highlighter-rouge">m=2^21</code> KiB, 128-bit salt, and 256-bit tag as the first recommended option, and Argon2id with <code class="language-plaintext highlighter-rouge">t=3</code>, <code class="language-plaintext highlighter-rouge">p=4</code>, <code class="language-plaintext highlighter-rouge">m=2^16</code> KiB, 128-bit salt, and 256-bit tag as the second recommended option for memory-constrained environments. <a href="#fnref:argon2-rec" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:argon2-tradeoff" role="doc-endnote">
      <p>RFC 9106, Section 7.2, <a href="https://datatracker.ietf.org/doc/html/rfc9106#section-7.2">“Security against Time-Space Trade-off Attacks”</a>, discusses attacks that store fewer memory blocks and recompute missing blocks, measuring advantage through time-area product. The same section references the Argon2 paper: Biryukov, Dinu, and Khovratovich, <a href="https://www.cryptolux.org/images/0/0d/Argon2.pdf">“Argon2: the memory-hard function for password hashing and other applications”</a>, 2017. <a href="#fnref:argon2-tradeoff" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Teo</name><email>zengwc.teo2016@outlook.com</email></author><category term="computer-science" /><category term="password-hashing" /><category term="argon2" /><category term="scrypt" /><category term="memory-hard-functions" /><category term="authentication" /><category term="security" /><category term="cryptography" /><summary type="html"><![CDATA[A password hash is not just a slow hash. Memory-hard schemes make offline guessing rent RAM, burn bandwidth, carry per-user salts, and fit inside a real login latency budget.]]></summary></entry><entry><title type="html">The Hash Join Gets a Spillway</title><link href="https://sir-teo.github.io/blogs/software-engineering/2026/06/18/the-hash-join-gets-a-spillway.html" rel="alternate" type="text/html" title="The Hash Join Gets a Spillway" /><published>2026-06-18T09:48:00-04:00</published><updated>2026-06-18T09:48:00-04:00</updated><id>https://sir-teo.github.io/blogs/software-engineering/2026/06/18/the-hash-join-gets-a-spillway</id><content type="html" xml:base="https://sir-teo.github.io/blogs/software-engineering/2026/06/18/the-hash-join-gets-a-spillway.html"><![CDATA[<p>The tiny hash-join story fits on one napkin:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>build a hash table from the smaller input
scan the larger input
probe the table for matching keys
</code></pre></div></div>

<p>That is the pleasant case. It is also the case that hides the interesting
engineering.</p>

<p>The executor does not always receive a build side that fits in memory. Cardinality
estimates are wrong. Tuple widths are larger than expected. A key distribution is
not uniform. Several queries are competing for memory. At that point the hash
join needs a spillway: a way to turn one too-large join into many smaller joins
without changing the answer.</p>

<p>The trick is to partition both relations by the join key.</p>

<p>If a build tuple <code class="language-plaintext highlighter-rouge">r</code> and a probe tuple <code class="language-plaintext highlighter-rouge">s</code> can join, then their join keys are
equal, so a deterministic partition hash sends them to the same batch:</p>

\[h(r.key)=h(s.key).\]

<p>Therefore:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>join(R, S)
=
union over batches i of join(R_i, S_i)
</code></pre></div></div>

<p>The hash table only has to hold one build batch at a time.</p>

<h2 id="two-hashes-two-jobs">Two Hashes, Two Jobs</h2>

<p>It helps to separate two hashes that are often blurred together.</p>

<p>The <strong>partition hash</strong> decides which batch a tuple belongs to. It is the spillway
contract. Corresponding build and probe tuples must go to the same batch.</p>

<p>The <strong>lookup hash</strong> is used inside one resident batch to find matching build
tuples quickly.</p>

<p>These are conceptually different jobs. The first bounds memory by cutting the
problem into files. The second avoids comparing every tuple inside a file.</p>

<p>Shapiro’s 1986 paper describes three hash-based join strategies: simple hash,
GRACE hash, and hybrid hash.<sup id="fnref:shapiro" role="doc-noteref"><a href="#fn:shapiro" class="footnote" rel="footnote">1</a></sup> The shared idea is partitioning. If the
smaller relation cannot fit in memory, partition both inputs into corresponding
subsets, then join matching subsets. The hybrid algorithm keeps one partition
resident while spilling the rest, so it degrades from an in-memory hash join
toward GRACE-style partitioning as memory becomes scarce.</p>

<p>That is the shape modern systems still expose. PostgreSQL’s <code class="language-plaintext highlighter-rouge">EXPLAIN ANALYZE</code>
shows <code class="language-plaintext highlighter-rouge">Buckets</code>, <code class="language-plaintext highlighter-rouge">Batches</code>, and hash memory usage on hash nodes; its docs note
that more than one batch implies disk space is involved even when the display
does not show the disk amount.<sup id="fnref:pg-explain" role="doc-noteref"><a href="#fn:pg-explain" class="footnote" rel="footnote">2</a></sup> PostgreSQL also makes the memory
budget for hash operations depend on <code class="language-plaintext highlighter-rouge">work_mem * hash_mem_multiplier</code>.<sup id="fnref:pg-memory" role="doc-noteref"><a href="#fn:pg-memory" class="footnote" rel="footnote">3</a></sup></p>

<p>So the line:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Buckets: 65536  Batches: 8  Memory Usage: 4096kB
</code></pre></div></div>

<p>is not decoration. It is a field report from the spillway.</p>

<h2 id="lab-batches-are-a-memory-promise">Lab: Batches Are a Memory Promise</h2>

<p>The lab below builds deterministic synthetic key-count tables for a build input
and a probe input. It estimates the batch count from the build-side memory
budget, partitions both sides by the same hash, then doubles the batch count
until every build batch fits or a single key is too large to split.</p>

<p>It does not simulate PostgreSQL tuple headers, cache misses, executor state
machines, parallel barriers, filesystem behavior, or output materialization.
The purpose is narrower:</p>

<ul>
  <li>show why build and probe sides must be partitioned together;</li>
  <li>show why <code class="language-plaintext highlighter-rouge">Batches &gt; 1</code> means extra temporary writes and reads;</li>
  <li>show why skew can make the original batch estimate grow;</li>
  <li>show the degenerate case where one hot key is larger than memory.</li>
</ul>

<style>
  .hash-join-lab {
    background: #ffffff;
    border: 1px solid #d7dee5;
    border-radius: 8px;
    box-sizing: border-box;
    color: #172026;
    margin: 2rem 0;
    max-width: 100%;
    overflow: hidden;
    padding: 1rem;
  }
  .hash-join-lab__controls {
    display: grid;
    gap: 0.8rem 1rem;
    grid-template-columns: repeat(auto-fit, minmax(136px, 1fr));
    margin-bottom: 1rem;
  }
  .hash-join-lab label {
    color: #39444e;
    display: grid;
    font-family: var(--font-ui);
    font-size: 0.82rem;
    font-weight: 700;
    gap: 0.35rem;
    line-height: 1.25;
  }
  .hash-join-lab input,
  .hash-join-lab select {
    accent-color: #0f766e;
    width: 100%;
  }
  .hash-join-lab select {
    background: #ffffff;
    border: 1px solid #d7dee5;
    border-radius: 6px;
    color: #172026;
    font: inherit;
    min-height: 2rem;
    padding: 0.25rem 0.45rem;
  }
  .hash-join-lab output {
    color: #172026;
    font-variant-numeric: tabular-nums;
    font-weight: 800;
  }
  .hash-join-lab__metrics {
    display: grid;
    gap: 0.55rem;
    grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
    margin: 0.85rem 0 0.95rem;
  }
  .hash-join-lab__metric {
    background: #f8fafc;
    border: 1px solid #e3e9ef;
    border-radius: 6px;
    min-height: 4rem;
    padding: 0.6rem 0.68rem;
  }
  .hash-join-lab__metric span {
    color: #5d6872;
    display: block;
    font-size: 0.7rem;
    font-weight: 800;
    letter-spacing: 0;
    line-height: 1.2;
    text-transform: uppercase;
  }
  .hash-join-lab__metric strong {
    color: #172026;
    display: block;
    font-size: 1.05rem;
    font-variant-numeric: tabular-nums;
    line-height: 1.15;
    margin-top: 0.28rem;
    overflow-wrap: anywhere;
  }
  .hash-join-lab__metric--good strong {
    color: #0f766e;
  }
  .hash-join-lab__metric--warn strong {
    color: #b45309;
  }
  .hash-join-lab__metric--bad strong {
    color: #be123c;
  }
  .hash-join-lab canvas {
    aspect-ratio: 940 / 650;
    border: 1px solid #d7dee5;
    border-radius: 8px;
    box-sizing: border-box;
    display: block;
    height: auto;
    max-width: 100%;
    width: 100%;
  }
  .hash-join-lab__legend {
    align-items: center;
    color: #5d6872;
    display: flex;
    flex-wrap: wrap;
    font-size: 0.78rem;
    font-weight: 800;
    gap: 0.8rem;
    margin-top: 0.75rem;
  }
  .hash-join-lab__legend span {
    align-items: center;
    display: inline-flex;
    gap: 0.35rem;
  }
  .hash-join-lab__legend i {
    border-radius: 999px;
    display: inline-block;
    height: 0.68rem;
    width: 0.68rem;
  }
  .hash-join-lab__note {
    color: #5d6872;
    font-size: 0.88rem;
    margin: 0.75rem 0 0;
  }
</style>

<div class="hash-join-lab" data-hash-join-lab="">
  <div class="hash-join-lab__controls">
    <label>
      Key profile
      <select data-param="profile" aria-label="Key distribution profile">
        <option value="zipf" selected="">Long-tail keys</option>
        <option value="uniform">Uniform keys</option>
        <option value="hot">Single hot key</option>
        <option value="burst">Two-cluster skew</option>
      </select>
    </label>
    <label>
      Build rows <output data-value="buildRows">240,000</output>
      <input type="range" data-param="buildRows" min="20000" max="800000" step="10000" value="240000" aria-label="Build side rows" />
    </label>
    <label>
      Probe rows <output data-value="probeRows">620,000</output>
      <input type="range" data-param="probeRows" min="20000" max="1600000" step="20000" value="620000" aria-label="Probe side rows" />
    </label>
    <label>
      Distinct keys <output data-value="keys">12,000</output>
      <input type="range" data-param="keys" min="256" max="65536" step="256" value="12000" aria-label="Number of distinct join keys" />
    </label>
    <label>
      Memory rows <output data-value="memoryRows">42,000</output>
      <input type="range" data-param="memoryRows" min="4000" max="240000" step="2000" value="42000" aria-label="Build rows that fit in memory" />
    </label>
    <label>
      Skew exponent <output data-value="skew">0.95</output>
      <input type="range" data-param="skew" min="0" max="180" step="5" value="95" aria-label="Distribution skew" />
    </label>
    <label>
      Seed <output data-value="seed">29</output>
      <input type="range" data-param="seed" min="1" max="9999" step="1" value="29" aria-label="Random seed" />
    </label>
  </div>
  <div class="hash-join-lab__metrics" data-metrics=""></div>
  <canvas data-canvas="" width="940" height="650" aria-label="Hash join spill lab showing build-side batches, executor batch growth, temporary I/O, and top skewed keys"></canvas>
  <div class="hash-join-lab__legend">
    <span><i style="background:#0f766e"></i>Build rows</span>
    <span><i style="background:#2563eb"></i>Probe rows / GRACE comparison</span>
    <span><i style="background:#b45309"></i>Memory or write/read boundary</span>
    <span><i style="background:#be123c"></i>Overflow</span>
    <span><i style="background:#7c3aed"></i>Batch growth</span>
  </div>
  <p class="hash-join-lab__note" data-note="">
    Long-tail keys: 240,000 build rows, 620,000 probe rows, memory for 42,000
    build rows. The planner-sized 8 batches become 16; all build batches fit.
  </p>
</div>

<script defer="" src="/blogs/assets/js/hash-join-spill-lab.js?v=20260618c"></script>

<h2 id="the-default-run">The Default Run</h2>

<p>The default run has:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">240,000</code> build rows;</li>
  <li><code class="language-plaintext highlighter-rouge">620,000</code> probe rows;</li>
  <li><code class="language-plaintext highlighter-rouge">12,000</code> distinct keys;</li>
  <li>memory for <code class="language-plaintext highlighter-rouge">42,000</code> build rows;</li>
  <li>a long-tail key distribution with skew exponent <code class="language-plaintext highlighter-rouge">0.95</code>.</li>
</ul>

<p>The initial estimate is:</p>

\[\left\lceil {240000\over 42000} \right\rceil = 6\]

<p>rounded up to <code class="language-plaintext highlighter-rouge">8</code> power-of-two batches. Under the actual key distribution, one
of those batches is too large, so the executor model doubles to <code class="language-plaintext highlighter-rouge">16</code> batches.
The largest build batch then has <code class="language-plaintext highlighter-rouge">35,434</code> rows, below the <code class="language-plaintext highlighter-rouge">42,000</code> row memory
budget.</p>

<p>The hybrid spill ledger reports:</p>

<table>
  <thead>
    <tr>
      <th>Quantity</th>
      <th style="text-align: right">Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Original batches</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">8</code></td>
    </tr>
    <tr>
      <td>Actual batches</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">16</code></td>
    </tr>
    <tr>
      <td>Largest build batch</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">35,434</code> rows</td>
    </tr>
    <tr>
      <td>Build rows kept in resident batch 0</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">11,523</code></td>
    </tr>
    <tr>
      <td>Temporary rows written plus read</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">1,638,784</code></td>
    </tr>
    <tr>
      <td>Top build key</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">14,233</code> rows</td>
    </tr>
    <tr>
      <td>Estimated output pairs</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">1.05B</code></td>
    </tr>
  </tbody>
</table>

<p>The last number is a separate warning. A spill strategy controls memory. It
does not make a high-multiplicity join small. If many build and probe tuples
share a key, the output can still be enormous.</p>

<p>The exported audit grid is reproducible from Node:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">lab</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">"</span><span class="s2">./assets/js/hash-join-spill-lab.js</span><span class="dl">"</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">EXPECTED_CASE_SHAPE</span> <span class="o">=</span> <span class="p">[</span>
  <span class="dl">"</span><span class="s2">1:uniform:120000/260000:mem=40000:4-&gt;4:growth=0:fits=true:30853:570148:12:11/11</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">2:zipf:240000/620000:mem=42000:8-&gt;16:growth=1:fits=true:35434:1638784:14233:11/11</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">3:hot:220000/500000:mem=35000:8-&gt;8:growth=0:fits=false:65129:1295540:42592:11/11</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">4:burst:360000/700000:mem=56000:8-&gt;8:growth=0:fits=true:47659:1858390:148:11/11</span><span class="dl">"</span>
<span class="p">];</span>
<span class="kd">const</span> <span class="nx">EXPECTED_BY_PROFILE_SHAPE</span> <span class="o">=</span> <span class="p">[</span>
  <span class="dl">"</span><span class="s2">uniform:4-&gt;4:fits=true:1/1:11/11</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">zipf:8-&gt;16:fits=true:1/1:11/11</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">hot:8-&gt;8:fits=false:1/1:11/11</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">burst:8-&gt;8:fits=true:1/1:11/11</span><span class="dl">"</span>
<span class="p">];</span>
<span class="kd">const</span> <span class="nx">EXPECTED_CHECK_NAMES</span> <span class="o">=</span> <span class="p">[</span>
  <span class="dl">"</span><span class="s2">build rows preserved</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">probe rows preserved</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">batch count power of two</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">partition build rows preserved</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">partition probe rows preserved</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">temp accounting nonnegative</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">hybrid does not spill more than grace</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">fits or has skew explanation</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">top key count agrees</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">uniform case fits after batching</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">hot key can exceed memory</span><span class="dl">"</span>
<span class="p">];</span>
<span class="kd">const</span> <span class="nx">EXPECTED_CHECK_ROWS</span> <span class="o">=</span> <span class="p">[</span>
  <span class="dl">"</span><span class="s2">1:uniform:build rows preserved=true|probe rows preserved=true|batch count power of two=true|partition build rows preserved=true|partition probe rows preserved=true|temp accounting nonnegative=true|hybrid does not spill more than grace=true|fits or has skew explanation=true|top key count agrees=true|uniform case fits after batching=true|hot key can exceed memory=true</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">2:zipf:build rows preserved=true|probe rows preserved=true|batch count power of two=true|partition build rows preserved=true|partition probe rows preserved=true|temp accounting nonnegative=true|hybrid does not spill more than grace=true|fits or has skew explanation=true|top key count agrees=true|uniform case fits after batching=true|hot key can exceed memory=true</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">3:hot:build rows preserved=true|probe rows preserved=true|batch count power of two=true|partition build rows preserved=true|partition probe rows preserved=true|temp accounting nonnegative=true|hybrid does not spill more than grace=true|fits or has skew explanation=true|top key count agrees=true|uniform case fits after batching=true|hot key can exceed memory=true</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">4:burst:build rows preserved=true|probe rows preserved=true|batch count power of two=true|partition build rows preserved=true|partition probe rows preserved=true|temp accounting nonnegative=true|hybrid does not spill more than grace=true|fits or has skew explanation=true|top key count agrees=true|uniform case fits after batching=true|hot key can exceed memory=true</span><span class="dl">"</span>
<span class="p">];</span>
<span class="kd">const</span> <span class="nx">EXPECTED_CASES</span> <span class="o">=</span> <span class="nx">EXPECTED_CASE_SHAPE</span><span class="p">.</span><span class="nx">length</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">EXPECTED_CHECKS</span> <span class="o">=</span> <span class="nx">EXPECTED_CASES</span> <span class="o">*</span> <span class="nx">EXPECTED_CHECK_NAMES</span><span class="p">.</span><span class="nx">length</span><span class="p">;</span>

<span class="kd">function</span> <span class="nx">sameList</span><span class="p">(</span><span class="nx">actual</span><span class="p">,</span> <span class="nx">expected</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nx">actual</span><span class="p">.</span><span class="nx">length</span> <span class="o">===</span> <span class="nx">expected</span><span class="p">.</span><span class="nx">length</span> <span class="o">&amp;&amp;</span>
    <span class="nx">actual</span><span class="p">.</span><span class="nx">every</span><span class="p">((</span><span class="nx">value</span><span class="p">,</span> <span class="nx">index</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">value</span> <span class="o">===</span> <span class="nx">expected</span><span class="p">[</span><span class="nx">index</span><span class="p">]);</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nx">caseShape</span><span class="p">(</span><span class="nx">row</span><span class="p">,</span> <span class="nx">index</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="s2">`</span><span class="p">${</span><span class="nx">index</span> <span class="o">+</span> <span class="mi">1</span><span class="p">}</span><span class="s2">:</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">profile</span><span class="p">}</span><span class="s2">:</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">buildRows</span><span class="p">}</span><span class="s2">/</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">probeRows</span><span class="p">}</span><span class="s2">:`</span> <span class="o">+</span>
    <span class="s2">`mem=</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">memoryRows</span><span class="p">}</span><span class="s2">:</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">originalBatches</span><span class="p">}</span><span class="s2">-&gt;</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">actualBatches</span><span class="p">}</span><span class="s2">:`</span> <span class="o">+</span>
    <span class="s2">`growth=</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">growthEvents</span><span class="p">}</span><span class="s2">:fits=</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">fits</span><span class="p">}</span><span class="s2">:`</span> <span class="o">+</span>
    <span class="s2">`</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">maxBuildBatchRows</span><span class="p">}</span><span class="s2">:</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">hybridTempRows</span><span class="p">}</span><span class="s2">:</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">topBuildKeyRows</span><span class="p">}</span><span class="s2">:`</span> <span class="o">+</span>
    <span class="s2">`</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">passedChecks</span><span class="p">}</span><span class="s2">/</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">totalChecks</span><span class="p">}</span><span class="s2">`</span><span class="p">;</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nx">byProfileShape</span><span class="p">(</span><span class="nx">row</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="s2">`</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">profile</span><span class="p">}</span><span class="s2">:</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">batches</span><span class="p">}</span><span class="s2">:fits=</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">fits</span><span class="p">}</span><span class="s2">:`</span> <span class="o">+</span>
    <span class="s2">`</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">passed</span><span class="p">}</span><span class="s2">/</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">total</span><span class="p">}</span><span class="s2">:</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">passedChecks</span><span class="p">}</span><span class="s2">/</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">totalChecks</span><span class="p">}</span><span class="s2">`</span><span class="p">;</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nx">checkRow</span><span class="p">(</span><span class="nx">row</span><span class="p">,</span> <span class="nx">index</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="s2">`</span><span class="p">${</span><span class="nx">index</span> <span class="o">+</span> <span class="mi">1</span><span class="p">}</span><span class="s2">:</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">profile</span><span class="p">}</span><span class="s2">:`</span> <span class="o">+</span>
    <span class="nx">row</span><span class="p">.</span><span class="nx">checks</span><span class="p">.</span><span class="nx">map</span><span class="p">((</span><span class="nx">check</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="s2">`</span><span class="p">${</span><span class="nx">check</span><span class="p">.</span><span class="nx">name</span><span class="p">}</span><span class="s2">=</span><span class="p">${</span><span class="nx">check</span><span class="p">.</span><span class="nx">ok</span><span class="p">}</span><span class="s2">`</span><span class="p">).</span><span class="nx">join</span><span class="p">(</span><span class="dl">"</span><span class="s2">|</span><span class="dl">"</span><span class="p">);</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nx">rowDrifts</span><span class="p">(</span><span class="nx">actual</span><span class="p">,</span> <span class="nx">expected</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="p">{</span>
    <span class="na">missing</span><span class="p">:</span> <span class="nx">expected</span><span class="p">.</span><span class="nx">filter</span><span class="p">((</span><span class="nx">row</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="o">!</span><span class="nx">actual</span><span class="p">.</span><span class="nx">includes</span><span class="p">(</span><span class="nx">row</span><span class="p">)),</span>
    <span class="na">extra</span><span class="p">:</span> <span class="nx">actual</span><span class="p">.</span><span class="nx">filter</span><span class="p">((</span><span class="nx">row</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="o">!</span><span class="nx">expected</span><span class="p">.</span><span class="nx">includes</span><span class="p">(</span><span class="nx">row</span><span class="p">))</span>
  <span class="p">};</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nx">hasRowDrift</span><span class="p">(</span><span class="nx">drift</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nx">drift</span><span class="p">.</span><span class="nx">missing</span><span class="p">.</span><span class="nx">length</span> <span class="o">&gt;</span> <span class="mi">0</span> <span class="o">||</span> <span class="nx">drift</span><span class="p">.</span><span class="nx">extra</span><span class="p">.</span><span class="nx">length</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>

<span class="kd">const</span> <span class="nx">audit</span> <span class="o">=</span> <span class="nx">lab</span><span class="p">.</span><span class="nx">auditGrid</span><span class="p">();</span>
<span class="kd">const</span> <span class="nx">failed</span> <span class="o">=</span> <span class="nx">audit</span><span class="p">.</span><span class="nx">cases</span><span class="p">.</span><span class="nx">filter</span><span class="p">(</span>
  <span class="p">(</span><span class="nx">row</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="o">!</span><span class="nx">row</span><span class="p">.</span><span class="nx">passed</span> <span class="o">||</span> <span class="nx">row</span><span class="p">.</span><span class="nx">passedChecks</span> <span class="o">!==</span> <span class="nx">row</span><span class="p">.</span><span class="nx">totalChecks</span>
<span class="p">);</span>
<span class="nx">console</span><span class="p">.</span><span class="nx">table</span><span class="p">(</span><span class="nx">audit</span><span class="p">.</span><span class="nx">byProfile</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">shape</span> <span class="o">=</span> <span class="p">{</span>
  <span class="na">byProfile</span><span class="p">:</span> <span class="nx">audit</span><span class="p">.</span><span class="nx">byProfile</span><span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">byProfileShape</span><span class="p">),</span>
  <span class="na">cases</span><span class="p">:</span> <span class="nx">audit</span><span class="p">.</span><span class="nx">cases</span><span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">caseShape</span><span class="p">),</span>
  <span class="na">checkRows</span><span class="p">:</span> <span class="nx">audit</span><span class="p">.</span><span class="nx">cases</span><span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">checkRow</span><span class="p">)</span>
<span class="p">};</span>
<span class="kd">const</span> <span class="nx">checkRowDrifts</span> <span class="o">=</span> <span class="nx">rowDrifts</span><span class="p">(</span><span class="nx">shape</span><span class="p">.</span><span class="nx">checkRows</span><span class="p">,</span> <span class="nx">EXPECTED_CHECK_ROWS</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">shapeErrors</span> <span class="o">=</span> <span class="p">[];</span>
<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">sameList</span><span class="p">(</span><span class="nx">shape</span><span class="p">.</span><span class="nx">cases</span><span class="p">,</span> <span class="nx">EXPECTED_CASE_SHAPE</span><span class="p">))</span> <span class="p">{</span>
  <span class="nx">shapeErrors</span><span class="p">.</span><span class="nx">push</span><span class="p">({</span> <span class="na">name</span><span class="p">:</span> <span class="dl">"</span><span class="s2">cases</span><span class="dl">"</span><span class="p">,</span> <span class="na">actual</span><span class="p">:</span> <span class="nx">shape</span><span class="p">.</span><span class="nx">cases</span><span class="p">,</span> <span class="na">expected</span><span class="p">:</span> <span class="nx">EXPECTED_CASE_SHAPE</span> <span class="p">});</span>
<span class="p">}</span>
<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">sameList</span><span class="p">(</span><span class="nx">shape</span><span class="p">.</span><span class="nx">byProfile</span><span class="p">,</span> <span class="nx">EXPECTED_BY_PROFILE_SHAPE</span><span class="p">))</span> <span class="p">{</span>
  <span class="nx">shapeErrors</span><span class="p">.</span><span class="nx">push</span><span class="p">({</span>
    <span class="na">name</span><span class="p">:</span> <span class="dl">"</span><span class="s2">byProfile</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">actual</span><span class="p">:</span> <span class="nx">shape</span><span class="p">.</span><span class="nx">byProfile</span><span class="p">,</span>
    <span class="na">expected</span><span class="p">:</span> <span class="nx">EXPECTED_BY_PROFILE_SHAPE</span>
  <span class="p">});</span>
<span class="p">}</span>
<span class="nx">audit</span><span class="p">.</span><span class="nx">cases</span><span class="p">.</span><span class="nx">forEach</span><span class="p">((</span><span class="nx">row</span><span class="p">,</span> <span class="nx">index</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">names</span> <span class="o">=</span> <span class="nx">row</span><span class="p">.</span><span class="nx">checks</span><span class="p">.</span><span class="nx">map</span><span class="p">((</span><span class="nx">check</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">check</span><span class="p">.</span><span class="nx">name</span><span class="p">);</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">sameList</span><span class="p">(</span><span class="nx">names</span><span class="p">,</span> <span class="nx">EXPECTED_CHECK_NAMES</span><span class="p">))</span> <span class="p">{</span>
    <span class="nx">shapeErrors</span><span class="p">.</span><span class="nx">push</span><span class="p">({</span>
      <span class="na">name</span><span class="p">:</span> <span class="s2">`case </span><span class="p">${</span><span class="nx">index</span> <span class="o">+</span> <span class="mi">1</span><span class="p">}</span><span class="s2"> check names`</span><span class="p">,</span>
      <span class="na">actual</span><span class="p">:</span> <span class="nx">names</span><span class="p">,</span>
      <span class="na">expected</span><span class="p">:</span> <span class="nx">EXPECTED_CHECK_NAMES</span>
    <span class="p">});</span>
  <span class="p">}</span>
<span class="p">});</span>
<span class="k">if</span> <span class="p">(</span><span class="nx">hasRowDrift</span><span class="p">(</span><span class="nx">checkRowDrifts</span><span class="p">))</span> <span class="p">{</span>
  <span class="nx">shapeErrors</span><span class="p">.</span><span class="nx">push</span><span class="p">({</span> <span class="na">name</span><span class="p">:</span> <span class="dl">"</span><span class="s2">check rows</span><span class="dl">"</span><span class="p">,</span> <span class="na">drift</span><span class="p">:</span> <span class="nx">checkRowDrifts</span> <span class="p">});</span>
<span class="p">}</span>

<span class="nx">console</span><span class="p">.</span><span class="nx">table</span><span class="p">(</span><span class="nx">audit</span><span class="p">.</span><span class="nx">cases</span><span class="p">.</span><span class="nx">map</span><span class="p">((</span><span class="nx">row</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">({</span>
  <span class="na">profile</span><span class="p">:</span> <span class="nx">row</span><span class="p">.</span><span class="nx">profile</span><span class="p">,</span>
  <span class="na">batches</span><span class="p">:</span> <span class="s2">`</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">originalBatches</span><span class="p">}</span><span class="s2">-&gt;</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">actualBatches</span><span class="p">}</span><span class="s2">`</span><span class="p">,</span>
  <span class="na">fits</span><span class="p">:</span> <span class="nx">row</span><span class="p">.</span><span class="nx">fits</span><span class="p">,</span>
  <span class="na">maxBuildBatch</span><span class="p">:</span> <span class="nx">row</span><span class="p">.</span><span class="nx">maxBuildBatchRows</span><span class="p">,</span>
  <span class="na">memoryRows</span><span class="p">:</span> <span class="nx">row</span><span class="p">.</span><span class="nx">memoryRows</span><span class="p">,</span>
  <span class="na">tempRows</span><span class="p">:</span> <span class="nx">row</span><span class="p">.</span><span class="nx">hybridTempRows</span><span class="p">,</span>
  <span class="na">topKeyRows</span><span class="p">:</span> <span class="nx">row</span><span class="p">.</span><span class="nx">topBuildKeyRows</span><span class="p">,</span>
  <span class="na">checks</span><span class="p">:</span> <span class="s2">`</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">passedChecks</span><span class="p">}</span><span class="s2">/</span><span class="p">${</span><span class="nx">row</span><span class="p">.</span><span class="nx">totalChecks</span><span class="p">}</span><span class="s2">`</span>
<span class="p">})));</span>
<span class="kd">const</span> <span class="nx">summary</span> <span class="o">=</span>
  <span class="s2">`</span><span class="p">${</span><span class="nx">audit</span><span class="p">.</span><span class="nx">passed</span><span class="p">}</span><span class="s2">/</span><span class="p">${</span><span class="nx">audit</span><span class="p">.</span><span class="nx">total</span><span class="p">}</span><span class="s2"> cases and </span><span class="p">${</span><span class="nx">audit</span><span class="p">.</span><span class="nx">passedChecks</span><span class="p">}</span><span class="s2">/</span><span class="p">${</span><span class="nx">audit</span><span class="p">.</span><span class="nx">totalChecks</span><span class="p">}</span><span class="s2"> checks passed`</span><span class="p">;</span>
<span class="k">if</span> <span class="p">(</span>
  <span class="nx">shapeErrors</span><span class="p">.</span><span class="nx">length</span> <span class="o">||</span>
  <span class="nx">failed</span><span class="p">.</span><span class="nx">length</span> <span class="o">||</span>
  <span class="o">!</span><span class="nx">audit</span><span class="p">.</span><span class="nx">ok</span> <span class="o">||</span>
  <span class="nx">audit</span><span class="p">.</span><span class="nx">total</span> <span class="o">!==</span> <span class="nx">EXPECTED_CASES</span> <span class="o">||</span>
  <span class="nx">audit</span><span class="p">.</span><span class="nx">totalChecks</span> <span class="o">!==</span> <span class="nx">EXPECTED_CHECKS</span> <span class="o">||</span>
  <span class="nx">audit</span><span class="p">.</span><span class="nx">passed</span> <span class="o">!==</span> <span class="nx">audit</span><span class="p">.</span><span class="nx">total</span> <span class="o">||</span>
  <span class="nx">audit</span><span class="p">.</span><span class="nx">passedChecks</span> <span class="o">!==</span> <span class="nx">audit</span><span class="p">.</span><span class="nx">totalChecks</span>
<span class="p">)</span> <span class="p">{</span>
  <span class="k">throw</span> <span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">({</span>
    <span class="nx">summary</span><span class="p">,</span>
    <span class="nx">shapeErrors</span><span class="p">,</span>
    <span class="nx">shape</span><span class="p">,</span>
    <span class="nx">failed</span><span class="p">,</span>
    <span class="na">totals</span><span class="p">:</span> <span class="p">{</span>
      <span class="na">total</span><span class="p">:</span> <span class="nx">audit</span><span class="p">.</span><span class="nx">total</span><span class="p">,</span>
      <span class="na">passed</span><span class="p">:</span> <span class="nx">audit</span><span class="p">.</span><span class="nx">passed</span><span class="p">,</span>
      <span class="na">passedChecks</span><span class="p">:</span> <span class="nx">audit</span><span class="p">.</span><span class="nx">passedChecks</span><span class="p">,</span>
      <span class="na">totalChecks</span><span class="p">:</span> <span class="nx">audit</span><span class="p">.</span><span class="nx">totalChecks</span>
    <span class="p">}</span>
  <span class="p">},</span> <span class="kc">null</span><span class="p">,</span> <span class="mi">2</span><span class="p">));</span>
<span class="p">}</span>
<span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="nx">summary</span><span class="p">);</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">hot</code> row is allowed to fail the fit test only because the audit also
requires a skew explanation. A spill plan that silently overflows memory is not
a valid plan.</p>

<p>The current grid covers four key profiles and 44 named checks. Those checks
verify row preservation before and after partitioning, power-of-two batch
counts, nonnegative temporary I/O, hybrid-vs-GRACE spill accounting, top-key
bookkeeping, and the two regression cases the prose leans on: uniform data fits
after batching, while a single hot key can exceed memory even after batch
growth.</p>

<h2 id="the-spill-invariant">The Spill Invariant</h2>

<p>Let the partition function be:</p>

\[b(k)=h(k) \bmod B.\]

<p>For each batch <code class="language-plaintext highlighter-rouge">i</code>, define:</p>

\[R_i = \{r \in R : b(r.key)=i\}\]

<p>and</p>

\[S_i = \{s \in S : b(s.key)=i\}.\]

<p>If <code class="language-plaintext highlighter-rouge">r.key = s.key</code>, then <code class="language-plaintext highlighter-rouge">b(r.key) = b(s.key)</code>. Therefore every true join pair
appears inside exactly one corresponding pair <code class="language-plaintext highlighter-rouge">(R_i, S_i)</code>. No valid pair is
lost by processing one batch at a time.</p>

<p>This is why the probe side must be spilled too. It is not enough to save the
build partitions. The executor also needs the matching probe tuples waiting in
the same batch files, or it would have to rescan the entire probe input for
every build batch.</p>

<p>The simplified temporary I/O cost for a GRACE-style partitioned hash join is
roughly:</p>

\[2(|R|+|S|)\]

<p>temporary rows: write both inputs into partitions, then read them back. A hybrid
hash join saves the resident batch. In the default lab run, batch <code class="language-plaintext highlighter-rouge">0</code> keeps
<code class="language-plaintext highlighter-rouge">11,523</code> build rows and its corresponding <code class="language-plaintext highlighter-rouge">29,085</code> probe rows out of temporary
files, so the hybrid ledger is smaller than the all-partitioned ledger.</p>

<h2 id="when-batches-explode">When Batches Explode</h2>

<p>PostgreSQL’s executor comments describe the modern dynamic version: if the inner
side does not fit, hash join can run in multiple batches; if the real hash table
grows too large, the executor increases the number of batches, always by powers
of two.<sup id="fnref:pg-source" role="doc-noteref"><a href="#fn:pg-source" class="footnote" rel="footnote">4</a></sup></p>

<p>That power-of-two detail is not cosmetic. It lets the executor add one more hash
bit to split every old batch into two possible new batches:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>old batch: h(key) &amp; 0b0111
new batch: h(key) &amp; 0b1111
</code></pre></div></div>

<p>Uniform data responds nicely. Long-tail data responds imperfectly. A single
heavy key does not respond at all, because all rows with that key must stay in
the same batch. PostgreSQL’s source comments call out this kind of best-effort
edge: if doubling leaves a batch with all or none of its tuples, growth can be
disabled globally, and later oversized batches may exceed the nominal budget.<sup id="fnref:pg-source:1" role="doc-noteref"><a href="#fn:pg-source" class="footnote" rel="footnote">4</a></sup></p>

<p>In the lab, switch <strong>Key profile</strong> to <code class="language-plaintext highlighter-rouge">Single hot key</code> and raise <strong>Skew exponent</strong>.
The top-key panel eventually turns red: one key alone is larger than memory.
No partition hash on the join key can split that key while preserving the join
invariant.</p>

<p>Real engines have more choices than this toy:</p>

<ul>
  <li>skew optimization for most-common values;</li>
  <li>recursive partitioning;</li>
  <li>bloom filters or semijoins to reduce one side before spilling;</li>
  <li>switching join algorithms;</li>
  <li>increasing memory for this operator;</li>
  <li>parallel work sharing;</li>
  <li>pushing predicates earlier;</li>
  <li>changing the query shape so the build side is genuinely smaller.</li>
</ul>

<p>The invariant remains the same. If a batch is too large because many distinct
keys landed there, more hash bits can help. If a batch is too large because one
key is too large, batch count is the wrong lever.</p>

<h2 id="reading-the-plan">Reading The Plan</h2>

<p>For a production PostgreSQL plan, I would read a hash join spill with four
questions:</p>

<ol>
  <li>Is <code class="language-plaintext highlighter-rouge">Batches</code> greater than <code class="language-plaintext highlighter-rouge">1</code>?</li>
  <li>Did actual batches exceed original batches?</li>
  <li>Is the build-side row estimate or width estimate badly wrong?</li>
  <li>Is the join key skewed enough that more batches cannot divide the problem?</li>
</ol>

<p>The first question says whether the spillway opened. The second says whether
the executor had to resize the spillway while running. The third asks whether
the optimizer walked into the wrong memory budget. The fourth asks whether the
data distribution violated the uniform partition fantasy.</p>

<p>That last phrase is the quiet contract behind hash join:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>partitioning is a memory theorem only if the partitions become small enough
</code></pre></div></div>

<p>Hash join is fast because equality makes a partition proof possible. It is
fragile because the proof says where matches go, not how evenly the data will
arrive.</p>

<h2 id="sources">Sources</h2>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:shapiro" role="doc-endnote">
      <p>Leonard D. Shapiro, <a href="https://cs-people.bu.edu/mathan/reading-groups/papers-classics/join.pdf">“Join Processing in Database Systems with Large Main Memories”</a>, <em>ACM Transactions on Database Systems</em> 11(3), 239-264, 1986. Shapiro presents simple, GRACE, and hybrid hash joins, with partitioning as the shared mechanism, and discusses partition overflow. <a href="#fnref:shapiro" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:pg-explain" role="doc-endnote">
      <p>PostgreSQL documentation, <a href="https://www.postgresql.org/docs/current/using-explain.html">“14.1. Using EXPLAIN”</a>. The docs state that hash nodes show buckets, batches, and peak hash memory; if batches exceed one, disk usage is involved even if that disk amount is not displayed. <a href="#fnref:pg-explain" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:pg-memory" role="doc-endnote">
      <p>PostgreSQL documentation, <a href="https://www.postgresql.org/docs/current/runtime-config-resource.html">“19.4. Resource Consumption”</a>. The docs describe <code class="language-plaintext highlighter-rouge">work_mem</code>, <code class="language-plaintext highlighter-rouge">hash_mem_multiplier</code>, and the fact that hash-based operations use the product as their memory limit. <a href="#fnref:pg-memory" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:pg-source" role="doc-endnote">
      <p>PostgreSQL source comments, <a href="https://doxygen.postgresql.org/nodeHashjoin_8c_source.html"><code class="language-plaintext highlighter-rouge">nodeHashjoin.c</code></a>, lines 26-56 in the generated source view. The comments describe multi-batch hash join, executor batch growth, power-of-two batch doubling, and the skew case where growth can be disabled. <a href="#fnref:pg-source" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:pg-source:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
  </ol>
</div>]]></content><author><name>Teo</name><email>zengwc.teo2016@outlook.com</email></author><category term="software-engineering" /><category term="hash-join" /><category term="databases" /><category term="query-execution" /><category term="spilling" /><category term="postgresql" /><category term="join-algorithms" /><category term="data-systems" /><summary type="html"><![CDATA[A hash join larger than memory survives by partitioning both inputs into matching batches. Spilling is not a side effect; it is the algorithm's escape hatch.]]></summary></entry><entry><title type="html">The Tree Rotates Toward Reuse</title><link href="https://sir-teo.github.io/blogs/computer-science/2026/06/18/the-tree-rotates-toward-reuse.html" rel="alternate" type="text/html" title="The Tree Rotates Toward Reuse" /><published>2026-06-18T09:38:00-04:00</published><updated>2026-06-18T09:38:00-04:00</updated><id>https://sir-teo.github.io/blogs/computer-science/2026/06/18/the-tree-rotates-toward-reuse</id><content type="html" xml:base="https://sir-teo.github.io/blogs/computer-science/2026/06/18/the-tree-rotates-toward-reuse.html"><![CDATA[<p>A balanced binary search tree carries a visible promise.</p>

<p>Red-black colors, AVL heights, B-tree page sizes, treap priorities: some extra
state says why the next path should be short.</p>

<p>A splay tree makes a stranger bargain. It stores no balance metadata. It lets
the shape drift. After every successful access, it rotates the accessed node to
the root.</p>

<p>That sounds almost too eager. Why spend rotations on a lookup that already
found its key?</p>

<p>Because lookup traces are rarely independent uniform samples. Programs ask for
the same object again. They walk around a neighborhood of keys. They change
phases. They scan. A splay tree is a binary search tree that bets on this
temporal structure by editing the tree after the fact.</p>

<p>The slogan is not “splay trees are faster.” A more honest slogan is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pay now to make the recent path shallow
</code></pre></div></div>

<p>Sometimes the bet wins. Sometimes the rotations are just overhead.</p>

<h2 id="the-local-rule">The Local Rule</h2>

<p>Search as usual. When the key is found, repeatedly rotate it upward until it
becomes the root.</p>

<p>The cases are classified by the accessed node <code class="language-plaintext highlighter-rouge">x</code>, its parent <code class="language-plaintext highlighter-rouge">p</code>, and its
grandparent <code class="language-plaintext highlighter-rouge">g</code>.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>zig      x has no grandparent: rotate x over p
zig-zig  x and p are both left children or both right children:
         rotate p over g, then x over p
zig-zag  x is a left child and p is a right child, or the reverse:
         rotate x over p, then x over g
</code></pre></div></div>

<p>The important detail is the double rotation. A naive “rotate the accessed node
one edge at a time” rule is tempting, but Sleator and Tarjan showed that this
simple move-to-root heuristic does not get the same amortized guarantees.<sup id="fnref:sleator-tarjan" role="doc-noteref"><a href="#fn:sleator-tarjan" class="footnote" rel="footnote">1</a></sup>
The zig-zig and zig-zag steps are the accounting trick. They pull <code class="language-plaintext highlighter-rouge">x</code> up while
also compressing a chunk of the old search path.</p>

<p>After an access, the last key is at the root. If the exact same key is accessed
again, the search is one comparison and no rotations. If nearby keys are
accessed next, many of the same ancestors have also been pulled closer.</p>

<h2 id="the-accounting-is-not-stored-in-the-tree">The Accounting Is Not Stored In The Tree</h2>

<p>Splay analysis assigns arbitrary positive weights to items. These weights are
not fields in the nodes. They are a proof device.</p>

<p>For a node <code class="language-plaintext highlighter-rouge">x</code>, define:</p>

\[s(x)=\sum_{y\in subtree(x)} w(y)\]

<p>and</p>

\[r(x)=\log_2 s(x).\]

<p>The potential of the tree is the sum of ranks:</p>

\[\Phi(T)=\sum_x r(x).\]

<p>Sleator and Tarjan’s access lemma says that splaying a node <code class="language-plaintext highlighter-rouge">x</code> in a tree with
root <code class="language-plaintext highlighter-rouge">t</code> has amortized cost at most<sup id="fnref:sleator-tarjan:1" role="doc-noteref"><a href="#fn:sleator-tarjan" class="footnote" rel="footnote">1</a></sup></p>

\[3(r(t)-r(x))+1.\]

<p>Equivalently, up to constants, the cost is controlled by</p>

\[\log {s(t)\over s(x)}.\]

<p>That line is the machine room. Choose all weights equal and the lemma gives the
ordinary logarithmic amortized bound. Choose weights proportional to long-run
access frequencies and it gives static optimality. Change weights as the access
sequence unfolds and it gives a working-set bound.</p>

<p>The algorithm did not change. Only the analysis did.</p>

<h2 id="what-the-theorems-say">What The Theorems Say</h2>

<p>For a sequence of <code class="language-plaintext highlighter-rouge">m</code> accesses on <code class="language-plaintext highlighter-rouge">n</code> keys, the original paper proves a balance
theorem:</p>

\[O((m+n)\log n + m).\]

<p>So over a long enough sequence, splaying is within a constant factor of ordinary
balanced search trees in total access time.</p>

<p>It also proves a static optimality theorem. If key <code class="language-plaintext highlighter-rouge">i</code> is accessed <code class="language-plaintext highlighter-rouge">q(i)</code> times,
then the total access time is bounded by</p>

\[O\left(m+\sum_i q(i)\log {m\over q(i)}\right),\]

<p>assuming every item appears at least once. That resembles an entropy bound: a
key that is asked for often is allowed to become cheap without the tree being
told its frequency in advance.</p>

<p>The working-set theorem is even closer to a systems intuition. Let <code class="language-plaintext highlighter-rouge">t(j)</code> be the
number of distinct keys accessed since the previous access to the key requested
at time <code class="language-plaintext highlighter-rouge">j</code>, or since the beginning of the sequence if this is the key’s first
access. Splay trees have total access time</p>

\[O\left(n\log n + m + \sum_{j=1}^{m}\log(t(j)+1)\right).\]

<p>Recent keys are cheap. Stale keys are allowed to be expensive.</p>

<p>There are also important boundaries. Sleator and Tarjan conjectured dynamic
optimality: roughly, that splay trees are within a constant factor of any binary
search tree algorithm that can also rotate between accesses, even one tailored
to the whole sequence. That remains a conjecture. Later work proved pieces of
the adaptive picture, including Tarjan’s linear-time theorem for one sequential
in-order pass and Cole’s dynamic-finger theorem, which bounds access cost by
the rank distance from the previous access.<sup id="fnref:tarjan-scan" role="doc-noteref"><a href="#fn:tarjan-scan" class="footnote" rel="footnote">2</a></sup><sup id="fnref:cole-finger" role="doc-noteref"><a href="#fn:cole-finger" class="footnote" rel="footnote">3</a></sup></p>

<p>The safe statement is:</p>

<blockquote>
  <p>Splaying has strong amortized guarantees and several proven locality-sensitive
bounds, but not every finite trace beats a well-tuned balanced tree under every
cost model.</p>
</blockquote>

<h2 id="lab-charge-the-rotations">Lab: Charge The Rotations</h2>

<p>The lab below implements a bottom-up splay tree with parent pointers and a
fixed balanced binary search tree over the same keys.</p>

<p>For each access, it records:</p>

<ul>
  <li>splay search comparisons;</li>
  <li>splay rotations;</li>
  <li>splay primitive cost, defined here as comparisons plus rotations;</li>
  <li>fixed balanced-tree comparisons;</li>
  <li>working-set distance in the generated trace.</li>
</ul>

<p>That cost model is intentionally unsentimental. The theorem is amortized and
abstract. Real code still pays for pointer writes, cache behavior, branch
prediction, allocator layout, and concurrency constraints. Counting comparisons
plus rotations is not a full CPU model, but it keeps the rotation bill visible.</p>

<style>
  .splay-tree-lab {
    background: #ffffff;
    border: 1px solid #d7dee5;
    border-radius: 8px;
    box-sizing: border-box;
    color: #172026;
    margin: 2rem 0;
    max-width: 100%;
    overflow: hidden;
    padding: 1rem;
  }
  .splay-tree-lab__controls {
    display: grid;
    gap: 0.8rem 1rem;
    grid-template-columns: repeat(auto-fit, minmax(136px, 1fr));
    margin-bottom: 1rem;
  }
  .splay-tree-lab label {
    color: #39444e;
    display: grid;
    font-family: var(--font-ui);
    font-size: 0.82rem;
    font-weight: 700;
    gap: 0.35rem;
    line-height: 1.25;
  }
  .splay-tree-lab input,
  .splay-tree-lab select {
    accent-color: #0f766e;
    width: 100%;
  }
  .splay-tree-lab select {
    background: #ffffff;
    border: 1px solid #d7dee5;
    border-radius: 6px;
    color: #172026;
    font: inherit;
    min-height: 2rem;
    padding: 0.25rem 0.45rem;
  }
  .splay-tree-lab output {
    color: #172026;
    font-variant-numeric: tabular-nums;
    font-weight: 800;
  }
  .splay-tree-lab__metrics {
    display: grid;
    gap: 0.55rem;
    grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
    margin: 0.85rem 0 0.95rem;
  }
  .splay-tree-lab__metric {
    background: #f8fafc;
    border: 1px solid #e3e9ef;
    border-radius: 6px;
    min-height: 4rem;
    padding: 0.6rem 0.68rem;
  }
  .splay-tree-lab__metric span {
    color: #5d6872;
    display: block;
    font-size: 0.7rem;
    font-weight: 800;
    letter-spacing: 0;
    line-height: 1.2;
    text-transform: uppercase;
  }
  .splay-tree-lab__metric strong {
    color: #172026;
    display: block;
    font-size: 1.08rem;
    font-variant-numeric: tabular-nums;
    line-height: 1.15;
    margin-top: 0.28rem;
    overflow-wrap: anywhere;
  }
  .splay-tree-lab__metric--good strong {
    color: #0f766e;
  }
  .splay-tree-lab__metric--bad strong {
    color: #be123c;
  }
  .splay-tree-lab canvas {
    aspect-ratio: 940 / 650;
    border: 1px solid #d7dee5;
    border-radius: 8px;
    box-sizing: border-box;
    display: block;
    height: auto;
    max-width: 100%;
    width: 100%;
  }
  .splay-tree-lab__legend {
    align-items: center;
    color: #5d6872;
    display: flex;
    flex-wrap: wrap;
    font-size: 0.78rem;
    font-weight: 800;
    gap: 0.8rem;
    margin-top: 0.75rem;
  }
  .splay-tree-lab__legend span {
    align-items: center;
    display: inline-flex;
    gap: 0.35rem;
  }
  .splay-tree-lab__legend i {
    border-radius: 999px;
    display: inline-block;
    height: 0.68rem;
    width: 0.68rem;
  }
  .splay-tree-lab__note {
    color: #5d6872;
    font-size: 0.88rem;
    margin: 0.75rem 0 0;
  }
</style>

<div class="splay-tree-lab" data-splay-tree-lab="">
  <div class="splay-tree-lab__controls">
    <label>
      Trace
      <select data-param="scenario" aria-label="Access trace">
        <option value="walk" selected="">Local walk</option>
        <option value="phase">Phase shift</option>
        <option value="hot">Stable hot set</option>
        <option value="scan">Sequential scan</option>
        <option value="zipf">Zipf-like</option>
      </select>
    </label>
    <label>
      Keys <output data-value="keys">63</output>
      <input type="range" data-param="keys" min="15" max="255" step="1" value="63" aria-label="Number of keys" />
    </label>
    <label>
      Accesses <output data-value="accesses">1200</output>
      <input type="range" data-param="accesses" min="120" max="5000" step="20" value="1200" aria-label="Number of accesses" />
    </label>
    <label>
      Hot/recent keys <output data-value="hotSet">8</output>
      <input type="range" data-param="hotSet" min="2" max="64" step="1" value="8" aria-label="Hot set or recent-key threshold" />
    </label>
    <label>
      Seed <output data-value="seed">31</output>
      <input type="range" data-param="seed" min="1" max="9999" step="1" value="31" aria-label="Random seed" />
    </label>
  </div>
  <div class="splay-tree-lab__metrics" data-metrics=""></div>
  <canvas data-canvas="" width="940" height="650" aria-label="Splay tree trace lab comparing a self-adjusting tree with a fixed balanced binary search tree"></canvas>
  <div class="splay-tree-lab__legend">
    <span><i style="background:#0f766e"></i>Splay primitive cost</span>
    <span><i style="background:#2563eb"></i>Fixed balanced comparisons</span>
    <span><i style="background:#b45309"></i>Last access path</span>
    <span><i style="background:#7c3aed"></i>Recent working set</span>
  </div>
  <p class="splay-tree-lab__note" data-note="">
    Local walk over 63 keys and 1200 accesses. Splay averages 4.98 primitive
    actions; the fixed balanced tree averages 5.10 comparisons.
  </p>
</div>

<script defer="" src="/blogs/assets/js/splay-tree-lab.js?v=20260618c"></script>

<h2 id="the-default-run">The Default Run</h2>

<p>The default local-walk trace has 63 keys and 1,200 accesses. It starts near the
middle key and mostly moves by small rank steps, with occasional wider jumps.</p>

<p>The measured result:</p>

<table>
  <thead>
    <tr>
      <th>Measure</th>
      <th style="text-align: right">Splay tree</th>
      <th style="text-align: right">Fixed balanced tree</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Average search comparisons</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">2.99</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">5.10</code></td>
    </tr>
    <tr>
      <td>Average rotations</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">1.99</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">0.00</code></td>
    </tr>
    <tr>
      <td>Average primitive cost</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">4.98</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">5.10</code></td>
    </tr>
    <tr>
      <td>95th percentile cost</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">11</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">6</code></td>
    </tr>
  </tbody>
</table>

<p>The average barely favors splaying under this deliberately simple cost model,
but the distribution is different. The splay tree has cheaper repeated and local
accesses, and more expensive cold jumps.</p>

<p>Bucketed by working-set distance:</p>

<table>
  <thead>
    <tr>
      <th>Distinct keys since previous access</th>
      <th style="text-align: right">Count</th>
      <th style="text-align: right">Splay primitive avg</th>
      <th style="text-align: right">Fixed balanced avg</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>same key</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">233</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">1.04</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">5.24</code></td>
    </tr>
    <tr>
      <td>1-2</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">260</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">3.81</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">5.09</code></td>
    </tr>
    <tr>
      <td>3-7</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">279</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">5.78</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">5.08</code></td>
    </tr>
    <tr>
      <td>8-31</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">326</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">7.18</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">5.02</code></td>
    </tr>
    <tr>
      <td>32+</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">102</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">7.67</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">5.12</code></td>
    </tr>
  </tbody>
</table>

<p>That is the working-set theorem in miniature, not as a proof but as a diagnostic
shape. The recent buckets are where the rotations pay rent. The stale buckets
are where the tree admits it has been optimized for something else.</p>

<p>Switch to the phase-shift trace and the bill becomes more visible. The tree
adapts, but it can spend rotations chasing the new hot region. Switch to the
sequential scan and remember that the classic sequential-access theorem is about
one sorted pass from an arbitrary starting tree, not about every possible
cyclic-scan workload under a comparisons-plus-rotations accounting.</p>

<h2 id="implementation-notes">Implementation Notes</h2>

<p>The browser code builds the splay tree as an initially balanced tree over
<code class="language-plaintext highlighter-rouge">1..n</code>, then uses bottom-up parent-pointer rotations:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>while x is not root:
    if x has no grandparent:
        rotate x
    else if x and parent are on the same side:
        rotate parent
        rotate x
    else:
        rotate x
        rotate x
</code></pre></div></div>

<p>The audit checks that:</p>

<ul>
  <li>inorder traversal remains exactly <code class="language-plaintext highlighter-rouge">1..n</code>;</li>
  <li>the root has no parent pointer;</li>
  <li>parent pointers agree with child links;</li>
  <li>every accessed key is found;</li>
  <li>every accessed key becomes the root after access;</li>
  <li>rolling averages remain finite;</li>
  <li>rotations are nonnegative;</li>
  <li>the fixed balanced baseline stays within its comparison bound;</li>
  <li>a repeated access costs one comparison and zero rotations.</li>
</ul>

<p>Those checks do not prove the theorem. They make the visualization less likely
to be a decorative lie. The exported sweep reports 51 named checks: ten
invariants across each trace, plus one scan-specific generation check.</p>

<p>To reproduce the deterministic audit cases from the repository root:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>node - <span class="o">&lt;&lt;</span><span class="sh">'</span><span class="no">NODE</span><span class="sh">'
const lab = require("./assets/js/splay-tree-lab.js");
const EXPECTED_CASE_SHAPE = [
  "1:hot:keys=31:accesses=300:hot=5:seed=7:5.73/3.37/2.37/3.85:rot=710:repeat=1:10/10",
  "2:phase:keys=63:accesses=1200:hot=8:seed=31:7.47/4.24/3.24/5.07:rot=3885:repeat=1:10/10",
  "3:scan:keys=63:accesses=256:hot=6:seed=11:8.83/4.91/3.91/5.10:rot=1002:repeat=none:11/11",
  "4:walk:keys=127:accesses=1600:hot=12:seed=19:5.03/3.01/2.01/6.02:rot=3223:repeat=1:10/10",
  "5:zipf:keys=127:accesses=2000:hot=10:seed=23:11.89/6.45/5.45/6.27:rot=10890:repeat=1:10/10"
];
const EXPECTED_COMMON_CHECK_NAMES = [
  "inorder key count",
  "inorder sorted",
  "root parent is null",
  "parent pointers",
  "all accesses found",
  "accessed key becomes root",
  "finite averages",
  "nonnegative rotations",
  "balanced comparison bound",
  "repeat access becomes root-cheap"
];
const EXPECTED_SCAN_CHECK_NAMES = EXPECTED_COMMON_CHECK_NAMES.concat([
  "scan generated in order"
]);
const EXPECTED_CASES = EXPECTED_CASE_SHAPE.length;
const EXPECTED_CHECKS =
  EXPECTED_CASES * EXPECTED_COMMON_CHECK_NAMES.length +
  (EXPECTED_SCAN_CHECK_NAMES.length - EXPECTED_COMMON_CHECK_NAMES.length);
const EXPECTED_CHECK_ROWS = EXPECTED_CASE_SHAPE.map((shape, index) =&gt; {
  const scenario = shape.split(":")[1];
  const names =
    scenario === "scan" ? EXPECTED_SCAN_CHECK_NAMES : EXPECTED_COMMON_CHECK_NAMES;
  return `</span><span class="k">${</span><span class="nv">index</span><span class="p"> + 1</span><span class="k">}</span><span class="sh">:` + names.map((name) =&gt; `</span><span class="k">${</span><span class="nv">name</span><span class="k">}</span><span class="sh">=true`).join("|");
});

function sameList(actual, expected) {
  return actual.length === expected.length &amp;&amp;
    actual.every((value, index) =&gt; value === expected[index]);
}

function repeatCost(value) {
  return value == null ? "none" : String(value);
}

function caseShape(row) {
  return `</span><span class="k">${</span><span class="nv">row</span><span class="p">.caseId</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.scenario</span><span class="k">}</span><span class="sh">:keys=</span><span class="k">${</span><span class="nv">row</span><span class="p">.keys</span><span class="k">}</span><span class="sh">:` +
    `accesses=</span><span class="k">${</span><span class="nv">row</span><span class="p">.accesses</span><span class="k">}</span><span class="sh">:hot=</span><span class="k">${</span><span class="nv">row</span><span class="p">.hotSet</span><span class="k">}</span><span class="sh">:seed=</span><span class="k">${</span><span class="nv">row</span><span class="p">.seed</span><span class="k">}</span><span class="sh">:` +
    `</span><span class="k">${</span><span class="nv">row</span><span class="p">.splayAveragePrimitive.toFixed(2)</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.splayAverageSearch.toFixed(2)</span><span class="k">}</span><span class="sh">/` +
    `</span><span class="k">${</span><span class="nv">row</span><span class="p">.splayAverageRotations.toFixed(2)</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.balancedAverageSearch.toFixed(2)</span><span class="k">}</span><span class="sh">:` +
    `rot=</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalRotations</span><span class="k">}</span><span class="sh">:repeat=</span><span class="k">${</span><span class="nv">repeatCost</span><span class="p">(row.repeatedSameKeyCost)</span><span class="k">}</span><span class="sh">:` +
    `</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">`;
}

function checkRow(row) {
  return `</span><span class="k">${</span><span class="nv">row</span><span class="p">.caseId</span><span class="k">}</span><span class="sh">:` +
    row.checks.map((check) =&gt; `</span><span class="k">${</span><span class="nv">check</span><span class="p">.name</span><span class="k">}</span><span class="sh">=</span><span class="k">${</span><span class="nv">check</span><span class="p">.ok</span><span class="k">}</span><span class="sh">`).join("|");
}

function rowDrifts(actual, expected) {
  return {
    missing: expected.filter((row) =&gt; !actual.includes(row)),
    extra: actual.filter((row) =&gt; !expected.includes(row))
  };
}

function hasRowDrift(drift) {
  return drift.missing.length &gt; 0 || drift.extra.length &gt; 0;
}

const audit = lab.auditGrid();
const failed = audit.cases.filter(
  (row) =&gt; !row.passed || row.passedChecks !== row.totalChecks
);
const shape = {
  cases: audit.cases.map(caseShape),
  checkRows: audit.cases.map(checkRow)
};
const checkRowDrifts = rowDrifts(shape.checkRows, EXPECTED_CHECK_ROWS);
const shapeErrors = [];
if (!sameList(shape.cases, EXPECTED_CASE_SHAPE)) {
  shapeErrors.push({ name: "cases", actual: shape.cases, expected: EXPECTED_CASE_SHAPE });
}
audit.cases.forEach((row) =&gt; {
  const names = row.checks.map((check) =&gt; check.name);
  const expected =
    row.scenario === "scan" ? EXPECTED_SCAN_CHECK_NAMES : EXPECTED_COMMON_CHECK_NAMES;
  if (!sameList(names, expected)) {
    shapeErrors.push({
      name: `case </span><span class="k">${</span><span class="nv">row</span><span class="p">.caseId</span><span class="k">}</span><span class="sh"> check names`,
      actual: names,
      expected
    });
  }
});
if (hasRowDrift(checkRowDrifts)) {
  shapeErrors.push({ name: "checkRows", drift: checkRowDrifts });
}
console.table(audit.cases.map((row) =&gt; ({
  scenario: row.scenario,
  keys: row.keys,
  accesses: row.accesses,
  splayAvg: row.splayAveragePrimitive.toFixed(2),
  balancedAvg: row.balancedAverageSearch.toFixed(2),
  rotations: row.splayAverageRotations.toFixed(2),
  checks: `</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">`,
  passed: row.passed
})));
const summary =
  `</span><span class="k">${</span><span class="nv">audit</span><span class="p">.passed</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">audit</span><span class="p">.total</span><span class="k">}</span><span class="sh"> cases and </span><span class="k">${</span><span class="nv">audit</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">audit</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh"> checks passed`;
if (
  shapeErrors.length ||
  failed.length ||
  !audit.ok ||
  audit.total !== EXPECTED_CASES ||
  audit.totalChecks !== EXPECTED_CHECKS ||
  audit.passed !== audit.total ||
  audit.passedChecks !== audit.totalChecks
) {
  throw new Error(JSON.stringify({
    summary,
    shapeErrors,
    shape,
    checkRowDrifts,
    failed,
    errors: audit.errors,
    totals: {
      total: audit.total,
      passed: audit.passed,
      passedChecks: audit.passedChecks,
      totalChecks: audit.totalChecks
    }
  }, null, 2));
}
console.log(summary);
</span><span class="no">NODE
</span></code></pre></div></div>

<h2 id="when-would-i-use-this">When Would I Use This?</h2>

<p>Splay trees are appealing when:</p>

<ul>
  <li>access locality is strong and changes over time;</li>
  <li>amortized bounds are acceptable;</li>
  <li>storing balance metadata is unattractive;</li>
  <li>simple split, join, and “move this thing to the top” behavior is useful.</li>
</ul>

<p>They are less appealing when:</p>

<ul>
  <li>individual-operation latency must be tightly bounded;</li>
  <li>reads must not mutate the structure;</li>
  <li>concurrent readers are common;</li>
  <li>memory layout and cache behavior dominate pointer-level comparison counts;</li>
  <li>a B-tree, hash table, radix tree, or sorted array better matches the workload.</li>
</ul>

<p>The most useful lesson is broader than the data structure. A splay tree is an
online adaptation rule. It has no model of the future, only a disciplined way to
make the recent past cheap. Amortized analysis explains why that discipline does
not collapse into arbitrary thrashing.</p>

<p>The tree rotates toward reuse. Whether reuse is actually there is a property of
the trace.</p>

<h2 id="sources">Sources</h2>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:sleator-tarjan" role="doc-endnote">
      <p>Daniel D. Sleator and Robert E. Tarjan, <a href="https://www.cs.cmu.edu/~sleator/papers/self-adjusting.pdf">“Self-Adjusting Binary Search Trees”</a>, <em>Journal of the ACM</em> 32(3), 652-686, 1985. ACM DOI page: <a href="https://dl.acm.org/doi/10.1145/3828.3835">https://dl.acm.org/doi/10.1145/3828.3835</a>. The paper introduces splay trees, the access lemma, balance theorem, static optimality theorem, working-set theorem, and dynamic-optimality conjecture. <a href="#fnref:sleator-tarjan" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:sleator-tarjan:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:tarjan-scan" role="doc-endnote">
      <p>Robert E. Tarjan, <a href="https://link.springer.com/article/10.1007/BF02579253">“Sequential access in splay trees takes linear time”</a>, <em>Combinatorica</em> 5, 367-378, 1985. The paper proves the sequential-access theorem for splay trees. <a href="#fnref:tarjan-scan" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:cole-finger" role="doc-endnote">
      <p>Richard Cole, <a href="https://epubs.siam.org/doi/10.1137/S009753979732699X">“On the Dynamic Finger Conjecture for Splay Trees. Part II: The Proof”</a>, <em>SIAM Journal on Computing</em> 30(1), 44-85, 2000. The abstract states the dynamic-finger bound: amortized access cost <code class="language-plaintext highlighter-rouge">O(log(d+1))</code> for rank distance <code class="language-plaintext highlighter-rouge">d</code> from the preceding access, after an <code class="language-plaintext highlighter-rouge">O(n)</code> initialization cost. <a href="#fnref:cole-finger" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Teo</name><email>zengwc.teo2016@outlook.com</email></author><category term="computer-science" /><category term="splay-trees" /><category term="self-adjusting-trees" /><category term="binary-search-trees" /><category term="amortized-analysis" /><category term="working-set" /><category term="data-structures" /><category term="algorithms" /><summary type="html"><![CDATA[A splay tree keeps no balance bits. It pays rotations after each access so the next access can be cheaper if the trace has locality.]]></summary></entry><entry><title type="html">The Difference Peels Out of the Table</title><link href="https://sir-teo.github.io/blogs/data-systems/2026/06/18/the-difference-peels-out-of-the-table.html" rel="alternate" type="text/html" title="The Difference Peels Out of the Table" /><published>2026-06-18T09:22:00-04:00</published><updated>2026-06-18T09:22:00-04:00</updated><id>https://sir-teo.github.io/blogs/data-systems/2026/06/18/the-difference-peels-out-of-the-table</id><content type="html" xml:base="https://sir-teo.github.io/blogs/data-systems/2026/06/18/the-difference-peels-out-of-the-table.html"><![CDATA[<p>A Bloom filter is a one-way kind of memory. It can answer:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>is this key possibly present?
</code></pre></div></div>

<p>It cannot answer:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>which keys are in you?
</code></pre></div></div>

<p>That second question sounds too much like asking a shadow to produce the object
that cast it. Most of the time, that is exactly right. A saturated Bloom filter
does not contain a recoverable list.</p>

<p>An invertible Bloom lookup table changes the bargain. It stores a small
algebraic receipt per cell: a count, an XOR of keys, and a checksum XOR. If a
cell is holding exactly one remaining key, the table can recognize that fact,
recover the key, delete it from its other cells, and possibly reveal more single
keys.</p>

<p>That makes the structure feel less like a membership filter and more like a
peelable puzzle.</p>

<p>The useful systems move is subtraction. If Alice and Bob hold two huge, mostly
equal sets, they can build compatible IBLTs, subtract the tables cell by cell,
and try to list only the symmetric difference. Shared keys cancel. The residue
is the part they need to exchange.</p>

<h2 id="the-problem-is-not-membership">The Problem Is Not Membership</h2>

<p>Suppose two replicas each hold file-block IDs, calendar event IDs, routing
updates, or content-addressed chunks:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Alice: many keys
Bob:   many keys
</code></pre></div></div>

<p>The expensive but simple reconciliation protocol is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>exchange sorted lists, diff them
</code></pre></div></div>

<p>That spends bandwidth proportional to the total set sizes. A log can do better
when both sides share prior context, but logs have their own operational price:
they have to be maintained, retained, compacted, and interpreted correctly.</p>

<p>Eppstein, Goodrich, Uyeda, and Varghese framed the no-prior-context version
cleanly in their Difference Digest paper: compute the set difference in a single
round with communication proportional to the size of the difference, not the
size of the original sets.<sup id="fnref:difference-digest" role="doc-noteref"><a href="#fn:difference-digest" class="footnote" rel="footnote">1</a></sup></p>

<p>That is the shape of the problem:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>large sets, small difference, no shared update log
</code></pre></div></div>

<p>An ordinary Bloom filter is the wrong tool for this particular job. It can help
one side guess which of its keys the other might have, but it still communicates
information shaped by the full set and it has false positives. Set
reconciliation needs a way for common keys to disappear.</p>

<h2 id="the-cell-receipt">The Cell Receipt</h2>

<p>In the key-only IBLT used by the lab, each cell stores:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>count    = signed number of mapped keys
keyXor   = XOR of mapped keys
hashXor  = XOR of checksum(key) for mapped keys
</code></pre></div></div>

<p>Each key maps to <code class="language-plaintext highlighter-rouge">k</code> cells. Insert updates every mapped cell:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>count   += 1
keyXor  ^= key
hashXor ^= checksum(key)
</code></pre></div></div>

<p>Deletion uses the same XOR updates and subtracts from the count:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>count   -= 1
keyXor  ^= key
hashXor ^= checksum(key)
</code></pre></div></div>

<p>Goodrich and Mitzenmacher’s IBLT paper describes the general key-value version
with a count field, a key-sum field, and a value-sum field; it also notes that
XOR can replace sums in many settings to avoid overflow issues.<sup id="fnref:iblt" role="doc-noteref"><a href="#fn:iblt" class="footnote" rel="footnote">2</a></sup></p>

<p>For reconciliation, Alice builds a table for her set. Bob builds a table with
the same size, hash functions, and checksum function. Then one side subtracts:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>diff.count   = alice.count - bob.count
diff.keyXor  = alice.keyXor ^ bob.keyXor
diff.hashXor = alice.hashXor ^ bob.hashXor
</code></pre></div></div>

<p>A shared key appears once in Alice’s table and once in Bob’s table. It maps to
the same cells. In the subtraction, its count contribution becomes zero and its
XOR contribution cancels.</p>

<p>Only disagreement remains.</p>

<h2 id="the-peel">The Peel</h2>

<p>A cell is <em>pure</em> when it looks like exactly one remaining key:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>abs(count) == 1
hashXor == checksum(keyXor)
</code></pre></div></div>

<p>If <code class="language-plaintext highlighter-rouge">count == 1</code>, the key belongs to Alice but not Bob. If <code class="language-plaintext highlighter-rouge">count == -1</code>, it
belongs to Bob but not Alice. After recording that key, the decoder removes it
from every cell it hashes to.</p>

<p>That deletion can expose new pure cells:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>find pure cell
recover key and side
delete that key from its k cells
repeat
</code></pre></div></div>

<p>This is not merely a convenient implementation. Goodrich and Mitzenmacher point
out that IBLT listing is the same peeling process used to find the 2-core of a
random hypergraph: cells are vertices, keys are hyperedges, and decoding stops
when every remaining vertex has degree at least two.<sup id="fnref:iblt-core" role="doc-noteref"><a href="#fn:iblt-core" class="footnote" rel="footnote">3</a></sup></p>

<p>That gives the most important failure mode a concrete shape:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>no pure cells left, but nonempty cells remain
</code></pre></div></div>

<p>The table is not lying. It is stuck.</p>

<h2 id="the-lab">The Lab</h2>

<p>The lab below builds two full synthetic sets, inserts both into compatible IBLTs,
subtracts the tables, and runs the pure-cell peeling decoder. It also runs a
small deterministic capacity sweep so the failure cliff is visible.</p>

<p>The byte accounting is intentionally simple: each cell is treated as three
32-bit words, <code class="language-plaintext highlighter-rouge">count</code>, <code class="language-plaintext highlighter-rouge">keyXor</code>, and <code class="language-plaintext highlighter-rouge">hashXor</code>. Real systems choose wider
checksums, salts, encodings, and failure budgets.</p>

<style>
  .iblt-lab {
    background: #ffffff;
    border: 1px solid #d7dee5;
    border-radius: 8px;
    box-sizing: border-box;
    color: #172026;
    margin: 2rem 0;
    max-width: 100%;
    overflow: hidden;
    padding: 1rem;
  }

  .iblt-lab__controls {
    display: grid;
    gap: 0.85rem 1rem;
    grid-template-columns: repeat(auto-fit, minmax(145px, 1fr));
    margin-bottom: 1rem;
  }

  .iblt-lab label,
  .iblt-lab__metric span,
  .iblt-lab__legend,
  .iblt-lab__note {
    color: #5d6872;
    font-family: var(--font-ui);
    font-size: 0.82rem;
    line-height: 1.4;
  }

  .iblt-lab label {
    display: grid;
    font-weight: 650;
    gap: 0.35rem;
    min-width: 0;
  }

  .iblt-lab input,
  .iblt-lab select {
    accent-color: #0f766e;
    box-sizing: border-box;
    font: inherit;
    margin: 0;
    min-width: 0;
    width: 100%;
  }

  .iblt-lab select {
    color: #172026;
  }

  .iblt-lab output {
    color: #172026;
    font-variant-numeric: tabular-nums;
    font-weight: 780;
  }

  .iblt-lab__metrics {
    display: grid;
    gap: 0.55rem;
    grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
    margin: 0.4rem 0 0.8rem;
  }

  .iblt-lab__metric {
    background: #f8fafc;
    border: 1px solid #e3e9ef;
    border-radius: 6px;
    min-height: 4rem;
    padding: 0.62rem 0.7rem;
  }

  .iblt-lab__metric--good {
    background: #ecfdf5;
    border-color: #bbf7d0;
  }

  .iblt-lab__metric--bad {
    background: #fff1f2;
    border-color: #fecdd3;
  }

  .iblt-lab__metric span {
    display: block;
  }

  .iblt-lab__metric strong {
    color: #172026;
    display: block;
    font-family: var(--font-ui);
    font-size: 1rem;
    font-variant-numeric: tabular-nums;
    line-height: 1.2;
    margin-top: 0.25rem;
    overflow-wrap: anywhere;
  }

  .iblt-lab canvas {
    background: #ffffff;
    border: 1px solid #d7dee5;
    border-radius: 8px;
    box-sizing: border-box;
    display: block;
    height: 760px;
    margin-top: 0.75rem;
    width: 100%;
  }

  .iblt-lab__legend {
    display: flex;
    flex-wrap: wrap;
    gap: 0.45rem 1rem;
    margin-top: 0.8rem;
  }

  .iblt-lab__legend span {
    align-items: center;
    display: inline-flex;
    gap: 0.35rem;
  }

  .iblt-lab__legend i {
    border-radius: 999px;
    display: inline-block;
    height: 0.65rem;
    width: 0.65rem;
  }

  .iblt-lab__note {
    margin: 0.65rem 0 0;
  }

  @media (max-width: 680px) {
    .iblt-lab {
      padding: 0.8rem;
    }

    .iblt-lab__controls,
    .iblt-lab__metrics {
      grid-template-columns: 1fr;
    }

    .iblt-lab canvas {
      height: 1120px;
    }
  }
</style>

<div class="iblt-lab" data-iblt-lab="">
  <div class="iblt-lab__controls">
    <label>
      Scenario <output data-output="scenario">balanced edits</output>
      <select data-param="scenario" aria-label="Set reconciliation scenario">
        <option value="balanced" selected="">balanced edits</option>
        <option value="alice">Alice has more</option>
        <option value="bob">Bob has more</option>
        <option value="split">partition burst</option>
      </select>
    </label>
    <label>
      Shared keys <output data-output="common">2,500</output>
      <input type="range" min="100" max="8000" step="100" value="2500" data-param="common" aria-label="Shared key count" />
    </label>
    <label>
      Difference <output data-output="difference">84</output>
      <input type="range" min="6" max="360" step="6" value="84" data-param="difference" aria-label="Symmetric difference size" />
    </label>
    <label>
      Cells per diff <output data-output="factor">2.20x</output>
      <input type="range" min="1.05" max="2.8" step="0.05" value="2.2" data-param="factor" aria-label="IBLT cells per difference key" />
    </label>
    <label>
      Hashes <output data-output="hashes">3</output>
      <input type="range" min="3" max="6" step="1" value="3" data-param="hashes" aria-label="Hash functions per key" />
    </label>
    <label>
      Seed <output data-output="seed">29</output>
      <input type="range" min="1" max="9999" step="1" value="29" data-param="seed" aria-label="Deterministic random seed" />
    </label>
  </div>
  <div class="iblt-lab__metrics" data-metrics=""></div>
  <canvas data-canvas="" width="980" height="760" aria-label="IBLT reconciliation lab showing two sets, subtracted cells, peeling order, and capacity sweep"></canvas>
  <div class="iblt-lab__legend">
    <span><i style="background:#64748b"></i> shared keys</span>
    <span><i style="background:#2563eb"></i> Alice-only</span>
    <span><i style="background:#be123c"></i> Bob-only</span>
    <span><i style="background:#0f766e"></i> IBLT capacity</span>
    <span><i style="background:#b45309"></i> stuck residue</span>
  </div>
  <p class="iblt-lab__note" data-note=""></p>
</div>

<script defer="" src="/blogs/assets/js/iblt-reconciliation-lab.js?v=20260618d"></script>

<p>The default run has <code class="language-plaintext highlighter-rouge">2,500</code> shared keys and <code class="language-plaintext highlighter-rouge">84</code> differing keys. The two full
lists would cost about <code class="language-plaintext highlighter-rouge">20.3 KB</code> under the lab’s 32-bit key model; the IBLT
message costs about <code class="language-plaintext highlighter-rouge">2.2 KB</code> and decodes the exact difference.</p>

<p>The exported audit checks five deterministic scenarios and reports 25 named
checks. It verifies that:</p>

<ul>
  <li>a shared set cancels to an empty subtracted table;</li>
  <li>decoded Alice-only and Bob-only keys match the exact generated difference;</li>
  <li>every peeled cell has a valid side, <code class="language-plaintext highlighter-rouge">+1</code> or <code class="language-plaintext highlighter-rouge">-1</code>;</li>
  <li>successful decodes leave no residual cells; and</li>
  <li>the subtracted table’s nonempty cells are bounded by the number of difference
keys times the hash count.</li>
</ul>

<p>The compact reproduction check is:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>node - <span class="o">&lt;&lt;</span><span class="sh">'</span><span class="no">NODE</span><span class="sh">'
const lab = require("./assets/js/iblt-reconciliation-lab.js");
const EXPECTED_CASES = 5;
const EXPECTED_CHECKS = 25;
const EXPECTED_CHECK_NAMES = [
  "decoded-difference-exact",
  "peeled-cell-sign-valid",
  "shared-set-cancels-empty",
  "subtracted-cells-bounded",
  "successful-decode-empty-residual"
];
const EXPECTED_CASE_SHAPE = [
  "alice:5:24:62:3:2.55:19:24:0:744:3296",
  "balanced:5:140:308:5:2.2:73:140:0:3696:26160",
  "bob:5:48:84:4:1.75:16:48:0:1008:7392",
  "default-balanced:5:84:185:3:2.2:59:84:0:2220:20336",
  "split:5:90:171:3:1.9:61:90:0:2052:9960"
];
const EXPECTED_CASE_ROWS = [
  "alice:diff=24:cells=62:hashes=3:factor=2.55:pure=19:peeled=24:residual=0:bytes=744/3296:ratio=4.43:checks=5/5:passed=true",
  "balanced:diff=140:cells=308:hashes=5:factor=2.2:pure=73:peeled=140:residual=0:bytes=3696/26160:ratio=7.08:checks=5/5:passed=true",
  "bob:diff=48:cells=84:hashes=4:factor=1.75:pure=16:peeled=48:residual=0:bytes=1008/7392:ratio=7.33:checks=5/5:passed=true",
  "default-balanced:diff=84:cells=185:hashes=3:factor=2.2:pure=59:peeled=84:residual=0:bytes=2220/20336:ratio=9.16:checks=5/5:passed=true",
  "split:diff=90:cells=171:hashes=3:factor=1.9:pure=61:peeled=90:residual=0:bytes=2052/9960:ratio=4.85:checks=5/5:passed=true"
];
const EXPECTED_CHECK_ROWS = [
  "alice:decoded-difference-exact=true|peeled-cell-sign-valid=true|shared-set-cancels-empty=true|subtracted-cells-bounded=true|successful-decode-empty-residual=true",
  "balanced:decoded-difference-exact=true|peeled-cell-sign-valid=true|shared-set-cancels-empty=true|subtracted-cells-bounded=true|successful-decode-empty-residual=true",
  "bob:decoded-difference-exact=true|peeled-cell-sign-valid=true|shared-set-cancels-empty=true|subtracted-cells-bounded=true|successful-decode-empty-residual=true",
  "default-balanced:decoded-difference-exact=true|peeled-cell-sign-valid=true|shared-set-cancels-empty=true|subtracted-cells-bounded=true|successful-decode-empty-residual=true",
  "split:decoded-difference-exact=true|peeled-cell-sign-valid=true|shared-set-cancels-empty=true|subtracted-cells-bounded=true|successful-decode-empty-residual=true"
];
const EXPECTED_RESIDUAL_SHAPE = [
  "alice:0",
  "balanced:0",
  "bob:0",
  "default-balanced:0",
  "split:0"
];
const EXPECTED_TOTALS = {
  rows: EXPECTED_CASES,
  passed: EXPECTED_CASES,
  total: EXPECTED_CASES,
  passedChecks: EXPECTED_CHECKS,
  totalChecks: EXPECTED_CHECKS
};

function sameJson(actual, expected) {
  return JSON.stringify(actual) === JSON.stringify(expected);
}

function rowDrifts(actual, expected) {
  const count = Math.max(actual.length, expected.length);
  const drift = [];
  for (let index = 0; index &lt; count; index += 1) {
    if (actual[index] !== expected[index]) {
      drift.push({ index, actual: actual[index], expected: expected[index] });
    }
  }
  return drift;
}

const audit = lab.auditIBLTLab();
const auditShape = {
  totals: {
    rows: audit.cases.length,
    passed: audit.passed,
    total: audit.total,
    passedChecks: audit.passedChecks,
    totalChecks: audit.totalChecks
  },
  caseChecks: audit.cases.map((row) =&gt; [
    row.scenario,
    row.totalChecks,
    row.diffSize,
    row.cells,
    row.hashes,
    row.factor,
    row.initialPure,
    row.peeled,
    row.residual,
    row.bytesIblt,
    row.bytesLists
  ].join(":")).sort(),
  caseRows: audit.cases.map((row) =&gt;
    `</span><span class="k">${</span><span class="nv">row</span><span class="p">.scenario</span><span class="k">}</span><span class="sh">:diff=</span><span class="k">${</span><span class="nv">row</span><span class="p">.diffSize</span><span class="k">}</span><span class="sh">:cells=</span><span class="k">${</span><span class="nv">row</span><span class="p">.cells</span><span class="k">}</span><span class="sh">:` +
    `hashes=</span><span class="k">${</span><span class="nv">row</span><span class="p">.hashes</span><span class="k">}</span><span class="sh">:factor=</span><span class="k">${</span><span class="nv">row</span><span class="p">.factor</span><span class="k">}</span><span class="sh">:pure=</span><span class="k">${</span><span class="nv">row</span><span class="p">.initialPure</span><span class="k">}</span><span class="sh">:` +
    `peeled=</span><span class="k">${</span><span class="nv">row</span><span class="p">.peeled</span><span class="k">}</span><span class="sh">:residual=</span><span class="k">${</span><span class="nv">row</span><span class="p">.residual</span><span class="k">}</span><span class="sh">:` +
    `bytes=</span><span class="k">${</span><span class="nv">row</span><span class="p">.bytesIblt</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.bytesLists</span><span class="k">}</span><span class="sh">:` +
    `ratio=</span><span class="k">${</span><span class="nv">row</span><span class="p">.compressionRatio.toFixed(2)</span><span class="k">}</span><span class="sh">:` +
    `checks=</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">:passed=</span><span class="k">${</span><span class="nv">row</span><span class="p">.passed</span><span class="k">}</span><span class="sh">`
  ).sort(),
  checkRows: audit.cases.map((row) =&gt;
    `</span><span class="k">${</span><span class="nv">row</span><span class="p">.scenario</span><span class="k">}</span><span class="sh">:` +
    row.checks.map((check) =&gt; `</span><span class="k">${</span><span class="nv">check</span><span class="p">.name</span><span class="k">}</span><span class="sh">=</span><span class="k">${</span><span class="nv">check</span><span class="p">.ok</span><span class="k">}</span><span class="sh">`).sort().join("|")
  ).sort(),
  checkNames: Array.from(new Set(audit.cases.flatMap(
    (row) =&gt; row.checks.map((check) =&gt; check.name)
  ))).sort(),
  residualByScenario: Object.entries(audit.byScenario)
    .map(([scenario, row]) =&gt; `</span><span class="k">${</span><span class="nv">scenario</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.residual</span><span class="k">}</span><span class="sh">`)
    .sort()
};
const caseRowDrifts = rowDrifts(auditShape.caseRows, EXPECTED_CASE_ROWS);
const checkRowDrifts = rowDrifts(auditShape.checkRows, EXPECTED_CHECK_ROWS);
const shapeErrors = [
  sameJson(auditShape.totals, EXPECTED_TOTALS) ? null : "totals",
  sameJson(auditShape.caseChecks, EXPECTED_CASE_SHAPE) ? null : "caseChecks",
  caseRowDrifts.length ? "caseRows" : null,
  checkRowDrifts.length ? "checkRows" : null,
  sameJson(auditShape.checkNames, EXPECTED_CHECK_NAMES) ? null : "checkNames",
  sameJson(auditShape.residualByScenario, EXPECTED_RESIDUAL_SHAPE)
    ? null
    : "residualByScenario"
].filter(Boolean);
if (shapeErrors.length) {
  throw new Error(
    `audit shape drifted in </span><span class="k">${</span><span class="nv">shapeErrors</span><span class="p">.join(</span><span class="s2">", "</span><span class="p">)</span><span class="k">}</span><span class="sh">:</span><span class="se">\n</span><span class="sh">` +
    JSON.stringify({
      auditShape,
      drifts: {
        caseRows: caseRowDrifts,
        checkRows: checkRowDrifts
      }
    }, null, 2)
  );
}
const summary =
  `</span><span class="k">${</span><span class="nv">audit</span><span class="p">.passed</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">audit</span><span class="p">.total</span><span class="k">}</span><span class="sh"> scenarios and ` +
  `</span><span class="k">${</span><span class="nv">audit</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">audit</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh"> checks passed`;
const failedCases = audit.cases.filter(
  (row) =&gt; !row.passed || row.passedChecks !== row.totalChecks
);
if (failedCases.length || !audit.ok ||
    audit.passed !== audit.total ||
    audit.passedChecks !== audit.totalChecks) {
  throw new Error(JSON.stringify({
    summary,
    errors: audit.errors,
    failedCases,
    shapeErrors,
    auditShape
  }, null, 2));
}

console.table(audit.cases.map((row) =&gt; ({
  scenario: row.scenario,
  diff: row.diffSize,
  cells: row.cells,
  hashes: row.hashes,
  factor: row.factor,
  initialPure: row.initialPure,
  peeled: row.peeled,
  residual: row.residual,
  messageKB: (row.bytesIblt / 1024).toFixed(1),
  listKB: (row.bytesLists / 1024).toFixed(1),
  checks: `</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">`,
  passed: row.passed
})));
console.log(summary);
</span><span class="no">NODE
</span></code></pre></div></div>

<h2 id="sizing-is-the-algorithm">Sizing Is the Algorithm</h2>

<p>The lab’s <strong>Cells per diff</strong> slider is the important one.</p>

<p>Push it down toward <code class="language-plaintext highlighter-rouge">1.05x</code>. Many runs stop with residual cells. The decoder is
not confused; it has reached a 2-core. Every remaining key is entangled with
other remaining keys, so no cell can testify alone.</p>

<p>Push it up. Pure cells appear, the peeling cascade starts, and the table lists
the whole difference.</p>

<p>Goodrich and Mitzenmacher give 2-core threshold constants for several hash
counts; for example, the table in their paper lists about <code class="language-plaintext highlighter-rouge">1.222</code> for <code class="language-plaintext highlighter-rouge">k = 3</code>,
<code class="language-plaintext highlighter-rouge">1.295</code> for <code class="language-plaintext highlighter-rouge">k = 4</code>, and <code class="language-plaintext highlighter-rouge">1.425</code> for <code class="language-plaintext highlighter-rouge">k = 5</code>.<sup id="fnref:iblt-core:1" role="doc-noteref"><a href="#fn:iblt-core" class="footnote" rel="footnote">3</a></sup> Those are asymptotic
thresholds under random-hypergraph assumptions, not a promise that a tiny
browser demo with a 32-bit checksum should run exactly at the constant. In
production, the sizing question belongs to a failure budget.</p>

<p>This is also why the number of hash locations is not monotone magic. Difference
Digest notes the tradeoff directly: too few hashes do not propagate enough
peeling information, while too many hashes can make it hard to find a pure cell
at the start; values like 3 or 4 work well in practice.<sup id="fnref:difference-hashes" role="doc-noteref"><a href="#fn:difference-hashes" class="footnote" rel="footnote">4</a></sup></p>

<h2 id="what-this-toy-leaves-out">What This Toy Leaves Out</h2>

<p>The lab assumes the difference size is known well enough to size the table.
Difference Digest adds a Strata Estimator for that missing first step, because an
IBLT that is too small may fail and an IBLT that is too large wastes
bandwidth.<sup id="fnref:difference-digest:1" role="doc-noteref"><a href="#fn:difference-digest" class="footnote" rel="footnote">1</a></sup></p>

<p>The lab also uses a short checksum. Real deployments choose checksum widths so
that a false pure cell is negligible relative to the system’s risk budget. They
also have to version the hash functions, salt them, serialize cell fields
unambiguously, and decide what to do about duplicate keys, value changes, and
malicious peers.</p>

<p>The conceptual invariant is still compact:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>common keys cancel
single remaining keys reveal themselves
revealed keys delete themselves
</code></pre></div></div>

<p>An IBLT is not a smaller list. It is a table that can sometimes turn a set
difference back into a list by finding enough places where only one key is left
standing.</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:difference-digest" role="doc-endnote">
      <p>David Eppstein, Michael T. Goodrich, Frank Uyeda, and George Varghese, <a href="https://conferences.sigcomm.org/sigcomm/2011/papers/sigcomm/p218.pdf">“What’s the Difference? Efficient Set Reconciliation without Prior Context”</a>, SIGCOMM 2011. The paper describes a Difference Digest for one-round set reconciliation with communication proportional to the set difference, adapts whole-IBF subtraction for reconciliation, and adds a Strata Estimator for difference-size estimation. Google Research also hosts the <a href="https://research.google/pubs/whats-the-difference-efficient-set-reconciliation-without-prior-context/">publication summary</a>. <a href="#fnref:difference-digest" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:difference-digest:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:iblt" role="doc-endnote">
      <p>Michael T. Goodrich and Michael Mitzenmacher, <a href="https://arxiv.org/abs/1101.2245">“Invertible Bloom Lookup Tables”</a>, Allerton 2011 / arXiv version. The paper defines an IBLT as a Bloom-filter-like table for key-value pairs supporting insert, delete, lookup, and listing; the PDF describes the count, keySum, and valueSum fields and notes XOR variants for many settings. <a href="#fnref:iblt" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:iblt-core" role="doc-endnote">
      <p>Goodrich and Mitzenmacher, <a href="https://arxiv.org/pdf/1101.2245">“Invertible Bloom Lookup Tables”</a>, Section 2.4. Their listing algorithm repeatedly removes cells of count 1, connects the process to the 2-core of a random hypergraph, and gives threshold constants for several hash counts. <a href="#fnref:iblt-core" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:iblt-core:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:difference-hashes" role="doc-endnote">
      <p>Eppstein et al., <a href="https://conferences.sigcomm.org/sigcomm/2011/papers/sigcomm/p218.pdf">“What’s the Difference?”</a>, Section 3. The paper discusses <code class="language-plaintext highlighter-rouge">hash_count</code>, checksum fields for purity checks, negative counts after subtraction, and reports that hash counts 3 or 4 work well in practice. <a href="#fnref:difference-hashes" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Teo</name><email>zengwc.teo2016@outlook.com</email></author><category term="data-systems" /><category term="iblt" /><category term="invertible-bloom-lookup-tables" /><category term="set-reconciliation" /><category term="bloom-filters" /><category term="graph-peeling" /><category term="distributed-systems" /><summary type="html"><![CDATA[An invertible Bloom lookup table lets two large, similar sets subtract their summaries and recover only the keys that differ by peeling pure cells.]]></summary></entry><entry><title type="html">The Posting List Splits Its Bits</title><link href="https://sir-teo.github.io/blogs/computer-science/2026/06/18/the-posting-list-splits-its-bits.html" rel="alternate" type="text/html" title="The Posting List Splits Its Bits" /><published>2026-06-18T09:05:00-04:00</published><updated>2026-06-18T09:05:00-04:00</updated><id>https://sir-teo.github.io/blogs/computer-science/2026/06/18/the-posting-list-splits-its-bits</id><content type="html" xml:base="https://sir-teo.github.io/blogs/computer-science/2026/06/18/the-posting-list-splits-its-bits.html"><![CDATA[<p>A posting list is already half an index.</p>

<p>For a term in a search engine, the document IDs arrive sorted:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>3, 4, 7, 13, 14, 15, 21, ...
</code></pre></div></div>

<p>The classical compression move is to store gaps:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>3, 1, 3, 6, 1, 1, 6, ...
</code></pre></div></div>

<p>Small gaps get short codes. That is a good instinct. It is also a sequential
instinct: to find the 10,000th posting or skip to the first posting at least
<code class="language-plaintext highlighter-rouge">x</code>, the decoder often has to walk through earlier codes or auxiliary skip
blocks.</p>

<p>Elias-Fano takes a different route. It treats the sorted list as a monotone
sequence and stores the absolute values in two pieces:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>low bits:  literal fixed-width suffixes
high bits: a unary bitmap with select support
</code></pre></div></div>

<p>The representation is not just a compressed byte stream. It is a compressed
addressing scheme.</p>

<p>The construction is usually traced to Peter Elias’s work on static files and
Robert Fano’s independent work on associative memory.<sup id="fnref:elias-fano" role="doc-noteref"><a href="#fn:elias-fano" class="footnote" rel="footnote">1</a></sup></p>

<h2 id="split-each-value">Split Each Value</h2>

<p>Let the list contain \(n\) integers:</p>

\[0 \le x_0 &lt; x_1 &lt; \cdots &lt; x_{n-1} &lt; U.\]

<p>Pick:</p>

\[\ell = \left\lfloor \log_2(U/n) \right\rfloor.\]

<p>Write each value as:</p>

\[x_i = h_i 2^\ell + l_i,\]

<p>where \(l_i\) is the low \(\ell\)-bit suffix and \(h_i\) is the remaining high
part.</p>

<p>The low parts are easy:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>l_0, l_1, l_2, ...
</code></pre></div></div>

<p>stored in fixed width. This costs \(n\ell\) bits.</p>

<p>The high parts are monotone. Elias-Fano stores them by setting one bit per
element in an upper bitmap:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>H[h_i + i] = 1.
</code></pre></div></div>

<p>That <code class="language-plaintext highlighter-rouge">+ i</code> is the little translation that keeps equal high parts from colliding.
If several values share the same high part, their one-bits appear consecutively.
Zeros in the bitmap mark high-part buckets with no value or with a boundary
between groups.</p>

<p>To read the \(i\)th value, select the \(i\)th one-bit in <code class="language-plaintext highlighter-rouge">H</code>:</p>

\[h_i = \operatorname{select}_1(H, i) - i.\]

<p>Then attach the stored low suffix:</p>

\[x_i = h_i 2^\ell + l_i.\]

<p>That is the whole shape. The high bits are not decoded by replaying gaps. They
are recovered by a select query.</p>

<h2 id="the-space-bargain">The Space Bargain</h2>

<p>The exact bit count in the lab is:</p>

\[n\ell + n + \left\lfloor \frac{U-1}{2^\ell} \right\rfloor + 1.\]

<p>The first term is the low-bit array. The rest is the upper bitmap: one bit for
each element plus enough zero slots to name the high buckets.</p>

<p>With the usual choice of \(\ell\), this is close to:</p>

\[n \log_2(U/n) + O(n)\]

<p>bits. Lucene’s Elias-Fano encoder documentation phrases the engineering bound
as at most roughly <code class="language-plaintext highlighter-rouge">2 + ceil(log2(upperBound / numValues))</code> bits per encoded
number, under its upper-bound convention.<sup id="fnref:lucene" role="doc-noteref"><a href="#fn:lucene" class="footnote" rel="footnote">2</a></sup></p>

<p>That is why the representation is attractive for sparse monotone lists. A raw
fixed-width array spends \(\lceil \log_2 U \rceil\) bits per value. A bitset
spends \(U\) bits whether the term appears in ten documents or ten million.
Elias-Fano spends bits according to the average spacing.</p>

<p>The catch is density. If the list is very dense, a bitset can win. Vigna’s
quasi-succinct index paper makes this engineering point explicitly by switching
to ranked characteristic bitvectors for dense cases.<sup id="fnref:vigna" role="doc-noteref"><a href="#fn:vigna" class="footnote" rel="footnote">3</a></sup> Lucene’s docs make
a similar practical comparison against fixed bitsets.<sup id="fnref:lucene:1" role="doc-noteref"><a href="#fn:lucene" class="footnote" rel="footnote">2</a></sup></p>

<p>So the right slogan is not “Elias-Fano beats bitsets.” It is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Elias-Fano is a sparse monotone sequence layout with useful navigation baked in.
</code></pre></div></div>

<h2 id="the-lab">The Lab</h2>

<p>The lab below builds deterministic synthetic posting lists, encodes them with
Elias-Fano, and compares the bit budget against three baselines:</p>

<ul>
  <li>fixed-width absolute document IDs;</li>
  <li>a full universe bitset;</li>
  <li>byte-aligned varint gaps; and</li>
  <li>the Elias-Fano split itself.</li>
</ul>

<p>It also checks two operations:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">access(i)</code>, by decoding <code class="language-plaintext highlighter-rouge">select1(i) - i</code> plus the low bits; and</li>
  <li><code class="language-plaintext highlighter-rouge">nextGEQ(target)</code>, implemented here as a binary search over decoded access.</li>
</ul>

<p>That <code class="language-plaintext highlighter-rouge">nextGEQ</code> implementation is intentionally simple. Production structures
avoid decoding one item at a time by adding rank/select samples, broadword
search, and skip pointers. The point of the artifact is to expose the invariant,
not to pretend a few dozen lines of JavaScript is a tuned search engine.</p>

<style>
  .elias-fano-lab {
    background: #ffffff;
    border: 1px solid #d7dee5;
    border-radius: 8px;
    box-sizing: border-box;
    color: #172026;
    margin: 2rem 0;
    max-width: 100%;
    overflow: hidden;
    padding: 1rem;
  }

  .elias-fano-lab__controls {
    display: grid;
    gap: 0.85rem 1rem;
    grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
    margin-bottom: 1rem;
  }

  .elias-fano-lab label,
  .elias-fano-lab__metric span,
  .elias-fano-lab__note,
  .elias-fano-lab__legend {
    color: #5d6872;
    font-family: var(--font-ui);
    font-size: 0.82rem;
    line-height: 1.4;
  }

  .elias-fano-lab label {
    display: grid;
    font-weight: 650;
    gap: 0.35rem;
    min-width: 0;
  }

  .elias-fano-lab input,
  .elias-fano-lab select {
    accent-color: #0f766e;
    box-sizing: border-box;
    font: inherit;
    margin: 0;
    min-width: 0;
    width: 100%;
  }

  .elias-fano-lab select {
    color: #172026;
  }

  .elias-fano-lab output {
    color: #172026;
    font-variant-numeric: tabular-nums;
    font-weight: 780;
  }

  .elias-fano-lab__metrics {
    display: grid;
    gap: 0.55rem;
    grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
    margin: 0.4rem 0 0.8rem;
  }

  .elias-fano-lab__metric {
    background: #f8fafc;
    border: 1px solid #e3e9ef;
    border-radius: 6px;
    min-height: 4rem;
    padding: 0.62rem 0.7rem;
  }

  .elias-fano-lab__metric--good {
    background: #ecfdf5;
    border-color: #bbf7d0;
  }

  .elias-fano-lab__metric--bad {
    background: #fff1f2;
    border-color: #fecdd3;
  }

  .elias-fano-lab__metric span {
    display: block;
  }

  .elias-fano-lab__metric strong {
    color: #172026;
    display: block;
    font-family: var(--font-ui);
    font-size: 1rem;
    font-variant-numeric: tabular-nums;
    line-height: 1.2;
    margin-top: 0.25rem;
    overflow-wrap: anywhere;
  }

  .elias-fano-lab canvas {
    background: #ffffff;
    border: 1px solid #d7dee5;
    border-radius: 8px;
    box-sizing: border-box;
    display: block;
    height: 760px;
    margin-top: 0.75rem;
    width: 100%;
  }

  .elias-fano-lab__legend {
    display: flex;
    flex-wrap: wrap;
    gap: 0.45rem 1rem;
    margin-top: 0.8rem;
  }

  .elias-fano-lab__legend span {
    align-items: center;
    display: inline-flex;
    gap: 0.35rem;
  }

  .elias-fano-lab__legend i {
    border-radius: 999px;
    display: inline-block;
    height: 0.65rem;
    width: 0.65rem;
  }

  .elias-fano-lab__note {
    margin: 0.65rem 0 0;
  }

  @media (max-width: 680px) {
    .elias-fano-lab {
      padding: 0.8rem;
    }

    .elias-fano-lab__controls,
    .elias-fano-lab__metrics {
      grid-template-columns: 1fr;
    }

    .elias-fano-lab canvas {
      height: 1120px;
    }
  }
</style>

<div class="elias-fano-lab" data-elias-fano-lab="">
  <div class="elias-fano-lab__controls">
    <label>
      Scenario <output data-output="scenario">clustered postings</output>
      <select data-param="scenario" aria-label="Posting list scenario">
        <option value="clustered" selected="">clustered postings</option>
        <option value="uniform">uniform gaps</option>
        <option value="dense">dense predicate</option>
        <option value="runs">runs and jumps</option>
      </select>
    </label>
    <label>
      Universe <output data-output="universe">100,000</output>
      <input type="range" min="1000" max="1000000" step="1000" value="100000" data-param="universe" aria-label="Document universe" />
    </label>
    <label>
      Postings <output data-output="count">900</output>
      <input type="range" min="16" max="8000" step="1" value="900" data-param="count" aria-label="Posting count" />
    </label>
    <label>
      Locality <output data-output="locality">45%</output>
      <input type="range" min="0" max="100" step="5" value="45" data-param="locality" aria-label="Posting locality" />
    </label>
    <label>
      Access rank <output data-output="queryRank">#42</output>
      <input type="range" min="0" max="899" step="1" value="42" data-param="queryRank" aria-label="Access rank" />
    </label>
    <label>
      Skip target <output data-output="target">42,000</output>
      <input type="range" min="0" max="99999" step="1" value="42000" data-param="target" aria-label="Next greater or equal target" />
    </label>
    <label>
      Seed <output data-output="seed">17</output>
      <input type="range" min="1" max="9999" step="1" value="17" data-param="seed" aria-label="Deterministic random seed" />
    </label>
  </div>
  <div class="elias-fano-lab__metrics" data-metrics=""></div>
  <canvas data-canvas="" width="980" height="760" aria-label="Elias-Fano lab showing posting distribution, high-low bit split, storage ledger, and access plus skip query"></canvas>
  <div class="elias-fano-lab__legend">
    <span><i style="background:#0f766e"></i> Elias-Fano</span>
    <span><i style="background:#2563eb"></i> low bits</span>
    <span><i style="background:#b45309"></i> high bits</span>
    <span><i style="background:#be123c"></i> skip target</span>
  </div>
  <p class="elias-fano-lab__note" data-note=""></p>
</div>

<script defer="" src="/blogs/assets/js/elias-fano-lab.js?v=20260618c"></script>

<p>The lab has a built-in audit. It reports 35 named checks: decode,
monotonicity, high-bit placement, one-bit count, direct access, <code class="language-plaintext highlighter-rouge">nextGEQ</code>,
and target-sweep lower bounds across five scenarios:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>node - <span class="o">&lt;&lt;</span><span class="sh">'</span><span class="no">NODE</span><span class="sh">'
const lab = require("./assets/js/elias-fano-lab.js");
const EXPECTED_CASE_KEYS = [
  "1:default-clustered:100000:900:6",
  "2:uniform:4096:64:6",
  "3:clustered:100000:1500:6",
  "4:dense:2048:1200:0",
  "5:runs:250000:2200:6"
];
const EXPECTED_CASE_ROWS = [
  "1:default-clustered:100000:900:l=6:ef=8.74:varbyte=10.57:target=true:checks=7/7:passed=true",
  "2:uniform:4096:64:l=6:ef=8.00:varbyte=8.00:target=true:checks=7/7:passed=true",
  "3:clustered:100000:1500:l=6:ef=8.04:varbyte=9.02:target=true:checks=7/7:passed=true",
  "4:dense:2048:1200:l=0:ef=2.71:varbyte=8.01:target=true:checks=7/7:passed=true",
  "5:runs:250000:2200:l=6:ef=8.78:varbyte=8.35:target=true:checks=7/7:passed=true"
];
const EXPECTED_CHECK_SHAPE = [
  { name: "decode", ok: true },
  { name: "monotone-input", ok: true },
  { name: "high-bit-positions", ok: true },
  { name: "one-bit-count", ok: true },
  { name: "access-query", ok: true },
  { name: "next-geq-query", ok: true },
  { name: "target-sweep", ok: true }
];
const EXPECTED_CASES = EXPECTED_CASE_KEYS.length;
const EXPECTED_CHECKS = EXPECTED_CASES * EXPECTED_CHECK_SHAPE.length;

function sameList(left, right) {
  return left.length === right.length &amp;&amp;
    left.every((value, index) =&gt; value === right[index]);
}

function sameJson(left, right) {
  return JSON.stringify(left) === JSON.stringify(right);
}

function rowDrifts(actual, expected) {
  const count = Math.max(actual.length, expected.length);
  const drift = [];
  for (let index = 0; index &lt; count; index += 1) {
    if (actual[index] !== expected[index]) {
      drift.push({ index, actual: actual[index], expected: expected[index] });
    }
  }
  return drift;
}

function auditShape(audit) {
  return {
    cases: audit.cases.map(
      (row) =&gt; `</span><span class="k">${</span><span class="nv">row</span><span class="p">.caseId</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.scenario</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.universe</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.count</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.l</span><span class="k">}</span><span class="sh">`
    ),
    caseRows: audit.cases.map(
      (row) =&gt; `</span><span class="k">${</span><span class="nv">row</span><span class="p">.caseId</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.scenario</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.universe</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.count</span><span class="k">}</span><span class="sh">:` +
        `l=</span><span class="k">${</span><span class="nv">row</span><span class="p">.l</span><span class="k">}</span><span class="sh">:ef=</span><span class="k">${</span><span class="nv">row</span><span class="p">.bitsPerValue.toFixed(2)</span><span class="k">}</span><span class="sh">:` +
        `varbyte=</span><span class="k">${</span><span class="nv">row</span><span class="p">.varbyteBitsPerValue.toFixed(2)</span><span class="k">}</span><span class="sh">:` +
        `target=</span><span class="k">${</span><span class="nv">row</span><span class="p">.targetOk</span><span class="k">}</span><span class="sh">:checks=</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">:` +
        `passed=</span><span class="k">${</span><span class="nv">row</span><span class="p">.passed</span><span class="k">}</span><span class="sh">`
    ),
    checksByCase: audit.cases.map((row) =&gt;
      row.checks.map((check) =&gt; ({
        name: check.name,
        ok: check.ok
      }))
    )
  };
}

const audit = lab.auditEliasFanoLab();
const shape = auditShape(audit);
const failed = audit.cases.filter(
  (row) =&gt; !row.passed || row.passedChecks !== row.totalChecks
);
const caseRowDrifts = rowDrifts(shape.caseRows, EXPECTED_CASE_ROWS);
const shapeErrors = [
  sameList(shape.cases, EXPECTED_CASE_KEYS) ? null : "cases",
  caseRowDrifts.length ? "caseRows" : null,
  shape.checksByCase.every((checks) =&gt; sameJson(checks, EXPECTED_CHECK_SHAPE))
    ? null
    : "checksByCase"
].filter(Boolean);
console.table(audit.cases.map((row) =&gt; ({
  scenario: row.scenario,
  universe: row.universe,
  count: row.count,
  l: row.l,
  efBitsPerValue: row.bitsPerValue.toFixed(2),
  varbyteBitsPerValue: row.varbyteBitsPerValue.toFixed(2),
  checks: `</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">`,
  targetOk: row.targetOk,
  passed: row.passed
})));
const summary =
  `</span><span class="k">${</span><span class="nv">audit</span><span class="p">.passed</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">audit</span><span class="p">.total</span><span class="k">}</span><span class="sh"> scenarios and ` +
  `</span><span class="k">${</span><span class="nv">audit</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">audit</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh"> checks passed`;
if (
  shapeErrors.length ||
  audit.total !== EXPECTED_CASES ||
  audit.totalChecks !== EXPECTED_CHECKS ||
  failed.length ||
  !audit.ok ||
  audit.passed !== audit.total ||
  audit.passedChecks !== audit.totalChecks
) {
  throw new Error(JSON.stringify({
    summary,
    shapeErrors,
    shape,
    drifts: { caseRows: caseRowDrifts },
    failed,
    errors: audit.errors,
    expectedCases: EXPECTED_CASES,
    expectedChecks: EXPECTED_CHECKS,
    total: audit.total,
    totalChecks: audit.totalChecks
  }, null, 2));
}
console.log(summary);
</span><span class="no">NODE
</span></code></pre></div></div>

<p>The checks cover uniform, clustered, dense, and run-heavy lists. They assert
that:</p>

<ul>
  <li>decoding every index reproduces the original sorted list;</li>
  <li>every one-bit is at position <code class="language-plaintext highlighter-rouge">high(x[i]) + i</code>;</li>
  <li>the number of one-bits equals the number of postings;</li>
  <li><code class="language-plaintext highlighter-rouge">access(i)</code> agrees with the original list; and</li>
  <li><code class="language-plaintext highlighter-rouge">nextGEQ(target)</code> agrees with a naive lower-bound scan.</li>
</ul>

<h2 id="why-this-is-useful-for-search">Why This Is Useful for Search</h2>

<p>Vigna’s quasi-succinct index paper begins with a contrast against ordinary
gap-compressed inverted indexes: document pointers are stored in increasing
order, and gap compression gives small gaps short codes, but the proposed index
uses a quasi-succinct representation of monotone sequences instead.<sup id="fnref:vigna:1" role="doc-noteref"><a href="#fn:vigna" class="footnote" rel="footnote">3</a></sup></p>

<p>That change matters because query engines do not only enumerate postings. They
also skip. A conjunctive query needs to keep several posting lists aligned:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>advance the shorter list to at least the current document of the longer list
</code></pre></div></div>

<p>If the compressed representation supports <code class="language-plaintext highlighter-rouge">access</code> and <code class="language-plaintext highlighter-rouge">nextGEQ</code> cheaply, the
index can stay compressed while still behaving like an addressable object.</p>

<p>This is the reason Elias-Fano keeps reappearing in search systems, graph
compression, and succinct libraries. The high bitmap is not decorative. It is
where the navigation lives.</p>

<h2 id="what-this-toy-does-not-include">What This Toy Does Not Include</h2>

<p>The JavaScript lab stores the <code class="language-plaintext highlighter-rouge">select1</code> positions explicitly. That would be
wasteful in a real compressed index. Production implementations store sampled
rank/select support, use word-level bit tricks, and often add skip pointers.
Vigna describes longword addressing and broadword bit search as key engineering
pieces for query speed.<sup id="fnref:vigna:2" role="doc-noteref"><a href="#fn:vigna" class="footnote" rel="footnote">3</a></sup></p>

<p>The lab also encodes one list at a time. Modern variants exploit clustering and
partitioning. Ottaviano and Venturini’s partitioned Elias-Fano work describes a
two-level representation that improves the compression/time tradeoff, especially
when posting lists have locally clustered regions.<sup id="fnref:partitioned" role="doc-noteref"><a href="#fn:partitioned" class="footnote" rel="footnote">4</a></sup> Pibiri and
Venturini later show that clustering related posting lists can reduce
redundancy across lists, while still building on Elias-Fano representations of
monotone sequences.<sup id="fnref:clustered" role="doc-noteref"><a href="#fn:clustered" class="footnote" rel="footnote">5</a></sup></p>

<p>Those refinements do not change the core picture:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>low bits carry local offsets
high bits carry a selectable map
</code></pre></div></div>

<p>Once you see that split, Elias-Fano stops looking like just another integer
codec. It is a way to store a sorted list so that the compressed form still has
addresses.</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:elias-fano" role="doc-endnote">
      <p>Original sources: Peter Elias, <a href="https://dl.acm.org/doi/10.1145/321812.321820">“Efficient Storage and Retrieval by Content and Address of Static Files”</a>, <em>Journal of the ACM</em> 21(2), 1974; Robert M. Fano, <a href="https://csg.csail.mit.edu/pubs/memos/Memo-61/Memo-61.pdf">“On the Number of Bits Required to Implement an Associative Memory”</a>, MIT Project MAC Memorandum 61, 1971. <a href="#fnref:elias-fano" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:lucene" role="doc-endnote">
      <p>Apache Lucene 4.10.2 API documentation for <a href="https://lucene.apache.org/core/4_10_2/core/org/apache/lucene/util/packed/EliasFanoEncoder.html"><code class="language-plaintext highlighter-rouge">EliasFanoEncoder</code></a>. The docs describe the high-bits/low-bits representation, the choice of <code class="language-plaintext highlighter-rouge">L</code>, the bits-per-number bound, and the comparison with fixed bitsets. <a href="#fnref:lucene" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:lucene:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:vigna" role="doc-endnote">
      <p>Sebastiano Vigna, <a href="https://vigna.di.unimi.it/ftp/papers/QuasiSuccinctIndices.pdf">“Quasi-Succinct Indices”</a>, WSDM 2013; <a href="https://arxiv.org/abs/1206.4300">arXiv page</a>. Vigna applies Elias-Fano-style monotone sequence representations to inverted indexes and discusses random access, skipping, and broadword engineering. <a href="#fnref:vigna" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:vigna:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:vigna:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:partitioned" role="doc-endnote">
      <p>Giuseppe Ottaviano and Rossano Venturini, <a href="https://dl.acm.org/doi/10.1145/2600428.2609615">“Partitioned Elias-Fano Indexes”</a>, SIGIR 2014. The abstract describes a two-level representation that partitions lists and encodes chunks and endpoints with Elias-Fano. <a href="#fnref:partitioned" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:clustered" role="doc-endnote">
      <p>Giulio Ermanno Pibiri and Rossano Venturini, <a href="https://pages.di.unipi.it/rossano/assets/pdf/papers/TOIS17.pdf">“Clustered Elias-Fano Indexes”</a>, ACM Transactions on Information Systems. The paper reviews Elias-Fano for monotone sequences, gives the <code class="language-plaintext highlighter-rouge">H</code> and <code class="language-plaintext highlighter-rouge">L</code> encoding layout, and studies clustered posting-list representations. <a href="#fnref:clustered" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Teo</name><email>zengwc.teo2016@outlook.com</email></author><category term="computer-science" /><category term="elias-fano" /><category term="succinct-data-structures" /><category term="inverted-indexes" /><category term="compression" /><category term="rank-select" /><category term="search-systems" /><summary type="html"><![CDATA[Elias-Fano encoding stores a monotone integer list by writing low bits literally and turning high bits into a selectable unary bitmap.]]></summary></entry><entry><title type="html">The Counter Only Remembers the Exponent</title><link href="https://sir-teo.github.io/blogs/computer-science/2026/06/18/the-counter-only-remembers-the-exponent.html" rel="alternate" type="text/html" title="The Counter Only Remembers the Exponent" /><published>2026-06-18T09:00:00-04:00</published><updated>2026-06-18T09:00:00-04:00</updated><id>https://sir-teo.github.io/blogs/computer-science/2026/06/18/the-counter-only-remembers-the-exponent</id><content type="html" xml:base="https://sir-teo.github.io/blogs/computer-science/2026/06/18/the-counter-only-remembers-the-exponent.html"><![CDATA[<p>An exact counter is boring in the best possible way. Every event adds one. The
stored integer is the answer.</p>

<p>The boring version has a sharp memory cliff. An 8-bit counter stops at 255. If
you need many counters and only have bytes, the obvious repair is to give every
counter more bytes. Robert Morris’s 1978 note starts from exactly that kind of
machine constraint: many event counters, byte-sized storage, and no room for
16-bit counters.<sup id="fnref:morris" role="doc-noteref"><a href="#fn:morris" class="footnote" rel="footnote">1</a></sup></p>

<p>Morris’s trick is to stop storing the count. Store something closer to its
logarithm, and increment that stored value only sometimes.</p>

<p>For a base \(b &gt; 1\), keep an integer register \(X\). On each event:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>with probability b^(-X): X &lt;- X + 1
otherwise:               X stays fixed
</code></pre></div></div>

<p>Decode the register as:</p>

\[\hat n(X) = \frac{b^X - 1}{b - 1}.\]

<p>When \(X=0\), the estimate is 0. When \(X=1\), the estimate is 1. Later, the
steps get wider. The counter has become a staircase with random landing times.</p>

<p>The surprising part is not that this compresses the count. The surprising part
is that the decoded value is unbiased:</p>

\[\mathbb{E}\left[\hat n(X_N)\right] = N.\]

<p>The counter forgot the exact path, but in expectation it kept the total.</p>

<h2 id="the-one-line-martingale">The One-Line Martingale</h2>

<p>Suppose the register currently has value \(X\). Define</p>

\[Y = \frac{b^X - 1}{b - 1}.\]

<p>After the next event, \(X\) increments with probability \(b^{-X}\). The expected
next decoded value is:</p>

\[\begin{aligned}
\mathbb{E}[Y' \mid X]
&amp;= (1 - b^{-X})\frac{b^X - 1}{b - 1}
 + b^{-X}\frac{b^{X+1} - 1}{b - 1} \\
&amp;= \frac{b^X - 1}{b - 1} + 1 \\
&amp;= Y + 1.
\end{aligned}\]

<p>That is the whole conservation law. Each real event adds one unit to the
expected decoded estimate. Starting from zero, after \(N\) events the decoded
estimate has expectation \(N\).</p>

<p>Morris used a parameter \(a\) rather than the base directly:</p>

\[b = 1 + \frac{1}{a},
\qquad
\hat n(X) = a\left(\left(1 + \frac{1}{a}\right)^X - 1\right).\]

<p>This version has variance:</p>

\[\operatorname{Var}(\hat n) = \frac{N(N-1)}{2a}.\]

<p>So for large \(N\), the relative standard deviation is approximately:</p>

\[\frac{\sqrt{\operatorname{Var}(\hat n)}}{N}
\approx \frac{1}{\sqrt{2a}}.\]

<p>That relative-error scale is almost independent of the count. It is paid for
with range. In a fixed-width register, larger \(a\) means a base closer to one,
smaller jumps, lower variance, and a smaller maximum decodable count.</p>

<p>Morris’s example used 8-bit counters with \(a=30\). The largest decodable count
is about:</p>

\[30\left(\left(1 + \frac{1}{30}\right)^{255} - 1\right)
\approx 1.28 \times 10^5.\]

<p>His paper reports the same order of magnitude, “about 130,000”, with a relative
error around a quarter at 95% confidence under the normal approximation.<sup id="fnref:morris:1" role="doc-noteref"><a href="#fn:morris" class="footnote" rel="footnote">1</a></sup></p>

<h2 id="the-lab">The Lab</h2>

<p>The lab below simulates Morris counters directly. It uses a geometric waiting
time for the next successful register increment, which is equivalent to
processing events one at a time but much faster when the register is already
large.</p>

<p>The controls expose the main engineering tradeoffs:</p>

<ul>
  <li><strong>true events</strong> is the real stream length;</li>
  <li><strong>precision a</strong> is Morris’s parameter;</li>
  <li><strong>register bits</strong> fixes the largest stored exponent;</li>
  <li><strong>trials</strong> repeats the experiment to show the sampling distribution; and</li>
  <li><strong>replicas</strong> averages independent counters, lowering variance by the usual
factor of the number of replicas.</li>
</ul>

<style>
  .morris-counter-lab {
    background: #ffffff;
    border: 1px solid #d7dee5;
    border-radius: 8px;
    box-sizing: border-box;
    color: #172026;
    margin: 2rem 0;
    max-width: 100%;
    overflow: hidden;
    padding: 1rem;
  }

  .morris-counter-lab__controls {
    display: grid;
    gap: 0.85rem 1rem;
    grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
    margin-bottom: 1rem;
  }

  .morris-counter-lab label,
  .morris-counter-lab__metric span,
  .morris-counter-lab__note,
  .morris-counter-lab__legend {
    color: #5d6872;
    font-family: var(--font-ui);
    font-size: 0.82rem;
    line-height: 1.4;
  }

  .morris-counter-lab label {
    display: grid;
    font-weight: 650;
    gap: 0.35rem;
    min-width: 0;
  }

  .morris-counter-lab input {
    accent-color: #0f766e;
    box-sizing: border-box;
    font: inherit;
    margin: 0;
    min-width: 0;
    width: 100%;
  }

  .morris-counter-lab output {
    color: #172026;
    font-variant-numeric: tabular-nums;
    font-weight: 780;
  }

  .morris-counter-lab__metrics {
    display: grid;
    gap: 0.55rem;
    grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
    margin: 0.4rem 0 0.8rem;
  }

  .morris-counter-lab__metric {
    background: #f8fafc;
    border: 1px solid #e3e9ef;
    border-radius: 6px;
    min-height: 4rem;
    padding: 0.62rem 0.7rem;
  }

  .morris-counter-lab__metric--warn {
    background: #fffbeb;
    border-color: #fde68a;
  }

  .morris-counter-lab__metric span {
    display: block;
  }

  .morris-counter-lab__metric strong {
    color: #172026;
    display: block;
    font-family: var(--font-ui);
    font-size: 1rem;
    font-variant-numeric: tabular-nums;
    line-height: 1.2;
    margin-top: 0.25rem;
    overflow-wrap: anywhere;
  }

  .morris-counter-lab canvas {
    background: #ffffff;
    border: 1px solid #d7dee5;
    border-radius: 8px;
    box-sizing: border-box;
    display: block;
    height: 760px;
    margin-top: 0.75rem;
    width: 100%;
  }

  .morris-counter-lab__legend {
    display: flex;
    flex-wrap: wrap;
    gap: 0.45rem 1rem;
    margin-top: 0.8rem;
  }

  .morris-counter-lab__legend span {
    align-items: center;
    display: inline-flex;
    gap: 0.35rem;
  }

  .morris-counter-lab__legend i {
    border-radius: 999px;
    display: inline-block;
    height: 0.65rem;
    width: 0.65rem;
  }

  .morris-counter-lab__note {
    margin: 0.65rem 0 0;
  }

  @media (max-width: 680px) {
    .morris-counter-lab {
      padding: 0.8rem;
    }

    .morris-counter-lab__controls,
    .morris-counter-lab__metrics {
      grid-template-columns: 1fr;
    }

    .morris-counter-lab canvas {
      height: 1120px;
    }
  }
</style>

<div class="morris-counter-lab" data-morris-counter-lab="">
  <div class="morris-counter-lab__controls">
    <label>
      True events <output data-output="events">80,000</output>
      <input type="range" min="1000" max="200000" step="1000" value="80000" data-param="events" aria-label="True event count" />
    </label>
    <label>
      Precision a <output data-output="precision">30</output>
      <input type="range" min="4" max="120" step="1" value="30" data-param="precision" aria-label="Morris precision parameter a" />
    </label>
    <label>
      Register bits <output data-output="bits">8</output>
      <input type="range" min="4" max="12" step="1" value="8" data-param="bits" aria-label="Register bits" />
    </label>
    <label>
      Trials <output data-output="trials">900</output>
      <input type="range" min="200" max="2500" step="100" value="900" data-param="trials" aria-label="Monte Carlo trials" />
    </label>
    <label>
      Replicas <output data-output="replicas">1</output>
      <input type="range" min="1" max="16" step="1" value="1" data-param="replicas" aria-label="Independent counters averaged" />
    </label>
    <label>
      Seed <output data-output="seed">53</output>
      <input type="range" min="1" max="9999" step="1" value="53" data-param="seed" aria-label="Deterministic random seed" />
    </label>
  </div>
  <div class="morris-counter-lab__metrics" data-metrics=""></div>
  <canvas data-canvas="" width="980" height="760" aria-label="Morris approximate counting lab showing a stochastic register staircase, error distribution, range-accuracy tradeoff, and register decoding curve"></canvas>
  <div class="morris-counter-lab__legend">
    <span><i style="background:#334155"></i> exact count</span>
    <span><i style="background:#0f766e"></i> Morris estimate</span>
    <span><i style="background:#2563eb"></i> theory</span>
    <span><i style="background:#b45309"></i> selected setting</span>
  </div>
  <p class="morris-counter-lab__note" data-note=""></p>
</div>

<script defer="" src="/blogs/assets/js/morris-counter-lab.js?v=20260618c"></script>

<p>The artifact includes exact checks for small counts and seeded Monte Carlo
checks for larger counts: 10 audit cases and 24 named checks.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>node - <span class="o">&lt;&lt;</span><span class="sh">'</span><span class="no">NODE</span><span class="sh">'
const lab = require("./assets/js/morris-counter-lab.js");
const EXPECTED_EXACT_ROWS = 6;
const EXPECTED_SCENARIO_ROWS = 4;
const EXPECTED_ROWS = 10;
const EXPECTED_CHECKS = 24;
const EXPECTED_EXACT_EVENTS = [0, 1, 2, 8, 35, 60];
const EXPECTED_SCENARIO_EVENTS = [1000, 40000, 80000, 160000];
const EXPECTED_EXACT_CHECK_NAMES = [
  "exact expectation equals count",
  "exact variance matches formula"
];
const EXPECTED_SCENARIO_CHECK_NAMES = [
  "empirical mean is inside standard-error envelope",
  "saturation is either absent or explicitly near capacity",
  "seeded replay is deterministic"
];
const EXPECTED_TOTALS = {
  exactRows: EXPECTED_EXACT_ROWS,
  scenarioRows: EXPECTED_SCENARIO_ROWS,
  passed: EXPECTED_ROWS,
  total: EXPECTED_ROWS,
  passedChecks: EXPECTED_CHECKS,
  totalChecks: EXPECTED_CHECKS
};
const EXPECTED_EXACT_SHAPE = [
  "0:meanOk=true:varianceOk=true:checks=2/2",
  "1:meanOk=true:varianceOk=true:checks=2/2",
  "2:meanOk=true:varianceOk=true:checks=2/2",
  "8:meanOk=true:varianceOk=true:checks=2/2",
  "35:meanOk=true:varianceOk=true:checks=2/2",
  "60:meanOk=true:varianceOk=true:checks=2/2"
];
const EXPECTED_SCENARIO_SHAPE = [
  "80000:theoretical=80000:observed=80174.089:saturated=0:deterministic=true:meanOk=true:noBadSaturation=true:checks=3/3",
  "1000:theoretical=1000:observed=996.258:saturated=0:deterministic=true:meanOk=true:noBadSaturation=true:checks=3/3",
  "40000:theoretical=40000:observed=40013.436:saturated=0:deterministic=true:meanOk=true:noBadSaturation=true:checks=3/3",
  "160000:theoretical=160000:observed=159830.740:saturated=0:deterministic=true:meanOk=true:noBadSaturation=true:checks=3/3"
];
const EXPECTED_CHECK_SHAPE = [
  "exact:0:exact expectation equals count:true",
  "exact:0:exact variance matches formula:true",
  "exact:1:exact expectation equals count:true",
  "exact:1:exact variance matches formula:true",
  "exact:2:exact expectation equals count:true",
  "exact:2:exact variance matches formula:true",
  "exact:8:exact expectation equals count:true",
  "exact:8:exact variance matches formula:true",
  "exact:35:exact expectation equals count:true",
  "exact:35:exact variance matches formula:true",
  "exact:60:exact expectation equals count:true",
  "exact:60:exact variance matches formula:true",
  "scenario:80000:seeded replay is deterministic:true",
  "scenario:80000:empirical mean is inside standard-error envelope:true",
  "scenario:80000:saturation is either absent or explicitly near capacity:true",
  "scenario:1000:seeded replay is deterministic:true",
  "scenario:1000:empirical mean is inside standard-error envelope:true",
  "scenario:1000:saturation is either absent or explicitly near capacity:true",
  "scenario:40000:seeded replay is deterministic:true",
  "scenario:40000:empirical mean is inside standard-error envelope:true",
  "scenario:40000:saturation is either absent or explicitly near capacity:true",
  "scenario:160000:seeded replay is deterministic:true",
  "scenario:160000:empirical mean is inside standard-error envelope:true",
  "scenario:160000:saturation is either absent or explicitly near capacity:true"
];

function sameList(actual, expected) {
  return actual.length === expected.length &amp;&amp;
    actual.every((value, index) =&gt; value === expected[index]);
}

function sameJson(actual, expected) {
  return JSON.stringify(actual) === JSON.stringify(expected);
}

function exactShape(row) {
  return `</span><span class="k">${</span><span class="nv">row</span><span class="p">.events</span><span class="k">}</span><span class="sh">:meanOk=</span><span class="k">${</span><span class="nv">row</span><span class="p">.meanOk</span><span class="k">}</span><span class="sh">:varianceOk=</span><span class="k">${</span><span class="nv">row</span><span class="p">.varianceOk</span><span class="k">}</span><span class="sh">:` +
    `checks=</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">`;
}

function scenarioShape(row) {
  return `</span><span class="k">${</span><span class="nv">row</span><span class="p">.events</span><span class="k">}</span><span class="sh">:theoretical=</span><span class="k">${</span><span class="nv">row</span><span class="p">.theoreticalMean</span><span class="k">}</span><span class="sh">:` +
    `observed=</span><span class="k">${</span><span class="nv">row</span><span class="p">.observedMean.toFixed(3)</span><span class="k">}</span><span class="sh">:saturated=</span><span class="k">${</span><span class="nv">row</span><span class="p">.saturatedTrials</span><span class="k">}</span><span class="sh">:` +
    `deterministic=</span><span class="k">${</span><span class="nv">row</span><span class="p">.deterministic</span><span class="k">}</span><span class="sh">:meanOk=</span><span class="k">${</span><span class="nv">row</span><span class="p">.meanOk</span><span class="k">}</span><span class="sh">:` +
    `noBadSaturation=</span><span class="k">${</span><span class="nv">row</span><span class="p">.noBadSaturation</span><span class="k">}</span><span class="sh">:` +
    `checks=</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">`;
}

function checkShape(row, kind) {
  return row.checks.map((check) =&gt; `</span><span class="k">${</span><span class="nv">kind</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.events</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">check</span><span class="p">.name</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">check</span><span class="p">.passed</span><span class="k">}</span><span class="sh">`);
}

const audit = lab.auditMorrisCounterLab();
console.table(audit.exact.map((row) =&gt; ({
  events: row.events,
  meanOk: row.meanOk,
  varianceOk: row.varianceOk,
  checks: `</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">`
})));
console.table(audit.scenarios.map((row) =&gt; ({
  events: row.events,
  theoretical: row.theoreticalMean,
  observed: row.observedMean.toFixed(1),
  saturatedTrials: row.saturatedTrials,
  deterministic: row.deterministic,
  checks: `</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">`
})));
const auditShape = {
  totals: {
    exactRows: audit.exact.length,
    scenarioRows: audit.scenarios.length,
    passed: audit.passed,
    total: audit.total,
    passedChecks: audit.passedChecks,
    totalChecks: audit.totalChecks
  },
  exactCheckNames: Array.from(new Set(audit.exact.flatMap(
    (row) =&gt; row.checks.map((check) =&gt; check.name)
  ))).sort(),
  exactEvents: audit.exact.map((row) =&gt; row.events).sort((a, b) =&gt; a - b),
  exactShape: audit.exact.map(exactShape),
  scenarioCheckNames: Array.from(new Set(audit.scenarios.flatMap(
    (row) =&gt; row.checks.map((check) =&gt; check.name)
  ))).sort(),
  scenarioEvents: audit.scenarios.map((row) =&gt; row.events).sort((a, b) =&gt; a - b),
  scenarioShape: audit.scenarios.map(scenarioShape),
  checkShape: audit.exact.flatMap((row) =&gt; checkShape(row, "exact"))
    .concat(audit.scenarios.flatMap((row) =&gt; checkShape(row, "scenario")))
};
const shapeErrors = [
  sameJson(auditShape.totals, EXPECTED_TOTALS) ? null : "totals",
  sameJson(auditShape.exactEvents, EXPECTED_EXACT_EVENTS) ? null : "exactEvents",
  sameJson(auditShape.scenarioEvents, EXPECTED_SCENARIO_EVENTS) ? null : "scenarioEvents",
  sameJson(auditShape.exactCheckNames, EXPECTED_EXACT_CHECK_NAMES) ? null : "exactCheckNames",
  sameJson(auditShape.scenarioCheckNames, EXPECTED_SCENARIO_CHECK_NAMES)
    ? null
    : "scenarioCheckNames",
  sameList(auditShape.exactShape, EXPECTED_EXACT_SHAPE) ? null : "exactShape",
  sameList(auditShape.scenarioShape, EXPECTED_SCENARIO_SHAPE) ? null : "scenarioShape",
  sameList(auditShape.checkShape, EXPECTED_CHECK_SHAPE) ? null : "checkShape"
].filter(Boolean);
if (shapeErrors.length) {
  throw new Error(
    `audit shape drifted in </span><span class="k">${</span><span class="nv">shapeErrors</span><span class="p">.join(</span><span class="s2">", "</span><span class="p">)</span><span class="k">}</span><span class="sh">:</span><span class="se">\n</span><span class="sh">` +
    JSON.stringify(auditShape, null, 2)
  );
}

const failedExactCases = audit.exact.filter(
  (row) =&gt; !row.passed || row.passedChecks !== row.totalChecks
);
const failedScenarioCases = audit.scenarios.filter(
  (row) =&gt; !row.passed || row.passedChecks !== row.totalChecks
);
if (!audit.ok || failedExactCases.length || failedScenarioCases.length ||
    audit.passed !== audit.total ||
    audit.passedChecks !== audit.totalChecks) {
  throw new Error(JSON.stringify({
    failures: audit.failures,
    failedExactCases,
    failedScenarioCases,
    shapeErrors,
    auditShape
  }, null, 2));
}
console.log(
  `</span><span class="k">${</span><span class="nv">audit</span><span class="p">.passed</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">audit</span><span class="p">.total</span><span class="k">}</span><span class="sh"> audit cases and ` +
  `</span><span class="k">${</span><span class="nv">audit</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">audit</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh"> checks passed`
);
</span><span class="no">NODE
</span></code></pre></div></div>

<p>Those named checks verify:</p>

<ul>
  <li>the dynamic-programmed expectation matches the true count for small \(N\);</li>
  <li>the dynamic-programmed variance matches \(N(N-1)/(2a)\);</li>
  <li>the jump simulation is deterministic under a fixed seed;</li>
  <li>empirical means land within a standard-error envelope around the true count;
and</li>
  <li>register saturation is flagged rather than silently folded into the
unbiasedness claim.</li>
</ul>

<h2 id="the-register-has-a-ceiling">The Register Has a Ceiling</h2>

<p>The formula is unbiased only before the register saturates. If the register
hits its maximum value, the implementation cannot keep taking logarithmic
steps. It clips.</p>

<p>That is not a theoretical nuisance. It is the systems bargain.</p>

<p>For \(r\) register bits, the largest stored value is \(2^r - 1\). With Morris’s
parameter \(a\), the largest decoded count is:</p>

\[n_{\max} = a\left(\left(1 + \frac{1}{a}\right)^{2^r - 1} - 1\right).\]

<p>For fixed \(r\):</p>

<ul>
  <li>lowering \(a\) increases the range but increases relative noise;</li>
  <li>raising \(a\) lowers relative noise but shortens the range; and</li>
  <li>averaging independent counters lowers noise but spends multiple registers.</li>
</ul>

<p>This is why the lab has both a “max decoded” metric and a “saturated trials”
metric. A run can look stable because the counter stopped moving. That is not a
successful approximation.</p>

<h2 id="a-tiny-counter-is-not-a-tiny-truth">A Tiny Counter Is Not a Tiny Truth</h2>

<p>Morris’s counter is sometimes described as using \(O(\log \log N)\) bits. That
is the memory headline, and it is real: the register value tracks a logarithm of
the count, so the number of bits needed to store that register grows like the
logarithm of a logarithm.<sup id="fnref:flajolet" role="doc-noteref"><a href="#fn:flajolet" class="footnote" rel="footnote">2</a></sup></p>

<p>The headline hides the variance bill.</p>

<p>For the base-2 version, where \(a=1\),</p>

\[\operatorname{Var}(\hat n) = \frac{N(N-1)}{2}.\]

<p>The relative standard deviation is about 70%. That is not a dashboard counter.
It is a tiny, unbiased, very noisy sketch.</p>

<p>The parameterized version matters because it lets a system decide how much of
that noise to buy down. In the lab default, \(a=30\) gives a theoretical
relative standard deviation near 13%. Averaging four independent counters cuts
that in half, but uses four registers. Raising \(a\) also lowers variance, but
the 8-bit range falls quickly.</p>

<p>Nelson and Yu’s later work is useful perspective here: approximate counting is
not just a historical curiosity but a clean problem about the best possible
memory/error/failure-probability tradeoff.<sup id="fnref:nelson-yu" role="doc-noteref"><a href="#fn:nelson-yu" class="footnote" rel="footnote">3</a></sup> One way to improve
accuracy is to average many Morris counters; another is to change the base.
Those choices can have different space consequences even when they look similar
from the variance formula alone.</p>

<h2 id="where-this-belongs">Where This Belongs</h2>

<p>A Morris counter is a good fit when:</p>

<ul>
  <li>many counters are needed;</li>
  <li>each counter only needs approximate statistical weight;</li>
  <li>relative error is acceptable;</li>
  <li>the maximum representable count has been budgeted; and</li>
  <li>the downstream consumer understands that the estimate is noisy.</li>
</ul>

<p>It is a poor fit when:</p>

<ul>
  <li>exact thresholds matter;</li>
  <li>low counts need exact treatment beyond the first event;</li>
  <li>saturation would be operationally invisible;</li>
  <li>adversarial manipulation of randomized counters is in scope; or</li>
  <li>the system needs to subtract, merge, or audit individual event identities.</li>
</ul>

<p>The counter is elegant because it does one thing cleanly:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>spend randomness so a small exponent can stand in for a large count
</code></pre></div></div>

<p>That is not the same as remembering the count. It is remembering just enough of
the logarithm that the expectation comes out right.</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:morris" role="doc-endnote">
      <p>Robert Morris, <a href="https://graphics.stanford.edu/courses/cs321/Private/Readings/p840-morris.pdf">“Counting Large Numbers of Events in Small Registers”</a>, <em>Communications of the ACM</em> 21(10), 1978. Morris motivates the problem with byte-sized counters, gives the randomized update rule, and analyzes the parameterized version with 8-bit counters counting roughly 130,000 events. <a href="#fnref:morris" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:morris:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:flajolet" role="doc-endnote">
      <p>Philippe Flajolet, <a href="https://algo.inria.fr/flajolet/Publications/Flajolet85c.pdf">“Approximate Counting: A Detailed Analysis”</a>, <em>BIT</em> 25, 113-134, 1985. Flajolet gives a detailed distributional analysis and frames approximate counting as maintaining large counts in small counters; Springer lists the DOI as <a href="https://link.springer.com/article/10.1007/BF01934993">10.1007/BF01934993</a>. <a href="#fnref:flajolet" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:nelson-yu" role="doc-endnote">
      <p>Jelani Nelson and Huacheng Yu, <a href="https://people.eecs.berkeley.edu/~minilek/publications/papers/approx_count.pdf">“Optimal Bounds for Approximate Counting”</a>, PODS 2022. The paper reviews the Morris counter, including the unbiased estimator and variance for a parameterized base, before proving modern upper and lower bounds. <a href="#fnref:nelson-yu" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Teo</name><email>zengwc.teo2016@outlook.com</email></author><category term="computer-science" /><category term="morris-counter" /><category term="approximate-counting" /><category term="streaming-algorithms" /><category term="randomized-algorithms" /><category term="probabilistic-data-structures" /><category term="sketches" /><summary type="html"><![CDATA[Morris approximate counting trades exact increments for a tiny logarithmic register, with an unbiased estimator and a variance bill you can compute.]]></summary></entry><entry><title type="html">The Smallest Counter Takes the Blame</title><link href="https://sir-teo.github.io/blogs/computer-science/2026/06/18/the-smallest-counter-takes-the-blame.html" rel="alternate" type="text/html" title="The Smallest Counter Takes the Blame" /><published>2026-06-18T08:36:00-04:00</published><updated>2026-06-18T08:36:00-04:00</updated><id>https://sir-teo.github.io/blogs/computer-science/2026/06/18/the-smallest-counter-takes-the-blame</id><content type="html" xml:base="https://sir-teo.github.io/blogs/computer-science/2026/06/18/the-smallest-counter-takes-the-blame.html"><![CDATA[<p>A frequency table is the honest solution until the vocabulary stops fitting.</p>

<p>The hard part is not counting. It is naming. A Count-Min Sketch can answer
“how many times did this key appear?” for a key you already know how to ask
about, but it does not remember the keys themselves. Heavy-hitter detection has
a different shape: after a stream has passed, the algorithm must say which
items were common.</p>

<p>Space-Saving is one of the cleanest counter-based answers to that problem. It
keeps a small table of named items. When a new unmonitored item arrives and the
table is full, it evicts the item with the smallest stored count. The newcomer
inherits that count as blame for the past.</p>

<p>That little inheritance rule is the whole trick.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>if x is monitored:
  count[x] += 1
else if an empty counter exists:
  monitor x with count = 1 and error = 0
else:
  let y be an item with minimum stored count m
  stop monitoring y
  monitor x with count = m + 1 and error = m
</code></pre></div></div>

<p>The stored count is not presented as truth. It is a receipt:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>true count of x lies in [count[x] - error[x], count[x]]
</code></pre></div></div>

<p>The right endpoint is the optimistic estimate. The left endpoint is the part
that survived the audit.</p>

<h2 id="the-minimum-counter-is-a-certificate-boundary">The Minimum Counter Is a Certificate Boundary</h2>

<p>Let the table have \(k\) counters. After \(n\) stream updates, the sum of all
stored counts is exactly \(n\), because every event increments exactly one
counter. Therefore the current minimum counter \(m_{\min}\) is at most
\(n/k\).</p>

<p>Space-Saving’s more interesting invariant is about names:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>any item whose true frequency is greater than the current minimum counter
must be monitored
</code></pre></div></div>

<p>The invariant is easy to miss because evictions look destructive. The
destruction is tracked.</p>

<p>If an item is monitored with stored count \(c\) and stored error \(e\), then
its true count \(f\) is bounded by:</p>

\[c - e \le f \le c.\]

<p>The upper bound exists because every stored count may include mass from earlier
items that occupied the same counter. The lower bound exists because
<code class="language-plaintext highlighter-rouge">error = m</code> records exactly how much prehistory the newcomer inherited at its
most recent insertion. Future arrivals of the same item increment the counter
honestly.</p>

<p>For an unmonitored item \(z\), the invariant says:</p>

\[f(z) \le m_{\min}.\]

<p>Sketch of the induction:</p>

<ul>
  <li>Before the table fills, unmonitored items have frequency zero.</li>
  <li>If an unmonitored item arrives while the table is full, it immediately
becomes monitored.</li>
  <li>If a monitored item is evicted, it was sitting in a minimum counter, so its
true count was no larger than that counter. The global minimum counter never
decreases after the replacement.</li>
</ul>

<p>So the minimum counter is not just an implementation detail. It is the boundary
between “could still be hiding” and “must have a named counter.”</p>

<p>That boundary gives the threshold rule:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>if the heavy-hitter threshold phi n is greater than m_min,
then Space-Saving has no false negatives above that threshold.
</code></pre></div></div>

<p>False positives are still possible if you report by the upper count. The stored
lower bound separates those cases:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>count[x] &gt;= threshold          -&gt; candidate
count[x] - error[x] &gt;= threshold -&gt; certified heavy hitter
</code></pre></div></div>

<p>That difference is where the algorithm stops being a vibe and becomes an
auditable data structure.</p>

<h2 id="the-lab">The Lab</h2>

<p>The lab below generates a deterministic Zipf-like stream with optional drift,
runs Space-Saving, and compares the summary against the exact frequency table.
The exact table is used only for the audit and visualization.</p>

<p>The panels show:</p>

<ul>
  <li>exact top items, with monitored items ringed;</li>
  <li>stored counters as intervals <code class="language-plaintext highlighter-rouge">[count - error, count]</code>;</li>
  <li>threshold candidates, guaranteed hits, and misses; and</li>
  <li>one exact-rank query interpreted through the Space-Saving receipt.</li>
</ul>

<p>The default setting uses 64 counters for 50,000 events. The threshold is 1.2%
of the stream, which is above the current minimum counter in the generated run.
That makes the no-miss certificate visible on first load.</p>

<style>
  .space-saving-lab {
    background: #ffffff;
    border: 1px solid #d7dee5;
    border-radius: 8px;
    box-sizing: border-box;
    color: #172026;
    margin: 2rem 0;
    max-width: 100%;
    overflow: hidden;
    padding: 1rem;
  }

  .space-saving-lab__controls {
    display: grid;
    gap: 0.85rem 1rem;
    grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
    margin-bottom: 1rem;
  }

  .space-saving-lab label,
  .space-saving-lab__metric span,
  .space-saving-lab__note,
  .space-saving-lab__legend {
    color: #5d6872;
    font-family: var(--font-ui);
    font-size: 0.82rem;
    line-height: 1.4;
  }

  .space-saving-lab label {
    display: grid;
    font-weight: 650;
    gap: 0.35rem;
    min-width: 0;
  }

  .space-saving-lab input {
    accent-color: #0f766e;
    box-sizing: border-box;
    font: inherit;
    margin: 0;
    min-width: 0;
    width: 100%;
  }

  .space-saving-lab output {
    color: #172026;
    font-variant-numeric: tabular-nums;
    font-weight: 780;
  }

  .space-saving-lab__metrics {
    display: grid;
    gap: 0.55rem;
    grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
    margin: 0.4rem 0 0.8rem;
  }

  .space-saving-lab__metric {
    background: #f8fafc;
    border: 1px solid #e3e9ef;
    border-radius: 6px;
    min-height: 4rem;
    padding: 0.62rem 0.7rem;
  }

  .space-saving-lab__metric--good {
    background: #ecfdf5;
    border-color: #bbf7d0;
  }

  .space-saving-lab__metric--bad {
    background: #fff1f2;
    border-color: #fecdd3;
  }

  .space-saving-lab__metric span {
    display: block;
  }

  .space-saving-lab__metric strong {
    color: #172026;
    display: block;
    font-family: var(--font-ui);
    font-size: 1rem;
    font-variant-numeric: tabular-nums;
    line-height: 1.2;
    margin-top: 0.25rem;
    overflow-wrap: anywhere;
  }

  .space-saving-lab canvas {
    background: #ffffff;
    border: 1px solid #d7dee5;
    border-radius: 8px;
    box-sizing: border-box;
    display: block;
    height: 760px;
    margin-top: 0.75rem;
    width: 100%;
  }

  .space-saving-lab__legend {
    display: flex;
    flex-wrap: wrap;
    gap: 0.45rem 1rem;
    margin-top: 0.8rem;
  }

  .space-saving-lab__legend span {
    align-items: center;
    display: inline-flex;
    gap: 0.35rem;
  }

  .space-saving-lab__legend i {
    border-radius: 999px;
    display: inline-block;
    height: 0.65rem;
    width: 0.65rem;
  }

  .space-saving-lab__note {
    margin: 0.65rem 0 0;
  }

  @media (max-width: 680px) {
    .space-saving-lab {
      padding: 0.8rem;
    }

    .space-saving-lab__controls,
    .space-saving-lab__metrics {
      grid-template-columns: 1fr;
    }

    .space-saving-lab canvas {
      height: 1120px;
    }
  }
</style>

<div class="space-saving-lab" data-space-saving-lab="">
  <div class="space-saving-lab__controls">
    <label>
      Stream events <output data-output="events">50,000</output>
      <input type="range" min="5000" max="120000" step="5000" value="50000" data-param="events" aria-label="Stream events" />
    </label>
    <label>
      Vocabulary <output data-output="vocabulary">2,000</output>
      <input type="range" min="100" max="8000" step="100" value="2000" data-param="vocabulary" aria-label="Vocabulary size" />
    </label>
    <label>
      Zipf exponent <output data-output="skew">1.05</output>
      <input type="range" min="0" max="160" step="5" value="105" data-param="skew" aria-label="Zipf exponent scaled by one hundred" />
    </label>
    <label>
      Counters <output data-output="counters">64</output>
      <input type="range" min="8" max="160" step="4" value="64" data-param="counters" aria-label="Number of Space-Saving counters" />
    </label>
    <label>
      Threshold <output data-output="threshold">1.2%</output>
      <input type="range" min="2" max="60" step="1" value="12" data-param="threshold" aria-label="Heavy hitter threshold in permille" />
    </label>
    <label>
      Top-k audit <output data-output="topK">10</output>
      <input type="range" min="3" max="25" step="1" value="10" data-param="topK" aria-label="Top k audit size" />
    </label>
    <label>
      Query rank <output data-output="queryRank">#8</output>
      <input type="range" min="1" max="40" step="1" value="8" data-param="queryRank" aria-label="Exact rank to query" />
    </label>
    <label>
      Midstream drift <output data-output="drift">25%</output>
      <input type="range" min="0" max="100" step="5" value="25" data-param="drift" aria-label="Midstream vocabulary drift" />
    </label>
    <label>
      Seed <output data-output="seed">41</output>
      <input type="range" min="1" max="9999" step="1" value="41" data-param="seed" aria-label="Deterministic random seed" />
    </label>
  </div>
  <div class="space-saving-lab__metrics" data-metrics=""></div>
  <canvas data-canvas="" width="980" height="760" aria-label="Space-Saving heavy hitters lab showing exact leaders, counter intervals, threshold audit, and rank query receipt"></canvas>
  <div class="space-saving-lab__legend">
    <span><i style="background:#334155"></i> exact count</span>
    <span><i style="background:#0f766e"></i> certified lower mass</span>
    <span><i style="background:#2563eb"></i> upper estimate</span>
    <span><i style="background:#7c3aed"></i> query interval</span>
  </div>
  <p class="space-saving-lab__note" data-note=""></p>
</div>

<script defer="" src="/blogs/assets/js/space-saving-lab.js?v=20260618c"></script>

<p>The audit is part of the artifact, not just a visual sanity check:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>node - <span class="o">&lt;&lt;</span><span class="sh">'</span><span class="no">NODE</span><span class="sh">'
const lab = require("./assets/js/space-saving-lab.js");
const EXPECTED_SCENARIO_ROWS = 5;
const EXPECTED_REPLAY_ROWS = 1;
const EXPECTED_ROWS = 6;
const EXPECTED_CHECKS = 36;
const EXPECTED_SCENARIO_CHECK_NAMES = [
  "counter-total",
  "guaranteed-threshold",
  "minimum-n-over-k",
  "monitored-intervals",
  "threshold-no-miss",
  "top-k-range",
  "unmonitored-min-bound"
];
const EXPECTED_REPLAY_CHECK_NAMES = ["fixed-seed-replay"];
const EXPECTED_CASE_SHAPE = [
  "default:7:50000:2000:64:600:10:15:1",
  "fixed-seed-replay:1:50000:2000:64:600:10:15:1",
  "stress-1:7:5000:100:8:100:0:8:0",
  "stress-2:7:120000:8000:160:240:38:39:1",
  "stress-3:7:25000:500:24:300:11:24:0",
  "stress-4:7:70000:2500:48:560:17:48:0"
];
const EXPECTED_CASE_ROWS = [
  "default:checks=7/7:passed=true:events=50000:vocab=2000:counters=64:min=592:threshold=600:trueHeavy=10:reported=15:noMiss=true",
  "fixed-seed-replay:checks=1/1:passed=true:events=50000:vocab=2000:counters=64:min=592:threshold=600:trueHeavy=10:reported=15:noMiss=true",
  "stress-1:checks=7/7:passed=true:events=5000:vocab=100:counters=8:min=624:threshold=100:trueHeavy=0:reported=8:noMiss=false",
  "stress-2:checks=7/7:passed=true:events=120000:vocab=8000:counters=160:min=93:threshold=240:trueHeavy=38:reported=39:noMiss=true",
  "stress-3:checks=7/7:passed=true:events=25000:vocab=500:counters=24:min=973:threshold=300:trueHeavy=11:reported=24:noMiss=false",
  "stress-4:checks=7/7:passed=true:events=70000:vocab=2500:counters=48:min=864:threshold=560:trueHeavy=17:reported=48:noMiss=false"
];
const EXPECTED_CHECK_ROWS = [
  "default:counter-total=true|monitored-intervals=true|unmonitored-min-bound=true|threshold-no-miss=true|guaranteed-threshold=true|minimum-n-over-k=true|top-k-range=true",
  "fixed-seed-replay:fixed-seed-replay=true",
  "stress-1:counter-total=true|monitored-intervals=true|unmonitored-min-bound=true|threshold-no-miss=true|guaranteed-threshold=true|minimum-n-over-k=true|top-k-range=true",
  "stress-2:counter-total=true|monitored-intervals=true|unmonitored-min-bound=true|threshold-no-miss=true|guaranteed-threshold=true|minimum-n-over-k=true|top-k-range=true",
  "stress-3:counter-total=true|monitored-intervals=true|unmonitored-min-bound=true|threshold-no-miss=true|guaranteed-threshold=true|minimum-n-over-k=true|top-k-range=true",
  "stress-4:counter-total=true|monitored-intervals=true|unmonitored-min-bound=true|threshold-no-miss=true|guaranteed-threshold=true|minimum-n-over-k=true|top-k-range=true"
];
const EXPECTED_TOTALS = {
  scenarioRows: EXPECTED_SCENARIO_ROWS,
  replayRows: EXPECTED_REPLAY_ROWS,
  passed: EXPECTED_ROWS,
  total: EXPECTED_ROWS,
  passedChecks: EXPECTED_CHECKS,
  totalChecks: EXPECTED_CHECKS
};

function sameJson(actual, expected) {
  return JSON.stringify(actual) === JSON.stringify(expected);
}

function rowDrifts(actual, expected) {
  const count = Math.max(actual.length, expected.length);
  const drift = [];
  for (let index = 0; index &lt; count; index += 1) {
    if (actual[index] !== expected[index]) {
      drift.push({ index, actual: actual[index], expected: expected[index] });
    }
  }
  return drift;
}

const audit = lab.auditSpaceSavingLab();
console.table(audit.cases.map((row) =&gt; ({
  scenario: row.scenario,
  events: row.events,
  vocabulary: row.vocabulary,
  counters: row.counters,
  minCount: row.minCount,
  threshold: row.thresholdCount,
  trueHeavy: row.trueHeavy,
  reported: row.reported,
  noMiss: row.noMissCondition,
  checks: `</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">`
})));
const scenarioRows = audit.cases.filter((row) =&gt; row.scenario !== "fixed-seed-replay");
const replayRows = audit.cases.filter((row) =&gt; row.scenario === "fixed-seed-replay");
const auditShape = {
  totals: {
    scenarioRows: scenarioRows.length,
    replayRows: replayRows.length,
    passed: audit.passed,
    total: audit.total,
    passedChecks: audit.passedChecks,
    totalChecks: audit.totalChecks
  },
  caseChecks: audit.cases.map((row) =&gt; [
    row.scenario,
    row.totalChecks,
    row.events,
    row.vocabulary,
    row.counters,
    row.thresholdCount,
    row.trueHeavy,
    row.reported,
    row.noMissCondition ? 1 : 0
  ].join(":")).sort(),
  caseRows: audit.cases.map((row) =&gt;
    `</span><span class="k">${</span><span class="nv">row</span><span class="p">.scenario</span><span class="k">}</span><span class="sh">:checks=</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">:` +
    `passed=</span><span class="k">${</span><span class="nv">row</span><span class="p">.passed</span><span class="k">}</span><span class="sh">:events=</span><span class="k">${</span><span class="nv">row</span><span class="p">.events</span><span class="k">}</span><span class="sh">:vocab=</span><span class="k">${</span><span class="nv">row</span><span class="p">.vocabulary</span><span class="k">}</span><span class="sh">:` +
    `counters=</span><span class="k">${</span><span class="nv">row</span><span class="p">.counters</span><span class="k">}</span><span class="sh">:min=</span><span class="k">${</span><span class="nv">row</span><span class="p">.minCount</span><span class="k">}</span><span class="sh">:` +
    `threshold=</span><span class="k">${</span><span class="nv">row</span><span class="p">.thresholdCount</span><span class="k">}</span><span class="sh">:trueHeavy=</span><span class="k">${</span><span class="nv">row</span><span class="p">.trueHeavy</span><span class="k">}</span><span class="sh">:` +
    `reported=</span><span class="k">${</span><span class="nv">row</span><span class="p">.reported</span><span class="k">}</span><span class="sh">:noMiss=</span><span class="k">${</span><span class="nv">row</span><span class="p">.noMissCondition</span><span class="k">}</span><span class="sh">`
  ).sort(),
  checkRows: audit.cases.map((row) =&gt;
    `</span><span class="k">${</span><span class="nv">row</span><span class="p">.scenario</span><span class="k">}</span><span class="sh">:` +
    row.checks.map((check) =&gt; `</span><span class="k">${</span><span class="nv">check</span><span class="p">.name</span><span class="k">}</span><span class="sh">=</span><span class="k">${</span><span class="nv">check</span><span class="p">.passed</span><span class="k">}</span><span class="sh">`).join("|")
  ).sort(),
  replayCheckNames: Array.from(new Set(replayRows.flatMap(
    (row) =&gt; row.checks.map((check) =&gt; check.name)
  ))).sort(),
  scenarioCheckNames: Array.from(new Set(scenarioRows.flatMap(
    (row) =&gt; row.checks.map((check) =&gt; check.name)
  ))).sort()
};
const caseRowDrifts = rowDrifts(auditShape.caseRows, EXPECTED_CASE_ROWS);
const checkRowDrifts = rowDrifts(auditShape.checkRows, EXPECTED_CHECK_ROWS);
const shapeErrors = [
  sameJson(auditShape.totals, EXPECTED_TOTALS) ? null : "totals",
  sameJson(auditShape.caseChecks, EXPECTED_CASE_SHAPE) ? null : "caseChecks",
  caseRowDrifts.length ? "caseRows" : null,
  checkRowDrifts.length ? "checkRows" : null,
  sameJson(auditShape.scenarioCheckNames, EXPECTED_SCENARIO_CHECK_NAMES)
    ? null
    : "scenarioCheckNames",
  sameJson(auditShape.replayCheckNames, EXPECTED_REPLAY_CHECK_NAMES)
    ? null
    : "replayCheckNames"
].filter(Boolean);
if (shapeErrors.length) {
  throw new Error(
    `audit shape drifted in </span><span class="k">${</span><span class="nv">shapeErrors</span><span class="p">.join(</span><span class="s2">", "</span><span class="p">)</span><span class="k">}</span><span class="sh">:</span><span class="se">\n</span><span class="sh">` +
    JSON.stringify({
      auditShape,
      drifts: {
        caseRows: caseRowDrifts,
        checkRows: checkRowDrifts
      }
    }, null, 2)
  );
}

const failedCases = audit.cases.filter(
  (row) =&gt; !row.passed || row.passedChecks !== row.totalChecks
);
if (!audit.ok || failedCases.length ||
    audit.passed !== audit.total ||
    audit.passedChecks !== audit.totalChecks) {
  throw new Error(JSON.stringify({
    failures: audit.failures,
    failedCases,
    shapeErrors,
    auditShape
  }, null, 2));
}
console.log(
  `</span><span class="k">${</span><span class="nv">audit</span><span class="p">.passed</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">audit</span><span class="p">.total</span><span class="k">}</span><span class="sh"> audit scenarios and ` +
  `</span><span class="k">${</span><span class="nv">audit</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">audit</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh"> checks passed`
);
</span><span class="no">NODE
</span></code></pre></div></div>

<p>The 36 named checks cover five parameter settings plus a fixed-seed replay.
They assert that:</p>

<ul>
  <li>the sum of stored counters equals the stream length;</li>
  <li>every monitored item’s exact count lies in its stored interval;</li>
  <li>every unmonitored item’s exact count is at most the current minimum counter;</li>
  <li>when the threshold is above the minimum counter, no true heavy hitter is
unmonitored;</li>
  <li>any item whose lower bound crosses the threshold is truly above threshold;</li>
  <li>the top-<code class="language-plaintext highlighter-rouge">k</code> precision/recall summaries stay in probability range; and</li>
  <li>the implementation is deterministic for a fixed seed.</li>
</ul>

<h2 id="why-this-is-not-just-misra-gries-with-different-labels">Why This Is Not Just Misra-Gries With Different Labels</h2>

<p>Misra and Gries gave the older and beautifully terse repeated-elements
algorithm: keep at most \(k-1\) candidates; if a new item arrives and no empty
counter exists, decrement all counters and delete zeros.<sup id="fnref:misra-gries" role="doc-noteref"><a href="#fn:misra-gries" class="footnote" rel="footnote">1</a></sup> It is a
deletion ledger. Each global decrement cancels a group of distinct items.</p>

<p>Space-Saving uses a different ledger. It never decrements all counters. It
replaces the minimum counter and records that minimum as the new item’s error.
Metwally, Agrawal, and El Abbadi presented this as an integrated algorithm for
frequent items and top-\(k\) queries in data streams.<sup id="fnref:space-saving" role="doc-noteref"><a href="#fn:space-saving" class="footnote" rel="footnote">2</a></sup> Their
analysis gives the interval and rank-style properties the lab is exercising.</p>

<p>Both algorithms are deterministic and both are about the same bottleneck:
the stream has more possible names than the memory budget. But they leave
different receipts. Misra-Gries is especially clean for proving that all
items above \(n/k\) remain candidates. Space-Saving is especially convenient
when the table itself needs to rank candidates online by stored counts and
carry item-level error bounds.</p>

<p>Cormode and Hadjieleftheriou’s survey-style experimental comparison is useful
here because it evaluates frequent-item algorithms under shared conditions
rather than treating each sketch in isolation.<sup id="fnref:frequent-items" role="doc-noteref"><a href="#fn:frequent-items" class="footnote" rel="footnote">3</a></sup> Their concise
description of Space-Saving matches the implementation above: keep item-count
pairs, increment a monitored item, otherwise replace the smallest counter with
the new item and increment it.</p>

<h2 id="reading-a-space-saving-table-in-production">Reading a Space-Saving Table in Production</h2>

<p>The most common mistake is to treat <code class="language-plaintext highlighter-rouge">count</code> as exact.</p>

<p>It is safer to expose three views:</p>

<table>
  <thead>
    <tr>
      <th>View</th>
      <th>Rule</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>candidate</td>
      <td><code class="language-plaintext highlighter-rouge">count &gt;= threshold</code></td>
      <td>May be heavy; can include false positives</td>
    </tr>
    <tr>
      <td>certified</td>
      <td><code class="language-plaintext highlighter-rouge">count - error &gt;= threshold</code></td>
      <td>Heavy under the stored interval</td>
    </tr>
    <tr>
      <td>hidden bound</td>
      <td>unmonitored item <code class="language-plaintext highlighter-rouge">&lt;= m_min</code></td>
      <td>No unmonitored item can exceed the current minimum</td>
    </tr>
  </tbody>
</table>

<p>That third row is the one that feels least natural at first. The algorithm can
say something useful about keys it is not storing. It cannot name them, but it
can bound them.</p>

<p>This also explains the table-size rule of thumb. Since
\(m_{\min} \le n/k\), any threshold above \(n/k\) is protected from false
negatives. If the product question is “show me everything above 1% of traffic,”
then a table with a little more than 100 counters puts the minimum-counter
certificate in the right range. More counters reduce candidate ambiguity; they
do not make the upper counts exact.</p>

<h2 id="what-the-lab-does-not-claim">What the Lab Does Not Claim</h2>

<p>The implementation is deliberately simple. It scans the counter array to find
the minimum, so each update costs \(O(k)\). That is fine for a visible browser
experiment with at most 160 counters. A production implementation usually
keeps counters in a heap or a stream-summary structure so the minimum can be
found and updated efficiently.</p>

<p>The stream generator is synthetic. It uses a Zipf-like distribution and an
optional midstream item shift to create churn. This is good for exercising the
invariants, but it is not a benchmark for cache behavior, adversarial keys,
distributed merging, sliding windows, deletions, or weighted updates.</p>

<p>The deterministic guarantee is also narrower than a product guarantee. The
data structure can certify threshold coverage relative to its processed stream.
It cannot decide whether the stream is the right measurement of user behavior,
whether bot traffic was filtered correctly, whether a delayed pipeline double
counted events, or whether the dashboard threshold is the right operational
decision.</p>

<p>Space-Saving earns its place because the internal contract is crisp:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>small memory, named candidates, explicit error receipts
</code></pre></div></div>

<p>That is often exactly the contract a streaming system needs.</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:misra-gries" role="doc-endnote">
      <p>Jayadev Misra and David Gries, <a href="https://www.khoury.northeastern.edu/home/pandey/courses/cs7280/spring25/papers/mg.pdf">“Finding Repeated Elements”</a>, <em>Science of Computer Programming</em>, 1982. Their paper gives the classic counter algorithm for finding all values that occur more than \(N/k\) times. <a href="#fnref:misra-gries" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:space-saving" role="doc-endnote">
      <p>Ahmed Metwally, Divyakant Agrawal, and Amr El Abbadi, <a href="https://www.cs.ucsb.edu/sites/default/files/documents/2005-23.pdf">“Efficient Computation of Frequent and Top-k Elements in Data Streams”</a>, UCSB technical report 2005-23. The report introduces Space-Saving as an online algorithm for frequent and top-k elements and proves the count/error and minimum-counter properties used here. <a href="#fnref:space-saving" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:frequent-items" role="doc-endnote">
      <p>Graham Cormode and Marios Hadjieleftheriou, <a href="https://www.vldb.org/pvldb/vol1/1454225.pdf">“Finding frequent items in data streams”</a>, PVLDB 2008; journal version in <em>The VLDB Journal</em>, DOI <a href="https://link.springer.com/article/10.1007/s00778-009-0172-z">10.1007/s00778-009-0172-z</a>. The paper compares frequent-item methods under common experimental conditions and summarizes Space-Saving’s update rule and error scale. <a href="#fnref:frequent-items" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Teo</name><email>zengwc.teo2016@outlook.com</email></author><category term="computer-science" /><category term="space-saving" /><category term="heavy-hitters" /><category term="streaming-algorithms" /><category term="top-k" /><category term="frequency-estimation" /><category term="data-structures" /><summary type="html"><![CDATA[Space-Saving finds stream heavy hitters by replacing the smallest counter and carrying its value forward as an explicit error receipt.]]></summary></entry><entry><title type="html">The Alphabet Splits Into Bits</title><link href="https://sir-teo.github.io/blogs/computer-science/2026/06/18/the-alphabet-splits-into-bits.html" rel="alternate" type="text/html" title="The Alphabet Splits Into Bits" /><published>2026-06-18T08:28:00-04:00</published><updated>2026-06-18T08:28:00-04:00</updated><id>https://sir-teo.github.io/blogs/computer-science/2026/06/18/the-alphabet-splits-into-bits</id><content type="html" xml:base="https://sir-teo.github.io/blogs/computer-science/2026/06/18/the-alphabet-splits-into-bits.html"><![CDATA[<p>A bitvector knows how to count.</p>

<p>Give it a small rank directory and it can answer:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rank1(B, i) = number of 1 bits in B[0..i)
</code></pre></div></div>

<p>Add select support and it can also answer:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>select1(B, k) = position of the k-th 1 bit
</code></pre></div></div>

<p>That is enough for binary strings. The wavelet tree asks what happens when the
sequence is not binary.</p>

<p>The trick is to refuse the large alphabet at the top level. Split the alphabet in
two. Write <code class="language-plaintext highlighter-rouge">0</code> for symbols in the left half and <code class="language-plaintext highlighter-rouge">1</code> for symbols in the right
half. Then recursively build the same structure on the two stable subsequences.
After \(\lceil \log_2 \sigma \rceil\) levels, a symbol has been identified by a path
of binary decisions.</p>

<p>This is not just a cute encoding. It is a query plan.</p>

<h2 id="the-stored-object">The Stored Object</h2>

<p>Let \(S[0,n)\) be a static sequence over alphabet \(\Sigma\). A balanced wavelet
tree node owns a subalphabet \(A\) and the subsequence of \(S\) whose symbols are
in \(A\).</p>

<p>If \(A\) contains more than one symbol, split it into \(A_0\) and \(A_1\). Store a
bitvector \(B_v\):</p>

\[B_v[i] =
\begin{cases}
0 &amp; \text{if } S_v[i] \in A_0 \\
1 &amp; \text{if } S_v[i] \in A_1.
\end{cases}\]

<p>The left child receives the symbols of \(S_v\) in \(A_0\), in the same order. The
right child receives the symbols in \(A_1\), also in the same order. That stable
partition is the invariant. It is why positions can be translated from a parent
bitmap to a child bitmap with rank.</p>

<p>Grossi, Gupta, and Vitter introduced wavelet trees inside their 2003 work on
high-order entropy-compressed text indexes.<sup id="fnref:ggv" role="doc-noteref"><a href="#fn:ggv" class="footnote" rel="footnote">1</a></sup> Navarro’s survey is a good
map of the structure after it escaped that original setting: it can be viewed as
a sequence representation, a permutation/reordering device, or a grid of
points.<sup id="fnref:navarro" role="doc-noteref"><a href="#fn:navarro" class="footnote" rel="footnote">2</a></sup></p>

<p>The small version in this post is the sequence view.</p>

<h2 id="three-primitive-moves">Three Primitive Moves</h2>

<p>For <code class="language-plaintext highlighter-rouge">access(i)</code>, start at the root. Read the bit at position <code class="language-plaintext highlighter-rouge">i</code>.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bit 0: i &lt;- rank0(B, i), go left
bit 1: i &lt;- rank1(B, i), go right
</code></pre></div></div>

<p>When the descent reaches a leaf, that leaf’s symbol is <code class="language-plaintext highlighter-rouge">S[i]</code>.</p>

<p>For <code class="language-plaintext highlighter-rouge">rank(c, i)</code>, follow the path that would lead to symbol <code class="language-plaintext highlighter-rouge">c</code>. At every node,
translate the prefix length:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>c in left half:  i &lt;- rank0(B, i)
c in right half: i &lt;- rank1(B, i)
</code></pre></div></div>

<p>At the leaf, the remaining <code class="language-plaintext highlighter-rouge">i</code> is the number of occurrences of <code class="language-plaintext highlighter-rouge">c</code> in the
original prefix.</p>

<p>For <code class="language-plaintext highlighter-rouge">select(c, k)</code>, do the inverse. Start at the leaf for <code class="language-plaintext highlighter-rouge">c</code>, where the <code class="language-plaintext highlighter-rouge">k</code>-th
copy has local position <code class="language-plaintext highlighter-rouge">k - 1</code>. Walk upward. If this leaf was reached through a
left edge, ask the parent bitmap for the position of the corresponding zero; if
it was reached through a right edge, ask for the corresponding one.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>left edge:  parent_pos &lt;- select0(B, child_pos + 1)
right edge: parent_pos &lt;- select1(B, child_pos + 1)
</code></pre></div></div>

<p>Claude and Navarro’s practical study is a useful reminder that this elegant
story lives or dies on the engineering of rank/select over the node
bitvectors.<sup id="fnref:claude" role="doc-noteref"><a href="#fn:claude" class="footnote" rel="footnote">3</a></sup> The lab here uses visible prefix arrays, not a compressed
RRR or broadword implementation, so the accounting is explanatory rather than
competitive.</p>

<h2 id="range-quantile-is-the-same-descent">Range Quantile Is The Same Descent</h2>

<p>Now ask a different question:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>what is the k-th smallest symbol in S[l..r)?
</code></pre></div></div>

<p>At a node, count how many active range items go left:</p>

\[z =
\mathrm{rank}_0(B_v, r) -
\mathrm{rank}_0(B_v, l).\]

<p>If \(k \le z\), the answer is in the left child. Translate the range to:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>l &lt;- rank0(B, l)
r &lt;- rank0(B, r)
</code></pre></div></div>

<p>Otherwise, the answer is in the right child. Subtract the left count and
translate with <code class="language-plaintext highlighter-rouge">rank1</code>:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>k &lt;- k - z
l &lt;- rank1(B, l)
r &lt;- rank1(B, r)
</code></pre></div></div>

<p>Repeat until a leaf. Gagie, Puglisi, and Turpin used this observation to support
range quantile queries, including medians as the special case where <code class="language-plaintext highlighter-rouge">k</code> is the
middle rank, in \(O(\log \sigma)\) time using a balanced wavelet tree.<sup id="fnref:gagie" role="doc-noteref"><a href="#fn:gagie" class="footnote" rel="footnote">4</a></sup></p>

<p>The implementation below checks that descent against naive sorting for every
slice of each built-in sequence.</p>

<style>
  .wavelet-tree-lab {
    background: #ffffff;
    border: 1px solid #d7dee5;
    border-radius: 8px;
    box-sizing: border-box;
    color: #172026;
    margin: 2rem 0;
    max-width: 100%;
    overflow: hidden;
    padding: 1rem;
  }

  .wavelet-tree-lab__controls {
    display: grid;
    gap: 0.85rem 1rem;
    grid-template-columns: repeat(auto-fit, minmax(142px, 1fr));
    margin-bottom: 1rem;
  }

  .wavelet-tree-lab label {
    color: #39444e;
    display: grid;
    font-family: var(--font-ui);
    font-size: 0.82rem;
    font-weight: 650;
    gap: 0.35rem;
    line-height: 1.25;
    min-width: 0;
  }

  .wavelet-tree-lab output {
    color: #172026;
    display: block;
    font-variant-numeric: tabular-nums;
    font-weight: 750;
    min-height: 1.15rem;
    overflow-wrap: anywhere;
  }

  .wavelet-tree-lab input,
  .wavelet-tree-lab select {
    accent-color: #0f766e;
    box-sizing: border-box;
    margin: 0;
    max-width: 100%;
    width: 100%;
  }

  .wavelet-tree-lab select {
    background: #ffffff;
    border: 1px solid #d7dee5;
    border-radius: 6px;
    color: #172026;
    font: inherit;
    min-height: 2.1rem;
    padding: 0.25rem 0.45rem;
  }

  .wavelet-tree-lab__metrics {
    display: grid;
    gap: 0.55rem;
    grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
    margin: 0 0 0.9rem;
  }

  .wavelet-tree-lab__metric {
    background: #f8fafc;
    border: 1px solid #e3e9ef;
    border-radius: 6px;
    min-height: 3.85rem;
    padding: 0.6rem 0.68rem;
  }

  .wavelet-tree-lab__metric--good {
    border-color: rgba(15, 118, 110, 0.38);
  }

  .wavelet-tree-lab__metric--bad {
    border-color: rgba(190, 18, 60, 0.38);
  }

  .wavelet-tree-lab__metric span {
    color: #5d6872;
    display: block;
    font-family: var(--font-ui);
    font-size: 0.68rem;
    font-weight: 740;
    letter-spacing: 0;
    line-height: 1.18;
    margin-bottom: 0.22rem;
    text-transform: uppercase;
  }

  .wavelet-tree-lab__metric strong {
    color: #172026;
    display: block;
    font-family: var(--font-ui);
    font-size: clamp(0.78rem, 2.2vw, 1rem);
    line-height: 1.22;
    overflow-wrap: anywhere;
  }

  .wavelet-tree-lab canvas {
    background: #ffffff;
    border: 1px solid #d7dee5;
    border-radius: 8px;
    box-sizing: border-box;
    display: block;
    height: auto;
    max-width: 100%;
    width: 100%;
  }

  .wavelet-tree-lab__note {
    color: #5d6872;
    font-family: var(--font-ui);
    font-size: 0.82rem;
    line-height: 1.45;
    margin: 0.85rem 0 0;
  }
</style>

<div class="wavelet-tree-lab" data-wavelet-tree-lab="">
  <div class="wavelet-tree-lab__controls">
    <label>
      Sequence
      <output data-value="sample">toy genome</output>
      <select data-param="sample" aria-label="Sequence">
        <option value="genome" selected="">Toy genome</option>
        <option value="mississippi">Mississippi</option>
        <option value="abracadabra">Abracadabra</option>
        <option value="prices">Price codes</option>
      </select>
    </label>
    <label>
      Symbol
      <output data-value="symbol">A</output>
      <select data-param="symbol" aria-label="Query symbol"></select>
    </label>
    <label>
      Access position
      <output data-value="position">7</output>
      <input type="range" min="0" max="24" step="1" value="7" data-param="position" aria-label="Access position" />
    </label>
    <label>
      Rank prefix end
      <output data-value="prefixEnd">16 chars</output>
      <input type="range" min="0" max="25" step="1" value="16" data-param="prefixEnd" aria-label="Rank prefix end" />
    </label>
    <label>
      Select occurrence
      <output data-value="selectK">5</output>
      <input type="range" min="1" max="10" step="1" value="5" data-param="selectK" aria-label="Select occurrence" />
    </label>
    <label>
      Range start
      <output data-value="rangeStart">3</output>
      <input type="range" min="0" max="24" step="1" value="3" data-param="rangeStart" aria-label="Range start" />
    </label>
    <label>
      Range end
      <output data-value="rangeEnd">18</output>
      <input type="range" min="1" max="25" step="1" value="18" data-param="rangeEnd" aria-label="Range end" />
    </label>
    <label>
      Range rank
      <output data-value="kth">7</output>
      <input type="range" min="1" max="15" step="1" value="7" data-param="kth" aria-label="Range quantile rank" />
    </label>
  </div>
  <div class="wavelet-tree-lab__metrics" data-metrics=""></div>
  <canvas data-canvas="" width="980" height="770" aria-label="Wavelet tree lab showing alphabet splits, bitvectors, rank-select ledgers, and range quantile descent"></canvas>
  <p class="wavelet-tree-lab__note" data-note=""></p>
</div>

<script defer="" src="/blogs/assets/js/wavelet-tree-lab.js?v=20260618c"></script>

<h2 id="what-the-lab-audits">What The Lab Audits</h2>

<p>The module exports the same implementation used by the browser:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>node - <span class="o">&lt;&lt;</span><span class="sh">'</span><span class="no">NODE</span><span class="sh">'
const lab = require("./assets/js/wavelet-tree-lab.js");
const EXPECTED_CASE_SHAPE = [
  "1:genome:all:24/5:58:24/125/24/2600:7",
  "2:genome:#:24/5:58:24/125/24/2600:7",
  "3:genome:A:24/5:58:24/125/24/2600:7",
  "4:genome:C:24/5:58:24/125/24/2600:7",
  "5:genome:G:24/5:58:24/125/24/2600:7",
  "6:genome:T:24/5:58:24/125/24/2600:7",
  "7:mississippi:all:12/5:29:12/65/12/364:7",
  "8:mississippi:#:12/5:29:12/65/12/364:7",
  "9:mississippi:i:12/5:29:12/65/12/364:7",
  "10:mississippi:m:12/5:29:12/65/12/364:7",
  "11:mississippi:p:12/5:29:12/65/12/364:7",
  "12:mississippi:s:12/5:29:12/65/12/364:7",
  "13:abracadabra:all:12/6:32:12/78/12/364:7",
  "14:abracadabra:#:12/6:32:12/78/12/364:7",
  "15:abracadabra:a:12/6:32:12/78/12/364:7",
  "16:abracadabra:b:12/6:32:12/78/12/364:7",
  "17:abracadabra:c:12/6:32:12/78/12/364:7",
  "18:abracadabra:d:12/6:32:12/78/12/364:7",
  "19:abracadabra:r:12/6:32:12/78/12/364:7",
  "20:prices:all:20/10:67:20/210/20/1540:7",
  "21:prices:0:20/10:67:20/210/20/1540:7",
  "22:prices:1:20/10:67:20/210/20/1540:7",
  "23:prices:2:20/10:67:20/210/20/1540:7",
  "24:prices:3:20/10:67:20/210/20/1540:7",
  "25:prices:4:20/10:67:20/210/20/1540:7",
  "26:prices:5:20/10:67:20/210/20/1540:7",
  "27:prices:6:20/10:67:20/210/20/1540:7",
  "28:prices:7:20/10:67:20/210/20/1540:7",
  "29:prices:8:20/10:67:20/210/20/1540:7",
  "30:prices:9:20/10:67:20/210/20/1540:7"
];
const EXPECTED_SAMPLE_SHAPE = [
  "abracadabra:7 cases/49 checks:12/6:32:84/546/84/364",
  "genome:6 cases/42 checks:24/5:58:144/750/144/2600",
  "mississippi:6 cases/42 checks:12/5:29:72/390/72/364",
  "prices:11 cases/77 checks:20/10:67:220/2310/220/1540"
];
const EXPECTED_CRITICAL_SHAPE = [
  "access agrees with the original sequence:30/30",
  "compressed bitmap entropy sums to sequence H0:30/30",
  "internal bitmaps preserve stable partition lengths:30/30",
  "range quantile agrees with sorting every slice:30/30",
  "rank agrees with naive prefix counts:30/30",
  "raw bitmap budget is bounded by fixed-width storage:30/30",
  "select agrees with naive occurrence positions:30/30"
];
const EXPECTED_CASES = EXPECTED_CASE_SHAPE.length;
const EXPECTED_CHECKS = EXPECTED_CASES * EXPECTED_CRITICAL_SHAPE.length;
const EXPECTED_TOTALS = {
  rows: EXPECTED_CASES,
  samples: EXPECTED_SAMPLE_SHAPE.length,
  criticalChecks: EXPECTED_CRITICAL_SHAPE.length,
  passed: EXPECTED_CASES,
  total: EXPECTED_CASES,
  checked: EXPECTED_CHECKS,
  passedChecks: EXPECTED_CHECKS,
  totalChecks: EXPECTED_CHECKS
};
const EXPECTED_CASE_ROWS = EXPECTED_CASE_SHAPE.map(
  (row) =&gt; `</span><span class="k">${</span><span class="nv">row</span><span class="k">}</span><span class="sh">:checks=</span><span class="k">${</span><span class="nv">EXPECTED_CRITICAL_SHAPE</span><span class="p">.length</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">EXPECTED_CRITICAL_SHAPE</span><span class="p">.length</span><span class="k">}</span><span class="sh">:` +
    "failed=none:passed=true"
);

function sameList(left, right) {
  return left.length === right.length &amp;&amp;
    left.every((value, index) =&gt; value === right[index]);
}

function sameJson(left, right) {
  return JSON.stringify(left) === JSON.stringify(right);
}

function symbolKey(symbol) {
  return symbol == null ? "all" : symbol;
}

function caseShape(row) {
  return `</span><span class="k">${</span><span class="nv">row</span><span class="p">.caseId</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.sample</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">symbolKey</span><span class="p">(row.symbol)</span><span class="k">}</span><span class="sh">:` +
    `</span><span class="k">${</span><span class="nv">row</span><span class="p">.length</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.alphabet</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.waveletBits</span><span class="k">}</span><span class="sh">:` +
    `</span><span class="k">${</span><span class="nv">row</span><span class="p">.accessChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.rankChecks</span><span class="k">}</span><span class="sh">/` +
    `</span><span class="k">${</span><span class="nv">row</span><span class="p">.selectChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.rangeQuantileChecks</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">`;
}

function caseRow(row) {
  return `</span><span class="k">${</span><span class="nv">caseShape</span><span class="p">(row)</span><span class="k">}</span><span class="sh">:checks=</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">:` +
    `failed=</span><span class="k">${</span><span class="nv">row</span><span class="p">.failedChecks.length ? row.failedChecks.join(</span><span class="s2">"|"</span><span class="p">) </span>:<span class="p"> </span><span class="s2">"none"</span><span class="k">}</span><span class="sh">:` +
    `passed=</span><span class="k">${</span><span class="nv">row</span><span class="p">.passed</span><span class="k">}</span><span class="sh">`;
}

function rowDrifts(actual, expected) {
  return {
    missing: expected.filter((row) =&gt; !actual.includes(row)),
    extra: actual.filter((row) =&gt; !expected.includes(row))
  };
}

function hasRowDrift(drift) {
  return drift.missing.length &gt; 0 || drift.extra.length &gt; 0;
}

function auditShape(audit) {
  return {
    totals: {
      rows: audit.cases.length,
      samples: audit.bySample.length,
      criticalChecks: audit.criticalChecks.length,
      passed: audit.passed,
      total: audit.total,
      checked: audit.checked,
      passedChecks: audit.passedChecks,
      totalChecks: audit.totalChecks
    },
    cases: audit.cases.map(caseShape),
    caseRows: audit.cases.map(caseRow),
    bySample: audit.bySample.map(
      (row) =&gt; `</span><span class="k">${</span><span class="nv">row</span><span class="p">.sample</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.cases</span><span class="k">}</span><span class="sh"> cases/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh"> checks:` +
        `</span><span class="k">${</span><span class="nv">row</span><span class="p">.length</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.alphabet</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.waveletBits</span><span class="k">}</span><span class="sh">:` +
        `</span><span class="k">${</span><span class="nv">row</span><span class="p">.accessChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.rankChecks</span><span class="k">}</span><span class="sh">/` +
        `</span><span class="k">${</span><span class="nv">row</span><span class="p">.selectChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.maxRangeQuantileChecks</span><span class="k">}</span><span class="sh">`
    ),
    criticalChecks: audit.criticalChecks.map(
      (row) =&gt; `</span><span class="k">${</span><span class="nv">row</span><span class="p">.check</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.passed</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.total</span><span class="k">}</span><span class="sh">`
    )
  };
}

const audit = lab.auditWaveletTreeLab();
const shape = auditShape(audit);
const failed = audit.cases.filter(
  (row) =&gt; !row.passed || row.passedChecks !== row.totalChecks
);
const failedCriticalChecks = audit.criticalChecks.filter(
  (row) =&gt; !row.ok || row.passed !== row.total
);
const caseRowDrifts = rowDrifts(shape.caseRows, EXPECTED_CASE_ROWS);
const shapeErrors = [
  sameJson(shape.totals, EXPECTED_TOTALS) ? null : "totals",
  sameList(shape.cases, EXPECTED_CASE_SHAPE) ? null : "cases",
  hasRowDrift(caseRowDrifts) ? "caseRows" : null,
  sameList(shape.bySample, EXPECTED_SAMPLE_SHAPE) ? null : "bySample",
  sameList(shape.criticalChecks, EXPECTED_CRITICAL_SHAPE) ? null : "criticalChecks"
].filter(Boolean);
console.table(audit.bySample);
console.table(audit.criticalChecks);
const summary =
  `</span><span class="k">${</span><span class="nv">audit</span><span class="p">.passed</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">audit</span><span class="p">.total</span><span class="k">}</span><span class="sh"> cases and </span><span class="k">${</span><span class="nv">audit</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">audit</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh"> checks passed`;
if (
  shapeErrors.length ||
  failed.length ||
  failedCriticalChecks.length ||
  !audit.ok ||
  audit.passed !== audit.total ||
  audit.passedChecks !== audit.totalChecks
) {
  throw new Error(JSON.stringify({
    summary,
    shapeErrors,
    shape,
    caseRowDrifts,
    failed,
    failedCriticalChecks,
    errors: audit.errors,
    passed: audit.passed,
    total: audit.total,
    passedChecks: audit.passedChecks,
    totalChecks: audit.totalChecks
  }, null, 2));
}
console.log(summary);
</span><span class="no">NODE
</span></code></pre></div></div>

<p>For every built-in sequence, the audit checks:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">access(i)</code> returns the original symbol for every position;</li>
  <li><code class="language-plaintext highlighter-rouge">rank(c, i)</code> equals a direct prefix scan for every symbol and prefix length;</li>
  <li><code class="language-plaintext highlighter-rouge">select(c, k)</code> returns the same position as a direct occurrence scan;</li>
  <li>range quantile agrees with sorting every nonempty slice and taking the
requested rank;</li>
  <li>every internal bitmap length equals its stable partition children; and</li>
  <li>the sum of zero-order entropy over node bitmaps equals the sequence’s
zero-order empirical entropy.</li>
</ul>

<p>The last check is the compression ledger. The raw balanced tree stores at most
\(n \lceil \log_2 \sigma \rceil\) bitmap bits, because every original symbol
contributes one bit per level on its root-to-leaf path. If each node bitmap were
compressed to its own zero-order entropy while keeping rank/select, the entropy
terms telescope to \(nH_0(S)\). Navarro gives the clean derivation in the survey:
the root separates the alphabet into two groups; children separate those groups
again; after the last split, the sum has become
\(\sum_{c \in \Sigma} n_c \log_2(n/n_c)\).</p>

<h2 id="where-this-toy-stops">Where This Toy Stops</h2>

<p>The implementation is static and intentionally literal. It stores one prefix
count per bit position, so rank is easy to inspect. A production compact sequence
representation would replace those arrays with succinct rank/select structures,
possibly use Huffman-shaped or multiary wavelet trees, or use a wavelet matrix to
remove pointer-heavy tree navigation for large alphabets.</p>

<p>The important invariant survives those engineering choices:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>parent range + rank on a stable bitmap = child range
</code></pre></div></div>

<p>Once that invariant clicks, the structure stops looking like a compression
curiosity. It is a way to keep asking binary questions without forgetting where
the original positions went.</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:ggv" role="doc-endnote">
      <p>Roberto Grossi, Ankur Gupta, and Jeffrey Scott Vitter, <a href="https://courses.cs.duke.edu/spring03/cps296.5/presentations/sodaconf03.pdf">“High-Order Entropy-Compressed Text Indexes”</a>, <em>SODA</em>, 2003. Bibliographic record: <a href="https://kuscholarworks.ku.edu/entities/publication/f58ebe57-2bcd-48f8-bfb0-0a9e66ad89ed">KU ScholarWorks</a>. <a href="#fnref:ggv" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:navarro" role="doc-endnote">
      <p>Gonzalo Navarro, <a href="https://users.dcc.uchile.cl/~gnavarro/ps/cpm12.pdf">“Wavelet Trees for All”</a>, <em>CPM</em>, 2012. Springer record and DOI: <a href="https://doi.org/10.1007/978-3-642-31265-6_2">10.1007/978-3-642-31265-6_2</a>. <a href="#fnref:navarro" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:claude" role="doc-endnote">
      <p>Francisco Claude and Gonzalo Navarro, <a href="https://users.dcc.uchile.cl/~gnavarro/ps/spire08.1.pdf">“Practical Rank/Select Queries over Arbitrary Sequences”</a>, <em>SPIRE</em>, 2008. DOI: <a href="https://doi.org/10.1007/978-3-540-89097-3_18">10.1007/978-3-540-89097-3_18</a>. <a href="#fnref:claude" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:gagie" role="doc-endnote">
      <p>Travis Gagie, Simon J. Puglisi, and Andrew Turpin, <a href="https://arxiv.org/abs/0903.4726">“Range Quantile Queries: Another Virtue of Wavelet Trees”</a>, arXiv:0903.4726, 2009/2010. DOI: <a href="https://doi.org/10.48550/arXiv.0903.4726">10.48550/arXiv.0903.4726</a>. <a href="#fnref:gagie" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Teo</name><email>zengwc.teo2016@outlook.com</email></author><category term="computer-science" /><category term="wavelet-tree" /><category term="rank-select" /><category term="succinct-data-structures" /><category term="range-quantile" /><category term="compressed-indexes" /><category term="algorithms" /><summary type="html"><![CDATA[A wavelet tree makes rank, select, access, and range quantile queries by recursively turning a large alphabet into stable bitvector decisions.]]></summary></entry><entry><title type="html">The Best Candidate Waits For A Record</title><link href="https://sir-teo.github.io/blogs/statistics/2026/06/18/the-best-candidate-waits-for-a-record.html" rel="alternate" type="text/html" title="The Best Candidate Waits For A Record" /><published>2026-06-18T08:20:00-04:00</published><updated>2026-06-18T08:20:00-04:00</updated><id>https://sir-teo.github.io/blogs/statistics/2026/06/18/the-best-candidate-waits-for-a-record</id><content type="html" xml:base="https://sir-teo.github.io/blogs/statistics/2026/06/18/the-best-candidate-waits-for-a-record.html"><![CDATA[<p>The secretary problem is usually remembered as a slogan:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>look at about 37% of the candidates,
then take the next one who beats everyone so far
</code></pre></div></div>

<p>That slogan is memorable, but it hides the actual object. This is not a general
rule for hiring, dating, search, or life. It is an optimal-stopping theorem for
a narrow information contract:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>n candidates arrive in uniformly random order
you see only relative ranks among candidates already seen
you must accept or reject immediately
you win only if you select the single best candidate
</code></pre></div></div>

<p>Under that contract, the only candidates worth accepting are <strong>records</strong>:
someone better than every earlier candidate. A non-record is already known not
to be the global best.</p>

<p>The decision is therefore not “is this person good?” It is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>has the sampling period been long enough
that a new record is worth stopping on?
</code></pre></div></div>

<h2 id="the-finite-formula">The Finite Formula</h2>

<p>Pick a cutoff <code class="language-plaintext highlighter-rouge">r</code>. Reject candidates <code class="language-plaintext highlighter-rouge">1..r-1</code>. From candidate <code class="language-plaintext highlighter-rouge">r</code> onward, accept
the first record. If no record appears, the final candidate is taken and usually
loses.</p>

<p>For <code class="language-plaintext highlighter-rouge">r &gt; 1</code>, this rule succeeds exactly when the best candidate appears at some
position <code class="language-plaintext highlighter-rouge">i &gt;= r</code> and the best among the first <code class="language-plaintext highlighter-rouge">i-1</code> candidates lies inside the
sample window <code class="language-plaintext highlighter-rouge">1..r-1</code>. Conditional on the global best appearing at position
<code class="language-plaintext highlighter-rouge">i</code>, the previous maximum is equally likely to occupy any of the first <code class="language-plaintext highlighter-rouge">i-1</code>
positions. So</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>P(success | cutoff r)
  = (r-1)/n * sum_{i=r}^n 1/(i-1).
</code></pre></div></div>

<p>For <code class="language-plaintext highlighter-rouge">r = 1</code>, the rule accepts the first candidate and succeeds with probability
<code class="language-plaintext highlighter-rouge">1/n</code>.</p>

<p>Let <code class="language-plaintext highlighter-rouge">n</code> grow and write <code class="language-plaintext highlighter-rouge">x = r/n</code>. The harmonic sum becomes an integral and the
success probability approaches</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>-x log x.
</code></pre></div></div>

<p>The maximum is at <code class="language-plaintext highlighter-rouge">x = 1/e</code>, with value <code class="language-plaintext highlighter-rouge">1/e</code>. That is where the 37% rule comes
from. Ferguson’s historical survey gives this derivation and points to Gilbert
and Mosteller’s 1966 paper as the basic treatment with many extensions.<sup id="fnref:ferguson" role="doc-noteref"><a href="#fn:ferguson" class="footnote" rel="footnote">1</a></sup></p>

<h2 id="the-backward-view">The Backward View</h2>

<p>The formula above proves the best cutoff among cutoff rules. The dynamic program
shows why a cutoff rule appears at all.</p>

<p>Suppose candidate <code class="language-plaintext highlighter-rouge">i</code> has arrived and is a record. If we stop, the chance that
this record is the global best is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>i / n
</code></pre></div></div>

<p>That may look backwards at first. A record at position <code class="language-plaintext highlighter-rouge">i</code> is the best of the
first <code class="language-plaintext highlighter-rouge">i</code> candidates; by exchangeability, the best-so-far is the global best
with probability <code class="language-plaintext highlighter-rouge">i/n</code>.</p>

<p>Let <code class="language-plaintext highlighter-rouge">V[i]</code> be the optimal success probability before seeing candidate <code class="language-plaintext highlighter-rouge">i</code>. If
candidate <code class="language-plaintext highlighter-rouge">i</code> is a record, stopping pays <code class="language-plaintext highlighter-rouge">i/n</code>; continuing pays <code class="language-plaintext highlighter-rouge">V[i+1]</code>. If it
is not a record, stopping pays zero, so continuing is the only rational move.
Because candidate <code class="language-plaintext highlighter-rouge">i</code> is a record with probability <code class="language-plaintext highlighter-rouge">1/i</code>,</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>V[i] = (1/i) * max(i/n, V[i+1])
     + (1 - 1/i) * V[i+1].
</code></pre></div></div>

<p>Run this recurrence backward from the end. The first index where <code class="language-plaintext highlighter-rouge">i/n &gt;= V[i+1]</code>
is the same cutoff found by the finite formula.</p>

<h2 id="lab-where-the-record-becomes-worth-it">Lab: Where The Record Becomes Worth It</h2>

<p>The lab computes:</p>

<ul>
  <li>the exact finite success curve for every cutoff;</li>
  <li>the backward dynamic program’s accept-record boundary;</li>
  <li>a seeded simulation of the selected cutoff;</li>
  <li>one visible shuffled order, with records and the selected candidate marked.</li>
</ul>

<style>
  .secretary-lab {
    background: #ffffff;
    border: 1px solid #d7dee5;
    border-radius: 8px;
    box-sizing: border-box;
    color: #172026;
    margin: 2rem 0;
    max-width: 100%;
    overflow: hidden;
    padding: 1rem;
  }

  .secretary-lab__controls {
    display: grid;
    gap: 0.85rem 1rem;
    grid-template-columns: repeat(auto-fit, minmax(142px, 1fr));
    margin-bottom: 1rem;
  }

  .secretary-lab label {
    color: #39444e;
    display: grid;
    font-family: var(--font-ui);
    font-size: 0.82rem;
    font-weight: 650;
    gap: 0.35rem;
    line-height: 1.25;
    min-width: 0;
  }

  .secretary-lab input {
    accent-color: #2563eb;
    background: #ffffff;
    border: 1px solid #cbd5df;
    border-radius: 6px;
    box-sizing: border-box;
    color: #172026;
    font: inherit;
    min-width: 0;
    padding: 0;
    width: 100%;
  }

  .secretary-lab output {
    color: #172026;
    font-variant-numeric: tabular-nums;
    font-weight: 760;
  }

  .secretary-lab__metrics {
    display: grid;
    gap: 0.55rem;
    grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
    margin-bottom: 0.85rem;
  }

  .secretary-lab__metric {
    background: #f8fafc;
    border: 1px solid #d7dee5;
    border-radius: 7px;
    box-sizing: border-box;
    min-width: 0;
    padding: 0.62rem 0.68rem;
  }

  .secretary-lab__metric span {
    color: #5d6872;
    display: block;
    font-family: var(--font-ui);
    font-size: 0.72rem;
    font-weight: 650;
    letter-spacing: 0;
    line-height: 1.2;
    text-transform: uppercase;
  }

  .secretary-lab__metric strong {
    color: #172026;
    display: block;
    font-family: var(--font-ui);
    font-size: 1rem;
    font-variant-numeric: tabular-nums;
    line-height: 1.2;
    margin-top: 0.24rem;
  }

  .secretary-lab__metric--good strong {
    color: #0f766e;
  }

  .secretary-lab__metric--bad strong {
    color: #be123c;
  }

  .secretary-lab canvas {
    aspect-ratio: 980 / 720;
    background: #ffffff;
    border: 1px solid #d7dee5;
    border-radius: 8px;
    box-sizing: border-box;
    display: block;
    height: auto;
    max-height: 760px;
    width: 100%;
  }

  .secretary-lab__note {
    color: #5d6872;
    font-family: var(--font-ui);
    font-size: 0.84rem;
    line-height: 1.45;
    margin: 0.8rem 0 0;
  }

  @media (max-width: 620px) {
    .secretary-lab {
      padding: 0.85rem;
    }

    .secretary-lab canvas {
      aspect-ratio: 430 / 860;
      max-height: none;
    }
  }
</style>

<div class="secretary-lab" data-secretary-stopping-lab="">
  <div class="secretary-lab__controls">
    <label>
      Candidates <output data-value="n">80</output>
      <input data-param="n" type="range" min="5" max="200" step="1" value="80" />
    </label>
    <label>
      First eligible record <output data-value="cutoff">30</output>
      <input data-param="cutoff" type="range" min="1" max="80" step="1" value="30" />
    </label>
    <label>
      Simulation trials <output data-value="trials">5.0k</output>
      <input data-param="trials" type="range" min="500" max="12000" step="500" value="5000" />
    </label>
    <label>
      Seed <output data-value="seed">23</output>
      <input data-param="seed" type="range" min="1" max="99" step="1" value="23" />
    </label>
  </div>
  <div class="secretary-lab__metrics" data-metrics=""></div>
  <canvas data-canvas="" width="980" height="720" aria-label="Secretary problem optimal stopping lab with finite cutoff curve, backward dynamic program, simulation, and sample order"></canvas>
  <p class="secretary-lab__note">The candidates are represented only by random ranks. The model does not include measurement noise, interviews with costs, multiple hires, actual score values, recall, fairness constraints, or strategic behavior.</p>
</div>

<script defer="" src="/blogs/assets/js/secretary-stopping-lab.js?v=20260618c"></script>

<p>For <code class="language-plaintext highlighter-rouge">n = 80</code>, the exact optimum is to reject 29 candidates and make candidate
30 the first eligible record. The exact success probability is about <code class="language-plaintext highlighter-rouge">0.3719</code>.
The limiting <code class="language-plaintext highlighter-rouge">1/e</code> answer is already a good mental model, but the finite curve
is the real answer.</p>

<h2 id="what-the-audit-checks">What The Audit Checks</h2>

<p>The exported Node audit does four things:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>enumerates all permutations for n = 5, 6, 7
checks every cutoff against the closed-form probability
runs backward induction for n = 12, 25, 80, 150
checks that the DP cutoff and value equal the exact finite optimum
</code></pre></div></div>

<p>It also runs the default seeded simulation and checks that the simulated success
rate is within a small tolerance of the exact probability. Ignoring the returned
case rows, the current top-level audit reports:</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">"exactAtCutoff"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.3719</span><span class="p">,</span><span class="w">
  </span><span class="nl">"ok"</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">"optimalCutoff"</span><span class="p">:</span><span class="w"> </span><span class="mi">30</span><span class="p">,</span><span class="w">
  </span><span class="nl">"optimalProbability"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.3719</span><span class="p">,</span><span class="w">
  </span><span class="nl">"passed"</span><span class="p">:</span><span class="w"> </span><span class="mi">26</span><span class="p">,</span><span class="w">
  </span><span class="nl">"passedChecks"</span><span class="p">:</span><span class="w"> </span><span class="mi">30</span><span class="p">,</span><span class="w">
  </span><span class="nl">"totalChecks"</span><span class="p">:</span><span class="w"> </span><span class="mi">30</span><span class="p">,</span><span class="w">
  </span><span class="nl">"total"</span><span class="p">:</span><span class="w"> </span><span class="mi">26</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The compact reproduction check is:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>node - <span class="o">&lt;&lt;</span><span class="sh">'</span><span class="no">NODE</span><span class="sh">'
const lab = require("./assets/js/secretary-stopping-lab.js");
const EXPECTED_EXACT_CASE_SHAPE = [
  "5:1:0.200000000000:0.200000000000:0.000e+0:1/1:closed form equals brute force=true",
  "5:2:0.416666666667:0.416666666667:5.551e-17:1/1:closed form equals brute force=true",
  "5:3:0.433333333333:0.433333333333:5.551e-17:1/1:closed form equals brute force=true",
  "5:4:0.350000000000:0.350000000000:0.000e+0:1/1:closed form equals brute force=true",
  "5:5:0.200000000000:0.200000000000:0.000e+0:1/1:closed form equals brute force=true",
  "6:1:0.166666666667:0.166666666667:0.000e+0:1/1:closed form equals brute force=true",
  "6:2:0.380555555556:0.380555555556:0.000e+0:1/1:closed form equals brute force=true",
  "6:3:0.427777777778:0.427777777778:0.000e+0:1/1:closed form equals brute force=true",
  "6:4:0.391666666667:0.391666666667:5.551e-17:1/1:closed form equals brute force=true",
  "6:5:0.300000000000:0.300000000000:0.000e+0:1/1:closed form equals brute force=true",
  "6:6:0.166666666667:0.166666666667:0.000e+0:1/1:closed form equals brute force=true",
  "7:1:0.142857142857:0.142857142857:0.000e+0:1/1:closed form equals brute force=true",
  "7:2:0.350000000000:0.350000000000:0.000e+0:1/1:closed form equals brute force=true",
  "7:3:0.414285714286:0.414285714286:5.551e-17:1/1:closed form equals brute force=true",
  "7:4:0.407142857143:0.407142857143:5.551e-17:1/1:closed form equals brute force=true",
  "7:5:0.352380952381:0.352380952381:0.000e+0:1/1:closed form equals brute force=true",
  "7:6:0.261904761905:0.261904761905:0.000e+0:1/1:closed form equals brute force=true",
  "7:7:0.142857142857:0.142857142857:0.000e+0:1/1:closed form equals brute force=true"
];
const EXPECTED_EXACT_SUMMARY_SHAPE = [
  "5:5:5/5:5.55e-17",
  "6:6:6/6:5.55e-17",
  "7:7:7/7:5.55e-17"
];
const EXPECTED_DP_CASE_SHAPE = [
  "12:5:5:0.395514670515:0.395514670515:0.000e+0:2/2:DP cutoff equals finite optimum=true|DP value equals finite optimum=true",
  "25:10:10:0.380916372563:0.380916372563:5.551e-17:2/2:DP cutoff equals finite optimum=true|DP value equals finite optimum=true",
  "80:30:30:0.371855486992:0.371855486992:1.110e-16:2/2:DP cutoff equals finite optimum=true|DP value equals finite optimum=true",
  "150:56:56:0.369997293685:0.369997293685:6.106e-16:2/2:DP cutoff equals finite optimum=true|DP value equals finite optimum=true"
];
const EXPECTED_DEFAULT_CHECK_SHAPE = [
  "cutoff range:1/1:passed=true",
  "curve bounded:1/1:passed=true",
  "formula finite:1/1:passed=true",
  "simulation close:1/1:passed=true"
];
const EXPECTED_ROWS =
  EXPECTED_EXACT_CASE_SHAPE.length +
  EXPECTED_DP_CASE_SHAPE.length +
  EXPECTED_DEFAULT_CHECK_SHAPE.length;
const EXPECTED_CHECKS = [
  ...EXPECTED_EXACT_CASE_SHAPE,
  ...EXPECTED_DP_CASE_SHAPE,
  ...EXPECTED_DEFAULT_CHECK_SHAPE
].reduce((sum, row) =&gt; sum + Number(row.match(/:(</span><span class="se">\d</span><span class="sh">+)</span><span class="se">\/</span><span class="sh">(</span><span class="se">\d</span><span class="sh">+)(?::|</span><span class="nv">$)</span><span class="sh">/)[2]), 0);

function sameList(actual, expected) {
  return actual.length === expected.length &amp;&amp;
    actual.every((value, index) =&gt; value === expected[index]);
}

function checkResults(row) {
  return row.checks.map((check) =&gt; `</span><span class="k">${</span><span class="nv">check</span><span class="p">.name</span><span class="k">}</span><span class="sh">=</span><span class="k">${</span><span class="nv">check</span><span class="p">.passed</span><span class="k">}</span><span class="sh">`).join("|");
}

function exactCaseShape(row) {
  return `</span><span class="k">${</span><span class="nv">row</span><span class="p">.n</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.cutoff</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.exact.toFixed(12)</span><span class="k">}</span><span class="sh">:` +
    `</span><span class="k">${</span><span class="nv">row</span><span class="p">.brute.toFixed(12)</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.difference.toExponential(3)</span><span class="k">}</span><span class="sh">:` +
    `</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">checkResults</span><span class="p">(row)</span><span class="k">}</span><span class="sh">`;
}

function exactSummaryShape(row) {
  return `</span><span class="k">${</span><span class="nv">row</span><span class="p">.n</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.cutoffs</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.checks</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.maxDifference</span><span class="k">}</span><span class="sh">`;
}

function dpCaseShape(row) {
  return `</span><span class="k">${</span><span class="nv">row</span><span class="p">.n</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.bestCutoff</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.dpFirstAccept</span><span class="k">}</span><span class="sh">:` +
    `</span><span class="k">${</span><span class="nv">row</span><span class="p">.bestProbability.toFixed(12)</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.dpValue.toFixed(12)</span><span class="k">}</span><span class="sh">:` +
    `</span><span class="k">${</span><span class="nv">row</span><span class="p">.valueDifference.toExponential(3)</span><span class="k">}</span><span class="sh">:` +
    `</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">checkResults</span><span class="p">(row)</span><span class="k">}</span><span class="sh">`;
}

function defaultCheckShape(row) {
  return `</span><span class="k">${</span><span class="nv">row</span><span class="p">.name</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">:passed=</span><span class="k">${</span><span class="nv">row</span><span class="p">.passed</span><span class="k">}</span><span class="sh">`;
}

const audit = lab.auditSecretaryStoppingLab();
const exactSummary = [5, 6, 7].map((n) =&gt; {
  const rows = audit.exactCases.filter((row) =&gt; row.n === n);
  return {
    n,
    cutoffs: rows.length,
    checks: `</span><span class="k">${</span><span class="nv">rows</span><span class="p">.reduce((sum, row) =&gt; sum + row.passedChecks, 0)</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">rows</span><span class="p">.reduce((sum, row) =&gt; sum + row.totalChecks, 0)</span><span class="k">}</span><span class="sh">`,
    maxDifference: Math.max(...rows.map((row) =&gt; row.difference)).toExponential(2)
  };
});
console.table(exactSummary);
console.table(audit.dpCases.map((row) =&gt; ({
  n: row.n,
  bestCutoff: row.bestCutoff,
  dpFirstAccept: row.dpFirstAccept,
  bestProbability: row.bestProbability.toFixed(4),
  valueDifference: row.valueDifference,
  checks: `</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">`
})));
console.table(audit.defaultChecks.map((row) =&gt; ({
  check: row.name,
  checks: `</span><span class="k">${</span><span class="nv">row</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">row</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh">`
})));
const shape = {
  defaultChecks: audit.defaultChecks.map(defaultCheckShape),
  dpCases: audit.dpCases.map(dpCaseShape),
  exactCases: audit.exactCases.map(exactCaseShape),
  exactSummary: exactSummary.map(exactSummaryShape)
};
const auditShape = {
  exactRows: audit.exactCases.length,
  dpRows: audit.dpCases.length,
  defaultChecks: audit.defaultChecks.length,
  passed: audit.passed,
  total: audit.total,
  passedChecks: audit.passedChecks,
  totalChecks: audit.totalChecks
};
const shapeErrors = [
  ["exactCases", shape.exactCases, EXPECTED_EXACT_CASE_SHAPE],
  ["exactSummary", shape.exactSummary, EXPECTED_EXACT_SUMMARY_SHAPE],
  ["dpCases", shape.dpCases, EXPECTED_DP_CASE_SHAPE],
  ["defaultChecks", shape.defaultChecks, EXPECTED_DEFAULT_CHECK_SHAPE]
].filter((row) =&gt; !sameList(row[1], row[2]))
  .map(([name, actual, expected]) =&gt; ({ name, actual, expected }));

const failedExactCases = audit.exactCases.filter(
  (row) =&gt; !row.passed || row.passedChecks !== row.totalChecks
);
const failedDpCases = audit.dpCases.filter(
  (row) =&gt; !row.passed || row.passedChecks !== row.totalChecks
);
const failedDefaultChecks = audit.defaultChecks.filter(
  (row) =&gt; !row.passed || row.passedChecks !== row.totalChecks
);
if (shapeErrors.length ||
    auditShape.total !== EXPECTED_ROWS ||
    auditShape.totalChecks !== EXPECTED_CHECKS ||
    !audit.ok || failedExactCases.length || failedDpCases.length ||
    failedDefaultChecks.length || audit.passed !== audit.total ||
    audit.passedChecks !== audit.totalChecks) {
  throw new Error(JSON.stringify({
    failures: audit.failures,
    failedExactCases,
    failedDpCases,
    failedDefaultChecks,
    shapeErrors,
    auditShape
  }, null, 2));
}
console.log(
  `</span><span class="k">${</span><span class="nv">audit</span><span class="p">.passed</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">audit</span><span class="p">.total</span><span class="k">}</span><span class="sh"> audit rows and ` +
  `</span><span class="k">${</span><span class="nv">audit</span><span class="p">.passedChecks</span><span class="k">}</span><span class="sh">/</span><span class="k">${</span><span class="nv">audit</span><span class="p">.totalChecks</span><span class="k">}</span><span class="sh"> checks passed`
);
</span><span class="no">NODE
</span></code></pre></div></div>

<p>That simulation tolerance is not the theorem. It is only a smoke test that the
Monte Carlo path agrees with the exact calculation. The theorem is in the
finite formula and the backward recurrence.</p>

<h2 id="the-boundary-of-the-story">The Boundary Of The Story</h2>

<p>The classic problem uses only relative ranks. That makes it robust to the scale
of candidate quality, but it also throws away information. If actual scores are
visible and drawn from a known distribution, the problem changes. Gilbert and
Mosteller studied that full-information version too.<sup id="fnref:gilbert-mosteller" role="doc-noteref"><a href="#fn:gilbert-mosteller" class="footnote" rel="footnote">2</a></sup> The
right rule becomes an adaptive threshold on observed values, not merely “wait
for the next record.”</p>

<p>Real decisions are further away. Hiring is not a random permutation with a
single hidden total order. Search often has costs, correlated arrivals, multiple
acceptable choices, delayed information, legal constraints, and humans who
deserve more than a toy objective function.</p>

<p>So the useful artifact is not the slogan. It is the receipt:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>what information arrives,
what decisions are irrevocable,
what payoff is being maximized,
and which stopping rule was priced into that payoff
</code></pre></div></div>

<p>Under the classic receipt, the best candidate waits for a record. Under a
different receipt, the number <code class="language-plaintext highlighter-rouge">37%</code> may be nothing but folklore wearing a lab
coat.</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:ferguson" role="doc-endnote">
      <p>Thomas S. Ferguson, <a href="https://projecteuclid.org/journals/statistical-science/volume-4/issue-3/Who-Solved-the-Secretary-Problem/10.1214/ss/1177012493.full">“Who Solved the Secretary Problem?”</a>, <em>Statistical Science</em> 4(3), 1989, pp. 282-289. A public PDF copy is hosted by Penn Math: <a href="https://www2.math.upenn.edu/~ted/210F10/References/Secretary.pdf">Secretary.pdf</a>. <a href="#fnref:ferguson" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:gilbert-mosteller" role="doc-endnote">
      <p>John P. Gilbert and Frederick Mosteller, <a href="https://www.jstor.org/stable/2283044">“Recognizing the Maximum of a Sequence”</a>, <em>Journal of the American Statistical Association</em> 61(313), 1966, pp. 35-73. JSTOR’s stable record is DOI-compatible: <a href="https://doi.org/10.2307/2283044">10.2307/2283044</a>. <a href="#fnref:gilbert-mosteller" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Teo</name><email>zengwc.teo2016@outlook.com</email></author><category term="statistics" /><category term="secretary-problem" /><category term="optimal-stopping" /><category term="dynamic-programming" /><category term="decision-theory" /><category term="simulation" /><category term="probability" /><summary type="html"><![CDATA[The secretary problem is not really about hiring. It is a finite optimal-stopping contract: spend a sample, then accept the first record, and verify the cutoff by dynamic programming.]]></summary></entry></feed>