<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://snwagh.com/feed.xml" rel="self" type="application/atom+xml"/><link href="https://snwagh.com/" rel="alternate" type="text/html" hreflang="en"/><updated>2026-08-16T07:34:30+00:00</updated><id>https://snwagh.com/feed.xml</id><title type="html">blank</title><subtitle>Sameer Wagh — privacy researcher and founder of SecretBit Ventures. Applied cryptography, secure computation, and privacy-preserving machine learning. </subtitle><entry><title type="html">Coin Parity with a Balance Scale</title><link href="https://snwagh.com/blog/2026/coin-parity/" rel="alternate" type="text/html" title="Coin Parity with a Balance Scale"/><published>2026-08-07T00:00:00+00:00</published><updated>2026-08-07T00:00:00+00:00</updated><id>https://snwagh.com/blog/2026/coin-parity</id><content type="html" xml:base="https://snwagh.com/blog/2026/coin-parity/"><![CDATA[<script defer="" src="/assets/js/post-toc.js"></script> <style>
  /* MathJax: kill any scroll container on display math; inline math must not disrupt line height */
  mjx-container[display="true"] { overflow: visible !important; }
  mjx-container:not([display="true"]) { display: inline-block; vertical-align: middle; }

  /* Red y-coins in the partition table */
  .y-coin { color: #e03131; }

  /* ── Collapsible formal note ───────────────────────────────────────── */
  details.pg-formal {
    border: 1px solid var(--global-divider-color);
    border-radius: 8px;
    padding: 0.6rem 1rem;
    margin: 1.4rem 0;
    background: var(--global-card-bg-color);
  }
  details.pg-formal summary {
    font-family: var(--font-sans);
    font-size: 0.82rem;
    font-weight: 700;
    cursor: pointer;
    color: var(--global-text-color-light);
    list-style: none;
  }
  details.pg-formal summary::before {
    content: "▸ ";
    font-size: 0.72rem;
  }
  details.pg-formal[open] summary::before { content: "▾ "; }
  details.pg-formal p {
    margin: 0.6rem 0 0;
    font-size: 0.87rem;
    color: var(--global-text-color);
    line-height: 1.6;
  }

  /* ── Parity Lab widget ─────────────────────────────────────────────── */
  #parity-lab {
    --lab-bg:         var(--global-bg-color);
    --lab-surface:    var(--global-card-bg-color);
    --lab-surface2:   color-mix(in srgb, var(--global-text-color) 5%, var(--global-bg-color));
    --lab-text:       var(--global-text-color);
    --lab-muted:      var(--global-text-color-light);
    --lab-border:     var(--global-divider-color);
    --lab-accent:     var(--global-theme-color);
    --lab-accentSoft: color-mix(in srgb, var(--global-theme-color) 10%, var(--global-bg-color));

    font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif);
    color: var(--lab-text);
    background: var(--lab-bg);
    border: 1px solid var(--lab-border);
    border-radius: 16px;
    padding: 10px;
    margin: 2rem 0;
    position: relative; /* SVG overlay anchor */
  }
  #parity-lab * { box-sizing: border-box; }
  #parity-lab button, #parity-lab select, #parity-lab input { font: inherit; }
  #parity-lab button:focus-visible,
  #parity-lab select:focus-visible,
  #parity-lab input:focus-visible {
    outline: 3px solid color-mix(in srgb, var(--lab-accent) 55%, transparent);
    outline-offset: 2px;
  }

  /* ── Layout rows ────────────────────────────────────────────────────── */
  /* left col: state panel; right col: flex-stack of controls + track */
  #parity-lab .top-grid {
    display: grid;
    grid-template-columns: 1fr 4fr;
    gap: 10px;
    margin-bottom: 10px;
    align-items: stretch;
  }
  #parity-lab .top-right {
    display: flex;
    flex-direction: column;
    gap: 8px;
    min-width: 0;
  }
  #parity-lab .panel {
    min-width: 0;
    padding: 8px 10px;
    background: var(--lab-surface);
    border: 1px solid var(--lab-border);
    border-radius: 14px;
    margin-bottom: 10px;
  }
  #parity-lab .panel:last-child { margin-bottom: 0; }
  /* panel-h replaces h2 so the post TOC doesn't pick these up */
  #parity-lab .panel-h {
    margin: 0 0 5px;
    font-size: 13px;
    font-weight: 700;
    color: var(--lab-text);
  }
  #parity-lab .panel-sub { margin: 0 0 10px; color: var(--lab-muted); font-size: 12px; line-height: 1.45; }

  /* ── Summary stats ──────────────────────────────────────────────────── */
  /* state panel aligns to top */
  #parity-lab .state-panel { display: flex; flex-direction: column; }
  #parity-lab .summary-stats { display: grid; grid-template-columns: 1fr; gap: 4px; }
  #parity-lab .stat { padding: 4px 8px; border: 1px solid var(--lab-border); border-radius: 8px; background: var(--lab-surface2); }
  #parity-lab .stat-label { display: block; margin-bottom: 1px; color: var(--lab-muted); font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; }
  #parity-lab .stat-value { display: block; font-size: 14px; font-weight: 800; }

  /* ── Controls ───────────────────────────────────────────────────────── */
  #parity-lab .controls { display: grid; gap: 5px; }
  #parity-lab .ctrl-bar { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 5px; }
  #parity-lab .ctrl-btns { display: flex; gap: 5px; align-items: stretch; margin-left: auto; }
  #parity-lab .field { display: grid; gap: 3px; }
  #parity-lab .field label { color: var(--lab-muted); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; }
  #parity-lab select {
    min-height: 32px;
    border: 1px solid var(--lab-border);
    border-radius: 8px;
    padding: 0 10px;
    background: var(--lab-surface2);
    color: var(--lab-text);
    font-size: 12px;
    width: 100%;
  }
  #parity-lab .ctrl-bar select { width: auto; min-width: 80px; }
  #parity-lab .btn {
    min-height: 32px;
    border: 1px solid var(--lab-border);
    border-radius: 8px;
    padding: 0 10px;
    background: var(--lab-surface2);
    color: var(--lab-text);
    cursor: pointer;
    font-size: 12px;
    font-weight: 700;
    transition: .15s ease;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  #parity-lab .btn:hover { transform: translateY(-1px); }
  #parity-lab .btn:disabled { cursor: not-allowed; opacity: .45; transform: none; }
  #parity-lab .btn-primary { background: var(--lab-accent); color: #fff; border-color: transparent; }
  #parity-lab .btn-quiet { background: transparent; }
  #parity-lab .switch-wrap {
    display: flex; align-items: flex-start;
    gap: 7px; padding: 2px 0;
    color: var(--lab-muted);
    font-size: 11px; font-weight: 600;
    cursor: pointer; line-height: 1.4;
  }
  #parity-lab .switch-wrap input { width: 15px; height: 15px; accent-color: var(--lab-accent); flex-shrink: 0; margin-top: 1px; }

  /* ── Track (binary-search path) ─────────────────────────────────────── */
  #parity-lab .track-head {
    display: flex; flex-wrap: wrap;
    justify-content: space-between; align-items: baseline;
    gap: 4px; margin-bottom: 4px;
  }
  #parity-lab .track-note { color: var(--lab-muted); font-size: 11px; }
  /* fixed min-height reserves space for max arc height (n=5 → ~50px arcs + 30px nodes + padding) */
  #parity-lab .track-scroll { overflow-x: auto; padding: 2px 2px 4px; min-height: 72px; }
  #parity-lab .track { min-width: max-content; display: flex; align-items: center; gap: 4px; }
  #parity-lab .track-node {
    width: 26px; height: 26px;
    display: grid; place-items: center;
    border: 1px solid var(--lab-border);
    border-radius: 999px;
    background: var(--lab-surface2);
    color: var(--lab-muted);
    font-size: 10px; font-weight: 800;
    flex-shrink: 0;
    transition: .2s ease;
  }
  #parity-lab .track-node.in-range  { border-color: color-mix(in srgb, var(--lab-accent) 45%, var(--lab-border)); }
  #parity-lab .track-node.endpoint  { border-color: var(--lab-accent); background: var(--lab-accentSoft); color: var(--lab-text); }
  #parity-lab .track-node.measured  { box-shadow: inset 0 0 0 2px var(--lab-text); color: var(--lab-text); }
  #parity-lab .track-node.next      { transform: translateY(-3px); border-color: transparent; background: var(--lab-accent); color: #fff; }
  #parity-lab .track-link           { width: 10px; height: 2px; background: var(--lab-border); flex-shrink: 0; }
  #parity-lab .track-link.in-range  { background: var(--lab-accent); }
  /* Arrow arcs scroll together with the node row inside .track-scroll */
  #parity-lab .track-arrows-svg  { display: block; overflow: visible; margin-top: 2px; }

  /* ── Game board ─────────────────────────────────────────────────────── */
  #parity-lab .pair-board {
    display: flex;
    flex-wrap: wrap;
    gap: 5px;
    justify-content: center;
  }
  #parity-lab .pair-card {
    display: grid; grid-template-columns: 1fr 1fr;
    flex: 0 0 auto; width: clamp(60px, 12%, 76px);
    gap: 4px; padding: 5px;
    border: 1px solid transparent;
    border-radius: 10px; background: transparent;
  }
  #parity-lab.full-view .pair-card { border-color: var(--lab-border); background: var(--lab-surface2); }
  #parity-lab .pair-tag {
    display: none; grid-column: 1 / -1;
    color: var(--lab-muted); font-size: 9px; font-weight: 800;
    letter-spacing: .08em; text-transform: uppercase;
  }
  #parity-lab.full-view .pair-tag { display: block; }

  #parity-lab .ball {
    --pairColor: var(--lab-accent);
    position: relative;
    width: clamp(24px, 4vw, 36px); aspect-ratio: 1;
    display: inline-grid; place-items: center;
    flex: 0 0 auto;
    border: 2px solid #c9c9c9;
    border-radius: 999px; background: #fff;
    box-shadow: 0 2px 4px color-mix(in srgb, #000 10%, transparent);
    font-size: clamp(7px, 1.2vw, 10px); font-weight: 800; letter-spacing: -.02em;
    transform-origin: center; will-change: transform, opacity;
  }
  #parity-lab.full-view .ball {
    border: 3px solid var(--pairColor);
    box-shadow: inset 0 -4px 8px color-mix(in srgb, var(--pairColor) 15%, transparent), 0 2px 4px color-mix(in srgb, #000 13%, transparent);
  }
  #parity-lab.full-view .ball.hollow  { background: #fff; }
  #parity-lab.full-view .ball.textured {
    background-color: color-mix(in srgb, var(--pairColor) 16%, #fff);
    background-image: repeating-linear-gradient(135deg, transparent 0 5px, color-mix(in srgb, var(--pairColor) 42%, transparent) 5px 7px);
  }
  #parity-lab .ball-value { display: none; }
  #parity-lab.full-view .ball-value { display: inline; }
  #parity-lab .pair-card .ball { width: 100%; max-width: 32px; justify-self: center; }

  /* ── Weighings ──────────────────────────────────────────────────────── */
  #parity-lab .weighings { display: grid; gap: 8px; margin-top: 10px; }
  #parity-lab .empty-stage {
    padding: 18px 14px;
    border: 1px dashed var(--lab-border); border-radius: 12px;
    color: var(--lab-muted); text-align: center; font-size: 12px;
  }
  #parity-lab .weigh-row {
    position: relative;
    display: grid;
    grid-template-columns: minmax(0, 1fr) 50px minmax(0, 1fr);
    align-items: stretch; gap: 6px; padding: 10px;
    border: 1px solid var(--lab-border); border-radius: 12px;
    background: var(--lab-surface);
  }
  #parity-lab .weigh-row:not(.active-source) { opacity: .65; }
  #parity-lab .weigh-meta {
    grid-column: 1 / -1; display: flex; flex-wrap: wrap;
    justify-content: space-between; align-items: center; gap: 6px;
  }
  #parity-lab .partition-label { font-size: 12px; font-weight: 800; }
  #parity-lab .outcome-text    { color: var(--lab-muted); font-size: 11px; }
  #parity-lab .pan {
    min-width: 0; min-height: 64px;
    display: flex; flex-wrap: wrap;
    justify-content: center; align-content: center;
    gap: 3px; padding: 6px;
    border-bottom: 4px solid color-mix(in srgb, var(--lab-text) 50%, transparent);
    border-radius: 10px; background: var(--lab-surface2);
  }
  #parity-lab .sign-wrap { display: grid; place-items: center; }
  #parity-lab .sign-box {
    width: 42px; height: 42px;
    display: grid; place-items: center;
    border: 2px solid var(--lab-border); border-radius: 12px;
    background: var(--lab-bg); color: transparent;
    font-size: 24px; font-weight: 900;
    transition: color .25s ease, transform .25s ease, border-color .25s ease, background .25s ease;
  }
  #parity-lab .sign-box.pending::after { content: "·"; color: var(--lab-muted); animation: cpw-pulse 1s infinite ease-in-out; }
  #parity-lab .sign-box.revealed {
    transform: scale(1.04);
    border-color: var(--lab-accent); background: var(--lab-accentSoft); color: var(--lab-text);
  }

  /* ── Truth & Narrative ──────────────────────────────────────────────── */
  #parity-lab .truth {
    display: none; margin-bottom: 8px; padding: 8px 10px;
    border-radius: 10px; background: var(--lab-accentSoft);
    font-size: 12px; line-height: 1.4;
  }
  #parity-lab.full-view .truth { display: block; }
  #parity-lab .narrative {
    min-height: 56px; padding: 10px;
    border: 1px dashed var(--lab-border); border-radius: 12px;
    color: var(--lab-muted); font-size: 12px; line-height: 1.5;
  }
  #parity-lab .narrative strong { color: var(--lab-text); }

  /* ── Result ─────────────────────────────────────────────────────────── */
  #parity-lab .result {
    display: none; gap: 4px;
    margin-top: 10px; padding: 14px;
    border: 2px solid var(--lab-accent); border-radius: 12px;
    background: var(--lab-accentSoft);
  }
  #parity-lab .result.show { display: grid; }
  #parity-lab .result-kicker { color: var(--lab-accent); font-size: 10px; font-weight: 900; letter-spacing: .12em; text-transform: uppercase; }
  #parity-lab .result-title  { font-size: clamp(15px, 2.2vw, 21px); font-weight: 900; letter-spacing: -.02em; }
  #parity-lab .result-copy   { color: var(--lab-muted); line-height: 1.5; font-size: 12px; }

  @keyframes cpw-pulse { 0%, 100% { opacity: .3; } 50% { opacity: 1; } }
  #parity-lab .movement-overlay { opacity: 0.7; pointer-events: none; }

  /* ── Scrollable math box (wide equations on mobile) ────────────────── */
  .math-overflow {
    overflow-x: auto;
    padding: 0.5rem 1.2rem;
    border: 1px solid var(--global-divider-color);
    border-radius: 8px;
    background: var(--global-card-bg-color);
    margin: 1.2rem 0;
  }

  /* ── Responsive ─────────────────────────────────────────────────────── */
  @media (max-width: 580px) {
    #parity-lab .top-grid { grid-template-columns: 1fr; }
  }
  @media (max-width: 400px) {
    #parity-lab .weigh-row { grid-template-columns: minmax(0,1fr) 38px minmax(0,1fr); gap: 3px; padding: 7px; }
    #parity-lab .pan { min-height: 56px; gap: 2px; padding: 4px 3px; }
    #parity-lab .sign-box { width: 32px; height: 38px; font-size: 20px; }
    #parity-lab .ball { width: clamp(20px, 7vw, 28px); }
  }
  @media (prefers-reduced-motion: reduce) {
    #parity-lab *, #parity-lab *::before, #parity-lab *::after {
      animation-duration: .01ms !important;
      animation-iteration-count: 1 !important;
      transition-duration: .01ms !important;
    }
  }
</style> <p>This puzzle comes from the amazing <a href="https://blog.tanyakhovanova.com/2026/07/coin-parity/">blog by Tanya Khovanova</a>.</p> <h2 id="the-puzzle">The puzzle</h2> <p>You have $2^n$ visually identical coins, each weighing either 10 or 11 gram. You have a standard balance that will weigh groups of coins. Your goal is to determine whether the parity of 10 gram coins (whether it is even or odd), using at most $n$ weighings.</p> <p>You can start with the smaller problems trying to solve this for 4 coins with 2 weighings, 8 coins with 3 weighings, or even 16/32 coinds in 4/5 weighings respectively.</p> <h2 id="visual-solution">Visual Solution</h2> <p>The interactive media below lets you try it for $n = {3, 4, 5}$ (8, 16, and 32 coins respectively) with $n$ weighings allowed. As you might guess, the solution is effectively a binary search (glad to be grinding LeetCode right now).</p> <p>Select the number of coins (8, 16, or 32) and the number of 10 Gram coins. Then click <strong>New puzzle</strong> (for a new coin assignment) and step through the weighings manually (<strong>Measure W₀</strong> button), or hit <strong>Run auto</strong> to watch the algorithm work automatically.</p> <div id="parity-lab"> <div class="shell"> <div class="top-grid"> <section class="panel state-panel" aria-label="Search state"> <div class="panel-h">Search state</div> <div class="summary-stats"> <div class="stat"> <span class="stat-label">Weighings used</span> <span id="step-stat" class="stat-value">0 / 3</span> </div> <div class="stat"> <span class="stat-label">Active interval</span> <span id="interval-stat" class="stat-value">—</span> </div> </div> </section> <div class="top-right"> <section class="panel" aria-label="Controls"> <div class="panel-h">Controls</div> <div class="controls" aria-label="Puzzle controls"> <div class="ctrl-bar"> <div class="field"> <label for="ball-count">Coins</label> <select id="ball-count"> <option value="8">8 (n = 3)</option> <option value="16">16 (n = 4)</option> <option value="32">32 (n = 5)</option> </select> </div> <div class="field"> <label for="zero-count">10 Gram Coins</label> <select id="zero-count"> <option value="random">Random</option> </select> </div> <div class="ctrl-btns"> <button id="new-btn" class="btn btn-primary" type="button">New puzzle</button> <button id="next-btn" class="btn" type="button">Measure W₀</button> <button id="auto-btn" class="btn" type="button">Run auto</button> </div> </div> <label class="switch-wrap"> <input id="reveal-toggle" type="checkbox" checked=""/> Full view — shows coin weights and pair colours </label> </div> </section> <section class="panel" aria-label="Binary-search path"> <div class="track-head"> <div class="panel-h" style="margin:0">Binary-search path</div> <div class="track-note">W<sub>m</sub> reverses W<sub>0</sub>; its sign is known without weighing.</div> </div> <div class="track-scroll"> <div id="track" class="track"></div> <svg id="track-arrows" class="track-arrows-svg" height="1" aria-hidden="true"></svg> </div> </section> </div> </div> <section class="panel"> <div class="panel-h">Game board</div> <p class="panel-sub"> Enable <em>Full view</em> above to reveal coin weights (10 or 11 g) and pair colours (hollow = X, textured = Y). </p> <div id="pair-board" class="pair-board active-source"></div> <section id="weighings" class="weighings" aria-label="Weighing history"> <div class="empty-stage">Choose <strong>Measure W₀</strong> or <strong>Run auto</strong> to begin.</div> </section> <section id="result" class="result" aria-live="polite"> <div class="result-kicker">Conclusion</div> <div id="result-title" class="result-title"></div> <div id="result-copy" class="result-copy"></div> </section> </section> <div id="truth" hidden=""></div> <div id="narrative" hidden="" aria-live="polite"></div> </div> </div> <script>
(() => {
  const root         = document.getElementById("parity-lab");
  const countSelect  = root.querySelector("#ball-count");
  const zeroSelect   = root.querySelector("#zero-count");
  const newBtn       = root.querySelector("#new-btn");
  const nextBtn      = root.querySelector("#next-btn");
  const autoBtn      = root.querySelector("#auto-btn");
  const revealToggle = root.querySelector("#reveal-toggle");
  const pairBoard    = root.querySelector("#pair-board");
  const weighings    = root.querySelector("#weighings");
  const track        = root.querySelector("#track");
  const trackArrows  = root.querySelector("#track-arrows");
  const narrative    = root.querySelector("#narrative");
  const truth        = root.querySelector("#truth");
  const stepStat     = root.querySelector("#step-stat");
  const intervalStat = root.querySelector("#interval-stat");
  const result       = root.querySelector("#result");
  const resultTitle  = root.querySelector("#result-title");
  const resultCopy   = root.querySelector("#result-copy");

  const reducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;

  let state    = null;
  let runToken = 0;

  // ── Utilities ──────────────────────────────────────────────────────────
  function shuffle(arr) {
    const a = arr.slice();
    for (let i = a.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [a[i], a[j]] = [a[j], a[i]];
    }
    return a;
  }

  const SUB_DIGITS = "₀₁₂₃₄₅₆₇₈₉";
  function sub(n) { return String(n).split("").map(d => SUB_DIGITS[+d]).join(""); }

  function pairColor(index, total) {
    return `hsl(${Math.round(index * 347 / Math.max(1, total)) % 360} 68% 52%)`;
  }

  function makeBall(ball) {
    const el = document.createElement("span");
    el.className = `ball ${ball.side === "x" ? "hollow" : "textured"}`;
    el.dataset.ballId = ball.id;
    el.style.setProperty("--pairColor", pairColor(ball.pair, state.m));
    el.setAttribute("aria-label", `${ball.label}: ${ball.weight === 0 ? 10 : 11}g`);
    const vEl = document.createElement("span");
    vEl.className = "ball-value";
    vEl.textContent = ball.weight === 0 ? "10" : "11";
    el.append(vEl);
    return el;
  }

  // ── Puzzle generation ──────────────────────────────────────────────────
  function populateZeroSelect(N) {
    const prev = zeroSelect.value;
    zeroSelect.innerHTML = "";
    const optR = document.createElement("option");
    optR.value = "random"; optR.textContent = "Random";
    zeroSelect.append(optR);
    for (let i = 0; i <= N; i++) {
      const opt = document.createElement("option");
      opt.value = String(i);
      opt.textContent = i === 0 ? "0 (all 11g)" : i === N ? `${N} (all 10g)` : String(i);
      zeroSelect.append(opt);
    }
    // Restore previous selection if still valid
    zeroSelect.value = (prev !== "random" && +prev >= 0 && +prev <= N) ? prev : "random";
  }

  function generatePuzzle() {
    const N = Math.max(8, Math.min(32, Number(countSelect.value) || 8));
    const n = Math.log2(N);
    const m = N / 2;
    populateZeroSelect(N);
    const zeroVal  = zeroSelect.value;
    const numZeros = zeroVal === "random"
      ? Math.floor(Math.random() * (N + 1))
      : Math.max(0, Math.min(N, +zeroVal));
    const weights  = shuffle([...Array(numZeros).fill(0), ...Array(N - numZeros).fill(1)]);
    const physical = Array.from({ length: N }, (_, i) => ({
      id: `b${Date.now()}_${i}_${Math.random().toString(36).slice(2)}`,
      weight: weights[i]
    }));
    const pairs = [];
    for (let i = 0; i < m; i++) {
      pairs.push({
        x: { ...physical[2 * i],     pair: i, side: "x", label: `X${i + 1}` },
        y: { ...physical[2 * i + 1], pair: i, side: "y", label: `Y${i + 1}` }
      });
    }
    state = { N, n, m, pairs, lo: 0, hi: m, signLo: null, signHi: null, steps: 0, history: [], transitions: [], busy: false, auto: false, done: false };
    resetPresentation();
  }

  function resetPresentation() {
    runToken++;
    root.querySelectorAll(".movement-overlay").forEach(el => el.remove());
    pairBoard.innerHTML = "";
    pairBoard.classList.add("active-source");
    state.pairs.forEach((pair, i) => {
      const card = document.createElement("div");
      card.className = "pair-card";
      const tag = document.createElement("div");
      tag.className = "pair-tag";
      tag.textContent = `Pair ${i + 1}`;
      card.append(tag, makeBall(pair.x), makeBall(pair.y));
      pairBoard.append(card);
    });
    weighings.innerHTML = `<div class="empty-stage">Choose <strong>Measure W₀</strong> or <strong>Run auto</strong> to begin.</div>`;
    result.classList.remove("show");
    resultTitle.textContent = resultCopy.textContent = "";
    narrative.innerHTML = `<strong>Pairs are ready.</strong> W₀ places each hollow X-coin on the left and its textured Y-partner on the right.`;
    updateTruth();
    renderTrack();
    updateStats();
    updateControls();
  }

  // ── Display ────────────────────────────────────────────────────────────
  function updateTruth() {
    const balls = state.pairs.flatMap(p => [p.x, p.y]);
    const ones  = balls.reduce((s, b) => s + b.weight, 0);
    const zeros = state.N - ones;
    truth.innerHTML = `<strong>Hidden:</strong> ${zeros} × 10g and ${ones} × 11g — count of 10g coins is <strong>${zeros % 2 === 0 ? "even" : "odd"}</strong>.`;
  }

  function updateStats() {
    stepStat.textContent     = `${state.steps} / ${state.n}`;
    intervalStat.textContent = state.steps === 0 ? "—" : `[${state.lo}, ${state.hi}]`;
  }

  function updateControls() {
    const next = nextIndex();
    nextBtn.disabled     = state.busy || state.done || next === null;
    autoBtn.disabled     = state.busy || state.done || next === null;
    newBtn.disabled      = state.busy;
    countSelect.disabled = state.busy;
    zeroSelect.disabled  = state.busy;
    nextBtn.textContent  = next === null ? "Search complete" : `Measure W${sub(next)}`;
    autoBtn.textContent  = state.auto ? "Running…" : "Run auto";
  }

  // ── Track ──────────────────────────────────────────────────────────────
  // Layout constants matching CSS: each node is 26px wide, links 10px, gap 4px
  const TN = 26, TL = 10, TG = 4;         // node width, link width, gap
  const TS = TN + TL + TG * 2;            // 44px per step
  const nodeX = k => k * TS + TN / 2;     // center x of node k
  const trackW = () => state.m * TS + TN; // total pixel width of track

  function renderTrack() {
    track.innerHTML = "";
    const measured = new Set(state.history.map(e => e.k));
    const next = nextIndex();
    for (let k = 0; k <= state.m; k++) {
      if (k > 0) {
        const link = document.createElement("div");
        link.className = "track-link" + (k > state.lo && k <= state.hi ? " in-range" : "");
        track.append(link);
      }
      const node = document.createElement("div");
      node.className = "track-node";
      node.textContent = k;
      node.title = `Partition W${k}`;
      if (k >= state.lo && k <= state.hi) node.classList.add("in-range");
      if (k === state.lo || k === state.hi) node.classList.add("endpoint");
      if (measured.has(k)) node.classList.add("measured");
      if (next === k) node.classList.add("next");
      track.append(node);
    }
    renderTrackArrows();
  }

  function renderTrackArrows() {
    const svg = trackArrows;
    while (svg.firstChild) svg.removeChild(svg.firstChild);
    const steps = state.history.length;
    const ARC = 14, INC = 7;
    // Use the MAX possible height for this puzzle (n weighings → n-1 arcs) so the SVG
    // never grows during gameplay — preventing layout shifts that misalign arrows.
    const W = trackW();
    const H = ARC + Math.max(0, state.n - 2) * INC + 12;
    svg.setAttribute("width",   W);
    svg.setAttribute("height",  H);
    svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
    svg.style.minWidth = W + "px";
    if (steps < 2) return;

    const NS = "http://www.w3.org/2000/svg";

    const defs = document.createElementNS(NS, "defs");
    svg.append(defs);

    for (let i = 0; i + 1 < steps; i++) {
      const mId = `tka_${i}`;
      const marker = document.createElementNS(NS, "marker");
      marker.setAttribute("id", mId);
      marker.setAttribute("markerWidth",  "6");
      marker.setAttribute("markerHeight", "6");
      marker.setAttribute("refX", "5"); marker.setAttribute("refY", "3");
      marker.setAttribute("orient", "auto");
      const mp = document.createElementNS(NS, "path");
      mp.setAttribute("d", "M0,0 L6,3 L0,6 z");
      mp.setAttribute("fill", "var(--lab-accent)");
      marker.append(mp);
      defs.append(marker);

      const x1   = nodeX(state.history[i].k);
      const x2   = nodeX(state.history[i + 1].k);
      const arcH = ARC + i * INC;
      const path = document.createElementNS(NS, "path");
      path.setAttribute("d",          `M ${x1} 3 Q ${(x1 + x2) / 2} ${arcH} ${x2} 3`);
      path.setAttribute("fill",       "none");
      path.setAttribute("stroke",     "var(--lab-accent)");
      path.setAttribute("stroke-width", "1.5");
      path.setAttribute("opacity",    "0.65");
      path.setAttribute("marker-end", `url(#${mId})`);
      svg.append(path);
    }
  }

  // ── Partition logic ────────────────────────────────────────────────────
  function partition(k) {
    const left = [], right = [];
    state.pairs.forEach((pair, i) => {
      if (i < k) { left.push(pair.y); right.push(pair.x); }
      else        { left.push(pair.x); right.push(pair.y); }
    });
    return { left, right };
  }

  function differenceAt(k) {
    const { left, right } = partition(k);
    return left.reduce((s, b) => s + b.weight, 0) - right.reduce((s, b) => s + b.weight, 0);
  }

  function nextIndex() {
    if (!state || state.done) return null;
    if (state.steps === 0) return 0;
    if (state.hi - state.lo <= 1) return null;
    return (state.lo + state.hi) / 2;
  }

  function signSym(v)    { return v > 0 ? ">" : v < 0 ? "<" : "="; }
  function signPhrase(v) { return v > 0 ? "left heavier" : v < 0 ? "right heavier" : "balanced"; }

  function createWeighRow(k) {
    const { left, right } = partition(k);
    const row = document.createElement("article");
    row.className = "weigh-row active-source";

    const meta = document.createElement("div"); meta.className = "weigh-meta";
    const lbl  = document.createElement("div"); lbl.className  = "partition-label";
    lbl.innerHTML = `Weighing ${state.steps + 1} / ${state.n} · W<sub>${k}</sub>`;
    const outcome = document.createElement("div"); outcome.className = "outcome-text";
    outcome.textContent = `Swap first ${k} pair${k === 1 ? "" : "s"}`;
    meta.append(lbl, outcome);

    const leftPan = document.createElement("div");
    leftPan.className = "pan"; leftPan.setAttribute("aria-label", "Left pan");
    left.forEach(b => leftPan.append(makeBall(b)));

    const signWrap = document.createElement("div"); signWrap.className = "sign-wrap";
    const sign     = document.createElement("div");
    sign.className = "sign-box pending"; sign.setAttribute("aria-label", "Pending");
    signWrap.append(sign);

    const rightPan = document.createElement("div");
    rightPan.className = "pan"; rightPan.setAttribute("aria-label", "Right pan");
    right.forEach(b => rightPan.append(makeBall(b)));

    row.append(meta, leftPan, signWrap, rightPan);
    return { row, sign, outcome };
  }

  const sleep = ms => new Promise(r => setTimeout(r, ms));

  // ── Movement arrows ────────────────────────────────────────────────────
  // Capture bottom-center of each ball (page-absolute) so arrows depart from coin bottom.
  function captureBottomRects(container) {
    const sx = window.scrollX, sy = window.scrollY;
    const map = new Map();
    container.querySelectorAll("[data-ball-id]").forEach(el => {
      const r = el.getBoundingClientRect();
      map.set(el.dataset.ballId, { cx: r.left + sx + r.width / 2, bottom: r.bottom + sy });
    });
    return map;
  }

  // Redraw all stored transitions from current coin positions.
  // Call this whenever the layout changes (toggle, resize) so arrows stay attached.
  function redrawAllArrows() {
    if (!state) return;
    root.querySelectorAll(".movement-overlay").forEach(el => el.remove());
    for (const t of state.transitions) {
      drawMovementArrows(captureBottomRects(t.fromRow), t.toRow, t.movedIds);
    }
  }

  // Draw straight arrows from bottom-center of old coin to top-center of new coin.
  // Arrows accumulate across weighings; only cleared on reset.
  function drawMovementArrows(fromRects, newRow, movedIds) {
    if (movedIds.size === 0) return;

    const NS = "http://www.w3.org/2000/svg";
    const rootR = root.getBoundingClientRect();
    const bd = parseInt(getComputedStyle(root).borderLeftWidth) || 0;
    const sx = window.scrollX, sy = window.scrollY;
    const ox = rootR.left + bd + sx;
    const oy = rootR.top  + bd + sy;

    const svg = document.createElementNS(NS, "svg");
    svg.setAttribute("class", "movement-overlay");
    const W = root.offsetWidth, H = root.offsetHeight;
    svg.setAttribute("width", W); svg.setAttribute("height", H);
    svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
    Object.assign(svg.style, {
      position: "absolute", left: "0", top: "0",
      width: W + "px", height: H + "px", overflow: "visible", zIndex: "10",
    });

    const accent = getComputedStyle(root).getPropertyValue("--lab-accent").trim() || "#6060ff";
    const defs = document.createElementNS(NS, "defs");
    const mId = `mva_${Date.now()}`;
    const marker = document.createElementNS(NS, "marker");
    marker.setAttribute("id", mId);
    marker.setAttribute("markerWidth", "6"); marker.setAttribute("markerHeight", "6");
    marker.setAttribute("refX", "5");        marker.setAttribute("refY", "3");
    marker.setAttribute("orient", "auto");
    const mp = document.createElementNS(NS, "path");
    mp.setAttribute("d", "M0,0 L6,3 L0,6 z"); mp.setAttribute("fill", accent);
    marker.append(mp); defs.append(marker); svg.append(defs);

    let count = 0;
    newRow.querySelectorAll("[data-ball-id]").forEach(el => {
      const id = el.dataset.ballId;
      if (!movedIds.has(id)) return;
      const from = fromRects.get(id);
      if (!from) return;
      const toR = el.getBoundingClientRect();
      // from: bottom-center of old coin (page-absolute → root-relative)
      const x1 = from.cx          - ox;
      const y1 = from.bottom       - oy;
      // to: top-center of new coin
      const x2 = toR.left + sx + toR.width / 2 - ox;
      const y2 = toR.top  + sy                  - oy;
      if (Math.abs(x1 - x2) < 2 && Math.abs(y1 - y2) < 2) return;
      const line = document.createElementNS(NS, "line");
      line.setAttribute("x1", x1); line.setAttribute("y1", y1);
      line.setAttribute("x2", x2); line.setAttribute("y2", y2);
      line.setAttribute("stroke", accent);
      line.setAttribute("stroke-width", "1.5");
      line.setAttribute("marker-end", `url(#${mId})`);
      svg.append(line);
      count++;
    });
    if (count === 0) return;
    root.append(svg);
  }

  // ── Step execution ─────────────────────────────────────────────────────
  async function performNext() {
    if (!state || state.busy || state.done) return;
    const k = nextIndex();
    if (k === null) return;
    state.busy = true;
    updateControls();
    const prevRow = weighings.querySelector(".weigh-row:last-child");
    const prevK   = state.history.length ? state.history.at(-1).k : null;
    // Compute moved ball IDs and capture FROM positions before new row is appended.
    // Without an internal scroll, old coin positions are stable so capture-before is accurate.
    const movedIds = new Set();
    let fromRects  = new Map();
    if (prevRow && prevK !== null) {
      const lo = Math.min(prevK, k), hi = Math.max(prevK, k);
      for (let i = lo; i < hi; i++) {
        movedIds.add(state.pairs[i].x.id);
        movedIds.add(state.pairs[i].y.id);
      }
      if (!reducedMotion && movedIds.size > 0) fromRects = captureBottomRects(prevRow);
    }
    root.querySelectorAll(".active-source").forEach(el => el.classList.remove("active-source"));
    if (weighings.querySelector(".empty-stage")) weighings.innerHTML = "";
    const built = createWeighRow(k);
    weighings.append(built.row);
    if (!reducedMotion && movedIds.size > 0) {
      // Store transition so redrawAllArrows() can replay it on toggle/resize.
      state.transitions.push({ fromRow: prevRow, toRow: built.row, movedIds });
      // Wait for the new row to be laid out, then draw arrows (accumulate, never clear).
      await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
      drawMovementArrows(fromRects, built.row, movedIds);
    }
    const diff = differenceAt(k);
    await sleep(reducedMotion ? 30 : 350);
    built.sign.classList.remove("pending"); built.sign.classList.add("revealed");
    built.sign.textContent = signSym(diff);
    built.sign.setAttribute("aria-label", signPhrase(diff));
    built.outcome.textContent = signPhrase(diff);
    applyMeasurement(k, diff);
    state.busy = false;
    renderTrack(); updateStats(); updateControls();
  }

  function applyMeasurement(k, diff) {
    const sign = Math.sign(diff);
    state.history.push({ k, diff, sign });
    state.steps++;
    if (sign === 0) {
      finish("even", `W${sub(k)} balances. By Lemma 1, D ≡ T (mod 2), so the count of 10g coins is even.`);
      return;
    }
    if (k === 0) {
      state.lo = 0; state.signLo = sign;
      state.hi = state.m; state.signHi = -sign;
      narrative.innerHTML =
        `<strong>D₀ ${signSym(sign)} 0.</strong> W<sub>${state.m}</sub> has the opposite sign. Binary-search in [0, ${state.m}].`;
    } else if (sign === state.signLo) {
      const oldLo = state.lo; state.lo = k; state.signLo = sign;
      narrative.innerHTML =
        `<strong>W<sub>${k}</sub> same sign as W<sub>${oldLo}</sub>.</strong> Left half discarded → interval [${state.lo}, ${state.hi}].`;
    } else {
      const oldHi = state.hi; state.hi = k; state.signHi = sign;
      narrative.innerHTML =
        `<strong>W<sub>${k}</sub> same sign as W<sub>${oldHi}</sub>.</strong> Right half discarded → interval [${state.lo}, ${state.hi}].`;
    }
    if (state.hi - state.lo === 1) {
      finish("odd",
        `After ${state.steps} weighing${state.steps === 1 ? "" : "s"}, W${sub(state.lo)} and W${sub(state.hi)} have opposite signs. Since |D_{k+1}−D_k| ≤ 2, an even total can't flip sign without hitting 0 — so the count of 10g coins is odd.`
      );
    }
  }

  function finish(parity, explanation) {
    state.done = true; state.auto = false;
    result.classList.add("show");
    resultTitle.textContent = `${parity === "even" ? "Even" : "Odd"} — count of 10g coins`;
    resultCopy.textContent  = explanation;
    narrative.innerHTML = `<strong>Search complete.</strong> ` +
      (parity === "even" ? "A balanced partition was found." : "No balanced partition in the allowed weighings.");
  }

  async function runAuto() {
    if (!state || state.auto || state.busy || state.done) return;
    state.auto = true;
    const token = ++runToken;
    updateControls();
    while (state.auto && !state.done && token === runToken) {
      await performNext();
      if (state.done || token !== runToken) break;
      await sleep(reducedMotion ? 80 : 650);
    }
    if (token === runToken) { state.auto = false; updateControls(); }
  }

  // ── Event wiring ───────────────────────────────────────────────────────
  newBtn.addEventListener("click",    generatePuzzle);
  nextBtn.addEventListener("click",   performNext);
  autoBtn.addEventListener("click",   runAuto);
  countSelect.addEventListener("change", generatePuzzle);
  zeroSelect.addEventListener("change",  generatePuzzle);
  revealToggle.addEventListener("change", () => {
    root.classList.toggle("full-view", revealToggle.checked);
    // Coins resize on toggle — redraw arrows from updated positions.
    requestAnimationFrame(() => redrawAllArrows());
  });

  let resizeTimer;
  window.addEventListener("resize", () => {
    clearTimeout(resizeTimer);
    resizeTimer = setTimeout(redrawAllArrows, 120);
  });

  // Apply initial full-view state from checkbox (checked by default)
  root.classList.toggle("full-view", revealToggle.checked);

  generatePuzzle();
})();
</script> <h2 id="formal-solution">Formal Solution</h2> <p>The argument is a discrete analogue of the <a href="https://en.wikipedia.org/wiki/Intermediate_value_theorem">intermediate value theorem</a>: if a quantity starts positive and ends negative while changing by at most 2 each step, it must hit zero somewhere along the way — and hitting zero is exactly what a balanced scale tells us. There are three key ideas:</p> <ol> <li>$\textbf{Reduction.}$ Checking the parity of the signed difference $D = L - R$ of any <em>complete</em> weighing (equal coins on each side) suffices to determine the parity of the coin count.</li> <li>$\textbf{Binary search.}$ We construct a sequence of $m+1$ complete weighings where consecutive weighings differ by a single swapped pair, so $\lvert D_{k+1} - D_k \rvert \le 2$. The sequence starts at $D_0$ and ends at $D_m = -D_0$. And then we run a binary search for a sign change in this sequence.</li> <li>$\textbf{Terminal condition.}$ Any balanced weighing along the binary search will conclude even parity. If none balance, two adjacent opposite-sign values must be found and I show that this forces odd parity.</li> </ol> <p>Thus the decision rule is: if any weighing balances, parity is even; if none balance, parity is odd.</p> <h3 id="notation-and-reduction">Notation and reduction</h3> <p>Let $N=2^n$. Since $N$ is even, the count of 10g coins and the count of 11g coins have the same parity (they sum to $N$), so it suffices to determine the parity of $T$, the number of 10g coins. We work only with <em>complete weighings</em>: those placing exactly $N/2$ coins on each pan. Let $m = N/2 = 2^{n-1}$.</p> <div class="lemma"> <span class="lemma-label">Lemma 1 — complete weighing parity</span> <p>For any complete weighing, let \(L\) and \(R\) denote the total weight of the left and right pans. Then the signed difference \(D = L - R\) satisfies \(D \equiv T \pmod{2}\).</p> </div> <details class="pg-formal"> <summary>Proof</summary> <p>Since every coin lies on exactly one pan, \(L + R = T\). Modulo 2, subtraction and addition agree, so \(D = L - R \equiv L + R = T \pmod{2}\). <span style="float:right">$\blacksquare$</span></p> </details> <p>In particular, a balanced weighing ($D = 0$) implies $T$ is even, and if $T$ is odd no complete weighing can ever balance.</p> <h3 id="sequence-of-weighings">Sequence of weighings</h3> <p>We will start by pairing the coins (randomly as the pairing is immaterial) into $m=2^{n-1}$ pairs $(x_1, y_1), \ldots, (x_m, y_m)$. Then, we construct a sequence of complete weighings as follows: for $k = 0, 1, \ldots, m$, define the complete weighing $W_k$ by</p> \[\begin{aligned} L_k &amp;= \{y_1, \ldots, y_k,\; x_{k+1}, \ldots, x_m\} \\ R_k &amp;= \{x_1, \ldots, x_k,\; y_{k+1}, \ldots, y_m\} \end{aligned}\] <p>So $W_0$ puts all $x$-coins on the left, and each step swaps one pair. For $k \in {0, 1, \ldots, m}$, denote by $D_k$ the signed weight difference of weighing $W_k$. Note that at $W_m$, the coins in each pair have swapped their position and thus: $D_m = -D_0$.</p> <h3 id="binary-search">Binary search</h3> <p>Weigh $W_0$. If it balances, we know that $T$ is even and we are done. Otherwise $D_0 \neq 0$ and since $D_m = -D_0$ the signed differences start and end with opposite signs. Next we run a binary search through the sequence $W_0, W_1, \ldots, W_m$ as follows: Maintain an interval of indices $[a,b]$ such that $D_a$ and $D_b$ have opposite signs. Initially this is $[0,m]$. Its length is a power of two.</p> <p>While $b-a&gt;1$, let $c = (a+b)/2$ and weigh $W_c$. If $D_c=0$, then the weighing balances and the parity is even. Otherwise, $D_c$ has the same sign as exactly one of $D_a$ and $D_b$. Retain the half-interval whose endpoint values have opposite signs:</p> \[[a,b] \leftarrow \begin{cases} [a,c],&amp;\text{if }D_aD_c&lt;0,\\ [c,b],&amp;\text{if }D_cD_b&lt;0. \end{cases}\] <p>Thus each additional weighing halves the interval while preserving the opposite-sign condition.</p> <p>The initial interval has length $m=2^{n-1}$. After $n-1$ midpoint weighings (plus the initial 1 weighing for $W_0$ gives a total of $n$ weighings), either a balance has occurred or the remaining interval has length $1$. In the latter case, we have adjacent indices $k$ and $k+1$ for which $D_k$ and $D_{k+1}$ are nonzero and have opposite signs.</p> <h3 id="terminal-condition">Terminal condition</h3> <p>Once we find two adjacent indices with opposite weight differences, we need to show that $T$ has to be odd. Now, we know that:</p> <div class="math-overflow"> $$ \begin{aligned} D_{k+1} - D_k &amp;= \bigl(\operatorname{weight}(y_{k+1}) - \operatorname{weight}(x_{k+1})\bigr) - \bigl(\operatorname{weight}(x_{k+1}) - \operatorname{weight}(y_{k+1})\bigr) \\ &amp;= 2\bigl(\operatorname{weight}(y_{k+1}) - \operatorname{weight}(x_{k+1})\bigr) \\ &amp;\in \{-2,\; 0,\; 2\} \end{aligned} $$ </div> <p>where the first equality follows from the single pair swap between $W_k, W_{k+1}$ and the last follows because each weight is 0 or 1.</p> <p>We complete the proof by contradiction. Suppose $T$ were even. By Lemma 1, all $D_j$ would be even. Then $D_k$ and $D_{k+1}$ are even, nonzero, and of opposite sign, so $\lvert D_{k+1} - D_k \rvert \ge 4$. But we just established that $\lvert D_{k+1} - D_k \rvert \le 2$, a contradiction.</p> <p>This completes the proof. <span style="float:right">$\blacksquare$</span></p> <h2 id="8-coins-solution">8 Coins solution</h2> <p>Below is the solution for eight coins by the above method. $n = 3$, $m = 4$, the five weighings are:</p> <div style="overflow-x:auto"> <table> <thead><tr><th></th><th>Left pan</th><th>Right pan</th></tr></thead> <tbody> <tr> <td>$W_0$</td> <td>$x_1\; x_2\; x_3\; x_4$</td> <td class="y-coin">$y_1\; y_2\; y_3\; y_4$</td> </tr> <tr> <td>$W_1$</td> <td><span class="y-coin">$y_1$</span>$\; x_2\; x_3\; x_4$</td> <td>$x_1\;$<span class="y-coin">$y_2\; y_3\; y_4$</span></td> </tr> <tr> <td>$W_2$</td> <td><span class="y-coin">$y_1\; y_2$</span>$\; x_3\; x_4$</td> <td>$x_1\; x_2\;$<span class="y-coin">$y_3\; y_4$</span></td> </tr> <tr> <td>$W_3$</td> <td><span class="y-coin">$y_1\; y_2\; y_3$</span>$\; x_4$</td> <td>$x_1\; x_2\; x_3\;$<span class="y-coin">$y_4$</span></td> </tr> <tr> <td>$W_4$</td> <td class="y-coin">$y_1\; y_2\; y_3\; y_4$</td> <td>$x_1\; x_2\; x_3\; x_4$</td> </tr> </tbody> </table> </div> <p>Weigh $W_0$; if unbalanced, weigh $W_2$ (the midpoint of $[0, 4]$). Based on whether $D_2$ shares the sign of $D_0$ or $D_4 = -D_0$, weigh $W_1$ or $W_3$. Three weighings suffice.</p>]]></content><author><name></name></author><category term="research"/><category term="combinatorics"/><category term="puzzles"/><category term="algorithms"/><summary type="html"><![CDATA[You have 2ⁿ visually identical coins, each weighing 0 or 1 gram. In at most n weighings on a balance scale, you can always determine whether the count of zero-weight coins is even or odd. The key is a binary search over a swap path.]]></summary></entry><entry><title type="html">A novel method for input privacy from LLMs</title><link href="https://snwagh.com/blog/2026/stained-glass-transform/" rel="alternate" type="text/html" title="A novel method for input privacy from LLMs"/><published>2026-08-06T00:00:00+00:00</published><updated>2026-08-06T00:00:00+00:00</updated><id>https://snwagh.com/blog/2026/stained-glass-transform</id><content type="html" xml:base="https://snwagh.com/blog/2026/stained-glass-transform/"><![CDATA[<script defer="" src="/assets/js/post-toc.js"></script> <style>
  .post-content strong, .post-content b { font-weight: 700; }

  /* ── MathJax: prevent horizontal overflow on mobile ─────────────── */
  mjx-container { overflow-x: auto; overflow-y: hidden; max-width: 100%; }
  /* inline math uses display:inline which ignores overflow — force inline-block so
     overflow-x:auto and max-width:100% actually take effect */
  mjx-container:not([display="true"]) { display: inline-block; vertical-align: middle; }

  /* ── note box ────────────────────────────────────────────────────── */
  .sgt-note {
    margin: 1.1rem 0;
    padding: .75rem 1rem;
    border-left: 3px solid var(--global-theme-color);
    background: color-mix(in srgb, var(--global-theme-color) 6%, transparent);
    border-radius: 0 6px 6px 0;
    font-size: .88rem;
    line-height: 1.6;
  }
  .sgt-note strong { color: var(--global-theme-color); }

  /* ── inline code ─────────────────────────────────────────────────── */
  code.sgt-eq {
    background: color-mix(in srgb, var(--global-text-color) 5%, transparent);
    padding: .12em .4em;
    border-radius: 4px;
    font-size: .88em;
  }

  /* ════════════════════════════════════════════════════════════════════
     Interactive SGT Explorer
  ════════════════════════════════════════════════════════════════════ */
  .sgt-box {
    margin: 1.6rem auto 2rem;
    max-width: 760px;
    border: 1px solid var(--global-divider-color);
    border-radius: 14px;
    background: var(--global-card-bg-color);
    box-shadow: 0 2px 12px rgba(0,0,0,.06);
    overflow: hidden;
  }
  .sgt-box-header {
    padding: .9rem 1.2rem .5rem;
    border-bottom: 1px solid var(--global-divider-color);
  }
  .sgt-box-title {
    font-size: .75rem;
    font-weight: 700;
    letter-spacing: .06em;
    text-transform: uppercase;
    color: var(--global-text-color-light);
    margin-bottom: .6rem;
  }
  .sgt-tabs {
    display: flex;
    gap: 0;
  }
  .sgt-tab {
    font: inherit;
    font-size: .78rem;
    padding: .3rem .9rem;
    border: 1px solid var(--global-divider-color);
    background: transparent;
    color: var(--global-text-color-light);
    cursor: pointer;
    transition: background .12s, color .12s;
    white-space: nowrap;
  }
  .sgt-tab:first-child { border-radius: 8px 0 0 8px; }
  .sgt-tab:last-child  { border-radius: 0 8px 8px 0; }
  .sgt-tab:not(:first-child) { border-left: none; }
  .sgt-tab.active {
    background: var(--global-theme-color);
    color: #fff;
    border-color: var(--global-theme-color);
  }

  .sgt-box-body { padding: 1rem 1.2rem 1.2rem; }

  /* prompt display */
  .sgt-prompt-label {
    font-size: .68rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: .06em;
    color: var(--global-text-color-light);
    margin-bottom: .25rem;
  }
  .sgt-prompt-text {
    font-size: .83rem;
    line-height: 1.55;
    color: var(--global-text-color);
    background: color-mix(in srgb, var(--global-text-color) 4%, transparent);
    padding: .55rem .8rem;
    border-radius: 7px;
    margin-bottom: .9rem;
    font-family: var(--font-mono, monospace);
  }

  /* obfuscate button */
  .sgt-btn-row { display: flex; gap: .55rem; align-items: center; margin-bottom: .9rem; }
  .sgt-btn {
    font: inherit;
    font-size: .8rem;
    padding: .38rem .9rem;
    border-radius: 8px;
    border: 1.5px solid var(--global-divider-color);
    background: var(--global-card-bg-color);
    color: var(--global-text-color);
    cursor: pointer;
    transition: background .12s;
  }
  .sgt-btn:hover { background: color-mix(in srgb, var(--global-text-color) 7%, transparent); }
  .sgt-btn.primary {
    background: var(--global-theme-color);
    border-color: var(--global-theme-color);
    color: #fff;
  }
  .sgt-btn.primary:hover { filter: brightness(1.1); }
  .sgt-btn:disabled { opacity: .45; cursor: default; filter: none; }
  .sgt-step-hint {
    font-size: .73rem;
    color: var(--global-text-color-light);
  }

  /* heatmap section */
  .sgt-heatmap-row {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: .75rem;
    margin-bottom: .9rem;
  }
  .sgt-heatmap-panel { display: flex; flex-direction: column; gap: .3rem; }
  .sgt-heatmap-label {
    font-size: .72rem;
    font-weight: 700;
    text-align: center;
    color: var(--global-text-color-light);
  }
  .sgt-canvas-wrap {
    position: relative;
    overflow: hidden;
    border-radius: 6px;
    border: 1px solid var(--global-divider-color);
    line-height: 0;
    background: var(--global-bg-color);
    min-height: 110px;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .sgt-canvas-placeholder {
    font-size: .75rem;
    color: var(--global-text-color-light);
    padding: 1rem;
    text-align: center;
  }
  .sgt-heatmap-canvas { display: block; width: 100%; height: auto; image-rendering: pixelated; }

  /* token label row beneath heatmap */
  .sgt-token-labels {
    display: flex;
    justify-content: flex-start;
    flex-wrap: nowrap;
    overflow: hidden;
    gap: 0;
  }
  .sgt-tok {
    font-size: .58rem;
    font-family: var(--font-mono, monospace);
    color: var(--global-text-color-light);
    text-align: center;
    flex: 1;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    padding: 0 1px;
  }

  /* per-token charts */
  .sgt-charts-row {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: .75rem;
    margin-bottom: .9rem;
  }
  .sgt-chart-panel { display: flex; flex-direction: column; gap: .3rem; }
  .sgt-chart-label {
    font-size: .7rem;
    font-weight: 700;
    color: var(--global-text-color-light);
    text-align: center;
  }
  .sgt-chart-canvas-wrap {
    border: 1px solid var(--global-divider-color);
    border-radius: 6px;
    overflow: hidden;
    background: var(--global-bg-color);
    min-height: 80px;
    display: flex;
    align-items: center;
    justify-content: center;
    line-height: 0;
  }
  .sgt-chart-canvas { display: block; width: 100%; height: auto; }

  /* output section */
  .sgt-output-section { margin-bottom: .9rem; }
  .sgt-output-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: .75rem;
    margin-top: .5rem;
  }
  .sgt-output-panel {
    border: 1px solid var(--global-divider-color);
    border-radius: 8px;
    padding: .6rem .8rem;
    background: var(--global-bg-color);
    min-height: 68px;
  }
  .sgt-output-tag {
    font-size: .62rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: .05em;
    margin-bottom: .25rem;
  }
  .sgt-output-tag.clean  { color: #2563eb; }
  .sgt-output-tag.obf    { color: #d97706; }
  .sgt-output-text {
    font-size: .79rem;
    line-height: 1.55;
    font-family: var(--font-mono, monospace);
    color: var(--global-text-color);
    white-space: pre-wrap;
    word-break: break-word;
  }
  .sgt-cursor { display: inline-block; width: .45em; height: 1em; background: currentColor; animation: sgt-blink .7s step-end infinite; vertical-align: text-bottom; }
  @keyframes sgt-blink { 0%,100%{opacity:1} 50%{opacity:0} }

  /* NN attack grid */
  .sgt-nn-section { margin-top: .1rem; }
  .sgt-nn-grid {
    display: flex;
    flex-wrap: wrap;
    gap: .4rem;
    margin-top: .45rem;
  }
  .sgt-nn-pill {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 2px;
    padding: .3rem .5rem .25rem;
    border-radius: 8px;
    border: 1.5px solid transparent;
    min-width: 52px;
  }
  .sgt-nn-pill.hit  { background: #dcfce7; border-color: #86efac; }
  .sgt-nn-pill.miss { background: #f0f9ff; border-color: #bae6fd; }
  .sgt-nn-orig  { font-size: .62rem; font-family: var(--font-mono, monospace); font-weight: 700; color: #1e3a5f; }
  .sgt-nn-arrow { font-size: .58rem; color: var(--global-text-color-light); }
  .sgt-nn-recon { font-size: .58rem; font-family: var(--font-mono, monospace); }
  .sgt-nn-pill.hit  .sgt-nn-recon { color: #15803d; }
  .sgt-nn-pill.miss .sgt-nn-recon { color: #0369a1; }
  .sgt-nn-mark { font-size: .68rem; font-weight: 700; }
  .sgt-nn-pill.hit  .sgt-nn-mark { color: #16a34a; }
  .sgt-nn-pill.miss .sgt-nn-mark { color: #0284c7; }

  .sgt-nn-legend {
    display: flex;
    gap: 1rem;
    font-size: .7rem;
    color: var(--global-text-color-light);
    margin-top: .5rem;
  }
  .sgt-nn-legend span { display: flex; align-items: center; gap: .3rem; }

  /* stats strip */
  .sgt-stats-strip {
    display: flex;
    gap: 1.2rem;
    font-size: .73rem;
    color: var(--global-text-color-light);
    margin-top: .6rem;
    flex-wrap: wrap;
  }
  .sgt-stat-num { font-weight: 700; color: var(--global-theme-color); }

  /* colorbar */
  .sgt-colorbar-row {
    display: flex;
    align-items: center;
    gap: .5rem;
    margin-top: .3rem;
    margin-bottom: .1rem;
    font-size: .65rem;
    color: var(--global-text-color-light);
  }
  .sgt-colorbar {
    height: 10px;
    flex: 1;
    border-radius: 3px;
    background: linear-gradient(to right, #d62b27, #f7f7f7, #2166ac);
  }

  @media (max-width: 540px) {
    .sgt-heatmap-row, .sgt-charts-row, .sgt-output-grid { grid-template-columns: 1fr; }
  }

  /* ════════════════════════════════════════════════════════════════════
     Scrollytelling widget
     Custom properties scoped to .scrolly so they don't leak into the
     rest of the post. All selectors below are intentionally narrow /
     unusual enough not to clash with al-folio theme classes.
  ════════════════════════════════════════════════════════════════════ */
  .scrolly{
    --ink:#1d1d1f;--muted:#6e6e73;
    --blue:#0071e3;--green:#34c759;
    --red:#ff3b30;--card:#fff;--border:rgba(0,0,0,.08);
    --r:14px;--ease:cubic-bezier(.25,.46,.45,.94);
    position:relative;
  }
  .sticky{
    position:sticky;top:var(--nav-h,57px);height:calc(100svh - var(--nav-h,57px));
    display:flex;align-items:flex-start;justify-content:center;padding-top:20px;
    overflow:visible;
  }
  .diagram-block{display:flex;flex-direction:column;width:min(820px,94vw)}
  .step-bar{
    background:var(--card);border:1px solid var(--border);border-radius:var(--r);
    box-shadow:0 2px 10px rgba(0,0,0,.05);
    padding:0 32px;text-align:center;
    opacity:0;transition:opacity .3s ease;
    margin-bottom:20px;height:160px;overflow:hidden;
    display:flex;flex-direction:column;justify-content:center;
  }
  .step-bar.visible{opacity:1}
  .diagram-area{position:relative}
  .scene{position:relative;width:100%}
  .sch{display:flex;flex-direction:column;align-items:stretch;gap:0;transition:opacity .5s var(--ease)}
  .sch.dimmed{opacity:.15}
  .col-labels{display:flex;margin-bottom:12px}
  .col-lbl{
    flex:1;text-align:center;font-size:16px;font-weight:700;
    letter-spacing:.04em;color:var(--ink);text-transform:uppercase;
    transition:opacity .4s ease,color .4s ease;
  }
  .col-lbl.off{opacity:0}
  .col-lbl-spacer{width:20px;flex-shrink:0}
  .s-box{
    background:var(--card);border:1px solid var(--border);border-radius:var(--r);
    box-shadow:0 2px 10px rgba(0,0,0,.05);padding:12px 16px;
    transition:opacity .5s var(--ease),box-shadow .3s ease;
  }
  .s-box.off{opacity:0;pointer-events:none}
  .prompt-section{margin-bottom:0}
  .prompt-box{
    background:linear-gradient(to right,#fff,#f0f4ff);border-color:#c7d0f8;
    padding:14px 24px;display:flex;flex-direction:column;align-items:center;gap:4px;
  }
  .prompt-lbl{font-size:9.5px;font-weight:700;letter-spacing:.09em;color:#8888aa;text-transform:uppercase}
  .prompt-text{font-size:15px;font-weight:500;color:var(--ink);text-align:center;line-height:1.4}
  .chips-wrap{
    display:flex;justify-content:center;max-height:0;overflow:hidden;
    opacity:0;pointer-events:none;transition:max-height .5s var(--ease),opacity .4s ease;
  }
  .chips-wrap.show{max-height:60px;opacity:1;pointer-events:auto}
  .chips{display:flex;gap:5px;flex-wrap:wrap;justify-content:center;margin-top:10px}
  .chip{
    background:var(--blue);color:#fff;border-radius:20px;
    font-size:11px;font-family:var(--font-mono,monospace);padding:4px 10px;
    transform:scale(0) translateY(-6px);opacity:0;
    transition:transform .38s var(--ease),opacity .28s ease;
  }
  .chip.pop{transform:scale(1) translateY(0);opacity:1}
  .arr-row{display:flex;align-items:center;height:28px;transition:opacity .45s ease;pointer-events:none}
  .arr-row.off{display:none}
  .arr-spacer{width:20px;flex-shrink:0}
  .arr-col{flex:1;display:flex;align-items:center;justify-content:center}
  .arr-svg{width:2px;overflow:visible;flex-shrink:0}
  .twin-row{display:flex;gap:20px;align-items:stretch;transition:opacity .45s var(--ease)}
  .twin-row.off{display:none}
  .twin-col{flex:1;min-width:0}
  .embed-box{border-color:#fca5a5;background:#fff1f2;display:flex;flex-direction:column;align-items:center;gap:3px}
  .embed-box.right{border-color:#86efac;background:#edfff4}
  .embed-box-h{font-size:12.5px;font-weight:700;color:var(--red)}
  .embed-box.right .embed-box-h{color:var(--green)}
  .embed-box-sub{font-size:10.5px;color:var(--muted);font-family:var(--font-mono,monospace)}
  /* Renamed from .sgt-box to avoid clash with the interactive explorer widget */
  .scrolly-sgt-node{
    border-color:#86efac;border-width:1.8px;
    background:linear-gradient(135deg,#f0fdf4,#dcfce7);
    display:flex;flex-direction:column;align-items:center;gap:4px;
  }
  .scrolly-sgt-node-h{font-size:10px;font-weight:700;letter-spacing:.06em;color:#166534;text-transform:uppercase}
  .scrolly-sgt-node-desc{font-size:11.5px;color:#15803d;text-align:center;line-height:1.45}
  .llm-box{
    border-color:#fca5a5;background:linear-gradient(to bottom,#fff1f2,#ffe4e6);
    display:flex;flex-direction:column;align-items:center;gap:4px;
    padding:20px 16px;min-height:90px;
  }
  .llm-box-h{font-size:15px;font-weight:700;color:var(--ink)}
  .llm-box-sub{font-size:10.5px;color:var(--muted)}
  .dots{display:flex;gap:5px;margin-top:8px;height:20px;align-items:center}
  .dot{width:7px;height:7px;border-radius:50%;background:var(--red);opacity:0;transition:opacity .4s ease}
  .dots.show .dot{opacity:1;animation:scrolly-bnc .9s infinite ease-in-out}
  .dots.show .dot:nth-child(2){animation-delay:.15s}
  .dots.show .dot:nth-child(3){animation-delay:.3s}
  @keyframes scrolly-bnc{0%,80%,100%{transform:scale(.6)}40%{transform:scale(1.1)}}
  .resp-box{
    border-color:#fca5a5;background:#fff;
    display:flex;flex-direction:column;gap:4px;padding:10px 14px;min-height:70px;
  }
  .resp-lbl{font-size:9px;font-weight:700;letter-spacing:.07em;color:#8888aa;text-transform:uppercase}
  .resp-text{font-size:11px;font-family:var(--font-mono,monospace);color:var(--ink);line-height:1.6;white-space:pre-wrap;word-break:break-word}
  .scrolly-cursor{display:inline-block;width:2px;height:.9em;background:var(--blue);vertical-align:text-bottom;animation:scrolly-blink .7s step-end infinite}
  @keyframes scrolly-blink{0%,100%{opacity:1}50%{opacity:0}}
  .embed-zoom{
    position:absolute;background:var(--card);border-radius:var(--r);
    box-shadow:0 16px 64px rgba(0,0,0,.14),0 4px 16px rgba(0,0,0,.08);
    overflow:hidden;opacity:0;pointer-events:none;
    transition:top .38s var(--ease),left .38s var(--ease),width .38s var(--ease),height .38s var(--ease),opacity .25s ease,border-radius .35s var(--ease);
    z-index:200;
  }
  .embed-zoom.show{opacity:1;pointer-events:auto}
  .embed-zoom-inner{padding:22px 24px 20px;height:100%;display:flex;flex-direction:column;gap:12px;overflow:hidden}
  .ez-header{display:flex;align-items:baseline;gap:10px;flex-shrink:0}
  .ez-title{font-size:15px;font-weight:700;color:var(--ink)}
  .ez-sub{font-size:11px;color:var(--muted);font-family:var(--font-mono,monospace)}
  .ez-badge{margin-left:auto;font-size:10px;font-weight:700;padding:3px 10px;border-radius:20px;background:#eff6ff;color:var(--blue);letter-spacing:.04em}
  .ev-grid{display:flex;flex-direction:column;gap:5px}
  .ev-grid.off{display:none}
  .ev-row{display:flex;gap:4px;align-items:center}
  .ev-tok{width:68px;flex-shrink:0;font-size:10.5px;font-family:var(--font-mono,monospace);color:var(--muted);text-align:right;padding-right:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .ev-cells{display:flex;gap:3px;flex:1}
  .ecell{
    flex:1;height:30px;border-radius:5px;
    display:flex;align-items:center;justify-content:center;
    font-size:9.5px;font-family:var(--font-mono,monospace);color:var(--ink);
    background:rgba(0,113,227,.06);border:1px solid rgba(0,113,227,.10);
    transition:background-color .5s var(--ease),color .4s ease,border-color .4s ease;
    overflow:hidden;white-space:nowrap;
  }
  .ecell.heat{color:transparent;border-color:transparent}
  .ev-more{flex-shrink:0;width:22px;font-size:11px;color:var(--muted);display:flex;align-items:center;justify-content:center}
  .ev-compare{display:none;flex-direction:column;gap:8px}
  .ev-compare.active{display:flex}
  .sgt-half{display:flex;flex-direction:column;gap:6px}
  .sgt-half.bot{opacity:0;transition:opacity .6s ease}
  .sgt-half.bot.show{opacity:1}
  .sgt-half-lbl{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;display:flex;align-items:center;gap:8px}
  .sgt-transform-arrow{display:flex;align-items:center;justify-content:center;gap:8px;padding:10px 0;opacity:0;transition:opacity .5s ease}
  .sgt-transform-arrow.show{opacity:1}
  .sgt-transform-lbl{font-size:10.5px;font-weight:700;letter-spacing:.06em;color:#166534;text-transform:uppercase}
  .hm-wrap{height:110px;position:relative}
  .hm-wrap canvas{width:100%;height:100%;display:block;border-radius:8px}
  .n-step{font-size:10px;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:var(--blue);margin-bottom:3px}
  .n-title{font-size:15px;font-weight:700;color:var(--ink);margin-bottom:2px;line-height:1.3}
  .n-body{font-size:13px;color:var(--muted);line-height:1.65}
  .trigs{position:absolute;top:0;left:0;width:100%;pointer-events:none}
  .trig{height:100svh}
  .conv-bypass{
    width:2px;min-height:50px;height:100%;
    background:repeating-linear-gradient(to bottom,#ff3b30 0,#ff3b30 4px,transparent 4px,transparent 7px);
    margin:0 auto;
  }
  .hm-img-panel{display:none;flex-direction:column;gap:0;flex:1;min-height:0}
  .hm-img-panel.active{display:flex}
  .hm-frame{position:relative;width:100%;flex:1;min-height:0}
  .hm-img{
    position:absolute;inset:0;width:100%;height:100%;
    object-fit:contain;object-position:top center;
    opacity:0;transition:opacity 520ms ease;will-change:opacity;
    user-select:none;pointer-events:none;
  }
  .hm-img.is-active{opacity:1;z-index:2}
  .hm-img.is-before{opacity:0;z-index:1}
  .hm-img.is-after{opacity:0;z-index:1}
  #hm-d8-clean.is-active{z-index:3}
  /* ── Mobile: single-column (show only the private / right flow) ── */
  @media(max-width:560px){
    .sticky{height:calc(100dvh - var(--nav-h,57px))}
    .trig{height:100dvh}
    .step-bar{padding:0 14px;height:auto;min-height:100px;padding-top:12px;padding-bottom:12px}
    .n-step{font-size:9px}
    .n-title{font-size:13px}
    .n-body{font-size:11px;line-height:1.5}
    /* hide left (conventional) column entirely */
    .arr-col:first-child,.arr-spacer,.col-lbl-spacer{display:none!important}
    .twin-col:first-child{display:none!important}
    #lbl-l{display:none!important}
    #lbl-r{text-align:center}
    /* tighter box sizing */
    .prompt-box{padding:10px 16px}
    .prompt-text{font-size:13px}
    .embed-box-h{font-size:11px}
    .embed-box-sub,.llm-box-sub{font-size:9px}
    .llm-box{padding:12px 10px;min-height:60px}
    .resp-lbl{font-size:8px}
    .resp-text{font-size:10px}
    .ev-tok{width:52px;font-size:9.5px}
    .ecell{height:26px;font-size:8.5px}
    /* embed popup: tighter padding, stack subtitle below title, hide badge */
    .embed-zoom-inner{padding:10px 14px 12px}
    .ez-header{flex-wrap:wrap;row-gap:2px}
    .ez-title{font-size:13px}
    .ez-sub{font-size:9px;flex:0 0 100%}
    .ez-badge{display:none}
    /* center images vertically in their frame on mobile */
    .hm-img{object-position:center}
  }
  @media(prefers-reduced-motion:reduce){.hm-img{transition:none}}
  @media(prefers-reduced-motion:reduce){.scrolly *{transition-duration:.01ms!important;animation-duration:.01ms!important}}

  /* ── pg-formal collapsible note (mirrors open-problem post) ────── */
  .pg-formal { margin: 1.2rem 0 1.6rem; border: 1px solid var(--global-divider-color); border-radius: 10px; padding: .2rem 1rem; background: var(--global-card-bg-color); }
  .pg-formal summary { cursor: pointer; font-family: var(--font-sans, system-ui, sans-serif); font-weight: 600; color: var(--global-theme-color); padding: .6rem .2rem; }
  .pg-formal[open] summary { margin-bottom: .4rem; }
  .pg-formal p { margin: .5rem 0; line-height: 1.6; }
</style> <p>One of the most interesting privacy technologies that I have come across is called <strong>Stained Glass Transform</strong> (SGT). This was invented by folks at <a href="https://protopia.ai/">Protopia AI</a> (their team includes my talented friend and collaborator <a href="https://www.linkedin.com/in/sidhartha-roy-ai/">Sid Roy</a> and in this blog I am looking into their technical paper [1]. The problem it addresses is one that anyone building on cloud LLM APIs encounters: you want the model’s intelligence, but you don’t want the LLM provider to see your prompt/data.</p> <h2 id="the-problem">The problem</h2> <p>When you call any hosted LLM endpoint (think ChatGPT, Claude.ai, OpenRouter, HuggingFace), you hand your prompt in the clear to a third-party server, which stores it in their database logs. This is a major concern given the increasingly personal nature of prompts and the mechanics of the data economy. A mechanism to let users benefit from LLMs while preserving the privacy of their input is therefore critical.</p> <h3 id="existing-solutions">Existing solutions</h3> <p>There are a few different ways to resolve this prompt privacy challenge.</p> <ul> <li>$\textbf{Local hosting.}$ Host the model yourself so the prompt never leaves your environment. <a href="https://ollama.com/">Ollama</a> makes this straightforward, letting you run Llama, Mistral, Gemma, and other open-weight models on consumer hardware with a single command. The obvious limitation is compute: a capable model needs a GPU with sufficient VRAM. Beyond that, you forfeit all the infrastructure that comes for free with hosted endpoints: load balancing, auto-scaling, automatic retries, hardware maintenance, and the operational overhead of keeping a model server healthy in production.</li> <li>$\textbf{Fully Homomorphic Encryption (FHE).}$ FHE allows computations directly on encrypted data so your prompt is encrypted on-device and the server processes it without ever decrypting it. This <a href="https://sofar.belfortlabs.cloud/">Belfort Labs demo</a> is a live in-browser experience that gives a feel for what FHE-based inference looks like in practice. On the open-source side, <a href="https://github.com/zama-ai/concrete-ml">Zama’s Concrete ML</a> is the leading library tackling the underlying hard cryptographic engineering. The downsides are steep: FHE inference is slower than plaintext, LM endpoints need significant re-engineering to operate over encrypted arithmetic (plaintext-ciphertext), and key management at scale is a non-trivial operational challenge.</li> <li>$\textbf{Trusted Execution Environments (TEEs).}$ TEEs (e.g. Intel SGX/TDX, AMD SEV, Confidential Containers) create hardware-isolated enclaves where code and data are hidden even from the host OS and cloud provider. This can be used to perform two-sided privacy where the server cannot see the user’s prompt and the model provider’s weights can simultaneously remain confidential. In practice, the user must still trust the hardware vendor’s attestation, GPU TEE support (needed for performant inference) is relatively new (NVIDIA Hopper is the first generation with production-ready confidential computing), and trust questions around the TEE hosting entity can undermine the privacy guarantees entirely.</li> </ul> <h3 id="stained-glass-transform-sgt">Stained Glass Transform (SGT)</h3> <p>The Stained Glass Transform is a novel solution to the same problem with a well-studied and rigorous notion of privacy. The solution involves sending obfuscated embeddings instead of raw text to the LLM provider and letting the provider’s endpoint do the rest.</p> <p>In other words, it moves the initial preparatory stages used by all LLMs (tokenization and embedding) to the user’s side. Using a trained machine learning model (their secret sauce), the embedding (and thus the prompt) is obfuscated. The key insight, however, is that this obfuscated prompt provides two empirically validated guarantees:</p> <ul> <li>$\textbf{(Utility preservation)}$ The LLM output on the obfuscated prompt is close to the LLM output on the raw text.</li> <li>$\textbf{(Privacy guarantee)}$ The raw text prompt is hard to reverse-engineer from the obfuscated embeddings.</li> </ul> <details class="pg-formal"> <summary>Viewing note</summary> <p>This interactive walkthrough is optimized for laptop-sized displays and mobile devices in portrait orientation. Other viewports — including landscape mobile and tablet — remain functional but may exhibit reduced layout fidelity.</p> </details> <div class="scrolly" id="scrolly"> <div class="sticky" id="sticky"> <div class="diagram-block"> <div class="step-bar" id="step-bar"> <div class="n-step" id="n-step"></div> <div class="n-title" id="n-title"></div> <div class="n-body" id="n-body"></div> </div> <div class="diagram-area" id="diagram-area"> <div class="scene" id="scene"> <div class="sch" id="sch"> <div class="col-labels"> <div class="col-lbl off" id="lbl-l">Conventional Flow</div> <div class="col-lbl-spacer"></div> <div class="col-lbl off" id="lbl-r">Private Flow</div> </div> <div class="prompt-section"> <div class="s-box prompt-box"> <span class="prompt-lbl">User Prompt</span> <span class="prompt-text">"How does a black hole form?"</span> <div class="chips-wrap" id="chips-wrap"> <div class="chips" id="chips"></div> </div> </div> </div> <div class="arr-row off" id="arr-embed"> <div class="arr-col"><svg class="arr-svg" viewBox="0 0 2 28" height="28"><line x1="1" y1="0" x2="1" y2="22" stroke="#ff3b30" stroke-width="1.8" stroke-dasharray="4 3"/><polygon points="1,28 -3,18 5,18" fill="#ff3b30"/></svg></div> <div class="arr-spacer"></div> <div class="arr-col"><svg class="arr-svg" viewBox="0 0 2 28" height="28"><line x1="1" y1="0" x2="1" y2="22" stroke="#34c759" stroke-width="1.8" stroke-dasharray="4 3"/><polygon points="1,28 -3,18 5,18" fill="#34c759"/></svg></div> </div> <div class="twin-row off" id="embed-row"> <div class="twin-col"><div class="s-box embed-box off" id="g-embl"><div class="embed-box-h">Embedder</div><div class="embed-box-sub">d = 2,048</div></div></div> <div class="twin-col"><div class="s-box embed-box right off" id="g-embr"><div class="embed-box-h">Embedder</div><div class="embed-box-sub">d = 2,048</div></div></div> </div> <div class="arr-row off" id="arr-sgt"> <div class="arr-col"><svg class="arr-svg" viewBox="0 0 2 28" height="28"><line x1="1" y1="0" x2="1" y2="28" stroke="#ff3b30" stroke-width="1.8" stroke-dasharray="4 3"/></svg></div> <div class="arr-spacer"></div> <div class="arr-col"><svg class="arr-svg" viewBox="0 0 2 28" height="28"><line x1="1" y1="0" x2="1" y2="22" stroke="#34c759" stroke-width="1.8" stroke-dasharray="4 3"/><polygon points="1,28 -3,18 5,18" fill="#34c759"/></svg></div> </div> <div class="twin-row off" id="g-sgt"> <div class="twin-col"><div class="conv-bypass"></div></div> <div class="twin-col"> <div class="s-box scrolly-sgt-node" id="sgt-inner"> <div class="scrolly-sgt-node-h">Stained Glass Transform</div> <div class="scrolly-sgt-node-desc">obfuscate embeddings<br/>using a trained model</div> </div> </div> </div> <div class="arr-row off" id="arr-llm"> <div class="arr-col"><svg class="arr-svg" viewBox="0 0 2 28" height="28"><line x1="1" y1="0" x2="1" y2="22" stroke="#ff3b30" stroke-width="1.8" stroke-dasharray="4 3"/><polygon points="1,28 -3,18 5,18" fill="#ff3b30"/></svg></div> <div class="arr-spacer"></div> <div class="arr-col"><svg class="arr-svg" viewBox="0 0 2 28" height="28"><line x1="1" y1="0" x2="1" y2="22" stroke="#34c759" stroke-width="1.8" stroke-dasharray="4 3"/><polygon points="1,28 -3,18 5,18" fill="#34c759"/></svg></div> </div> <div class="twin-row off" id="llm-row"> <div class="twin-col"><div class="s-box llm-box off" id="g-llml"><div class="llm-box-h">LLM</div><div class="llm-box-sub">LLaMA 3.2 1B · frozen</div><div class="dots" id="dots-l"><span class="dot"></span><span class="dot"></span><span class="dot"></span></div></div></div> <div class="twin-col"><div class="s-box llm-box off" id="g-llmr"><div class="llm-box-h">LLM</div><div class="llm-box-sub">LLaMA 3.2 1B · frozen</div><div class="dots" id="dots-r"><span class="dot"></span><span class="dot"></span><span class="dot"></span></div></div></div> </div> <div class="arr-row off" id="arr-resp"> <div class="arr-col"><svg class="arr-svg" viewBox="0 0 2 28" height="28"><line x1="1" y1="0" x2="1" y2="22" stroke="#ff3b30" stroke-width="1.8" stroke-dasharray="4 3"/><polygon points="1,28 -3,18 5,18" fill="#ff3b30"/></svg></div> <div class="arr-spacer"></div> <div class="arr-col"><svg class="arr-svg" viewBox="0 0 2 28" height="28"><line x1="1" y1="0" x2="1" y2="22" stroke="#34c759" stroke-width="1.8" stroke-dasharray="4 3"/><polygon points="1,28 -3,18 5,18" fill="#34c759"/></svg></div> </div> <div class="twin-row off" id="resp-row"> <div class="twin-col"><div class="s-box resp-box off" id="g-respl"><div class="resp-lbl">Response (conventional)</div><div class="resp-text" id="rt-l"></div></div></div> <div class="twin-col"><div class="s-box resp-box off" id="g-respr"><div class="resp-lbl">Response (prompt private)</div><div class="resp-text" id="rt-r"></div></div></div> </div> </div> </div> <div class="embed-zoom" id="embed-zoom"> <div class="embed-zoom-inner"> <div class="ez-header"> <div class="ez-title" id="ez-title">Token Embeddings</div> <div class="ez-sub" id="ez-sub">d = 2,048 dims · 7 tokens</div> <div class="ez-badge" id="ez-badge">LLaMA 3.2 1B</div> </div> <div class="ev-grid" id="ev-grid"></div> <div class="ev-compare" id="ev-compare"></div> <div class="hm-img-panel" id="hm-img-panel"> <div class="hm-frame"> <img class="hm-img" id="hm-d8" src="/assets/img/heatmap_D8.png" alt="8-dim heatmaps" draggable="false"/> <img class="hm-img" id="hm-d8-clean" src="/assets/img/heatmap_D8_clean_only.png" alt="8-dim clean embeddings" draggable="false"/> <img class="hm-img" id="hm-d128" src="/assets/img/heatmap_D128.png" alt="128-dim heatmaps" draggable="false"/> <img class="hm-img" id="hm-d2048" src="/assets/img/heatmap_D2048.png" alt="2048-dim heatmaps" draggable="false"/> </div> </div> </div> </div> </div> </div> </div> <div class="trigs" id="trigs"> <div class="trig"></div> <div class="trig"></div> <div class="trig"></div> <div class="trig"></div> <div class="trig"></div> <div class="trig"></div> <div class="trig"></div> <div class="trig"></div> <div class="trig"></div> <div class="trig"></div> <div class="trig"></div> <div class="trig"></div> </div> </div> <script>
(function(){
'use strict';

var TOKENS = ["How"," does"," a"," black"," hole"," form","?"];
var DISP_VALS = [
  [0.0099,-0.0155,0.0105,0.0229,-0.0012,-0.0067,0.0148,0.01],
  [0.0197,0.0151,0.0035,0.0093,0.0225,0.0159,0.0019,0.0187],
  [-0.0062,-0.0042,0.0281,-0.0291,-0.0042,-0.0217,0.0157,0.0038],
  [-0.0217,0.0024,-0.0005,-0.0447,-0.0439,0.004,0.0405,-0.0038],
  [0.0087,-0.0143,0.0164,-0.0305,-0.0374,0.0062,0.0048,-0.015],
  [0.0048,0.0137,0.0142,0.0145,-0.0251,0.0271,0.0347,-0.0033],
  [-0.0172,-0.014,0.0129,0.0084,0.0219,0.0074,0.0209,0.0143]
];
var cleanM = [
  [0.0099,0.0143,0.0112,-0.0278,0.0229,0.012,-0.0011,-0.0051,0.0164,-0.0315,0.0178,0.0193,-0.001,-0.0107,-0.0265,-0.0042,-0.0198,-0.0215,0.0225,0.0025,-0.0005,0.0234,-0.0073,0.0172,-0.0227,0.0086,-0.0112,0.0181,0.0388,-0.0156,0.0151,0.0253,-0.0305,-0.0273,0.0027,-0.0168,-0.0192,-0.0048,-0.0056,-0.0013],
  [0.0197,-0.0015,-0.0009,0.0033,0.0154,0.0381,-0.0065,0.0063,-0.0116,-0.0093,-0.0002,-0.006,-0.0077,0.004,-0.0132,0.0112,-0.0249,-0.0393,0.0332,-0.0117,-0.0006,0.0349,-0.0036,-0.0239,-0.0234,0.0135,0.0066,-0.022,0.0042,-0.011,0.0053,-0.0022,0.0139,0.0037,-0.0038,0.0215,-0.0023,0.011,0.0315,0.0192],
  [-0.0062,0.0216,0.0006,-0.0231,-0.0167,-0.0123,-0.0349,0.003,0.0366,0.0127,-0.0031,0.0175,0.0032,-0.0141,-0.042,0.0322,0.0193,0.0167,0.0315,0.0031,0.0219,0.0082,0.0161,-0.0073,0.0181,0.0086,-0.0347,0.0139,-0.019,0.0374,0.0137,0.0095,0.0208,-0.0139,0.027,-0.011,0.0123,-0.0078,-0.0045,-0.012],
  [-0.0217,0.0058,0.0198,0.0232,0.007,-0.0076,0.0107,-0.0317,-0.0048,0.0133,0.017,-0.0271,-0.0121,-0.0256,0.0025,0.0004,0.0074,-0.0049,-0.0067,-0.0132,-0.0188,0.0266,0.0104,0.0212,-0.0226,0.0101,-0.0131,0.012,-0.0072,0.0046,-0.0094,0.02,0.0396,-0.0023,0.04,0.0035,-0.0435,0.0011,-0.0021,-0.0094],
  [0.0087,-0.0058,-0.0203,0.0503,-0.0167,-0.0095,0.0217,-0.0205,0.007,0.0107,-0.0286,-0.0337,0.0227,0.0072,0.0302,-0.0024,0.011,-0.0104,-0.0099,0.0045,0.0254,0.0189,-0.0113,-0.0298,-0.0038,-0.0092,-0.0011,-0.0104,0.0105,-0.0378,-0.0136,0.0024,-0.0176,-0.0376,-0.027,0.0109,0.001,0.0006,-0.0074,0.0126],
  [0.0048,0.0015,-0.0147,-0.031,0.0152,-0.0247,0.0354,-0.0117,-0.0117,-0.0104,-0.0168,-0.015,0.0189,0.0386,-0.041,0.0017,-0.0117,-0.0308,0.0212,-0.0154,0.0142,-0.0127,0.0581,-0.0403,0.0018,-0.0244,-0.0101,0.0137,0.0449,0.0212,-0.0287,0.0003,-0.0219,-0.0304,0.0121,0.0181,0.0222,-0.0215,0.0149,-0.0493],
  [-0.0172,0.0312,-0.0176,0.0015,0.0325,-0.0176,-0.0417,0.0154,-0.0261,0.0142,-0.0161,0.0179,-0.0152,-0.0063,0.0005,0.015,0.0078,0.0154,0.0215,-0.0114,0.0006,0.0135,0.0081,-0.0096,-0.0021,0.0062,-0.0079,-0.0015,0.0064,0.0005,-0.0352,0.0133,0.0079,-0.0332,0.0177,0.0071,-0.0096,-0.0053,-0.0057,0.0107]
];
var obfM = [
  [-0.0258,0.0566,-0.0095,-0.0069,0.0577,0.0129,-0.0182,-0.0423,0.0373,-0.0148,0.0412,-0.0168,0.0312,-0.0048,-0.0183,-0.0328,-0.0031,-0.0219,0.0159,-0.0133,-0.0071,0.0016,-0.0259,0.0372,0.0085,0.0203,-0.0057,0.0388,0.0826,-0.0229,0.075,0.045,-0.0209,-0.0269,-0.023,0.0343,-0.0226,0.0385,0.0172,0.0114],
  [0.016,0.0505,-0.0213,0.0219,0.0365,0.0084,-0.0122,-0.0428,0.0448,0.0182,0.0174,-0.0418,0.0108,-0.004,-0.0037,0.015,-0.0069,-0.0592,0.0118,-0.0071,0.0205,0.0016,-0.0067,0.0118,-0.0215,0.009,-0.0177,0.0143,0.0617,0.0019,0.068,0.016,0.0049,0.0018,-0.0518,0.0483,-0.0122,0.0322,0.0307,0.0131],
  [-0.01,0.0342,-0.0306,0.0302,0.0256,-0.0208,-0.0218,-0.0398,0.03,0.0359,0.0271,-0.0168,0.0206,0.0042,-0.0137,0.0045,0.0113,0.0142,0.0106,0.0163,0.0098,-0.0191,-0.0085,0.0421,0.0116,-0.0006,-0.0173,0.0183,0.0345,0.0345,0.0359,0.0342,0.0048,-0.0117,-0.0183,0.0358,0.0186,0.0158,-0.0018,0.0166],
  [-0.0014,0.0427,-0.0109,0.0462,0.0201,-0.0254,0.0026,-0.065,0.0066,0.0519,0.0468,-0.0481,-0.0042,-0.0395,0.0061,0.0093,-0.0211,-0.0192,-0.0143,0.008,-0.008,0.0015,-0.0019,0.058,-0.0071,0.0146,0.0039,0.049,0.0235,0.0175,0.0286,0.0185,0.0104,-0.0127,0.0287,0.0532,-0.0526,0.031,0.0127,0.0028],
  [0.0216,0.0241,-0.0388,0.0637,0.0079,-0.0189,0.0144,-0.0715,0.0087,0.0291,0.0133,-0.0447,0.0282,-0.0011,0.0437,-0.0001,-0.0019,-0.0384,-0.0231,0.0069,0.0251,-0.0121,-0.0253,-0.0165,0.0044,-0.0068,0.0259,-0.0006,0.0496,-0.0282,0.0244,0.0079,-0.0432,-0.0525,-0.0563,0.0355,0.0184,0.0149,0.0054,0.0275],
  [-0.0075,0.0288,-0.0317,-0.0108,0.0457,-0.0285,0.0073,-0.0747,0.0011,0.0117,0.0112,-0.0367,0.0529,0.0175,-0.041,0.008,0.0003,-0.0441,-0.011,0.0204,0.0087,-0.0292,0.0331,-0.0073,0.0136,-0.0238,-0.0085,0.0382,0.0933,0.0408,0.0163,0.0071,-0.0331,-0.0451,-0.0264,0.0737,0.0249,0.0176,0.0306,-0.0192],
  [-0.0327,0.0544,-0.0501,0.0054,0.0531,-0.0087,-0.0274,-0.0069,-0.0118,0.0235,0.0277,-0.0165,0.0015,0.004,-0.0096,0.0201,-0.0072,-0.0124,0.0098,0.0061,0.0059,-0.0131,-0.0178,0.0324,-0.0112,0.0161,-0.0229,0.0191,0.0414,0.0018,-0.0054,0.0067,-0.0157,-0.029,-0.0403,0.031,-0.0023,0.0329,0.0002,0.0026]
];

var RESP_CLEAN   = 'A black hole is formed when a massive star\nruns out of fuel and collapses in on itself.\nThis collapse forms a singularity of infinite density.';
var RESP_OBF = 'A black hole forms when a massive star exhausts\nits nuclear fuel and gravity overwhelms all\noutward pressure — spacetime curves without limit.';

function rdbu(t){
  var r,g,b,s;
  if(t<0.5){s=t*2;r=Math.round(180+75*s);g=Math.round(180+75*s);b=Math.round(255-20*s);}
  else{s=(t-0.5)*2;r=Math.round(255-20*(1-s));g=Math.round(255-120*s);b=Math.round(235-200*s);}
  return[r,g,b];
}
function matRange(M){
  var lo=Infinity,hi=-Infinity;
  M.forEach(function(r){r.forEach(function(v){if(v<lo)lo=v;if(v>hi)hi=v;});});
  return{lo:lo,hi:hi};
}
function normalize(v,lo,hi){return hi===lo?0.5:(v-lo)/(hi-lo);}

/* ── Token chips ── */
var chipsEl=document.getElementById('chips');
var chipsWrap=document.getElementById('chips-wrap');
TOKENS.forEach(function(t){
  var s=document.createElement('span');s.className='chip';
  s.textContent=t.trim()||t;chipsEl.appendChild(s);
});
var chipsDone=false;
function popChips(){
  if(chipsDone){chipsWrap.classList.add('show');return;}
  chipsDone=true;chipsWrap.classList.add('show');
  var all=chipsEl.querySelectorAll('.chip');
  all.forEach(function(c,i){setTimeout(function(){c.classList.add('pop');},i*70);});
}
function hideChips(){
  chipsDone=false;chipsWrap.classList.remove('show');
  chipsEl.querySelectorAll('.chip').forEach(function(c){c.classList.remove('pop');});
}

/* ── Float values grid ── */
function buildGrid(){
  var grid=document.getElementById('ev-grid');grid.innerHTML='';
  var rng=matRange(DISP_VALS);
  DISP_VALS.forEach(function(row,i){
    var div=document.createElement('div');div.className='ev-row';
    var lbl=document.createElement('div');lbl.className='ev-tok';lbl.textContent=TOKENS[i].trim()||TOKENS[i];
    div.appendChild(lbl);
    var cells=document.createElement('div');cells.className='ev-cells';
    row.forEach(function(v,j){
      var c=document.createElement('div');c.className='ecell';
      c.setAttribute('data-row',i);c.setAttribute('data-col',j);
      var s=v.toFixed(4);if(v>=0)s=' '+s;c.textContent=s;
      var rgb=rdbu(normalize(v,rng.lo,rng.hi));
      c.setAttribute('data-rgb','rgb('+rgb.join(',')+')');
      cells.appendChild(c);
    });
    div.appendChild(cells);
    var more=document.createElement('div');more.className='ev-more';more.textContent='···';
    div.appendChild(more);grid.appendChild(div);
  });
}
function valToHeat(){
  document.querySelectorAll('.ecell').forEach(function(c){
    var col=parseInt(c.getAttribute('data-col'));
    var row=parseInt(c.getAttribute('data-row'));
    c.style.transitionDelay=(col*35+row*8)+'ms';
    c.style.backgroundColor=c.getAttribute('data-rgb');
    c.classList.add('heat');
  });
}
function resetHeat(){
  document.querySelectorAll('.ecell').forEach(function(c){
    c.style.transitionDelay='0ms';c.style.backgroundColor='';c.classList.remove('heat');
  });
}

/* ── Embed Zoom Panel ── */
var ezPanel=document.getElementById('embed-zoom');
var morphDone=false,sgtDone=false,streamDone=false;

/* ── HM image stage machine ── */
var hmImages=[
  document.getElementById('hm-d8'),
  document.getElementById('hm-d128'),
  document.getElementById('hm-d2048')
];
var hmClean=document.getElementById('hm-d8-clean');
var hmCleanFadeTimer=null;

function setHmStage(activeIdx){
  clearTimeout(hmCleanFadeTimer);
  hmClean.classList.remove('is-active','is-after');hmClean.classList.add('is-before');
  hmImages.forEach(function(img,i){
    img.classList.toggle('is-before',i<activeIdx);
    img.classList.toggle('is-active',i===activeIdx);
    img.classList.toggle('is-after',i>activeIdx);
  });
}
function setHmStageClean(){
  clearTimeout(hmCleanFadeTimer);
  hmClean.classList.remove('is-before','is-after');hmClean.classList.add('is-active');
  hmImages.forEach(function(img){img.classList.remove('is-active','is-before');img.classList.add('is-after');});
}
function setHmStage7(){
  clearTimeout(hmCleanFadeTimer);
  hmClean.classList.remove('is-before','is-after');hmClean.classList.add('is-active');
  hmImages[0].classList.remove('is-after','is-before');hmImages[0].classList.add('is-active');
  hmImages[1].classList.remove('is-active','is-before');hmImages[1].classList.add('is-after');
  hmImages[2].classList.remove('is-active','is-before');hmImages[2].classList.add('is-after');
  hmCleanFadeTimer=setTimeout(function(){
    hmClean.classList.remove('is-active');hmClean.classList.add('is-before');
  },560);
}
function showImgPanel(){document.getElementById('hm-img-panel').classList.add('active');}
function hideImgPanel(){
  clearTimeout(hmCleanFadeTimer);
  document.getElementById('hm-img-panel').classList.remove('active');
  hmClean.className='hm-img';
  hmImages.forEach(function(img){img.className='hm-img';});
}

function panelRect(ct){
  var da=document.getElementById('diagram-area');
  var daW=da?da.clientWidth:window.innerWidth;
  var daH=Math.max(380,window.innerHeight-200);
  var isMob=window.innerWidth<=560;
  var maxW=Math.min(760,daW*0.92);
  var h,iw;
  if(ct==='grid')          h=Math.min(380,Math.max(300,Math.round(daH*0.62)));
  else if(ct==='img-clean'){iw=maxW-48;h=isMob?Math.min(Math.round(daH*0.30),210):Math.round(iw*(951/4175))+78;}
  else if(ct==='img-full') {iw=maxW-48;h=isMob?Math.min(Math.round(daH*0.44),330):Math.min(Math.round(iw*(1830/4175))+78,Math.round(daH*0.92));}
  else                      h=Math.min(380,Math.round(daH*0.62));
  var top=isMob?8:Math.round(daH*0.04);
  h=Math.min(h,daH-top-16);
  return{left:Math.round((daW-maxW)/2),top:top,width:Math.round(maxW),height:Math.round(h)};
}
function daOffset(){
  var da=document.getElementById('diagram-area');
  return da?da.getBoundingClientRect():{top:0,left:0};
}
function openZoomFrom(el,ct){
  var o=daOffset();var r=el.getBoundingClientRect();
  ezPanel.style.top=(r.top-o.top)+'px';ezPanel.style.left=(r.left-o.left)+'px';
  ezPanel.style.width=r.width+'px';ezPanel.style.height=r.height+'px';
  ezPanel.style.borderRadius='var(--r)';ezPanel.classList.add('show');
  requestAnimationFrame(function(){requestAnimationFrame(function(){
    var pr=panelRect(ct);
    ezPanel.style.top=pr.top+'px';ezPanel.style.left=pr.left+'px';
    ezPanel.style.width=pr.width+'px';ezPanel.style.height=pr.height+'px';
    ezPanel.style.borderRadius='20px';
  });});
}
function zoomOutTo(el,onDone){
  var o=daOffset();var r=el.getBoundingClientRect();
  ezPanel.style.top=(r.top-o.top)+'px';ezPanel.style.left=(r.left-o.left)+'px';
  ezPanel.style.width=r.width+'px';ezPanel.style.height=r.height+'px';
  ezPanel.style.borderRadius='14px';
  setTimeout(function(){ezPanel.classList.remove('show');if(onDone)onDone();},420);
}

/* ── Typewriter ── */
function type(el,txt,spd,cb){
  var i=0;el.innerHTML='<span class="scrolly-cursor"></span>';
  var iv=setInterval(function(){
    if(i>=txt.length){clearInterval(iv);el.innerHTML=txt;if(cb)cb();return;}
    el.innerHTML=txt.slice(0,++i)+'<span class="scrolly-cursor"></span>';
  },spd);
}

/* ── Narration ── */
var NARR=[
  {s:'Overview',t:'Input prompt privacy.',b:'Comparative example of how the prompt stays private from the LLM provider using SGT. On the left is the conventional flow of using an LLM; on the right is the private flow.'},
  {s:'Step 1 — Tokenization',t:'Text breaks into tokens',b:'First thing LLMs do is to split the input into tokens. Each token then maps to a high-dimensional embedding vector. In this post, we use the LLaMA 3.2 1B model which uses 2,048 dimensional embeddings.'},
  {s:'Step 2 — Embeddings',t:'Tokens converted into embedding vectors',b:'These are actual LLaMA 3.2 1B embedding values. 2,048 dimensions per token encode each token into the embedding space while preserving semantic meaning; the first 8 of which are shown here.'},
  {s:'Step 3 — Semantic Heatmap',t:'Visualize the embedding vectors',b:'Similar tokens will have similar-looking patterns. This embedding vector is precisely what the SGT obfuscates and hides.'},
  {s:'Step 4 — Encoding',t:'Two identical encodings',b:'So far, both paths compute the same clean embeddings (however, note that in connection with the trust model, they differ in where they are computed). The private path will now obfuscate the clean embeddings.'},
  {s:'Step 5 — Stained Glass Transform',t:'75.5M-parameter transformer model',b:'The crux of the hiding is a transformer model that takes a 2,048-dimensional clean embedding vector and transforms it into a 2,048-dimensional obfuscated vector that (a) makes reverse engineering hard and (b) retains the prompt semantics. A great way to think of SGT is as an ML-based “homomorphic encryption” method.'},
  {s:'Step 6 — Original Embeddings',t:'Clean embeddings',b:'The heatmap of the clean LLaMA embeddings (2,048 dimensions per token encoding full meaning). An LLM provider can exactly reconstruct the prompt from these.'},
  {s:'Step 7 — Obfuscated Embeddings',t:'Embeddings obfuscated by SGT',b:'After SGT, the embeddings look like noise. The LLM can still provide a meaningful response but token identity cannot be recovered. This provides meaningful privacy to the input prompt.'},
  {s:'Step 8 — 128 Dimensions',t:'Zooming out to 128 dims',b:'The same clean and obfuscated embeddings now shown across 128 dimensions.'},
  {s:'Step 9 — 2,048 Dimensions',t:'The complete embedding space',b:'The actual 2,048 dimensional vectors sent to the LLM. This provides a quick visual to see how truly different the obfuscated version is, making prompt reconstruction infeasible.'},
  {s:'Step 10 — Sent to Provider',t:'Provider only sees noise',b:'The obfuscated embeddings travel to the cloud LLM. The model processes them without knowing the original tokens.'},
  {s:'Step 11 — Response',t:'LLMs generate output',b:'The most interesting part of this is that the LLM returns a meaningful response with the obfuscated embeddings. Note that this approach is fundamentally different and stronger than something like adding differentially private noise to the embedding vector.'},
];
var stepBar=document.getElementById('step-bar');
var narrTimer=null;
function setNarr(i){
  var d=NARR[i];if(!d)return;
  if(narrTimer){clearTimeout(narrTimer);narrTimer=null;}
  var wasVisible=stepBar.classList.contains('visible');
  stepBar.classList.remove('visible');
  var delay=wasVisible?180:0;
  narrTimer=setTimeout(function(){
    document.getElementById('n-step').textContent=d.s;
    document.getElementById('n-title').textContent=d.t;
    document.getElementById('n-body').textContent=d.b;
    stepBar.classList.add('visible');narrTimer=null;
  },delay);
}

function show(id){var e=document.getElementById(id);if(e)e.classList.remove('off');}
function hide(id){var e=document.getElementById(id);if(e)e.classList.add('off');}

var cur=-99;

function applyStage(s){
  if(s===cur)return;cur=s;
  ['arr-embed','arr-sgt','arr-llm','arr-resp'].forEach(hide);
  ['g-embl','g-embr','g-sgt','llm-row','resp-row'].forEach(hide);
  ['g-llml','g-llmr','g-respl','g-respr'].forEach(hide);
  ['lbl-l','lbl-r'].forEach(hide);
  hide('embed-row');
  if(s<=0){hideChips();}
  document.getElementById('sch').classList.remove('dimmed');
  document.getElementById('ev-grid').classList.remove('off');
  resetHeat();
  document.getElementById('dots-l').classList.remove('show');
  document.getElementById('dots-r').classList.remove('show');
  ezPanel.classList.remove('show');
  hideImgPanel();
  document.getElementById('ev-compare').classList.remove('active');
  if(s<3){morphDone=false;}
  if(s<6){sgtDone=false;}
  if(s<0){if(narrTimer){clearTimeout(narrTimer);narrTimer=null;}stepBar.classList.remove('visible');return;}
  setNarr(s);

  function showEmbedders(){
    document.getElementById('embed-row').classList.remove('off');
    show('g-embl');show('g-embr');
  }

  if(s===0){}

  if(s===1){show('arr-embed');['lbl-l','lbl-r'].forEach(show);popChips();showEmbedders();}

  if(s===2){
    show('arr-embed');['lbl-l','lbl-r'].forEach(show);popChips();showEmbedders();
    document.getElementById('sch').classList.add('dimmed');
    document.getElementById('ev-grid').classList.remove('off');
    document.getElementById('ev-compare').classList.remove('active');
    buildGrid();
    document.getElementById('ez-title').textContent='Token Embeddings';
    document.getElementById('ez-sub').textContent='d = 2,048 dims · 7 tokens';
    document.getElementById('ez-badge').textContent='LLaMA 3.2 1B';
    openZoomFrom(document.getElementById('embed-row'),'grid');
  }

  if(s===3){
    show('arr-embed');['lbl-l','lbl-r'].forEach(show);popChips();showEmbedders();
    document.getElementById('sch').classList.add('dimmed');
    document.getElementById('ev-grid').classList.remove('off');
    document.getElementById('ev-compare').classList.remove('active');
    buildGrid();
    document.getElementById('ez-title').textContent='Token Embeddings';
    document.getElementById('ez-sub').textContent='d = 2,048 dims · 7 tokens';
    document.getElementById('ez-badge').textContent='LLaMA 3.2 1B';
    if(!morphDone){
      openZoomFrom(document.getElementById('embed-row'),'grid');
      setTimeout(valToHeat,150);morphDone=true;
    } else {
      var pr3=panelRect('grid');
      ezPanel.style.top=pr3.top+'px';ezPanel.style.left=pr3.left+'px';
      ezPanel.style.width=pr3.width+'px';ezPanel.style.height=pr3.height+'px';
      ezPanel.style.borderRadius='20px';ezPanel.classList.add('show');valToHeat();
    }
  }

  if(s===4){
    show('arr-embed');['lbl-l','lbl-r'].forEach(show);popChips();showEmbedders();
    document.getElementById('sch').classList.remove('dimmed');
    zoomOutTo(document.getElementById('embed-row'),null);morphDone=false;
  }

  if(s===5){show('arr-embed');['lbl-l','lbl-r'].forEach(show);popChips();showEmbedders();show('arr-sgt');show('g-sgt');}

  if(s===6){
    show('arr-embed');['lbl-l','lbl-r'].forEach(show);popChips();showEmbedders();
    show('arr-sgt');show('g-sgt');
    document.getElementById('sch').classList.add('dimmed');
    document.getElementById('ev-grid').classList.add('off');
    document.getElementById('ez-title').textContent='Token Embeddings (Original)';
    document.getElementById('ez-sub').textContent='clean · 8 dims shown · 8 dimensions';
    document.getElementById('ez-badge').textContent='LLaMA 3.2 1B';
    showImgPanel();setHmStageClean();
    if(!sgtDone){openZoomFrom(document.getElementById('g-sgt'),'img-clean');sgtDone=true;}
    else{var pr6=panelRect('img-clean');ezPanel.style.top=pr6.top+'px';ezPanel.style.left=pr6.left+'px';ezPanel.style.width=pr6.width+'px';ezPanel.style.height=pr6.height+'px';ezPanel.style.borderRadius='20px';ezPanel.classList.add('show');}
  }

  if(s===7){
    show('arr-embed');['lbl-l','lbl-r'].forEach(show);popChips();showEmbedders();
    show('arr-sgt');show('g-sgt');
    document.getElementById('sch').classList.add('dimmed');
    document.getElementById('ev-grid').classList.add('off');
    document.getElementById('ez-title').textContent='Token Embeddings';
    document.getElementById('ez-sub').textContent='clean + obfuscated · 8 dimensions';
    document.getElementById('ez-badge').textContent='LLaMA 3.2 1B';
    showImgPanel();setHmStage7();
    if(!sgtDone){openZoomFrom(document.getElementById('g-sgt'),'img-full');sgtDone=true;}
    else{var pr7=panelRect('img-full');ezPanel.style.top=pr7.top+'px';ezPanel.style.left=pr7.left+'px';ezPanel.style.width=pr7.width+'px';ezPanel.style.height=pr7.height+'px';ezPanel.style.borderRadius='20px';ezPanel.classList.add('show');}
  }

  if(s===8){
    show('arr-embed');['lbl-l','lbl-r'].forEach(show);popChips();showEmbedders();
    show('arr-sgt');show('g-sgt');
    document.getElementById('sch').classList.add('dimmed');
    document.getElementById('ev-grid').classList.add('off');
    document.getElementById('ez-title').textContent='Token Embeddings';
    document.getElementById('ez-sub').textContent='clean + obfuscated · 128 dimensions';
    document.getElementById('ez-badge').textContent='LLaMA 3.2 1B';
    showImgPanel();setHmStage(1);
    var pr8=panelRect('img-full');
    ezPanel.style.top=pr8.top+'px';ezPanel.style.left=pr8.left+'px';ezPanel.style.width=pr8.width+'px';ezPanel.style.height=pr8.height+'px';
    ezPanel.style.borderRadius='20px';ezPanel.classList.add('show');
  }

  if(s===9){
    show('arr-embed');['lbl-l','lbl-r'].forEach(show);popChips();showEmbedders();
    show('arr-sgt');show('g-sgt');
    document.getElementById('sch').classList.add('dimmed');
    document.getElementById('ev-grid').classList.add('off');
    document.getElementById('ez-title').textContent='Token Embeddings';
    document.getElementById('ez-sub').textContent='clean + obfuscated · 2048 dimensions';
    document.getElementById('ez-badge').textContent='LLaMA 3.2 1B';
    showImgPanel();setHmStage(2);
    var pr9=panelRect('img-full');
    ezPanel.style.top=pr9.top+'px';ezPanel.style.left=pr9.left+'px';ezPanel.style.width=pr9.width+'px';ezPanel.style.height=pr9.height+'px';
    ezPanel.style.borderRadius='20px';ezPanel.classList.add('show');
  }

  if(s===10){
    show('arr-embed');['lbl-l','lbl-r'].forEach(show);popChips();showEmbedders();
    show('arr-sgt');show('g-sgt');
    document.getElementById('sch').classList.remove('dimmed');
    zoomOutTo(document.getElementById('g-sgt'),null);sgtDone=false;
  }

  if(s===11){
    show('arr-embed');['lbl-l','lbl-r'].forEach(show);popChips();showEmbedders();
    show('arr-sgt');show('g-sgt');show('arr-llm');
    document.getElementById('llm-row').classList.remove('off');
    show('g-llml');show('g-llmr');show('arr-resp');
    document.getElementById('resp-row').classList.remove('off');
    show('g-respl');show('g-respr');
    if(!streamDone){
      streamDone=true;
      setTimeout(function(){
        document.getElementById('dots-l').classList.add('show');
        document.getElementById('dots-r').classList.add('show');
      },150);
      setTimeout(function(){
        document.getElementById('dots-l').classList.remove('show');
        document.getElementById('dots-r').classList.remove('show');
        type(document.getElementById('rt-l'),RESP_OBF,7,null);
        setTimeout(function(){type(document.getElementById('rt-r'),RESP_CLEAN,6,null);},150);
      },800);
    }
  }
}

/* ── IntersectionObserver ── */
var trigs=document.querySelectorAll('.trig');
var obs=new IntersectionObserver(function(entries){
  entries.forEach(function(e){
    if(e.isIntersecting){var idx=Array.from(trigs).indexOf(e.target);applyStage(idx);}
  });
},{threshold:0.5});
trigs.forEach(function(t){obs.observe(t);});

/* Offset sticky panel below the site navbar */
(function(){var nav=document.querySelector('.navbar');var h=nav?Math.round(nav.getBoundingClientRect().height):57;document.documentElement.style.setProperty('--nav-h',h+'px');})();

/* Height covers all trigger stages */
document.getElementById('scrolly').style.height=(trigs.length*100)+'svh';

/* Crisp scroll: one wheel tick = one stage, only when the scrolly section is in view */
var snapLocked=false;
window.addEventListener('wheel',function(e){
  var scrollyEl=document.getElementById('scrolly');
  var r=scrollyEl.getBoundingClientRect();
  /* bail out if the widget isn't occupying the viewport */
  if(r.top > 100 || r.bottom < window.innerHeight - 100) return;
  /* let scroll pass through at the widget boundaries so the user can exit */
  if(e.deltaY > 0 && cur >= trigs.length - 1) return;
  if(e.deltaY < 0 && cur <= 0) return;
  e.preventDefault();
  if(snapLocked)return;
  snapLocked=true;
  var next=cur+(e.deltaY>0?1:-1);
  next=Math.max(0,Math.min(trigs.length-1,next));
  window.scrollTo({top:scrollyEl.offsetTop+window.innerHeight*next,behavior:'smooth'});
  setTimeout(function(){snapLocked=false;},420);
},{passive:false});

/* Arrow-key navigation (offset by scrolly position within the page) */
window.addEventListener('keydown',function(e){
  if(e.key==='ArrowDown'||e.key==='ArrowRight'){
    e.preventDefault();
    var n=Math.min(cur+1,trigs.length-1);
    var scrollyEl=document.getElementById('scrolly');
    window.scrollTo({top:scrollyEl.offsetTop+window.innerHeight*n,behavior:'smooth'});
  }
  if(e.key==='ArrowUp'||e.key==='ArrowLeft'){
    e.preventDefault();
    var n=Math.max(cur-1,0);
    var scrollyEl=document.getElementById('scrolly');
    window.scrollTo({top:scrollyEl.offsetTop+window.innerHeight*n,behavior:'smooth'});
  }
});

})();
</script> <h2 id="training-the-sgt">Training the SGT</h2> <p>The SGT paper is well-written and in this post I have simply followed their approach. While I describe my implementation choices such as architecture (which may not be fully detailed in the paper for IP reasons), I encourage the reader to refer to the paper for further details. The high-level idea is that you run a small local network (called the SGT) that takes the embedding sequence and replaces it with a perturbed version. Thus, the server never sees tokens or raw embeddings; it only ever processes the scrambled version. The crux of the work is showing how to efficiently train the SGT to preserve embedding privacy while retaining utility — that is, LLM output quality should not degrade.</p> <h3 id="embedding-transformation">Embedding transformation</h3> <p>The transform is stochastic:</p> \[\tilde{x} = x + \mu_\theta(x) + \exp(\log\sigma_\theta(x)) \cdot \varepsilon, \quad \varepsilon \sim \mathcal{N}(0, I)\] <p>The SGT predicts a deterministic shift $\mu_\theta$ and a per-dimension noise scale $\sigma_\theta$. Adding Gaussian noise with a learned variance means no two passes produce the same obfuscated embeddings — which is important for resisting repeated-query attacks.</p> <h3 id="architecture">Architecture</h3> <p>SGT is trained per model and in this blog, I use the model from the paper — Llama 3.2 1B. I chose the following small post-norm transformer encoder as the SGT module placed in front of the frozen LLM:</p> <div style="font-size:.82rem"> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">SGT</span><span class="p">(</span><span class="n">nn</span><span class="p">.</span><span class="n">Module</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">embed_dim</span><span class="o">=</span><span class="mi">2048</span><span class="p">,</span> <span class="n">num_layers</span><span class="o">=</span><span class="mi">2</span><span class="p">,</span> <span class="n">nhead</span><span class="o">=</span><span class="mi">8</span><span class="p">):</span>
        <span class="nf">super</span><span class="p">().</span><span class="nf">__init__</span><span class="p">()</span>
        <span class="n">enc_layer</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="nc">TransformerEncoderLayer</span><span class="p">(</span>
            <span class="n">d_model</span><span class="o">=</span><span class="n">embed_dim</span><span class="p">,</span> <span class="n">nhead</span><span class="o">=</span><span class="n">nhead</span><span class="p">,</span>
            <span class="n">dim_feedforward</span><span class="o">=</span><span class="n">embed_dim</span> <span class="o">*</span> <span class="mi">2</span><span class="p">,</span> <span class="n">dropout</span><span class="o">=</span><span class="mf">0.0</span><span class="p">,</span>
            <span class="n">batch_first</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">norm_first</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span>  <span class="c1"># post-norm keeps output bounded
</span>        <span class="p">)</span>
        <span class="n">self</span><span class="p">.</span><span class="n">encoder</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="nc">TransformerEncoder</span><span class="p">(</span>
            <span class="n">enc_layer</span><span class="p">,</span> <span class="n">num_layers</span><span class="o">=</span><span class="n">num_layers</span><span class="p">,</span> <span class="n">norm</span><span class="o">=</span><span class="n">nn</span><span class="p">.</span><span class="nc">LayerNorm</span><span class="p">(</span><span class="n">embed_dim</span><span class="p">)</span>
        <span class="p">)</span>
        <span class="n">self</span><span class="p">.</span><span class="n">mu_head</span>        <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="nc">Linear</span><span class="p">(</span><span class="n">embed_dim</span><span class="p">,</span> <span class="n">embed_dim</span><span class="p">)</span>
        <span class="n">self</span><span class="p">.</span><span class="n">log_sigma_head</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="nc">Linear</span><span class="p">(</span><span class="n">embed_dim</span><span class="p">,</span> <span class="n">embed_dim</span><span class="p">)</span>
        <span class="c1"># initialize as identity: mu=0, small sigma
</span>        <span class="n">nn</span><span class="p">.</span><span class="n">init</span><span class="p">.</span><span class="nf">zeros_</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">mu_head</span><span class="p">.</span><span class="n">weight</span><span class="p">);</span>   <span class="n">nn</span><span class="p">.</span><span class="n">init</span><span class="p">.</span><span class="nf">zeros_</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">mu_head</span><span class="p">.</span><span class="n">bias</span><span class="p">)</span>
        <span class="n">nn</span><span class="p">.</span><span class="n">init</span><span class="p">.</span><span class="nf">zeros_</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">log_sigma_head</span><span class="p">.</span><span class="n">weight</span><span class="p">)</span>
        <span class="n">nn</span><span class="p">.</span><span class="n">init</span><span class="p">.</span><span class="nf">constant_</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">log_sigma_head</span><span class="p">.</span><span class="n">bias</span><span class="p">,</span> <span class="o">-</span><span class="mf">2.0</span><span class="p">)</span>

    <span class="k">def</span> <span class="nf">forward</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">x</span><span class="p">,</span> <span class="n">padding_mask</span><span class="o">=</span><span class="bp">None</span><span class="p">):</span>
        <span class="n">h</span>         <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">encoder</span><span class="p">(</span><span class="n">x</span><span class="p">.</span><span class="nf">float</span><span class="p">(),</span> <span class="n">src_key_padding_mask</span><span class="o">=</span><span class="n">padding_mask</span><span class="p">)</span>
        <span class="n">mu</span>        <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">mu_head</span><span class="p">(</span><span class="n">h</span><span class="p">)</span>
        <span class="n">log_sigma</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">log_sigma_head</span><span class="p">(</span><span class="n">h</span><span class="p">).</span><span class="nf">clamp</span><span class="p">(</span><span class="o">-</span><span class="mf">6.0</span><span class="p">,</span> <span class="mf">3.0</span><span class="p">)</span>
        <span class="n">eps</span>       <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="nf">randn_like</span><span class="p">(</span><span class="n">h</span><span class="p">)</span>
        <span class="n">x_tilde</span>   <span class="o">=</span> <span class="n">x</span><span class="p">.</span><span class="nf">float</span><span class="p">()</span> <span class="o">+</span> <span class="n">mu</span> <span class="o">+</span> <span class="n">log_sigma</span><span class="p">.</span><span class="nf">exp</span><span class="p">()</span> <span class="o">*</span> <span class="n">eps</span>
        <span class="k">return</span> <span class="n">x_tilde</span><span class="p">.</span><span class="nf">to</span><span class="p">(</span><span class="n">x</span><span class="p">.</span><span class="n">dtype</span><span class="p">),</span> <span class="n">mu</span><span class="p">.</span><span class="nf">to</span><span class="p">(</span><span class="n">x</span><span class="p">.</span><span class="n">dtype</span><span class="p">),</span> <span class="n">log_sigma</span><span class="p">.</span><span class="nf">to</span><span class="p">(</span><span class="n">x</span><span class="p">.</span><span class="n">dtype</span><span class="p">)</span>
</code></pre></div> </div> </div> <p>For Llama 3.2 1B (embed_dim=2048), this SGT has <strong>75.5 M parameters</strong> — about 6% the size of the LLM it protects. It runs locally in milliseconds per token; the LLM never needs to move.</p> <h3 id="loss-functions">Loss functions</h3> <p>Training balances four objectives simultaneously: one utility loss and three obfuscation loss components (refer to the paper for more details — the authors explain the challenges and their choices well). I trained over 40K OpenOrca examples for 5000 steps on a Google Colab T4 GPU (best checkpoint was at step 4500).</p> <p><strong>Utility</strong> — the obfuscated sequence should produce the same distribution of next tokens as the clean sequence. I use KL divergence instead of hard-label cross-entropy, because a 128 K-vocab LM’s probability mass is spread across many tokens. Hard-label gradients are too sparse to compete with the obfuscation losses through 16 frozen transformer layers.</p> <div style="font-size:.82rem"> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">loss_utility</span><span class="p">(</span><span class="n">logits_obf</span><span class="p">,</span> <span class="n">logits_clean</span><span class="p">):</span>
    <span class="n">log_p_obf</span> <span class="o">=</span> <span class="n">F</span><span class="p">.</span><span class="nf">log_softmax</span><span class="p">(</span><span class="n">logits_obf</span><span class="p">.</span><span class="nf">reshape</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="n">V</span><span class="p">).</span><span class="nf">float</span><span class="p">(),</span> <span class="n">dim</span><span class="o">=-</span><span class="mi">1</span><span class="p">)</span>
    <span class="n">p_clean</span>   <span class="o">=</span> <span class="n">F</span><span class="p">.</span><span class="nf">softmax</span><span class="p">(</span><span class="n">logits_clean</span><span class="p">.</span><span class="nf">detach</span><span class="p">().</span><span class="nf">reshape</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="n">V</span><span class="p">).</span><span class="nf">float</span><span class="p">(),</span> <span class="n">dim</span><span class="o">=-</span><span class="mi">1</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">F</span><span class="p">.</span><span class="nf">kl_div</span><span class="p">(</span><span class="n">log_p_obf</span><span class="p">,</span> <span class="n">p_clean</span><span class="p">,</span> <span class="n">reduction</span><span class="o">=</span><span class="sh">"</span><span class="s">batchmean</span><span class="sh">"</span><span class="p">)</span>
</code></pre></div> </div> </div> <p><strong>AbsCosine</strong> — push the obfuscated embedding orthogonal to the original. If $\lvert\cos(\tilde{x}, x)\rvert$ is near zero, the nearest-neighbour attack can’t find the original token:</p> <div style="font-size:.82rem"> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">loss_abscosine</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">x_tilde</span><span class="p">):</span>
    <span class="n">cos</span> <span class="o">=</span> <span class="n">F</span><span class="p">.</span><span class="nf">cosine_similarity</span><span class="p">(</span><span class="n">x</span><span class="p">.</span><span class="nf">reshape</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="n">D</span><span class="p">),</span> <span class="n">x_tilde</span><span class="p">.</span><span class="nf">reshape</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="n">D</span><span class="p">),</span> <span class="n">dim</span><span class="o">=-</span><span class="mi">1</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">cos</span><span class="p">.</span><span class="nf">abs</span><span class="p">().</span><span class="nf">mean</span><span class="p">()</span>
</code></pre></div> </div> </div> <p><strong>Norm penalty</strong> — keep obfuscated norms close to clean norms per token, so the LLM’s internal normalizations behave as expected:</p> <div style="font-size:.82rem"> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">loss_norm_penalty</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">mu</span><span class="p">):</span>
    <span class="n">clean_norms</span>  <span class="o">=</span> <span class="n">x</span><span class="p">.</span><span class="nf">float</span><span class="p">().</span><span class="nf">norm</span><span class="p">(</span><span class="n">dim</span><span class="o">=-</span><span class="mi">1</span><span class="p">).</span><span class="nf">detach</span><span class="p">()</span>
    <span class="n">shifted_norms</span> <span class="o">=</span> <span class="p">(</span><span class="n">x</span><span class="p">.</span><span class="nf">float</span><span class="p">()</span> <span class="o">+</span> <span class="n">mu</span><span class="p">.</span><span class="nf">float</span><span class="p">()).</span><span class="nf">norm</span><span class="p">(</span><span class="n">dim</span><span class="o">=-</span><span class="mi">1</span><span class="p">)</span>
    <span class="nf">return </span><span class="p">(</span><span class="n">shifted_norms</span> <span class="o">-</span> <span class="n">clean_norms</span><span class="p">).</span><span class="nf">abs</span><span class="p">().</span><span class="nf">mean</span><span class="p">()</span>
</code></pre></div> </div> </div> <p><strong>Mutual information</strong> — a minibatch Monte Carlo estimate of \(I(\tilde{x}; x)\) in nats per dimension, computed in float64 to avoid cancellation. This directly minimizes how much information \(\tilde{x}\) retains about \(x\) across the learned distribution, not just pointwise:</p> <div style="font-size:.82rem"> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">loss_mi</span><span class="p">(</span><span class="n">x_tilde_A</span><span class="p">,</span> <span class="n">mu_A</span><span class="p">,</span> <span class="n">log_sigma_A</span><span class="p">,</span> <span class="n">x_clean_B</span><span class="p">,</span> <span class="n">mu_B</span><span class="p">,</span> <span class="n">log_sigma_B</span><span class="p">):</span>
    <span class="c1"># H(x̃ | x) from diagonal Gaussian component entropy
</span>    <span class="n">H_comp</span> <span class="o">=</span> <span class="p">(</span><span class="mf">0.5</span> <span class="o">*</span> <span class="n">LOG_2PIE</span> <span class="o">+</span> <span class="n">log_sigma_A_64</span><span class="p">).</span><span class="nf">sum</span><span class="p">(</span><span class="n">dim</span><span class="o">=</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="o">-</span><span class="mi">2</span><span class="p">)).</span><span class="nf">mean</span><span class="p">()</span>
    <span class="c1"># H(x̃) ≈ -E[log p_mix(x̃)] via minibatch GMM
</span>    <span class="n">log_prob</span> <span class="o">=</span> <span class="o">-</span><span class="mf">0.5</span> <span class="o">*</span> <span class="p">(</span><span class="n">diff</span><span class="p">.</span><span class="nf">pow</span><span class="p">(</span><span class="mi">2</span><span class="p">)</span> <span class="o">/</span> <span class="n">var_B</span> <span class="o">+</span> <span class="n">log_const</span><span class="p">).</span><span class="nf">sum</span><span class="p">(</span><span class="n">dim</span><span class="o">=</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="o">-</span><span class="mi">2</span><span class="p">))</span>
    <span class="n">H_mix</span>    <span class="o">=</span> <span class="o">-</span><span class="n">torch</span><span class="p">.</span><span class="nf">logsumexp</span><span class="p">(</span><span class="n">log_prob</span><span class="p">,</span> <span class="n">dim</span><span class="o">=</span><span class="mi">1</span><span class="p">).</span><span class="nf">mean</span><span class="p">()</span> <span class="o">+</span> <span class="n">math</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="n">B_B</span><span class="p">)</span>
    <span class="nf">return </span><span class="p">((</span><span class="n">H_mix</span> <span class="o">-</span> <span class="n">H_comp</span><span class="p">)</span> <span class="o">/</span> <span class="p">(</span><span class="n">T</span> <span class="o">*</span> <span class="n">d</span><span class="p">)).</span><span class="nf">float</span><span class="p">()</span>
</code></pre></div> </div> </div> <p>The final combined loss uses weights \((\alpha_u, \alpha_\text{acs}, \alpha_\text{norm}, \alpha_\text{mi}) = (2.0, 0.3, 0.05, 0.15)\). Getting these weights right took three iterations — the main failure mode is \(\alpha_u\) so large that the utility loss keeps \(\sigma\) tiny, leaving mutual information high throughout training. The loss curves are below:</p> <figure class="post-fig"> <picture> <source type="image/webp" srcset="/assets/img/sgt_loss_curves-760.webp 760w, /assets/img/sgt_loss_curves.webp 2221w" sizes="(max-width: 576px) 100vw, 760px"/> <img src="/assets/img/sgt_loss_curves.png" alt="SGT training loss curves over 5000 steps" width="2221" height="642" loading="lazy" decoding="async" style="width:100%;height:auto"/> </picture> <figcaption>Training loss curves over 5000 steps. The utility (KL) loss stabilizes early while the three obfuscation components continue to improve; the best checkpoint at step 4500 balances all four objectives.</figcaption> </figure> <p>Note that the training is probably a reasonable local optimum given that the privacy metrics and utility are worse than those reported in the paper.</p> <h2 id="does-the-llm-output-give-away-the-input">Does the LLM output give away the input?</h2> <p>The paper covers simple attack baselines and the same authors also construct a better reconstruction attack called <strong>BeamClean [2]</strong>. Given that their attack only considers the embedding vector, I was curious to see if a stronger attacker — one that can also see the <em>text output</em> produced by the model — could improve on BeamClean. <strong>BeamClean</strong> [2] finds the top vocabulary candidates at each token position by cosine similarity to the obfuscated embedding, scores them with a language-model prior, and runs beam search.</p> <p>I implemented two extensions that use the observed LLM output as an additional signal with regularization to prevent the language model from exploiting quirks in the garbled output (e.g., preferring Does over does for superficial reasons). The results, however, have been mixed and not significant enough to generalize broadly. While the current evidence suggests that BeamClean+output is no stronger than BeamClean alone, I leave it as an open question to rigorously verify.</p> <h2 id="takeaways">Takeaways</h2> <p>SGT is a genuinely clever idea. The key insight is that if the embedding layer can be made public, you can separate it from the inference pipeline to achieve strong privacy. This can be a great middle ground where the model owner retains ownership of the model while the user gets prompt privacy.</p> <ul> <li>It is genuinely surprising to me that a model can be trained to achieve two contrary objectives well: (1) obfuscation and (2) utility preservation. In this regard, SGT feels just as innovative as fully homomorphic encryption.</li> <li>I was able to train the model from scratch with limited resources. This is largely a credit to the paper being well-written and speaks to the academic community’s culture of knowledge sharing.</li> <li>Fully reproducing the paper’s reported NN-FR of 0.93 likely requires significantly more than 5,000 training steps. My checkpoint is a useful proof of concept that the approach works directionally, though not yet at the privacy levels claimed for production use.</li> <li>The SGT approach can also be packaged to provide a solution to the two sided privacy problem of running private evaluation benchmarks. For instance, a model owner with a model $M$ and a benchmark owner with a dataset $D$ would both like to evaluate $M(D)$ under the constraint that $M$ needs to be private from the benchmark owner (for intellectual property reasons) and $D$ needs to be private from model owner (to prevent contamination, overfitting, gamifying external auditing).</li> </ul> <h2 id="references">References</h2> <ol> <li>J. Roberts, K. Mylonakis, S. Roy, and K. Kale. “Learning Obfuscations Of LLM Embedding Sequences: Stained Glass Transform.” arXiv:2506.09452, 2025. To appear at IEEE S&amp;P 2026. <a href="https://arxiv.org/abs/2506.09452">arxiv.org/abs/2506.09452</a></li> <li>K. Kale, K. Mylonakis, J. Roberts, and S. Roy. “BeamClean: Language Aware Embedding Reconstruction.” arXiv:2505.13758, 2025. <a href="https://arxiv.org/abs/2505.13758">arxiv.org/abs/2505.13758</a></li> </ol>]]></content><author><name></name></author><category term="research"/><category term="privacy"/><category term="llm"/><category term="embeddings"/><summary type="html"><![CDATA[The Stained Glass Transform is a transformer-based network trained to scramble token embeddings while preserving their utility under LLM inference. The output is somewhat magical: the model still produces coherent output but the original prompt is unrecoverable.]]></summary></entry><entry><title type="html">Can an LLM solve this combinatorics problem?</title><link href="https://snwagh.com/blog/2026/open-problem/" rel="alternate" type="text/html" title="Can an LLM solve this combinatorics problem?"/><published>2026-07-26T00:00:00+00:00</published><updated>2026-07-26T00:00:00+00:00</updated><id>https://snwagh.com/blog/2026/open-problem</id><content type="html" xml:base="https://snwagh.com/blog/2026/open-problem/"><![CDATA[<script defer="" src="/assets/js/post-toc.js"></script> <script defer="" src="/assets/js/k3-optimal-policy.js"></script> <style>
  /* The al-folio theme renders <strong>/<b> at font-weight 400; restore real bold in the post body. */
  .post-content strong,
  .post-content b {
    font-weight: 700;
  }
</style> <p>The intersection of LLMs and mathematical research is a fascinating one. Mathematics is objective and verifiable, and today’s frontier models have been trained extensively on its concepts and methods. There is a particular sense of joy and wonder in watching LLMs solve previously unsolved (and genuinely difficult problems), while knowing that their capabilities emerge from mechanisms that are, at their core, surprisingly simple.</p> <p>To me, this is an extraordinary testament to what humans can achieve collectively through ingenuity, hard work, and engineering. Like many others working in this space, I became interested in testing the limits of LLMs on open mathematical problems through my own lens. This post tells the story of that process.</p> <h2 id="introduction">Introduction</h2> <p>You have 4 cards from a standard deck of cards: Ace, King of <span style="color:#dc2626">Hearts</span> and Spades (<span style="color:#dc2626">A♥</span>, <span style="color:#dc2626">K♥</span>, A♠, K♠). You can point to a card and ask one of two possible questions:</p> <ul> <li>Is this card Red?</li> <li>Is this card a King?</li> </ul> <p>These 4 cards are now shuffled. It is easy to find which card is which by asking 8 questions (each question on each card). However, your goal is to come up with a strategy for asking questions such that you can always find which card is which in 5 or fewer questions. Try your hand at the interactive media below. Drag a question from the right onto any card to learn one fact about it, then click “I know it!” once you know for sure the full arrangement. </p> <div class="takeaway" style="margin-bottom:1rem"> <span class="takeaway-label">Note — adversarial engine</span> This simulator answers each query <em>adversarially</em>: it picks whichever answer (Yes or No) leaves the most arrangements still possible, rather than revealing a fixed hidden permutation. That is exactly what makes the worst-case bound hard — you are playing against the toughest possible opponent. The same query on the same visible card state will therefore always produce the same answer, which can feel deterministic or "wrong." The <a href="#explore-the-game-yourself">Explore the game</a> section below uses an honest engine with a randomly fixed hidden permutation for a more natural experience. </div> <div class="wg" id="wg"> <div class="cg-bar" style="margin-bottom:.7rem;padding:.6rem 0 .7rem"> <div class="cg-stat"><span class="cg-stat-num" id="wg4-cons">24</span><span class="cg-stat-lab">arrangements left</span></div> <div class="cg-stat"><span class="cg-stat-num" id="wg4-probes">0</span><span class="cg-stat-lab">queries used</span></div> <div class="cg-stat"><span class="cg-stat-num" id="wg4-floor">5</span><span class="cg-stat-lab">information floor</span></div> </div> <div class="wg-body"> <div class="wg-main"> <div class="wg-subtitle"> <strong>Cards:</strong> <span style="color:#dc2626;font-weight:700">A&hearts; K&hearts;</span> A&spades; K&spades; &mdash; two questions (red?, king?).<br/> <strong>Goal:</strong> identify the full arrangement in 5 or fewer queries. </div> <div class="wg-cards" id="wg4-cards"></div> <div class="wg-msg" id="wg4-msg"></div> <div class="wg-ctrls"> <button class="wg-btn wg-primary" id="wg4-shuffle">Shuffle &amp; Play</button> <button class="wg-btn" id="wg4-guess" hidden="">I know it!</button> </div> </div> <div class="wg-side"> <div class="wg-side-sec"> <div class="wg-side-title">Questions &middot; drag to a card</div> <div class="wg-questions" id="wg4-questions"> <div class="wg-q" draggable="true" data-q="red">Red suit? (<span style="color:#dc2626">&hearts;&diams;</span>)</div> <div class="wg-q" draggable="true" data-q="king">Is this card a King?</div> </div> </div> <div class="wg-side-sec"> <div class="wg-side-title">Query log</div> <table class="wg-qtable"> <thead> <tr> <th>Pos</th> <th>Red?</th> <th>King?</th> </tr> </thead> <tbody id="wg4-qtbody"></tbody> </table> </div> </div> </div> <div class="wg-links"> Also try: <a href="#explore-the-game-yourself">8-card variation</a> &middot; <a href="#explore-the-game-yourself">16-card variation</a> </div> </div> <style>
  /* warm-up card game */
  .wg {
    margin: 1.3rem auto 1.8rem;
    max-width: 700px;
    border: 1px solid var(--global-divider-color);
    border-radius: 12px;
    padding: 1.1rem 1.3rem 1rem;
    background: var(--global-card-bg-color);
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  }

  .wg-tabs {
    display: flex;
    justify-content: center;
    gap: 0;
    margin-bottom: 0.85rem;
  }
  .wg-tab {
    font: inherit;
    font-size: 0.82rem;
    border: 1px solid var(--global-divider-color);
    background: transparent;
    color: var(--global-text-color-light);
    padding: 0.38rem 0.95rem;
    cursor: pointer;
    transition: background 0.15s, color 0.15s;
  }
  .wg-tab:first-child {
    border-radius: 8px 0 0 8px;
  }
  .wg-tab:last-child {
    border-radius: 0 8px 8px 0;
  }
  .wg-tab:not(:first-child) {
    border-left: none;
  }
  .wg-tab.wg-tab-on {
    background: var(--global-theme-color);
    color: #fff;
    border-color: var(--global-theme-color);
  }
  .wg-links {
    text-align: center;
    font-size: 0.76rem;
    color: var(--global-text-color-light);
    padding: 0.5rem 0 0.1rem;
    border-top: 1px solid var(--global-divider-color);
    margin-top: 0.6rem;
  }
  .wg-links a {
    color: var(--global-theme-color);
    text-decoration: none;
  }
  .wg-links a:hover { text-decoration: underline; }
  .wg-subtitle {
    text-align: center;
    font-size: 0.78rem;
    color: var(--global-text-color-light);
    line-height: 1.55;
  }
  .wg-body {
    display: flex;
    gap: 1.2rem;
    align-items: flex-start;
  }
  .wg-main {
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.75rem;
  }
  .wg-cards {
    display: flex;
    gap: 0.6rem;
    justify-content: center;
  }
  .wg-cards-grid {
    display: grid;
    grid-template-columns: repeat(4, 68px);
    gap: 6px;
    justify-content: center;
  }
  .wg-cards-grid4 {
    display: grid;
    grid-template-columns: repeat(4, 62px);
    gap: 5px;
    justify-content: center;
  }
  .wg-card-wrapper {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.25rem;
  }
  .wg-card {
    width: 68px;
    height: 96px;
    border: 1.5px solid #ccc;
    border-radius: 7px;
    background: #fff;
    position: relative;
    user-select: none;
    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
    font-family: Georgia, serif;
    overflow: hidden;
    transition: box-shadow 0.15s;
  }
  .wg-cards-grid4 .wg-card {
    width: 62px;
    height: 88px;
  }
  .wg-card.wg-drag-over {
    outline: 2px dashed var(--global-theme-color);
    outline-offset: 2px;
    box-shadow: 0 4px 12px color-mix(in srgb, var(--global-theme-color) 35%, transparent);
  }
  .wg-face {
    width: 100%;
    height: 100%;
    position: relative;
  }
  .wg-rank-tl {
    position: absolute;
    top: 4px;
    left: 7px;
    font-size: 13px;
    font-weight: 700;
    line-height: 1.15;
    text-align: center;
  }
  .wg-cards-grid4 .wg-rank-tl {
    font-size: 11px;
    left: 5px;
  }
  .wg-suit-mid {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    font-size: 36px;
    line-height: 1;
  }
  .wg-cards-grid4 .wg-suit-mid {
    font-size: 28px;
  }
  .wg-rank-br {
    position: absolute;
    bottom: 4px;
    right: 7px;
    font-size: 13px;
    font-weight: 700;
    line-height: 1.15;
    text-align: center;
    transform: rotate(180deg);
  }
  .wg-cards-grid4 .wg-rank-br {
    font-size: 11px;
    right: 5px;
  }
  .wg-back {
    width: 100%;
    height: 100%;
    background: repeating-linear-gradient(45deg, #3b82f6 0, #3b82f6 3px, #1d4ed8 3px, #1d4ed8 8px);
  }
  .wg-cardnum {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 0.9rem;
    font-weight: 900;
    color: rgba(255, 255, 255, 0.65);
    font-family: Georgia, serif;
    pointer-events: none;
  }
  .wg-cards-grid4 .wg-cardnum {
    font-size: 0.72rem;
  }
  .wg-card-info {
    min-height: 16px;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 2px;
  }
  .wg-cards-grid4 .wg-card-info {
    flex-direction: row;
    flex-wrap: wrap;
    justify-content: center;
    gap: 1px;
    max-width: 64px;
    min-height: 12px;
  }
  .wg-badge {
    font-size: 0.62rem;
    font-weight: 700;
    padding: 1px 5px;
    border-radius: 10px;
    white-space: nowrap;
  }
  .wg-cards-grid .wg-badge {
    font-size: 0.56rem;
    padding: 1px 3px;
  }
  .wg-cards-grid4 .wg-badge {
    font-size: 0.5rem;
    padding: 0 2px;
  }
  .wg-badge-yes {
    background: #dcfce7;
    color: #166534;
  }
  .wg-badge-no {
    background: #fee2e2;
    color: #991b1b;
  }
  @keyframes wg-pop {
    0% {
      transform: scale(0.88);
    }
    65% {
      transform: scale(1.04);
    }
    100% {
      transform: scale(1);
    }
  }
  .wg-card.wg-revealed {
    animation: wg-pop 0.22s ease-out;
  }
  .wg-side {
    width: 170px;
    flex-shrink: 0;
    display: flex;
    flex-direction: column;
    gap: 0.7rem;
  }
  .wg-qbox {
    border: 1.5px solid var(--global-divider-color);
    border-radius: 8px;
    padding: 6px 10px 8px;
    text-align: center;
    background: var(--global-bg-color);
  }
  .wg-qbox-label {
    font-size: 0.62rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--global-text-color-light);
    margin-bottom: 2px;
  }
  .wg-qbox-num {
    font-size: 1.6rem;
    font-weight: 800;
    color: var(--global-theme-color);
    line-height: 1;
  }
  .wg-side-sec {
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
  }
  .wg-side-title {
    font-size: 0.7rem;
    font-weight: 700;
    color: var(--global-text-color-light);
    text-transform: uppercase;
    letter-spacing: 0.04em;
  }
  .wg-questions {
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
  }
  .wg-q {
    font-size: 0.8rem;
    padding: 5px 9px;
    border: 1.5px solid var(--global-divider-color);
    border-radius: 7px;
    cursor: grab;
    user-select: none;
    background: var(--global-bg-color);
    transition: background 0.1s, border-color 0.1s;
  }
  .wg-q:hover {
    background: color-mix(in srgb, var(--global-theme-color) 8%, transparent);
    border-color: var(--global-theme-color);
  }
  .wg-q:active {
    cursor: grabbing;
  }
  .wg-qtable {
    width: 100%;
    font-size: 0.7rem;
    border-collapse: collapse;
  }
  .wg-qtable th {
    font-weight: 600;
    color: var(--global-text-color-light);
    padding: 2px 4px;
    text-align: center;
    border-bottom: 1px solid var(--global-divider-color);
  }
  .wg-qtable td {
    padding: 2px 4px;
    text-align: center;
    border-bottom: 1px solid color-mix(in srgb, var(--global-divider-color) 55%, transparent);
  }
  .wg-qtable .wg-qpos {
    text-align: left;
    color: var(--global-text-color-light);
  }
  .wg-qtable-sm th,
  .wg-qtable-sm td {
    padding: 1px 3px;
    font-size: 0.65rem;
  }
  .wg-qtable-scroll {
    max-height: 210px;
    overflow-y: auto;
  }
  .wg-yes {
    color: #166534;
    font-weight: 700;
  }
  .wg-no {
    color: #991b1b;
    font-weight: 700;
  }
  .wg-msg {
    font-size: 0.8rem;
    min-height: 1.15em;
    text-align: center;
  }
  .wg-msg.wg-warn {
    color: #b45309;
  }
  .wg-msg.wg-good {
    color: #059669;
  }
  .wg-ctrls {
    display: flex;
    gap: 0.5rem;
    justify-content: center;
  }
  .wg-btn {
    font-size: 0.8rem;
    padding: 5px 13px;
    border-radius: 7px;
    border: 1.5px solid var(--global-divider-color);
    background: var(--global-card-bg-color);
    color: var(--global-text-color);
    cursor: pointer;
    transition: background 0.1s;
  }
  .wg-btn:hover {
    background: color-mix(in srgb, var(--global-text-color) 7%, transparent);
  }
  .wg-btn.wg-primary {
    background: var(--global-theme-color);
    border-color: var(--global-theme-color);
    color: #fff;
  }
  .wg-btn.wg-primary:hover {
    filter: brightness(1.1);
  }
</style> <script>
  (function () {
    // ── Permutation generator: Heap's algorithm ─────────────────────────────
    function genPerms(n) {
      var result = [];
      var a = [];
      for (var pi = 0; pi < n; pi++) a.push(pi);
      var c = [];
      for (var ci = 0; ci < n; ci++) c.push(0);
      result.push(a.slice());
      var i = 0;
      while (i < n) {
        if (c[i] < i) {
          var swp = i % 2 === 0 ? 0 : c[i];
          var t = a[swp];
          a[swp] = a[i];
          a[i] = t;
          result.push(a.slice());
          c[i]++;
          i = 0;
        } else {
          c[i] = 0;
          i++;
        }
      }
      return result;
    }

    // ── Shared face renderer ────────────────────────────────────────────────
    function faceHTML(card) {
      var col = card.color;
      return (
        '<div class="wg-face">' +
        '<div class="wg-rank-tl" style="color:' +
        col +
        '">' +
        card.label +
        "<br>" +
        card.suit +
        "</div>" +
        '<div class="wg-suit-mid" style="color:' +
        col +
        '">' +
        card.suit +
        "</div>" +
        '<div class="wg-rank-br" style="color:' +
        col +
        '">' +
        card.label +
        "<br>" +
        card.suit +
        "</div>" +
        "</div>"
      );
    }

    // ── Game factory: adversarial (permutation tracking, for n≤8) ───────────
    function makeGame(cfg) {
      var CARDS = cfg.cards;
      var N = CARDS.length;
      var QUESTIONS = cfg.questions;
      var PERMS = genPerms(N);
      var pfx = cfg.prefix;

      var cardsEl = document.getElementById(pfx + "-cards");
      var msgEl = document.getElementById(pfx + "-msg");
      var shuffleBtn = document.getElementById(pfx + "-shuffle");
      var guessBtn = document.getElementById(pfx + "-guess");
      var qcountEl = document.getElementById(pfx + "-qcount");
      var questionsEl = document.getElementById(pfx + "-questions");
      var qtbody = document.getElementById(pfx + "-qtbody");

      var answers = {};
      var consistent = PERMS.map(function (_, i) {
        return i;
      });
      var honestArrangement = null;
      var queryCount = 0;
      var state = "idle";
      var autoTid = null;

      function buildCards(faceUp, initOrder) {
        cardsEl.innerHTML = "";
        for (var pos = 0; pos < N; pos++) {
          (function (p) {
            var wrapper = document.createElement("div");
            wrapper.className = "wg-card-wrapper";
            var card = document.createElement("div");
            card.className = "wg-card";
            card.id = pfx + "-card-" + p;
            card.innerHTML = faceUp
              ? faceHTML(CARDS[initOrder[p]])
              : '<div class="wg-back"></div><span class="wg-cardnum">' + (p + 1) + '</span>';
            var info = document.createElement("div");
            info.className = "wg-card-info";
            info.id = pfx + "-info-" + p;
            wrapper.appendChild(card);
            wrapper.appendChild(info);
            cardsEl.appendChild(wrapper);
            if (!faceUp) {
              card.addEventListener("dragover", function (e) {
                if (state !== "play") return;
                e.preventDefault();
                card.classList.add("wg-drag-over");
              });
              card.addEventListener("dragleave", function () {
                card.classList.remove("wg-drag-over");
              });
              card.addEventListener("drop", function (e) {
                e.preventDefault();
                card.classList.remove("wg-drag-over");
                if (state !== "play") return;
                var qtype = e.dataTransfer.getData("text/plain");
                var valid = false;
                for (var qi = 0; qi < QUESTIONS.length; qi++) {
                  if (QUESTIONS[qi].key === qtype) {
                    valid = true;
                    break;
                  }
                }
                if (!valid) return;
                askQuery(p, qtype);
              });
            }
          })(pos);
        }
      }

      function buildTable() {
        qtbody.innerHTML = "";
        for (var i = 0; i < N; i++) {
          var tr = document.createElement("tr");
          var html = '<td class="wg-qpos">' + (i + 1) + "</td>";
          for (var qi = 0; qi < QUESTIONS.length; qi++) {
            html +=
              '<td id="' + pfx + "-qt-" + i + "-" + QUESTIONS[qi].key + '">–</td>';
          }
          tr.innerHTML = html;
          qtbody.appendChild(tr);
        }
      }

      function askQuery(pos, qtype) {
        var key = pos + "_" + qtype;
        if (answers[key] !== undefined) {
          msgEl.className = "wg-msg";
          msgEl.textContent = "Already asked that for card " + (pos + 1) + ".";
          return;
        }
        queryCount++;
        if (qcountEl) qcountEl.textContent = queryCount;
        var yesSet = consistent.filter(function (pidx) {
          return CARDS[PERMS[pidx][pos]][qtype] === true;
        });
        var noSet = consistent.filter(function (pidx) {
          return CARDS[PERMS[pidx][pos]][qtype] === false;
        });
        // Honest: answer from pre-committed arrangement; Adversarial: keep larger set
        var ans = cfg.honest
          ? CARDS[honestArrangement[pos]][qtype] === true
          : yesSet.length >= noSet.length;
        consistent = ans ? yesSet : noSet;
        answers[key] = ans;
        if (xConsEl) xConsEl.textContent = consistent.length;
        if (xProbesStatEl) xProbesStatEl.textContent = queryCount;

        // Badge under card
        var shortLabel = qtype;
        for (var qi = 0; qi < QUESTIONS.length; qi++) {
          if (QUESTIONS[qi].key === qtype) {
            shortLabel = QUESTIONS[qi].short;
            break;
          }
        }
        var infoEl = document.getElementById(pfx + "-info-" + pos);
        var badge = document.createElement("span");
        badge.className = "wg-badge wg-badge-" + (ans ? "yes" : "no");
        badge.textContent = shortLabel + ": " + (ans ? "Yes" : "No");
        infoEl.appendChild(badge);

        // Table cell
        var cell = document.getElementById(pfx + "-qt-" + pos + "-" + qtype);
        if (cell) {
          cell.textContent = ans ? "Yes" : "No";
          cell.className = ans ? "wg-yes" : "wg-no";
        }

        // Auto-reveal when all questions answered for this position
        var allAnswered = true;
        for (var qi = 0; qi < QUESTIONS.length; qi++) {
          if (answers[pos + "_" + QUESTIONS[qi].key] === undefined) {
            allAnswered = false;
            break;
          }
        }
        if (allAnswered && consistent.length > 0) {
          var cardEl = document.getElementById(pfx + "-card-" + pos);
          if (cardEl && !cardEl.classList.contains("wg-revealed")) {
            cardEl.innerHTML = faceHTML(CARDS[PERMS[consistent[0]][pos]]);
            cardEl.classList.add("wg-revealed");
          }
        }

        var n = consistent.length;
        msgEl.className = "wg-msg" + (n === 1 ? " wg-good" : "");
        msgEl.innerHTML =
          n === 1
            ? "Only 1 arrangement fits — ready to guess!"
            : "<b>" + n + "</b> arrangements still possible.";
      }

      function doShuffle() {
        if (autoTid) { clearTimeout(autoTid); autoTid = null; if (xRunEl) xRunEl.textContent = "Run ►"; }
        state = "anim";
        if (guessBtn) guessBtn.hidden = true;
        msgEl.className = "wg-msg";
        msgEl.textContent = "";

        var FLIP_HALF = 120;
        var STAGGER = N <= 4 ? 140 : 80;

        for (var fi = 0; fi < N; fi++) {
          (function (idx) {
            var card = document.getElementById(pfx + "-card-" + idx);
            if (!card) return;
            setTimeout(function () {
              card.style.transition = "transform " + FLIP_HALF + "ms ease-in";
              card.style.transform = "scaleX(0)";
              setTimeout(function () {
                card.innerHTML = '<div class="wg-back"></div>';
                card.style.transition = "transform " + FLIP_HALF + "ms ease-out";
                card.style.transform = "scaleX(1)";
                setTimeout(function () {
                  card.style.transition = "";
                  card.style.transform = "";
                }, FLIP_HALF);
              }, FLIP_HALF);
            }, idx * STAGGER);
          })(fi);
        }

        var phase2 = (N - 1) * STAGGER + FLIP_HALF * 2 + 80;
        setTimeout(function () {
          var cards = cardsEl.querySelectorAll(".wg-card");
          var cRect = cardsEl.getBoundingClientRect();
          var sx = [], sy = [];
          for (var mi = 0; mi < cards.length; mi++) {
            var cr = cards[mi].getBoundingClientRect();
            sx.push(cr.left + cr.width / 2 - cRect.left);
            sy.push(cr.top + cr.height / 2 - cRect.top);
          }
          // vis[k] = current visual slot of DOM-card k
          var vis = [];
          for (var vi = 0; vi < cards.length; vi++) vis.push(vi);

          function rPerm(n) {
            var p = []; for (var i = 0; i < n; i++) p.push(i);
            for (var i = n - 1; i > 0; i--) {
              var j = Math.floor(Math.random() * (i + 1));
              var t = p[i]; p[i] = p[j]; p[j] = t;
            }
            return p;
          }

          // Fast move, hold, fast move, hold, fast move
          var MOVE = 110, HOLD = 120;
          function runPass(perm, onDone) {
            var nv = [];
            for (var k = 0; k < cards.length; k++) nv.push(perm[vis[k]]);
            for (var k = 0; k < cards.length; k++) {
              (function (card, ki) {
                card.style.transition = "transform " + MOVE + "ms ease-in-out";
                card.style.transform =
                  "translate(" + (sx[nv[ki]] - sx[ki]) + "px," + (sy[nv[ki]] - sy[ki]) + "px)";
              })(cards[k], k);
            }
            for (var k = 0; k < cards.length; k++) vis[k] = nv[k];
            setTimeout(onDone, MOVE + HOLD);
          }

          var passes = [rPerm(cards.length), rPerm(cards.length), rPerm(cards.length), rPerm(cards.length), rPerm(cards.length)];
          var pi = 0;
          function nextPass() {
            if (pi < passes.length) { runPass(passes[pi++], nextPass); return; }
            queryCount = 0;
            if (qcountEl) qcountEl.textContent = "0";
            answers = {};
            consistent = PERMS.map(function (_, i) { return i; });
            if (cfg.honest) honestArrangement = PERMS[Math.floor(Math.random() * PERMS.length)];
            if (xConsEl) xConsEl.textContent = consistent.length;
            if (xProbesStatEl) xProbesStatEl.textContent = "0";
            state = "play";
            buildCards(false, []);
            buildTable();
            shuffleBtn.textContent = "New Game";
            if (guessBtn) guessBtn.hidden = false;
            if (xRunEl) xRunEl.disabled = !xStratEl || xStratEl.value === "manual";
            msgEl.textContent = "Drag a question onto any face-down card.";
            if (pendingAutoRun) {
              pendingAutoRun = false;
              setTimeout(function() { if (xRunEl) xRunEl.click(); }, 60);
            }
          }
          nextPass();
        }, phase2);
      }

      shuffleBtn.addEventListener("click", doShuffle);

      if (guessBtn) guessBtn.addEventListener("click", function () {
        if (consistent.length > 1) {
          msgEl.className = "wg-msg wg-warn";
          msgEl.innerHTML =
            "⚠️ Not yet — <b>" +
            consistent.length +
            "</b> arrangements still fit. Keep asking!";
        } else {
          state = "solved";
          var sol = PERMS[consistent[0]];
          for (var pos = 0; pos < N; pos++) {
            var cardEl = document.getElementById(pfx + "-card-" + pos);
            if (cardEl && !cardEl.classList.contains("wg-revealed")) {
              cardEl.innerHTML = faceHTML(CARDS[sol[pos]]);
              cardEl.classList.add("wg-revealed");
            }
          }
          msgEl.className = "wg-msg wg-good";
          msgEl.innerHTML =
            "✓ Correct! Full arrangement in <b>" +
            queryCount +
            "</b> quer" +
            (queryCount === 1 ? "y" : "ies") +
            ".";
          guessBtn.hidden = true;
        }
      });

      var qEls = questionsEl.querySelectorAll(".wg-q");
      for (var qi = 0; qi < qEls.length; qi++) {
        (function (el) {
          el.addEventListener("dragstart", function (e) {
            e.dataTransfer.setData("text/plain", el.dataset.q);
            e.dataTransfer.effectAllowed = "copy";
            setTimeout(function () {
              el.style.opacity = "0.5";
            }, 0);
          });
          el.addEventListener("dragend", function () {
            el.style.opacity = "";
          });
        })(qEls[qi]);
      }

      // ── Touch drag (mobile) ──────────────────────────────────────────────
      var _tQ = null, _tGhost = null;
      for (var tqi = 0; tqi < qEls.length; tqi++) {
        (function (el) {
          el.addEventListener("touchstart", function (e) {
            e.preventDefault();
            _tQ = el.dataset.q;
            var t = e.touches[0];
            _tGhost = document.createElement("div");
            _tGhost.textContent = el.textContent;
            _tGhost.style.cssText = "position:fixed;z-index:9999;pointer-events:none;padding:5px 10px;" +
              "background:var(--global-theme-color);color:#fff;border-radius:6px;font-size:0.78rem;" +
              "white-space:nowrap;opacity:0.92;transform:translate(-50%,-130%);transition:none;";
            _tGhost.style.left = t.clientX + "px";
            _tGhost.style.top = t.clientY + "px";
            document.body.appendChild(_tGhost);
          }, { passive: false });
          el.addEventListener("touchmove", function (e) {
            e.preventDefault();
            if (!_tGhost) return;
            var t = e.touches[0];
            _tGhost.style.left = t.clientX + "px";
            _tGhost.style.top = t.clientY + "px";
          }, { passive: false });
          el.addEventListener("touchend", function (e) {
            e.preventDefault();
            if (_tGhost) { _tGhost.remove(); _tGhost = null; }
            if (!_tQ) return;
            var t = e.changedTouches[0];
            var hit = document.elementFromPoint(t.clientX, t.clientY);
            while (hit && !hit.classList.contains("wg-card")) hit = hit.parentElement;
            if (hit && hit.id) {
              var pos = parseInt(hit.id.replace(pfx + "-card-", ""), 10);
              if (!isNaN(pos)) askQuery(pos, _tQ);
            }
            _tQ = null;
          }, { passive: false });
          el.addEventListener("touchcancel", function () {
            if (_tGhost) { _tGhost.remove(); _tGhost = null; }
            _tQ = null;
          }, { passive: false });
        })(qEls[tqi]);
      }
      // ── Auto-play support (used when cfg.extras is provided) ──────────────
      var xStratEl = cfg.extras && cfg.extras.strategy ? document.getElementById(cfg.extras.strategy) : null;
      var xSpeedEl = cfg.extras && cfg.extras.speed ? document.getElementById(cfg.extras.speed) : null;
      var xRunEl = cfg.extras && cfg.extras.run ? document.getElementById(cfg.extras.run) : null;
      var xRevealEl = cfg.extras && cfg.extras.reveal ? document.getElementById(cfg.extras.reveal) : null;
      var xConsEl = cfg.extras && cfg.extras.cons ? document.getElementById(cfg.extras.cons) : null;
      var xProbesStatEl = cfg.extras && cfg.extras.probes ? document.getElementById(cfg.extras.probes) : null;

      function xGreedyPick() {
        var best = null, bestW = Infinity;
        for (var gp = 0; gp < N; gp++) {
          for (var gq = 0; gq < QUESTIONS.length; gq++) {
            var gt = QUESTIONS[gq].key;
            if (answers[gp + "_" + gt] !== undefined) continue;
            var y = 0;
            for (var gi = 0; gi < consistent.length; gi++) { if (CARDS[PERMS[consistent[gi]][gp]][gt]) y++; }
            var w = Math.max(y, consistent.length - y);
            if (w < bestW) { bestW = w; best = { pos: gp, qtype: gt }; }
          }
        }
        return best;
      }

      function xLayerByLayerPick() {
        var propOrder = QUESTIONS.slice().reverse().map(function(q){ return q.key; });
        for (var lpi = 0; lpi < propOrder.length; lpi++) {
          var lp = propOrder[lpi];
          for (var lpos = 0; lpos < N; lpos++) {
            if (answers[lpos + "_" + lp] !== undefined) continue;
            if (consistent.length === 0) return null;
            var lv0 = CARDS[PERMS[consistent[0]][lpos]][lp];
            var ldet = true;
            for (var lci = 1; lci < consistent.length; lci++) {
              if (CARDS[PERMS[consistent[lci]][lpos]][lp] !== lv0) { ldet = false; break; }
            }
            if (ldet) continue;
            return { pos: lpos, qtype: lp };
          }
        }
        return null;
      }


      // ── k=3 optimal strategy helpers (canonical form + policy table) ────────
      var cgAutos3 = null;
      function cgValOf(ci) {
        var v = 0;
        for (var bi = 0; bi < QUESTIONS.length; bi++) if (CARDS[ci][QUESTIONS[bi].key]) v |= (1 << bi);
        return v;
      }
      function cgRows() {
        var rows = new Array(N);
        for (var ri = 0; ri < N; ri++) rows[ri] = 0;
        for (var ci = 0; ci < consistent.length; ci++) {
          var perm = PERMS[consistent[ci]];
          for (var ri2 = 0; ri2 < N; ri2++) rows[ri2] |= (1 << cgValOf(perm[ri2]));
        }
        return rows;
      }
      function cgBuildAutos3() {
        var K3 = QUESTIONS.length;
        var sigmas = [];
        (function pp(a) {
          if (a.length === K3) { sigmas.push(a.slice()); return; }
          for (var si = 0; si < K3; si++) if (a.indexOf(si) < 0) { a.push(si); pp(a); a.pop(); }
        })([]);
        cgAutos3 = [];
        for (var si = 0; si < sigmas.length; si++) for (var x = 0; x < N; x++) {
          var sig = sigmas[si], val = new Array(N);
          for (var v = 0; v < N; v++) { var r = 0; for (var i = 0; i < K3; i++) r |= ((v >> sig[i]) & 1) << i; val[v] = r ^ x; }
          cgAutos3.push({ sigma: sig, val: val });
        }
      }
      function cgRemapMask3(m, val) {
        var r = 0;
        for (var v = 0; v < N; v++) if ((m >> v) & 1) r |= (1 << val[v]);
        return r;
      }
      function cgCanon3(rows) {
        if (!cgAutos3) cgBuildAutos3();
        var best = null;
        for (var ai = 0; ai < cgAutos3.length; ai++) {
          var val = cgAutos3[ai].val;
          var rm = rows.map(function(m) { return cgRemapMask3(m, val); });
          var idx = rm.map(function(_, i) { return i; }).sort(function(p, q) { return rm[p] - rm[q]; });
          var kstr = idx.map(function(i) { return rm[i]; }).join(",");
          if (best === null || kstr < best.kstr) best = { kstr: kstr, a: cgAutos3[ai], idx: idx };
        }
        return best;
      }
      var CG3_OPENING = [[0, 2], [1, 2], [2, 2], [3, 1], [4, 1], [5, 0]];
      function xNextOptimalK3() {
        if (QUESTIONS.length !== 3) return xGreedyPick();
        var POL = window.CG_K3_POLICY;
        if (!POL) return xLayerByLayerPick();
        var rows = cgRows(), cc = cgCanon3(rows);
        var budget = 16 - queryCount;
        var code = POL[cc.kstr + "|" + budget];
        if (code === undefined) return xLayerByLayerPick();
        var K3 = QUESTIONS.length;
        var canonPos = Math.floor(code / K3), canonBit = code % K3;
        return { pos: cc.idx[canonPos], qtype: QUESTIONS[cc.a.sigma[canonBit]].key };
      }
      function xNextFixedK3() {
        if (QUESTIONS.length !== 3) return xGreedyPick();
        for (var fi = 0; fi < CG3_OPENING.length; fi++) {
          var mv = CG3_OPENING[fi];
          var qt = QUESTIONS[mv[1]].key;
          if (answers[mv[0] + "_" + qt] === undefined) return { pos: mv[0], qtype: qt };
        }
        return xNextOptimalK3();
      }
      function xAutoStep() {
        if (state !== "play") { if (xRunEl) xRunEl.textContent = "Run ►"; return; }
        if (consistent.length === 1) {
          if (xRunEl) xRunEl.textContent = "Run ►";
          state = "solved";
          var sol = PERMS[consistent[0]];
          for (var sp = 0; sp < N; sp++) {
            var se = document.getElementById(pfx + "-card-" + sp);
            if (se && !se.classList.contains("wg-revealed")) { se.innerHTML = faceHTML(CARDS[sol[sp]]); se.classList.add("wg-revealed"); }
          }
          msgEl.className = "wg-msg wg-good";
          msgEl.innerHTML = "✓ Solved in <b>" + queryCount + "</b> quer" + (queryCount === 1 ? "y" : "ies") + ".";
          if (guessBtn) guessBtn.hidden = true;
          return;
        }
        var stratVal = xStratEl ? xStratEl.value : "greedy";
        var step = stratVal === "rec17" ? xLayerByLayerPick() :
                   stratVal === "opt16" ? xNextOptimalK3() :
                   stratVal === "fixed" ? xNextFixedK3() :
                   xGreedyPick(); // opt5, greedy, or default
        if (!step) { if (xRunEl) xRunEl.textContent = "Run ►"; return; }
        askQuery(step.pos, step.qtype);
        var delay = xSpeedEl ? Math.max(80, 1000 - parseInt(xSpeedEl.value)) : 500;
        autoTid = setTimeout(xAutoStep, delay);
      }

      var pendingAutoRun = false;
      if (xRunEl) {
        xRunEl.addEventListener("click", function () {
          if (state === "idle" && xStratEl && xStratEl.value !== "manual") {
            pendingAutoRun = true; shuffleBtn.click(); return;
          }
          if (state !== "play") return;
          if (autoTid) { clearTimeout(autoTid); autoTid = null; xRunEl.textContent = "Run ►"; }
          else { xRunEl.textContent = "Stop ◼"; xAutoStep(); }
        });
      }
      if (xStratEl) {
        xStratEl.addEventListener("change", function () {
          if (autoTid) { clearTimeout(autoTid); autoTid = null; if (xRunEl) xRunEl.textContent = "Run ►"; }
          if (xRunEl) xRunEl.disabled = xStratEl.value === "manual";
        });
      }
      if (xRevealEl) {
        xRevealEl.addEventListener("click", function () {
          if (state === "idle") return;
          if (autoTid) { clearTimeout(autoTid); autoTid = null; if (xRunEl) xRunEl.textContent = "Run ►"; }
          state = "solved";
          var sol = PERMS[consistent[0]];
          for (var rp = 0; rp < N; rp++) {
            var re = document.getElementById(pfx + "-card-" + rp);
            if (re && !re.classList.contains("wg-revealed")) { re.innerHTML = faceHTML(CARDS[sol[rp]]); re.classList.add("wg-revealed"); }
          }
          msgEl.className = "wg-msg wg-good"; msgEl.textContent = "Answer revealed.";
          if (guessBtn) guessBtn.hidden = true;
        });
      }

      var initOrder = [];
      for (var io = 0; io < N; io++) initOrder.push(io);
      buildCards(true, initOrder);
      buildTable();
    }

    // ── Game factory: honest (constraint propagation, for n=16) ────────────
    function makeGameCP(cfg) {
      var CARDS = cfg.cards;
      var N = CARDS.length;
      var QUESTIONS = cfg.questions;
      var pfx = cfg.prefix;

      var cardsEl = document.getElementById(pfx + "-cards");
      var msgEl = document.getElementById(pfx + "-msg");
      var shuffleBtn = document.getElementById(pfx + "-shuffle");
      var guessBtn = document.getElementById(pfx + "-guess");
      var qcountEl = document.getElementById(pfx + "-qcount");
      var questionsEl = document.getElementById(pfx + "-questions");
      var qtbody = document.getElementById(pfx + "-qtbody");

      var arrangement = [];
      var possible = [];
      var identifiedAt = {};
      var answers = {};
      var queryCount = 0;
      var state = "idle";
      var autoTid = null;

      function shuffleArr(arr) {
        var a = arr.slice();
        for (var i = a.length - 1; i > 0; i--) {
          var j = Math.floor(Math.random() * (i + 1));
          var t = a[i];
          a[i] = a[j];
          a[j] = t;
        }
        return a;
      }

      function initPossible() {
        possible = [];
        identifiedAt = {};
        for (var i = 0; i < N; i++) {
          var p = [];
          for (var j = 0; j < N; j++) p.push(j);
          possible.push(p);
        }
      }

      function revealCard(pos, cardIdx) {
        var cardEl = document.getElementById(pfx + "-card-" + pos);
        if (cardEl && !cardEl.classList.contains("wg-revealed")) {
          cardEl.innerHTML = faceHTML(CARDS[cardIdx]);
          cardEl.classList.add("wg-revealed");
        }
      }

      function propagate(pos) {
        if (possible[pos].length !== 1 || identifiedAt[pos] !== undefined) return;
        var cardIdx = possible[pos][0];
        identifiedAt[pos] = cardIdx;
        revealCard(pos, cardIdx);
        for (var p = 0; p < N; p++) {
          if (p === pos || identifiedAt[p] !== undefined) continue;
          var idx = possible[p].indexOf(cardIdx);
          if (idx !== -1) {
            possible[p].splice(idx, 1);
            if (possible[p].length === 1) propagate(p);
          }
        }
      }

      function buildCards(faceUp, initOrder) {
        cardsEl.innerHTML = "";
        for (var pos = 0; pos < N; pos++) {
          (function (p) {
            var wrapper = document.createElement("div");
            wrapper.className = "wg-card-wrapper";
            var card = document.createElement("div");
            card.className = "wg-card";
            card.id = pfx + "-card-" + p;
            card.innerHTML = faceUp
              ? faceHTML(CARDS[initOrder[p]])
              : '<div class="wg-back"></div><span class="wg-cardnum">' + (p + 1) + '</span>';
            var info = document.createElement("div");
            info.className = "wg-card-info";
            info.id = pfx + "-info-" + p;
            wrapper.appendChild(card);
            wrapper.appendChild(info);
            cardsEl.appendChild(wrapper);
            if (!faceUp) {
              card.addEventListener("dragover", function (e) {
                if (state !== "play") return;
                e.preventDefault();
                card.classList.add("wg-drag-over");
              });
              card.addEventListener("dragleave", function () {
                card.classList.remove("wg-drag-over");
              });
              card.addEventListener("drop", function (e) {
                e.preventDefault();
                card.classList.remove("wg-drag-over");
                if (state !== "play") return;
                var qtype = e.dataTransfer.getData("text/plain");
                var valid = false;
                for (var qi = 0; qi < QUESTIONS.length; qi++) {
                  if (QUESTIONS[qi].key === qtype) {
                    valid = true;
                    break;
                  }
                }
                if (!valid) return;
                askQuery(p, qtype);
              });
            }
          })(pos);
        }
      }

      function buildTable() {
        qtbody.innerHTML = "";
        for (var i = 0; i < N; i++) {
          var tr = document.createElement("tr");
          var html = '<td class="wg-qpos">' + (i + 1) + "</td>";
          for (var qi = 0; qi < QUESTIONS.length; qi++) {
            html +=
              '<td id="' + pfx + "-qt-" + i + "-" + QUESTIONS[qi].key + '">–</td>';
          }
          tr.innerHTML = html;
          qtbody.appendChild(tr);
        }
      }

      function countIdentified() {
        var n = 0;
        for (var p = 0; p < N; p++) if (identifiedAt[p] !== undefined) n++;
        return n;
      }

      function askQuery(pos, qtype) {
        var key = pos + "_" + qtype;
        if (answers[key] !== undefined) {
          msgEl.className = "wg-msg";
          msgEl.textContent = "Already asked that for card " + (pos + 1) + ".";
          return;
        }
        queryCount++;
        if (qcountEl) qcountEl.textContent = queryCount;
        if (xProbesStatEl) xProbesStatEl.textContent = queryCount;

        // Honest answer from pre-committed arrangement
        var ans = CARDS[arrangement[pos]][qtype] === true;
        answers[key] = ans;

        // Filter possible[pos]
        possible[pos] = possible[pos].filter(function (ci) {
          return CARDS[ci][qtype] === ans;
        });

        // Cascade propagation
        propagate(pos);

        // Badge under card (compact: first char of short + Y/N)
        var shortLabel = QUESTIONS[0].short;
        for (var qi = 0; qi < QUESTIONS.length; qi++) {
          if (QUESTIONS[qi].key === qtype) {
            shortLabel = QUESTIONS[qi].short;
            break;
          }
        }
        var infoEl = document.getElementById(pfx + "-info-" + pos);
        var badge = document.createElement("span");
        badge.className = "wg-badge wg-badge-" + (ans ? "yes" : "no");
        badge.textContent = shortLabel[0] + ":" + (ans ? "Y" : "N");
        infoEl.appendChild(badge);

        // Table cell
        var cell = document.getElementById(pfx + "-qt-" + pos + "-" + qtype);
        if (cell) {
          cell.textContent = ans ? "Y" : "N";
          cell.className = ans ? "wg-yes" : "wg-no";
        }

        // Status message
        var identCount = countIdentified();
        var remaining = N - identCount;
        msgEl.className = "wg-msg" + (remaining === 0 ? " wg-good" : "");
        msgEl.innerHTML =
          remaining === 0
            ? "All " + N + " cards identified — ready to guess!"
            : "<b>" + identCount + "</b> of <b>" + N + "</b> cards identified.";
      }

      function doShuffle() {
        if (autoTid) { clearTimeout(autoTid); autoTid = null; if (xRunEl) xRunEl.textContent = "Run ►"; }
        state = "anim";
        if (guessBtn) guessBtn.hidden = true;
        msgEl.className = "wg-msg";
        msgEl.textContent = "";

        var FLIP_HALF = 90;
        var STAGGER = 45;

        for (var fi = 0; fi < N; fi++) {
          (function (idx) {
            var card = document.getElementById(pfx + "-card-" + idx);
            if (!card) return;
            setTimeout(function () {
              card.style.transition = "transform " + FLIP_HALF + "ms ease-in";
              card.style.transform = "scaleX(0)";
              setTimeout(function () {
                card.innerHTML = '<div class="wg-back"></div>';
                card.style.transition = "transform " + FLIP_HALF + "ms ease-out";
                card.style.transform = "scaleX(1)";
                setTimeout(function () {
                  card.style.transition = "";
                  card.style.transform = "";
                }, FLIP_HALF);
              }, FLIP_HALF);
            }, idx * STAGGER);
          })(fi);
        }

        var totalFlip = (N - 1) * STAGGER + FLIP_HALF * 2 + 80;
        setTimeout(function () {
          var animCards = cardsEl.querySelectorAll(".wg-card");
          var cRect = cardsEl.getBoundingClientRect();
          var sx = [], sy = [];
          for (var mi = 0; mi < animCards.length; mi++) {
            var cr = animCards[mi].getBoundingClientRect();
            sx.push(cr.left + cr.width / 2 - cRect.left);
            sy.push(cr.top + cr.height / 2 - cRect.top);
          }
          var vis = [];
          for (var vi = 0; vi < animCards.length; vi++) vis.push(vi);

          function rPerm(n) {
            var p = []; for (var i = 0; i < n; i++) p.push(i);
            for (var i = n - 1; i > 0; i--) {
              var j = Math.floor(Math.random() * (i + 1));
              var t = p[i]; p[i] = p[j]; p[j] = t;
            }
            return p;
          }

          var MOVE = 110, HOLD = 120;
          function runPass(perm, onDone) {
            var nv = [];
            for (var k = 0; k < animCards.length; k++) nv.push(perm[vis[k]]);
            for (var k = 0; k < animCards.length; k++) {
              (function (card, ki) {
                card.style.transition = "transform " + MOVE + "ms ease-in-out";
                card.style.transform =
                  "translate(" + (sx[nv[ki]] - sx[ki]) + "px," + (sy[nv[ki]] - sy[ki]) + "px)";
              })(animCards[k], k);
            }
            for (var k = 0; k < animCards.length; k++) vis[k] = nv[k];
            setTimeout(onDone, MOVE + HOLD);
          }

          var passes = [rPerm(animCards.length), rPerm(animCards.length), rPerm(animCards.length), rPerm(animCards.length), rPerm(animCards.length)];
          var pi = 0;
          function nextPass() {
            if (pi < passes.length) { runPass(passes[pi++], nextPass); return; }
            answers = {};
            queryCount = 0;
            if (qcountEl) qcountEl.textContent = "0";
            if (xProbesStatEl) xProbesStatEl.textContent = "0";
            var base = [];
            for (var i = 0; i < N; i++) base.push(i);
            arrangement = shuffleArr(base);
            initPossible();
            state = "play";
            buildCards(false, []);
            buildTable();
            shuffleBtn.textContent = "New Game";
            if (guessBtn) guessBtn.hidden = false;
            if (xRunEl) xRunEl.disabled = !xStratEl || xStratEl.value === "manual";
            msgEl.textContent = "Drag a question onto any face-down card.";
            if (pendingAutoRunCP) {
              pendingAutoRunCP = false;
              setTimeout(function() { if (xRunEl) xRunEl.click(); }, 60);
            }
          }
          nextPass();
        }, totalFlip);
      }

      shuffleBtn.addEventListener("click", doShuffle);

      if (guessBtn) guessBtn.addEventListener("click", function () {
        var identCount = countIdentified();
        if (identCount < N) {
          msgEl.className = "wg-msg wg-warn";
          msgEl.innerHTML =
            "⚠️ Not yet — <b>" +
            (N - identCount) +
            "</b> cards still unidentified. Keep asking!";
        } else {
          state = "solved";
          for (var pos = 0; pos < N; pos++) {
            var cardEl = document.getElementById(pfx + "-card-" + pos);
            if (cardEl && !cardEl.classList.contains("wg-revealed")) {
              cardEl.innerHTML = faceHTML(CARDS[arrangement[pos]]);
              cardEl.classList.add("wg-revealed");
            }
          }
          msgEl.className = "wg-msg wg-good";
          msgEl.innerHTML =
            "✓ All " +
            N +
            " cards identified in <b>" +
            queryCount +
            "</b> quer" +
            (queryCount === 1 ? "y" : "ies") +
            ".";
          guessBtn.hidden = true;
        }
      });

      var qEls = questionsEl.querySelectorAll(".wg-q");
      for (var qi = 0; qi < qEls.length; qi++) {
        (function (el) {
          el.addEventListener("dragstart", function (e) {
            e.dataTransfer.setData("text/plain", el.dataset.q);
            e.dataTransfer.effectAllowed = "copy";
            setTimeout(function () {
              el.style.opacity = "0.5";
            }, 0);
          });
          el.addEventListener("dragend", function () {
            el.style.opacity = "";
          });
        })(qEls[qi]);
      }

      // ── Touch drag (mobile) ──────────────────────────────────────────────
      var _tQ = null, _tGhost = null;
      for (var tqi = 0; tqi < qEls.length; tqi++) {
        (function (el) {
          el.addEventListener("touchstart", function (e) {
            e.preventDefault();
            _tQ = el.dataset.q;
            var t = e.touches[0];
            _tGhost = document.createElement("div");
            _tGhost.textContent = el.textContent;
            _tGhost.style.cssText = "position:fixed;z-index:9999;pointer-events:none;padding:5px 10px;" +
              "background:var(--global-theme-color);color:#fff;border-radius:6px;font-size:0.78rem;" +
              "white-space:nowrap;opacity:0.92;transform:translate(-50%,-130%);transition:none;";
            _tGhost.style.left = t.clientX + "px";
            _tGhost.style.top = t.clientY + "px";
            document.body.appendChild(_tGhost);
          }, { passive: false });
          el.addEventListener("touchmove", function (e) {
            e.preventDefault();
            if (!_tGhost) return;
            var t = e.touches[0];
            _tGhost.style.left = t.clientX + "px";
            _tGhost.style.top = t.clientY + "px";
          }, { passive: false });
          el.addEventListener("touchend", function (e) {
            e.preventDefault();
            if (_tGhost) { _tGhost.remove(); _tGhost = null; }
            if (!_tQ) return;
            var t = e.changedTouches[0];
            var hit = document.elementFromPoint(t.clientX, t.clientY);
            while (hit && !hit.classList.contains("wg-card")) hit = hit.parentElement;
            if (hit && hit.id) {
              var pos = parseInt(hit.id.replace(pfx + "-card-", ""), 10);
              if (!isNaN(pos)) askQuery(pos, _tQ);
            }
            _tQ = null;
          }, { passive: false });
          el.addEventListener("touchcancel", function () {
            if (_tGhost) { _tGhost.remove(); _tGhost = null; }
            _tQ = null;
          }, { passive: false });
        })(qEls[tqi]);
      }
      // ── Auto-play support ────────────────────────────────────────────────
      var xStratEl = cfg.extras && cfg.extras.strategy ? document.getElementById(cfg.extras.strategy) : null;
      var xSpeedEl = cfg.extras && cfg.extras.speed ? document.getElementById(cfg.extras.speed) : null;
      var xRunEl = cfg.extras && cfg.extras.run ? document.getElementById(cfg.extras.run) : null;
      var xRevealEl = cfg.extras && cfg.extras.reveal ? document.getElementById(cfg.extras.reveal) : null;
      var xProbesStatEl = cfg.extras && cfg.extras.probes ? document.getElementById(cfg.extras.probes) : null;

      // ── Permanent-based strategy engine (mirrors the abstract widget exactly) ──
      // Cards have unique 4-bit encodings: bit b = QUESTIONS[b].key property.
      // cpValOf maps card-type index → abstract integer value (bijection over 0..15).
      function cpValOf(ci) {
        var v = 0;
        for (var bi = 0; bi < QUESTIONS.length; bi++) if (CARDS[ci][QUESTIONS[bi].key]) v |= (1 << bi);
        return v;
      }
      // cpSlotMask(i) builds the bitmask of abstract values still compatible with
      // position i given the answers probed so far (local constraints only).
      // The permanent then handles the global injectivity constraint.
      function cpSlotMask(i) {
        var mask = 0;
        for (var ci = 0; ci < CARDS.length; ci++) {
          var v = cpValOf(ci), ok = true;
          for (var bi = 0; bi < QUESTIONS.length; bi++) {
            var qt = QUESTIONS[bi].key;
            if (answers[i + "_" + qt] !== undefined && CARDS[ci][qt] !== answers[i + "_" + qt]) { ok = false; break; }
          }
          if (ok) mask |= (1 << v);
        }
        return mask;
      }
      function cpAllRows() { var r = []; for (var i = 0; i < N; i++) r.push(cpSlotMask(i)); return r; }
      function cpMaskBit(b, val) {
        var m = 0, N2 = 1 << QUESTIONS.length;
        for (var v = 0; v < N2; v++) if (((v >> b) & 1) === val) m |= (1 << v);
        return m;
      }
      function cpLiveCount(m) { var c = 0, N2 = 1 << QUESTIONS.length; for (var v = 0; v < N2; v++) c += (m >> v) & 1; return c; }
      function cpPopcount(x) { x = x-((x>>1)&0x55555555); x=(x&0x33333333)+((x>>2)&0x33333333); return(((x+(x>>4))&0x0f0f0f0f)*0x01010101)>>24; }
      // Ryser formula (float) -- same as abstract's permanent(). Used for move scoring.
      function cpPermanent(rows) {
        var n = rows.length, row = new Int32Array(n), result = 0, i, k;
        for (k = 1; k < (1 << n); k++) {
          var g = k ^ (k >> 1), prevg = (k - 1) ^ ((k - 1) >> 1), bit = g ^ prevg;
          var j = 31 - Math.clz32(bit), add = (g & bit) !== 0, prod = 1;
          for (i = 0; i < n; i++) { row[i] += add ? ((rows[i] >> j) & 1) : -((rows[i] >> j) & 1); }
          for (i = 0; i < n; i++) { prod *= row[i]; if (prod === 0) break; }
          if (prod !== 0) result += (cpPopcount(g) & 1 ? -1 : 1) * prod;
        }
        return (n & 1 ? -1 : 1) * result;
      }
      function cpIsForced(i, b, rows) {
        var r0 = rows.slice(); r0[i] = rows[i] & cpMaskBit(b, 0);
        if (cpPermanent(r0) === 0) return true;
        var r1 = rows.slice(); r1[i] = rows[i] & cpMaskBit(b, 1);
        return cpPermanent(r1) === 0;
      }
      // Score each (slot, bit) candidate by max(perm(yes-branch), perm(no-branch)).
      // Deduplicates by (slotMask, bit) so identical-constraint slots only score once.
      function cpScoredCandidates(bits, rows) {
        var seen = {}, list = [];
        for (var i = 0; i < N; i++) {
          if (cpLiveCount(rows[i]) < 2) continue;
          for (var bi = 0; bi < bits.length; bi++) {
            var b = bits[bi];
            if (answers[i + "_" + QUESTIONS[b].key] !== undefined) continue;
            var key = rows[i] * (QUESTIONS.length + 1) + b;
            if (seen[key] !== undefined) {
              // already scored this (mask,bit) -- add this slot as an alias
              list[seen[key]].slots.push(i); continue;
            }
            var r0 = rows.slice(); r0[i] = rows[i] & cpMaskBit(b, 0);
            var r1 = rows.slice(); r1[i] = rows[i] & cpMaskBit(b, 1);
            seen[key] = list.length;
            list.push({ slots: [i], bit: b, qtype: QUESTIONS[b].key,
                        score: Math.max(cpPermanent(r0), cpPermanent(r1)) });
          }
        }
        list.sort(function(a, c) { return a.score - c.score; });
        return list;
      }

      function xCPGreedyPick() {
        var rows = cpAllRows(), l = cpScoredCandidates([0,1,2,3], rows);
        return l.length ? { pos: l[0].slots[0], qtype: l[0].qtype } : null;
      }
      function xLayerByLayerPickCP() {
        var rows = cpAllRows();
        for (var b = QUESTIONS.length - 1; b >= 0; b--) {
          for (var i = 0; i < N; i++) {
            if (answers[i + "_" + QUESTIONS[b].key] !== undefined) continue;
            if (cpLiveCount(rows[i]) < 2) continue;
            if (!cpIsForced(i, b, rows)) return { pos: i, qtype: QUESTIONS[b].key };
          }
        }
        return null;
      }
      function xCPNNPick() {
        var rows = cpAllRows(), l = cpScoredCandidates([0,1,2,3], rows);
        if (!l.length) return null;
        var pick = (l.length > 1 && Math.random() < 0.15) ? l[1] : l[0];
        return { pos: pick.slots[0], qtype: pick.qtype };
      }
      function xCPQuotientPick() {
        // Phase 1: greedy on bits 1-3 (the 3-bit group), same as abstract's nextQuotient
        var rows = cpAllRows(), grp = cpScoredCandidates([1, 2, 3], rows);
        if (grp.length && !cpIsForced(grp[0].slots[0], grp[0].bit, rows))
          return { pos: grp[0].slots[0], qtype: grp[0].qtype };
        // Phase 2: probe bit 0 (face) for each unforced slot
        for (var i = 0; i < N; i++) {
          if (answers[i + "_" + QUESTIONS[0].key] !== undefined) continue;
          if (cpLiveCount(rows[i]) < 2) continue;
          if (!cpIsForced(i, 0, rows)) return { pos: i, qtype: QUESTIONS[0].key };
        }
        return null;
      }
      function xCPMSBPick() {
        // Probe the top bit (bit K-1 = QUESTIONS[last]) for every unforced slot first
        var rows = cpAllRows(), topB = QUESTIONS.length - 1;
        for (var i = 0; i < N; i++) {
          if (answers[i + "_" + QUESTIONS[topB].key] !== undefined) continue;
          if (cpLiveCount(rows[i]) < 2) continue;
          if (!cpIsForced(i, topB, rows)) return { pos: i, qtype: QUESTIONS[topB].key };
        }
        // Then fall through to greedy (like abstract's nextMSB)
        var l = cpScoredCandidates([0,1,2,3], rows);
        return l.length ? { pos: l[0].slots[0], qtype: l[0].qtype } : null;
      }
      function xCPAutoStep() {
        if (state !== "play") { if (xRunEl) xRunEl.textContent = "Run ►"; return; }
        if (countIdentified() === N) {
          if (xRunEl) xRunEl.textContent = "Run ►";
          state = "solved";
          msgEl.className = "wg-msg wg-good";
          msgEl.innerHTML = "✓ Solved in <b>" + queryCount + "</b> quer" + (queryCount === 1 ? "y" : "ies") + ".";
          if (guessBtn) guessBtn.hidden = true;
          return;
        }
        var stratVal = xStratEl ? xStratEl.value : "greedy";
        var step = stratVal === "layer" ? xLayerByLayerPickCP() :
                   stratVal === "nn" ? xCPNNPick() :
                   stratVal === "quot" ? xCPQuotientPick() :
                   stratVal === "msb" ? xCPMSBPick() :
                   xCPGreedyPick();
        if (!step) { if (xRunEl) xRunEl.textContent = "Run ►"; return; }
        askQuery(step.pos, step.qtype);
        var delay = xSpeedEl ? Math.max(80, 1000 - parseInt(xSpeedEl.value)) : 500;
        autoTid = setTimeout(xCPAutoStep, delay);
      }

      var pendingAutoRunCP = false;
      if (xRunEl) {
        xRunEl.addEventListener("click", function () {
          if (state === "idle" && xStratEl && xStratEl.value !== "manual") {
            pendingAutoRunCP = true; shuffleBtn.click(); return;
          }
          if (state !== "play") return;
          if (autoTid) { clearTimeout(autoTid); autoTid = null; xRunEl.textContent = "Run ►"; }
          else { xRunEl.textContent = "Stop ◼"; xCPAutoStep(); }
        });
      }
      if (xStratEl) {
        xStratEl.addEventListener("change", function () {
          if (autoTid) { clearTimeout(autoTid); autoTid = null; if (xRunEl) xRunEl.textContent = "Run ►"; }
          if (xRunEl) xRunEl.disabled = xStratEl.value === "manual";
        });
      }
      if (xRevealEl) {
        xRevealEl.addEventListener("click", function () {
          if (state === "idle") return;
          if (autoTid) { clearTimeout(autoTid); autoTid = null; if (xRunEl) xRunEl.textContent = "Run ►"; }
          state = "solved";
          for (var rp = 0; rp < N; rp++) { revealCard(rp, arrangement[rp]); }
          msgEl.className = "wg-msg wg-good"; msgEl.textContent = "Answer revealed.";
          if (guessBtn) guessBtn.hidden = true;
        });
      }

      var initOrder = [];
      for (var io = 0; io < N; io++) initOrder.push(io);
      buildCards(true, initOrder);
      buildTable();
    }

    // ── 4-card game: A K of hearts and spades, adversarial ─────────────────
    var CARDS4 = [
      { red: true, king: false, label: "A", suit: "♥", color: "#dc2626" },
      { red: true, king: true, label: "K", suit: "♥", color: "#dc2626" },
      { red: false, king: false, label: "A", suit: "♠", color: "#1e293b" },
      { red: false, king: true, label: "K", suit: "♠", color: "#1e293b" },
    ];
    var Q4 = [
      { key: "red", short: "Red" },
      { key: "king", short: "King" },
    ];
    makeGame({ cards: CARDS4, questions: Q4, prefix: "wg4", extras: { cons: "wg4-cons", probes: "wg4-probes" } });

    // ── 8-card game: A K of all 4 suits, adversarial ────────────────────────
    var CARDS8 = [
      { red: true, king: false, major: true, label: "A", suit: "♥", color: "#dc2626" },
      { red: true, king: true, major: true, label: "K", suit: "♥", color: "#dc2626" },
      { red: false, king: false, major: true, label: "A", suit: "♠", color: "#1e293b" },
      { red: false, king: true, major: true, label: "K", suit: "♠", color: "#1e293b" },
      { red: true, king: false, major: false, label: "A", suit: "♦", color: "#dc2626" },
      { red: true, king: true, major: false, label: "K", suit: "♦", color: "#dc2626" },
      { red: false, king: false, major: false, label: "A", suit: "♣", color: "#1e293b" },
      { red: false, king: true, major: false, label: "K", suit: "♣", color: "#1e293b" },
    ];
    var Q8 = [
      { key: "red", short: "Red" },
      { key: "king", short: "King" },
      { key: "major", short: "Maj" },
    ];

    // ── 16-card game: A 2 Q K of all 4 suits, honest + propagation ──────────
    var CARDS16 = [
      { face: false, odd: true, red: true, major: true, label: "A", suit: "♥", color: "#dc2626" },
      { face: false, odd: false, red: true, major: true, label: "2", suit: "♥", color: "#dc2626" },
      { face: true, odd: false, red: true, major: true, label: "Q", suit: "♥", color: "#dc2626" },
      { face: true, odd: true, red: true, major: true, label: "K", suit: "♥", color: "#dc2626" },
      { face: false, odd: true, red: false, major: true, label: "A", suit: "♠", color: "#1e293b" },
      { face: false, odd: false, red: false, major: true, label: "2", suit: "♠", color: "#1e293b" },
      { face: true, odd: false, red: false, major: true, label: "Q", suit: "♠", color: "#1e293b" },
      { face: true, odd: true, red: false, major: true, label: "K", suit: "♠", color: "#1e293b" },
      { face: false, odd: true, red: true, major: false, label: "A", suit: "♦", color: "#dc2626" },
      { face: false, odd: false, red: true, major: false, label: "2", suit: "♦", color: "#dc2626" },
      { face: true, odd: false, red: true, major: false, label: "Q", suit: "♦", color: "#dc2626" },
      { face: true, odd: true, red: true, major: false, label: "K", suit: "♦", color: "#dc2626" },
      { face: false, odd: true, red: false, major: false, label: "A", suit: "♣", color: "#1e293b" },
      { face: false, odd: false, red: false, major: false, label: "2", suit: "♣", color: "#1e293b" },
      { face: true, odd: false, red: false, major: false, label: "Q", suit: "♣", color: "#1e293b" },
      { face: true, odd: true, red: false, major: false, label: "K", suit: "♣", color: "#1e293b" },
    ];
    var Q16 = [
      { key: "face", short: "Face" },
      { key: "odd", short: "Odd" },
      { key: "red", short: "Red" },
      { key: "major", short: "Maj" },
    ];

    // Expose factories and data globally for the Explore section
    window._wgMakeGame = makeGame;
    window._wgMakeGameCP = makeGameCP;
    window._wgData = { cards4: CARDS4, q4: Q4, cards8: CARDS8, q8: Q8, cards16: CARDS16, q16: Q16 };
  })();
</script> <p>This post is about using an AI to solve an open question posed around a scaled version of this simple puzzle. The rest of the post is organized into these sections:</p> <ul> <li><a href="#16-card-puzzle">16-card version</a> scales the opening 4-card puzzle to 16 cards and then states the <a href="#an-open-question">open mathematical question</a>.</li> <li>The bulk covers <a href="#the-search-for-optimal-strategies">the search for optimal strategies</a>: the <a href="#the-execution-setup">LLM-agent execution setup</a>, how <a href="#agent-performance-vs-time">performance evolved across fifty iterations</a>, and a <a href="#explore-the-game-yourself">playable browser version</a> of the game (jump here to see the <a href="#agent-performance-vs-time">main result</a>).</li> <li>I close with <a href="#learnings-from-llm-assisted-solving">key learnings from the collaboration</a> and <a href="#related-work">connections to related open conjecture</a> by Donald Knuth that was recently disproved [5].</li> </ul> <h3 id="16-card-puzzle">16-card puzzle</h3> <p>This small problem can be extended to larger space and things get interesting there. You have <strong>sixteen unique cards</strong> (each encoding a 4-bit value \(0,1,\dots,15\)) and <strong>four binary properties</strong>. When you check property \(b\) of a card, you learn a single bit: bit \(b\) of that card’s hidden value. For instance, property $3$ reveals the most significant bit (MSB) of the card whereas property $0$ reveals its least significant bit (LSB).</p> <p>It is easy to see that four property checks fully determine any given card. The catch: the cards are all shuffled face-down, so you don’t know which card is which. You can trivially do this in 64 checks (each property check on each card). The puzzle is to recover the whole arrangement (<em>which card is which</em>) in under 50 queries in the worst case. Can you do it? (You can <a href="#explore-the-game-yourself">play with smaller variants of the problem</a> below.)</p> <h3 id="an-open-question">An open question</h3> <p>Underneath the story is an interesting permutation-based puzzle. A hidden permutation $\sigma$ scrambles ${0,\dots,15}$; a query names a (card, property) pair and returns that bit of that card’s hidden value $\sigma(\text{card})$. You stop the instant only one permutation is still consistent with every answer so far. Let $D(16)$ be the $\textbf{fewest number of queries that always suffice}$ even against an adversary who answers every query as unhelpfully as the facts still allow. The question I ask is: what is the exact value of $D(16)$?</p> <p>$\textbf{Information-Theoretic Analysis.}$ Counting sets a floor. There are \(16!\) possible arrangements and each answer is one bit, so no strategy can ever beat $D(16) \ge \lceil \log_2 16! \rceil = 45.$ And the lazy strategy — check all four properties of all sixteen cards — uses $16 \times 4 = 64$. Somewhere in $[45, 64]$ sits the true answer, but where?</p> <details class="pg-formal"> <summary>Formal statement &amp; open conjecture</summary> <p>Let $n = 2^k$ and write each value $0 \le i &lt; n$ as its $k$-bit string $b(i) \in \{0,1\}^k$. A permutation $\pi = (\pi_1,\dots,\pi_n)$ is encoded as the concatenation $E(\pi) = b(\pi_1)\,b(\pi_2)\cdots b(\pi_n) \in \{0,1\}^{nk}.$ A $\textbf{query}$ picks an index $t \in \{1,\dots,nk\}$ and returns the $t$-th bit of $E(\pi)$. Let $D(n)$ be the minimum number of queries that identifies $\pi$ uniquely in the $\textbf{worst case}$ (a query may depend on all previous queries and answers, i.e., queries can be $\textbf{adaptive}$). We know that $45 \leq D(16) \leq 64$. Determine $D(16)$.</p> <p><strong>Open conjecture:</strong> $D(16) = 46$.</p> </details> <details class="pg-formal"> <summary>Why this problem?</summary> The adaptive, sequential nature of the puzzle maps naturally onto how an LLM agent operates: propose a query, observe the answer, update beliefs, repeat. Furthermore, as an independent researcher working under real resource constraints: no GPU clusters, a single MacBook, a finite token budget, I needed a problem where meaningful progress was possible without industrial-scale compute. Computing $D(16)$ fits: it is cleanly stated, objectively verifiable, and sits outside what brute force can reach on a single machine, which makes every algorithmic improvement count. </details> <p>Note that there are many ways to formulate this problem, each connected to a different body of mathematics: as an adaptive bit-query optimal decision tree; as matchings in a bipartite graph, in turn connected to permanent computations; automorphisms to reduce the state-space size; and strategies around dynamic programming or brute-force traversal. Also, despite it being an open problem, I would classify it under the “attentionless” problems coined by Terence Tao.</p> <style>
.oq-collapsible { margin: 1.4rem 0 1rem; }
.oq-collapsible > summary {
  cursor: pointer;
  font-size: 1.17em;
  font-weight: 600;
  color: var(--global-text-color);
  list-style: disclosure-closed;
  padding: 0.1rem 0;
  user-select: none;
}
.oq-collapsible[open] > summary { margin-bottom: 0.7rem; }
.oq-collapsible > summary:hover { color: var(--global-theme-color); }
</style> <h2 id="the-search-for-optimal-strategies">The search for optimal strategies</h2> <p>I approached computing the optimal strategy from two sides. First, the set of strategies I could reason out by hand (each strategy a provable upper bound) but there was only so far I could go by pen and paper. Then I started with the main exploration: an LLM agent, turned loose on the search with a meta-harness, grinding the best-known strategy. This section lays out the results of that exploration. Before any search, it’s worth walking down the ladder of things a careful human would try (I encourage you to try it yourself) – each strategy gives a provable upper bound. The rest of this post is about the exploration of using AI to attempt this puzzle and learnings along the way.</p> <details class="pg-formal"> <summary>Human-derivable upper bounds</summary> <ul> <li>$\textbf{Read everything (64 uses).}$ Four properties × sixteen cards. Correct, exploits nothing.</li> <li>$\textbf{Skip the last card (60 uses).}$ Identify fifteen cards fully; the sixteenth is whatever’s left. \(15 \times 4 = 60\).</li> <li>$\textbf{Recursion (49 uses).}$ Check property 3 on 15 out of the 16 cards and infer the last card’s MSB. That creates two smaller groups of 8 each (15 uses). For each one, check property 2 on all but 1 and infer the last one (7+7 uses). Repeat the same for property 1 among the 4 smaller sub-groups (3+3+3+3 uses), and finally property 0 on one card of each pair across the 8 sub-groups (1+1+$\dots$+1 = 8). For a total of 49 uses.</li> <li>$\textbf{Human+computer (47 uses).}$ Here’s an improvement on the previous one. Check the <strong>top property</strong> of every card as before and split into two sub-problems of 8 cards each. A few different strategies settle that $D(8) = 16$ and use this to complete the 16-card problem in 15+16+16 = 47 queries.</li> <li>Note that it might seem like entropy splitting (like the game of 20 questions) might be optimal but simple experiments over the 8-card game show that it does not reach optimality. However, variants like entropy splitting with a bias towards unqueried blocks does achieve the optimal bound.</li> </ul> </details> <h3 id="the-execution-setup">The execution setup</h3> <p>I built a <strong>meta-harness</strong> and let an LLM agent run the research loop itself: propose an idea, implement it, run it, read the numbers, propose the next idea — across dozens of iterations. Rules and constraints that were integral in this exploration:</p> <ul> <li>$\textbf{Resource constraints.}$ All experiments were run on a single MacBook with no clusters/GPU farms on tap. A strategy has to earn its keep inside what a single box can spare which, as we’ll see, is exactly what makes the problem so stubborn. The longest runs were big exhaustive searches run unattended over weekends (and then some).</li> <li>$\textbf{One idea, one folder.}$ Every attempt (<code class="language-plaintext highlighter-rouge">v1</code>, <code class="language-plaintext highlighter-rouge">v2</code>, … <code class="language-plaintext highlighter-rouge">v50</code>) is a self-contained directory with its own solver, verifier, and notes. Results are isolated and reproducible; nothing leaks between versions. The failures are kept next to the wins. Only major optimizations and programming efforts spill as dependencies into other directories.</li> <li>$\textbf{Strategic steers.}$ Initially the agent required directional guidance and manual steer which eventually reduced with appropriate harness guardrails.</li> </ul> <h3 id="agent-performance-vs-time">Agent performance vs time</h3> <p>Here’s the whole campaign at a glance – we’ll unpack what the numbers mean below. Each point is one idea the agent tried. A <strong>solid dot</strong> is a <em>proven</em> worst case – an exhaustive minimax search that finished inside the budget. An <strong>error bar</strong> is a <em>sampled</em> heuristic: the average over 1000 random shuffles, with a whisker up to the worst case actually observed. The bold <strong>staircase</strong> is the best <em>guaranteed</em> strategy known at each stage – it only steps down when a new method <strong>proves</strong> it can always do better. The dashed line is the information floor, 45. <strong>Hover a point (tap on mobile), or use ← / →.</strong></p> <div class="pg-fig" id="pg-evo"> <div class="pg-legend" id="pg-legend"></div> <div class="pg-chart-wrap"> <svg class="pg-svg" viewBox="0 0 1000 560" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Best-known worst-case probe count falling across successive ideas"></svg> <div class="pg-tip" id="pg-tip" hidden=""></div> </div> <div class="pg-controls"> <button class="pg-btn" id="pg-prev" aria-label="Previous idea">←</button> <span class="pg-step-label" id="pg-step-label"></span> <button class="pg-btn" id="pg-next" aria-label="Next idea">→</button> </div> <div class="pg-card" id="pg-card"></div> </div> <p>The shape of the story: the staircase drops fast through the human-reachable strategies (64, 60, 49, 47), then <strong>stalls at 46</strong> – where an exact, structured solver finally <em>proves</em> a guarantee. While the heuristics swarm <em>below</em> it on typical shuffles (averaging ~44.5) yet can never <em>promise</em> it. There’s an independent problem of finding strategies that are good on average (but not worst case). But in this case, I focused on the worst case, i.e., <strong>guaranteeing</strong> that a certain bound suffices.</p> <h3 id="explore-the-game-yourself">Explore the game yourself</h3> <style>
  .expl ul { margin-top: 0.4rem; }
  .expl ul ul { margin: 0.15rem 0 0.55rem; }
</style> <p>I built the actual game in the browser so you can try the strategies yourself. Surprisingly – given how large the exhaustive searches were for the 16 card variant – all of them fit inside this single browser tab without noticeable performance issues. Be sure to explore the <strong>Learned policy (a small NN distilled from an expert)</strong> and the <strong>3+1 quotient decomposition</strong> for the 16 card game.</p> <details class="pg-formal"> <summary>Notes on how to use the interactive game.</summary> <ul> <li>Use the <strong>Non-abstract</strong> tab for the card game: drag properties onto cards to ask yes/no questions, or pick a strategy and hit Run. Switch to <strong>Abstract</strong> for the binary-grid view where rows are cards and columns are properties. I recommend abstract for the 8, 16 card variants.</li> <li>You can play the game yourself: <ul> <li><strong>How to play (card game).</strong> Drag a property chip onto a face-down card to reveal one bit; the counter tracks queries used. When every card is identified, it flips face-up automatically. Hit <strong>Reveal</strong> at any time to peek. Choose <strong>Greedy</strong> from the Strategy menu and press <strong>Run</strong> to watch the algorithm play – use the Speed slider to control pacing.</li> <li><strong>How to play (abstract grid).</strong> Click any <code class="language-plaintext highlighter-rouge">?</code> cell to probe that property of that card. The consistency count shows how many arrangements still match all answers (computed live as a permanent). When it drops to <strong>1</strong>, all cards are identified.</li> </ul> </li> <li>Or look at a strategy in action (the strategies change with the game size): <ul> <li><strong>4 cards (k=2):</strong> <ul> <li><em>Play it yourself</em> – probe by hand.</li> <li><em>Optimal (5)</em> – meets the information floor exactly.</li> </ul> </li> <li><strong>8 cards (k=3):</strong> <ul> <li><em>Play it yourself</em>.</li> <li><em>Optimal decision tree (16)</em> – provably best; sits right on the floor.</li> <li><em>Fixed opening → optimal (16)</em> – six blind, non-adaptive probes, then optimal play.</li> <li><em>Recursion / layer-by-layer (17)</em> – one probe above the floor.</li> </ul> </li> <li><strong>16 cards (k=4):</strong> <ul> <li><em>Play it yourself</em>.</li> <li><em>Exact-permanent greedy</em> – the strongest heuristic (~45 typical, 46 worst).</li> <li><em>Learned policy</em> – a neural-net stand-in that imitates greedy.</li> <li><em>3+1 quotient decomposition</em> – the proven 46-query policy.</li> <li><em>MSB split</em> – the 47-query idea (solve two 8-card halves).</li> <li><em>Layer-by-layer</em> – the ≤49 baseline.</li> </ul> </li> </ul> </li> </ul> </details> <div class="cg-outer" id="cg-outer"> <div class="cg-outer-hdr"> <div class="cg-modes" id="cg-modes"> <button class="cg-mode cg-mode-on" data-k="2" data-game="4">4 cards</button> <button class="cg-mode" data-k="3" data-game="8">8 cards</button> <button class="cg-mode" data-k="4" data-game="16">16 cards</button> </div> <div class="cg-view-row"> <button class="cg-toggle cg-toggle-on" id="cg-tog-noabs">Cards</button> <button class="cg-toggle" id="cg-tog-abs">Abstract</button> </div> </div> <div class="cg-np" id="cg-np"> <div id="cg-np-panel-4"> <div class="cg-bar"> <div class="cg-stat"><span class="cg-stat-num" id="cgp4-cons">24</span><span class="cg-stat-lab">arrangements left</span></div> <div class="cg-stat"><span class="cg-stat-num" id="cgp4-probes">0</span><span class="cg-stat-lab">probes used</span></div> <div class="cg-stat"><span class="cg-stat-num" id="cgp4-floor">5</span><span class="cg-stat-lab">information floor</span></div> </div> <div class="wg-body"> <div class="wg-main"> <div class="wg-cards" id="cgp4-cards"></div> </div> <div class="wg-side"> <div class="wg-side-sec"> <div class="wg-side-title">Properties &middot; drag to a card</div> <div class="wg-questions" id="cgp4-questions"> <div class="wg-q" draggable="true" data-q="red">Red suit? (<span style="color:#dc2626">&hearts;&diams;</span>)</div> <div class="wg-q" draggable="true" data-q="king">Is this card a King?</div> </div> </div> <div class="wg-side-sec"> <div class="wg-side-title">Query log</div> <table class="wg-qtable"> <thead><tr><th>Pos</th><th>Red?</th><th>King?</th></tr></thead> <tbody id="cgp4-qtbody"></tbody> </table> </div> </div> </div> <div class="cg-controls"> <button class="cg-btn cg-primary" id="cgp4-shuffle">New shuffle</button> <button class="cg-btn" id="cgp4-reveal">Reveal</button> <label class="cg-sel">Strategy <select id="cgp4-strategy"> <option value="manual">Play it yourself</option> <option value="opt5">Optimal (5 -- meets the information floor)</option> </select> </label> <button class="cg-btn" id="cgp4-run" disabled="">Run &#9654;</button> <label class="cg-sel cg-speed">Speed <input type="range" id="cgp4-speed" min="50" max="950" value="500"/> </label> </div> <div class="wg-msg" id="cgp4-msg">Drag a property onto any face-down card.</div> </div> <div id="cg-np-panel-8" hidden=""> <div class="cg-bar"> <div class="cg-stat"><span class="cg-stat-num" id="cgp8-cons">40320</span><span class="cg-stat-lab">arrangements left</span></div> <div class="cg-stat"><span class="cg-stat-num" id="cgp8-probes">0</span><span class="cg-stat-lab">probes used</span></div> <div class="cg-stat"><span class="cg-stat-num" id="cgp8-floor">16</span><span class="cg-stat-lab">information floor</span></div> </div> <div class="wg-body"> <div class="wg-main"> <div class="wg-cards wg-cards-grid" id="cgp8-cards"></div> </div> <div class="wg-side"> <div class="wg-side-sec"> <div class="wg-side-title">Properties &middot; drag to a card</div> <div class="wg-questions" id="cgp8-questions"> <div class="wg-q" draggable="true" data-q="red">Red suit? (<span style="color:#dc2626">&hearts;&diams;</span>)</div> <div class="wg-q" draggable="true" data-q="king">Is this card a King?</div> <div class="wg-q" draggable="true" data-q="major">Major suit? (&spades;<span style="color:#dc2626">&hearts;</span>)</div> </div> </div> <div class="wg-side-sec"> <div class="wg-side-title">Query log</div> <table class="wg-qtable wg-qtable-sm"> <thead><tr><th>#</th><th>Red</th><th>King</th><th>Maj</th></tr></thead> <tbody id="cgp8-qtbody"></tbody> </table> </div> </div> </div> <div class="cg-controls"> <button class="cg-btn cg-primary" id="cgp8-shuffle">New shuffle</button> <button class="cg-btn" id="cgp8-reveal">Reveal</button> <label class="cg-sel">Strategy <select id="cgp8-strategy"> <option value="manual">Play it yourself</option> <option value="opt16">Optimal decision tree (16 -- proven best)</option> <option value="fixed">Fixed opening → optimal (16, blind first 6)</option> <option value="rec17">Recursion / layer-by-layer (17)</option> </select> </label> <button class="cg-btn" id="cgp8-run" disabled="">Run &#9654;</button> <label class="cg-sel cg-speed">Speed <input type="range" id="cgp8-speed" min="50" max="950" value="500"/> </label> </div> <div class="wg-msg" id="cgp8-msg">Drag a property onto any face-down card.</div> </div> <div id="cg-np-panel-16" hidden=""> <div class="cg-bar"> <div class="cg-stat"><span class="cg-stat-num" id="cgp16-cons">&mdash;</span><span class="cg-stat-lab">arrangements left</span></div> <div class="cg-stat"><span class="cg-stat-num" id="cgp16-probes">0</span><span class="cg-stat-lab">probes used</span></div> <div class="cg-stat"><span class="cg-stat-num" id="cgp16-floor">45</span><span class="cg-stat-lab">information floor</span></div> </div> <div class="wg-body"> <div class="wg-main"> <div class="wg-cards wg-cards-grid4" id="cgp16-cards"></div> </div> <div class="wg-side"> <div class="wg-side-sec"> <div class="wg-side-title">Properties &middot; drag to a card</div> <div class="wg-questions" id="cgp16-questions"> <div class="wg-q" draggable="true" data-q="face">Face card? (Q or K)</div> <div class="wg-q" draggable="true" data-q="odd">Odd rank? (A or K)</div> <div class="wg-q" draggable="true" data-q="red">Red suit? (<span style="color:#dc2626">&hearts;&diams;</span>)</div> <div class="wg-q" draggable="true" data-q="major">Major suit? (&spades;<span style="color:#dc2626">&hearts;</span>)</div> </div> </div> <div class="wg-side-sec"> <div class="wg-side-title">Query log</div> <div class="wg-qtable-scroll"> <table class="wg-qtable wg-qtable-sm"> <thead><tr><th>#</th><th>Fc</th><th>Od</th><th>Rd</th><th>Mj</th></tr></thead> <tbody id="cgp16-qtbody"></tbody> </table> </div> </div> </div> </div> <div class="cg-controls"> <button class="cg-btn cg-primary" id="cgp16-shuffle">New shuffle</button> <button class="cg-btn" id="cgp16-reveal">Reveal</button> <label class="cg-sel">Strategy <select id="cgp16-strategy"> <option value="manual">Play it yourself</option> <option value="greedy">Exact-permanent greedy (near-optimal, ~45)</option> <option value="nn">Learned policy -- neural-net stand-in (imitates greedy)</option> <option value="quot">3 + 1 quotient decomposition (the 46 policy)</option> <option value="msb">MSB split → solve each half (the 47 idea)</option> <option value="layer">Layer-by-layer (a tidy ≤ 49)</option> </select> </label> <button class="cg-btn" id="cgp16-run" disabled="">Run &#9654;</button> <label class="cg-sel cg-speed">Speed <input type="range" id="cgp16-speed" min="50" max="950" value="500"/> </label> </div> <div class="wg-msg" id="cgp16-msg">Drag a property onto any face-down card.</div> </div> </div> <div class="cg" id="cg"> <div class="cg-bar"> <div class="cg-stat"><span class="cg-stat-num" id="cg-probes">0</span><span class="cg-stat-lab">probes used</span></div> <div class="cg-stat"><span class="cg-stat-num" id="cg-cons">40320</span><span class="cg-stat-lab">arrangements still possible</span></div> <div class="cg-stat"><span class="cg-stat-num" id="cg-floor">16</span><span class="cg-stat-lab">information floor</span></div> </div> <div class="cg-grid-wrap"><div class="cg-grid" id="cg-grid"></div></div> <div class="cg-controls"> <button class="cg-btn cg-primary" id="cg-new">New shuffle</button> <button class="cg-btn" id="cg-reveal">Reveal</button> <label class="cg-sel">Strategy <select id="cg-strategy"></select> </label> <button class="cg-btn" id="cg-run" disabled="">Run ▶</button> <label class="cg-sel cg-speed">Speed <input type="range" id="cg-speed" min="0" max="400" value="180" step="20"/> </label> </div> <div class="cg-msg" id="cg-msg">Click any <span class="cg-chip">?</span> to check that property of that card.</div> </div> </div> <script>
(function () {
  var W = window;
  if (!W._wgMakeGame) return;
  var d = W._wgData;

  W._wgMakeGame({ cards: d.cards4, questions: d.q4, prefix: "cgp4", honest: true,
    extras: { strategy: "cgp4-strategy", speed: "cgp4-speed", run: "cgp4-run", reveal: "cgp4-reveal",
              cons: "cgp4-cons", probes: "cgp4-probes" } });
  W._wgMakeGame({ cards: d.cards8, questions: d.q8, prefix: "cgp8", honest: true,
    extras: { strategy: "cgp8-strategy", speed: "cgp8-speed", run: "cgp8-run", reveal: "cgp8-reveal",
              cons: "cgp8-cons", probes: "cgp8-probes" } });
  W._wgMakeGameCP({ cards: d.cards16, questions: d.q16, prefix: "cgp16",
    extras: { strategy: "cgp16-strategy", speed: "cgp16-speed", run: "cgp16-run", reveal: "cgp16-reveal",
              probes: "cgp16-probes" } });

  // Tab switching for non-abstract panels (unified modes in header)
  var npBtns = document.querySelectorAll("#cg-modes .cg-mode");
  for (var ti = 0; ti < npBtns.length; ti++) {
    (function (btn) {
      btn.addEventListener("click", function () {
        var game = btn.getAttribute("data-game");
        document.getElementById("cg-np-panel-4").hidden = game !== "4";
        document.getElementById("cg-np-panel-8").hidden = game !== "8";
        document.getElementById("cg-np-panel-16").hidden = game !== "16";
      });
    })(npBtns[ti]);
  }

  // Abstract / Non-abstract toggle
  var togNoAbs = document.getElementById("cg-tog-noabs");
  var togAbs = document.getElementById("cg-tog-abs");
  var cgEl = document.getElementById("cg");
  var cgNpEl = document.getElementById("cg-np");

  function setCgMode(abstract) {
    cgEl.hidden = !abstract;
    cgNpEl.hidden = abstract;
    togAbs.classList.toggle("cg-toggle-on", abstract);
    togNoAbs.classList.toggle("cg-toggle-on", !abstract);
  }

  setCgMode(false);
  if (togAbs) togAbs.addEventListener("click", function () { setCgMode(true); });
  if (togNoAbs) togNoAbs.addEventListener("click", function () { setCgMode(false); });
})();
</script> <style>
  .cg-outer {
    border: 1px solid var(--global-divider-color);
    border-radius: 12px;
    padding: 0.9rem 1rem 1rem;
    margin: 0 0 1.4rem;
    background: var(--global-card-bg-color, var(--global-bg-color));
  }
  .cg-outer-hdr {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.8rem;
    margin-bottom: 0.9rem;
    flex-wrap: wrap;
  }
  .cg-view-row { display: flex; gap: 0.4rem; }
  .cg-toggle {
    font: inherit;
    font-size: 0.8rem;
    font-weight: 600;
    padding: 0.3rem 0.9rem;
    border-radius: 20px;
    border: 1.5px solid var(--global-divider-color);
    background: transparent;
    color: var(--global-text-color-light);
    cursor: pointer;
    transition: background 0.15s, border-color 0.15s, color 0.15s;
  }
  .cg-toggle.cg-toggle-on {
    background: var(--global-theme-color);
    border-color: var(--global-theme-color);
    color: #fff;
  }
  .cg-toggle:hover:not(.cg-toggle-on) {
    border-color: var(--global-theme-color);
    color: var(--global-theme-color);
  }
  .cg-np[hidden] { display: none; }
  .wg-select {
    width: 100%;
    font: inherit;
    font-size: 0.78rem;
    border: 1px solid var(--global-divider-color);
    border-radius: 6px;
    padding: 0.25rem 0.4rem;
    background: var(--global-bg-color);
    color: var(--global-text-color);
    margin-bottom: 0.4rem;
  }
  .wg-auto-row {
    display: flex;
    gap: 0.35rem;
    margin-bottom: 0.4rem;
  }
  .wg-auto-row .wg-btn { flex: 1; }
  .wg-speed-label {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    font-size: 0.7rem;
    color: var(--global-text-color-light);
  }
  .wg-speed-label input[type="range"] { flex: 1; }
</style> <p>Two things tend to jump out once you play. First, a <em>typical</em> shuffle collapses to a single answer well before 45 queries. It is useful to remember here that the floor is a <strong>worst-case</strong> promise, not an average. Second, the greedy strategy is eerily good on average yet still trips into 46 on the nastiest shuffles, which is the whole reason “prove 45” is hard and “prove 46” needed a weekend search. I encourage you to try all the different strategies of the 16-card game (despite their complexity, they run in your browser).</p> <h2 id="learnings-from-llm-assisted-solving">Learnings from LLM-assisted solving</h2> <p>Here are a few learnings from it, each tagged <span class="pm pos">+</span> for an upside or <span class="pm neg">−</span> for a limitation.</p> <style>
  .learn-list ul { list-style: none; padding-left: 0.2rem; margin-top: 0.6rem; }
  .learn-list li { margin: 0 0 0.9rem; padding-left: 1.5rem; text-indent: -1.5rem; }
  .pm { font-family: var(--font-mono, ui-monospace, monospace); font-weight: 800; margin-right: 0.6rem; }
  .pm.pos { color: #147d3f; }
  .pm.neg { color: #c0392b; }
</style> <div class="learn-list"> <ul> <li> <p><span class="pm pos">+</span> <strong>The hand-holding fades, and your harness is probably folded into next-gen models or training data.</strong> Across the roughly fifty iterations of this project and a year of frontier releases (Anthropic Opus 4.5 through 4.8, OpenAI ChatGPT 5.2 through 5.6), the amount of directional steering the agent needed dropped visibly. Early on I had to nudge it at almost every turn; by the end, the guardrails I had hand-built (one idea per folder, a mandatory sanity check before any long run) were increasingly things the model did on its own. This is partly explained by the context and structured storing of knowledge, but it does feel like the scaffolding you build around a model today is exactly the behaviour that gets absorbed into the next generation, so the harness feels like a temporary crutch, not a long-term asset.</p> </li> <li> <p><span class="pm neg">−</span> <strong>The agent will write an evaluation, but rarely proposes one.</strong> Trust in a result comes from evaluations and sanity checks, and the agent was reliably willing to <em>write</em> them but it almost never <em>suggested</em> them. Two of the most important pivots had to come from the top. First, the shift from <em>solving exactly</em> to <em>sampling</em>: it took an explicit directive to get the agent to stop chasing exact worst cases everywhere and start reporting distributions over a thousand random shuffles (the error bars in the chart up top). Second, and more telling, it never grasped on its own that an optimal-decision-tree problem is a poor target for machine learning. It threw six independent ML tracks at it (AlphaZero self-play, adaptive MCTS, value- and GNN-distillation, expert iteration); every one <em>matched</em> the exact-permanent policy and not one <em>beat</em> it – even after distilling a 1.1 GB expert into under 1 MB at 90%+ fidelity, it stalled at a ~10% error floor. It was like the network was trying to learn a hash function (ML is probably excellent at the average behaviour but structurally blind to problems that require such an exact global optimum). However, with appropriate contextual steer, some interesting strategies emerged out of this exploration.</p> </li> <li> <p><span class="pm pos">+</span> <strong>Language choice stops being a constraint.</strong> Writing in a language the user does not know is no longer a real barrier in this research-style loop: if speed is critical it reaches for C++, if memory safety matters most, Rust. This quietly removes a whole class of engineering constraints that normally dictate design choices. This is particularly relevant for exploratory study where with appropriate evaluations and checking, the actual choice of language is largely immaterial.</p> </li> <li> <p><span class="pm neg">−</span> <strong>Left alone it will explore, but exploration has a cost.</strong> Turned loose, the agent tries many things to varying depths and often does eventually arrive at a good answer – but the wandering is expensive. The lower-bound hunt – “can we <em>prove</em> 45 is impossible?” – sprawled across more than a dozen versions (v22, v29, v31–v43) of slack certificates and dead-state barriers; the full-game exact solve ran a forward frontier out to 27.85 GB (growing ≈ 2.5× per layer) before conceding the honest framing: this wants a cluster or a genuinely new lemma. Each detour was defensible on its own, yet together they burn real wall-clock and compute. So the question may not always be whether the agent <em>can</em> get there, but whether it is worth letting it roam to find out. Often the better move is to bring your own line of attack and solve the problem <em>with</em> it, rather than around it – steered, it is a superb collaborator; unsupervised, an expensive one.</p> </li> <li> <p><span class="pm neg">−</span> <strong>Results scale; insights don’t (yet).</strong> The agent was good at producing results. Each version came with a clean, measurable “answer”, but it was notably poor at producing <em>insight</em>. When it abandoned a line of attack (say the dead-state-barrier proofs of v32–v33), it rarely left a transferable lesson behind; more often a vague residue of “this didn’t look promising.” When I compare this with manual research experience, it usually leaves an “experience” behind (something like: X is hard to do in Y, Z reaches fundamental limits, etc.). I suspect this asymmetry has real consequences for research: we may see an explosion of new “results” and problems declared “settled,” alongside a quieter decline in the number of genuinely novel insights a field accumulates. This sentiment is probably better expressed by other mathematicians.</p> </li> <li> <p><span class="pm pos">+</span> <strong>Give it a crisp target and it is superhuman.</strong> The clearest example of this is the permanent (the exact count of still-consistent permutations to a partial state) which has been a critical primitive for this problem. Computing a permanent is #P-complete in general [7], but at a fixed size (16-card game where $k = 4$) the right algorithms make it tractable, and it is the bottleneck: even a single greedy game fires on the order of a thousand permanent evaluations (for every possible probe, compute 2 permanents). The agent optimised it to be over $1,000\times$ faster over the course of the project – from a Python reference down through C++, hand-written ARM NEON and x86 SSE4.1 vectorised Gray-code Ryser kernels, multiset-DP permanents with component caching, and automorphism-based state canonicalisation.</p> </li> <li> <p><span class="pm neg">−</span> <strong>At the frontier, watch for unearned leaps.</strong> Especially at the edge of what is known, the agent will make logical jumps that are not fully substantiated. “The gap of one” is the cleanest example: after a handful of attempts failed to close it, the agent was quick to treat $D(16) = 46$ as settled and the +1 gap as <em>structural</em>. The pattern is real and certified on many sub-games – a universal +1 across every residual boundary the search could reach – but “we couldn’t beat it in a few tries” is not a proof, and the full 45-vs-46 question remains open. Trust the results; verify the leaps.</p> </li> </ul> </div> <h2 id="related-work">Related work</h2> <p>A very closely related problem was originally proposed as a conjecture by Knuth and disproved by Florian Stober and Armin Weiß in 2022 [5].</p> <p>The problem is for comparison-based sorting (excluding sorts such as radix sort that require no comparisons). Questions ask what is the minimum number of comparisons required to sort a group of $n$ elements. They call this number $S(n)$, and fun puzzles along the way involve showing $S(4) = 5$ and $S(5) = 7$. In general, though, $S(n)$ has no known closed form. The best general algorithm is merge-insertion sort (Ford &amp; Johnson, 1959 [2]), whose comparison count $F(n)$ exactly matches the information-theoretic floor $\lceil \log_2 n! \rceil$ for every $n \le 11$ – which is what might have led Knuth to conjecture it might be optimal for all $n$ [1]. It is not: Manacher [3] showed merge-insertion is beaten for large $n$, and the counting floor itself first falls short at $n = 12$, where $F(12) = 30$ but $\lceil \log_2 12! \rceil = 29$. The smallest genuinely stubborn case was $n = 16$: the floor is $45$, merge-insertion spends $46$, and for decades nobody could prove which was right. Stober and Weiß settled it – $S(16) = 46$ – with an exhaustive bidirectional search on a single workstation, using an incomplete backward search as pruning <em>advice</em> for a complete forward one [5]. (That backward-as-advice trick is the closest thing to a lit exit sign our own $D(16)$ campaign has found; it is exactly what <code class="language-plaintext highlighter-rouge">v48</code> executes.)</p> <p><strong>Sorting is not (obviously) our problem.</strong> It is tempting to reduce one to the other – both identify a hidden permutation of $n$ items through binary, adversarial queries against the very same $\lceil \log_2 n! \rceil$ counting floor. But they reason about different objects. Comparison sorting lives on posets: each comparison refines a partial order, and the adversary’s remaining freedom is the number of linear extensions still consistent with it. Our bit-query game lives on bipartite compatibility: each probe pins one coordinate of one value, and the number of arrangements still consistent is the permanent of a $0/1$ matrix. I could not find a correspondence that carries a bound from one problem to the other – a comparison is not a coordinate reveal, and a permanent is not a count of linear extensions – so the numerical similarity/connection below is, as far as I can tell, two problems related but unrelated at the same time. If anyone can find a real connection between them, I am sure it will make for some insightful new mathematical work.</p> <p>Here are both sequences against their shared floor. Every $S(n)$ is known; $D(n)$ is settled through $n = 12$, still open for $n = 13, 14, 15$, and conjectured at $n = 16$:</p> <style>
  .dtab { border-collapse: collapse; margin: 1.4rem auto; font-family: var(--font-sans, system-ui, sans-serif); font-size: .9rem; font-variant-numeric: tabular-nums; color: var(--global-text-color); }
  .dtab th, .dtab td { padding: .18rem 1rem; text-align: center; border: none; line-height: 1.3; }
  .dtab thead th { font-weight: 600; vertical-align: bottom; }
  .dtab thead tr:first-child th { border-top: 1.5px solid var(--global-text-color); padding-top: .12rem; }
  .dtab thead .grp { border-bottom: 1px solid var(--global-text-color-light); padding-bottom: .16rem; font-weight: 600; }
  .dtab thead tr:first-child th[rowspan], .dtab thead tr:last-child th { border-bottom: 1px solid var(--global-text-color); padding-bottom: .28rem; }
  .dtab tbody tr:last-child td { border-bottom: 1.5px solid var(--global-text-color); }
  .dtab tr.dtab-sep td { border-top: 1px dashed var(--global-divider-color); }
  .dtab .gap0 { color: var(--global-text-color-lighter); }
  .dtab .gg { color: #147d3f; background: rgba(34, 197, 94, .16); border-radius: 6px; padding: .03rem .42rem; font-weight: 600; }
  .dtab .gr { color: #c0392b; background: rgba(231, 76, 60, .15); border-radius: 6px; padding: .03rem .42rem; font-weight: 600; }
  .dtab .dtab-q { color: var(--global-text-color-lighter); }
  .dtab .dtab-hi td { font-weight: 700; }
  .dtab .col-sep { border-left: 1px solid var(--global-divider-color); }
</style> <table class="dtab"> <thead> <tr> <th rowspan="2"><em>n</em></th> <th rowspan="2">⌈log₂&nbsp;<em>n</em>!⌉</th> <th colspan="2" class="grp col-sep">sorting</th> <th colspan="2" class="grp col-sep">bit-query</th> </tr> <tr> <th class="col-sep"><em>S</em>(<em>n</em>)</th><th>gap</th> <th class="col-sep"><em>D</em>(<em>n</em>)</th><th>gap</th> </tr> </thead> <tbody> <tr><td>2</td><td>1</td><td class="col-sep">1</td><td class="gap0">0</td><td class="col-sep">1</td><td class="gap0">0</td></tr> <tr><td>3</td><td>3</td><td class="col-sep">3</td><td class="gap0">0</td><td class="col-sep">3</td><td class="gap0">0</td></tr> <tr><td>4</td><td>5</td><td class="col-sep">5</td><td class="gap0">0</td><td class="col-sep">5</td><td class="gap0">0</td></tr> <tr><td>5</td><td>7</td><td class="col-sep">7</td><td class="gap0">0</td><td class="col-sep">8</td><td><span class="gr">+1</span></td></tr> <tr><td>6</td><td>10</td><td class="col-sep">10</td><td class="gap0">0</td><td class="col-sep">11</td><td><span class="gr">+1</span></td></tr> <tr><td>7</td><td>13</td><td class="col-sep">13</td><td class="gap0">0</td><td class="col-sep">14</td><td><span class="gr">+1</span></td></tr> <tr><td>8</td><td>16</td><td class="col-sep">16</td><td class="gap0">0</td><td class="col-sep">16</td><td class="gap0">0</td></tr> <tr><td>9</td><td>19</td><td class="col-sep">19</td><td class="gap0">0</td><td class="col-sep">20</td><td><span class="gr">+1</span></td></tr> <tr><td>10</td><td>22</td><td class="col-sep">22</td><td class="gap0">0</td><td class="col-sep">24</td><td><span class="gr">+2</span></td></tr> <tr><td>11</td><td>26</td><td class="col-sep">26</td><td class="gap0">0</td><td class="col-sep">27</td><td><span class="gr">+1</span></td></tr> <tr><td>12</td><td>29</td><td class="col-sep">30</td><td><span class="gg">+1</span></td><td class="col-sep">31</td><td><span class="gr">+2</span></td></tr> <tr class="dtab-sep"><td>13</td><td>33</td><td class="col-sep">34</td><td><span class="gg">+1</span></td><td class="col-sep dtab-q">?</td><td class="dtab-q">?</td></tr> <tr><td>14</td><td>37</td><td class="col-sep">38</td><td><span class="gg">+1</span></td><td class="col-sep dtab-q">?</td><td class="dtab-q">?</td></tr> <tr><td>15</td><td>41</td><td class="col-sep">42</td><td><span class="gg">+1</span></td><td class="col-sep dtab-q">?</td><td class="dtab-q">?</td></tr> <tr class="dtab-hi"><td>16</td><td>45</td><td class="col-sep">46</td><td><span class="gg">+1</span></td><td class="col-sep dtab-q">?</td><td class="dtab-q">?</td></tr> </tbody> </table> <p style="text-align:center;font-size:.82rem;color:var(--global-text-color-lighter);margin:-.3rem 0 1.2rem"> <span style="color:#147d3f;font-weight:600">green</span> = a gap above the floor &nbsp;·&nbsp; <span style="color:#c0392b;font-weight:600">red</span> = a bit-query gap that <em>differs</em> from sorting's. Every settled $D$ gap is red; whether the still-open $n = 16$ entry matches sorting as we conjecture or rather surprisingly, 45 is sufficient is the whole question now. </p> <p>Read down the columns and the two problems part ways and then meet again. Sorting hugs the floor: $S(n)$ equals $\lceil \log_2 n! \rceil$ for every $n \le 11$, first steps above it at $n = 12$, and famously stalls one probe over at $n = 16$. The bit-query game leaves the floor almost immediately (a $+1$ gap already at $n = 5$) and then wanders, even reaching $+2$ at $n = 10$. But at $n = 8$ the gap snaps back to $0$. In fact the bit-query game hits the floor exactly at every power of two below sixteen ($n = 2, 4, 8$), because $2^k$ values tile the $k$-bit cube with no wasted codewords and nothing is ever half-asked.</p> <p>The wandering in between is worth a caveat. When <strong>$n$ is not a power of two</strong>, the values ${0, \dots, n-1}$ only partly fill the $k$-bit cube: $2^k - n$ codewords go unused, so the game there is lumpier, far less symmetric, and its gaps are tangled up with that encoding waste rather than with anything fundamental. Those <strong>non-power-of-two gaps therefore say little about the clean powers-of-two cases</strong>: a $+2$ at $n = 10$ signals nothing for $n = 16$. Restricted to the powers of two (2, 4, 8, and 16), the only honest reading of the $D$ column is $0, 0, 0, \, ?$.</p> <p>While that makes $n = 16$, the next power of two, look like it should land right on the floor at $45$ (the very same “surely it’s tight here” intuition Knuth probably had about sorting). Both intuitions appear to break at sixteen: the best strategy I have found proves the upper bound of $D(16) \leq 46$, one probe above the floor – and <em>if</em> that is optimal, it closely echoes the proven $S(16) = 46$. For me, it would be very surprising if $D(16)$ happens to be 45. Anyway, whether this relation is a coincidence, or the shadow of a deeper mathematical connection I simply couldn’t find, is the question this whole exercise left me with.</p> <h2 id="summary">Summary</h2> <p>A quick summary of the problem so far. Note that $D(16)$ is the worst case queries to identify a permutation uniquely using bit-queries):</p> <ul> <li>Proved: $45 \leq D(16) \leq 46$</li> <li>Proved: The 3+1 decomposition requires exactly 46 in the worst case.</li> <li>Not proved: $D(16)=46$ (alternative open question: whether a fully interleaved 45-query strategy exists).</li> <li>Empirical: greedy typically finishes around 45, but this is not a guarantee.</li> <li>There are some interesting ML based approaches which approach the problem from an empirical angle (as opposed to provably X questions are required in the worst case).</li> </ul> <h2 id="references">References</h2> <ol> <li>D. E. Knuth. <em>The Art of Computer Programming, Vol. 3: Sorting and Searching</em> (2nd ed.), §5.3.1 (minimum-comparison sorting; the merge-insertion conjecture). Addison-Wesley, 1998.</li> <li>L. R. Ford Jr. and S. M. Johnson. “A tournament problem.” <em>The American Mathematical Monthly</em> 66(5): 387–389, 1959. <a href="https://doi.org/10.2307/2308750">doi:10.2307/2308750</a></li> <li>G. K. Manacher. “The Ford–Johnson sorting algorithm is not optimal.” <em>Journal of the ACM</em> 26(3): 441–456, 1979. <a href="https://doi.org/10.1145/322139.322145">doi:10.1145/322139.322145</a></li> <li>M. Peczarski. “The Ford–Johnson algorithm still unbeaten for less than 47 elements.” <em>Information Processing Letters</em> 101(3): 126–128, 2007. <a href="https://doi.org/10.1016/j.ipl.2006.09.001">doi:10.1016/j.ipl.2006.09.001</a></li> <li>F. Stober and A. Weiß. “Lower bounds for sorting 16, 17, and 18 elements.” arXiv:2206.05597, 2022. Proves $S(16) = 46$. <a href="https://arxiv.org/abs/2206.05597">arxiv.org/abs/2206.05597</a></li> <li>H. J. Ryser. <em>Combinatorial Mathematics</em> (Carus Mathematical Monographs 14). Mathematical Association of America, 1963. Ryser’s inclusion–exclusion formula for the permanent – used throughout the interactive game to count consistent arrangements.</li> <li>L. G. Valiant. “The complexity of computing the permanent.” <em>Theoretical Computer Science</em> 8(2): 189–201, 1979. Proves computing the permanent is #P-complete. <a href="https://doi.org/10.1016/0304-3975(79)90044-6">doi:10.1016/0304-3975(79)90044-6</a></li> </ol> <div class="pg-note">All numbers here come from the project's own solvers and game-players (k=4, N=16, information floor 45). "Proven" points are exhaustive minimax results; "sampled" points are averages over 1000 random permutations with the observed worst case. The chart and the playable game are static client-side renderings -- no computation or data leaves your browser.</div> <style>
.pg-formal { margin: 1.2rem 0 1.6rem; border: 1px solid var(--global-divider-color); border-radius: 10px; padding: .2rem 1rem; background: var(--global-card-bg-color); }
.pg-formal summary { cursor: pointer; font-family: var(--font-sans, system-ui, sans-serif); font-weight: 600; color: var(--global-theme-color); padding: .6rem .2rem; }
.pg-formal[open] summary { margin-bottom: .4rem; }
.pg-formal p { margin: .5rem 0; line-height: 1.6; }

.pg-fig { margin: 2rem 0; }
#pg-evo .pg-chart-wrap { width: 100%; position: relative; }
.pg-svg { width: 100%; height: auto; display: block; user-select: none; }
.pg-svg text { font-family: var(--font-sans, system-ui, sans-serif); }
.pg-tip { position: absolute; z-index: 5; pointer-events: none; max-width: 16rem;
  background: var(--global-bg-color); border: 1px solid var(--global-divider-color);
  border-radius: 8px; padding: .55rem .7rem; box-shadow: 0 6px 20px rgba(0,0,0,.15);
  font-family: var(--font-sans, system-ui, sans-serif); font-size: .78rem; line-height: 1.45;
  color: var(--global-text-color); transition: opacity .1s; }
.pg-tip[hidden] { display: none; }
.pg-tip b { color: var(--global-theme-color); }
.pg-legend { display: flex; flex-wrap: wrap; gap: 1.1rem; justify-content: center; margin-bottom: .4rem;
  font-family: var(--font-sans, system-ui, sans-serif); font-size: .86rem; color: var(--global-text-color-light); }
.pg-legend .k { display: inline-flex; align-items: center; gap: .4rem; }
.pg-legend .swatch-dot { width: .7rem; height: .7rem; border-radius: 999px; background: var(--global-theme-color); }
.pg-legend .swatch-bar { width: 0; height: .9rem; border-left: 3px solid var(--global-text-color-lighter); }
.pg-legend .swatch-stair { width: 1.1rem; height: 0; border-top: 3px solid var(--global-theme-color); }
.pg-legend .swatch-line { width: 1.1rem; height: 0; border-top: 2px dashed var(--global-text-color-lighter); }
.pg-controls { display: flex; align-items: center; justify-content: center; gap: 1rem; margin: 0.5rem 0 1rem; }
.pg-btn { font: inherit; font-family: var(--font-sans, system-ui, sans-serif);
  border: 1px solid var(--global-divider-color); background: transparent;
  color: var(--global-text-color); border-radius: 6px;
  width: 2.2rem; height: 2.2rem; cursor: pointer; line-height: 1;
  transition: border-color .15s, color .15s; }
.pg-btn:hover:not(:disabled) { border-color: var(--global-theme-color); color: var(--global-theme-color); }
.pg-btn:disabled { opacity: .35; cursor: default; }
.pg-step-label { font-family: var(--font-sans, system-ui, sans-serif); font-size: .8rem;
  letter-spacing: .04em; text-transform: uppercase; color: var(--global-text-color-lighter);
  min-width: 9rem; text-align: center; }
.pg-card { border: 1px solid var(--global-divider-color); border-radius: 10px; padding: 1.1rem 1.25rem; background: var(--global-card-bg-color); }
.pg-card-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.pg-card-title { font-family: var(--font-sans, system-ui, sans-serif); font-weight: 600; font-size: 1.05rem; color: var(--global-theme-color); }
.pg-card-num { font-family: var(--font-sans, system-ui, sans-serif); font-weight: 600; font-size: 1.3rem; }
.pg-card-num small { font-size: .8rem; font-weight: 400; color: var(--global-text-color-lighter); }
.pg-badge { font-family: var(--font-sans, system-ui, sans-serif); font-size: .72rem; font-weight: 600; padding: .12rem .5rem; border-radius: 999px; margin-left: .5rem; white-space: nowrap; }
.pg-badge.exact { color: #16794a; background: rgba(34,197,94,.14); }
.pg-badge.sampled { color: var(--global-text-color-lighter); background: var(--global-divider-color); }
.pg-card-desc { margin: .6rem 0 0; color: var(--global-text-color); line-height: 1.6; font-size: 1.02rem; }
.pg-note { font-size: .82rem; color: var(--global-text-color-lighter); border-left: 2px solid var(--global-divider-color); padding-left: .9rem; margin-top: 1.5rem; line-height: 1.5; }
.pg-attempts { display: grid; gap: .9rem; margin: 1rem 0 2rem; }
.pg-attempt { border: 1px solid var(--global-divider-color); border-radius: 10px; padding: .9rem 1.1rem; }
.pg-attempt-head { display: flex; align-items: baseline; justify-content: space-between; gap: .8rem; flex-wrap: wrap; }
.pg-attempt-name { font-family: var(--font-sans, system-ui, sans-serif); font-weight: 600; color: var(--global-theme-color); }
.pg-attempt p { margin: .4rem 0 0; line-height: 1.55; }
.pg-tag { font-family: var(--font-sans, system-ui, sans-serif); font-size: .72rem; color: var(--global-text-color-lighter); white-space: nowrap; }
.pg-tag.pg-bt { color: #b4690e; font-weight: 600; }

/* ── Explore-the-game widget ── */
.cg[hidden] { display: none; }
.cg-modes { display: flex; justify-content: center; gap: 0; margin-bottom: 0; }
.cg-mode { font: inherit; font-family: var(--font-sans, system-ui, sans-serif); font-size: .82rem; border: 1px solid var(--global-divider-color); background: transparent; color: var(--global-text-color-light); padding: .4rem .9rem; cursor: pointer; transition: background .15s, color .15s; }
.cg-mode:first-child { border-radius: 8px 0 0 8px; }
.cg-mode:last-child { border-radius: 0 8px 8px 0; }
.cg-mode:not(:first-child) { border-left: none; }
.cg-mode.cg-mode-on { background: var(--global-theme-color); color: #fff; border-color: var(--global-theme-color); }
.cg-bar { display: flex; flex-wrap: wrap; gap: 1.2rem; justify-content: center; margin-bottom: 1rem; }
.cg-stat { display: flex; flex-direction: column; align-items: center; min-width: 6rem; }
.cg-stat-num { font-family: var(--font-sans, system-ui, sans-serif); font-weight: 700; font-size: 1.35rem; color: var(--global-theme-color); font-variant-numeric: tabular-nums; }
.cg-stat-lab { font-family: var(--font-sans, system-ui, sans-serif); font-size: .68rem; letter-spacing: .03em; text-transform: uppercase; color: var(--global-text-color-lighter); text-align: center; }
.cg-grid-wrap { overflow-x: auto; }
.cg-grid { display: grid; grid-template-columns: auto repeat(4, clamp(26px, 7vw, 36px)) auto auto; gap: 3px; margin: 0 auto; width: max-content; }
.cg-h { font-family: var(--font-sans, system-ui, sans-serif); font-size: .62rem; letter-spacing: .02em; text-transform: uppercase; color: var(--global-text-color-lighter); text-align: center; padding: 2px 0; align-self: end; }
.cg-h.cg-sep { border-left: 1px solid var(--global-divider-color); padding-left: 9px; margin-left: 3px; }
.cg-rowlab { font-family: var(--font-sans, system-ui, sans-serif); font-size: .68rem; color: var(--global-text-color-lighter); display: flex; align-items: center; justify-content: flex-end; padding-right: 6px; white-space: nowrap; }
.cg-val { font-family: var(--font-mono, ui-monospace, monospace); font-size: .82rem; color: var(--global-text-color-lighter); display: flex; align-items: center; justify-content: center; min-width: 1.5rem; opacity: 0; transition: opacity .25s; }
.cg-val.cg-val-show { opacity: 1; color: var(--global-theme-color); font-weight: 700; }
.cg-ans { font-family: var(--font-mono, ui-monospace, monospace); font-size: .82rem; color: var(--global-text-color-lighter); display: flex; align-items: center; justify-content: center; min-width: 1.7rem; border-left: 1px solid var(--global-divider-color); padding-left: 9px; margin-left: 3px; opacity: .5; }
.cg-ans.cg-ans-show { opacity: 1; color: var(--global-text-color); font-weight: 600; }
.cg-cell { aspect-ratio: 1 / 1; border: 1px solid var(--global-divider-color); border-radius: 5px; display: flex; align-items: center; justify-content: center;
  font-family: var(--font-mono, ui-monospace, monospace); font-weight: 600; font-size: clamp(.7rem, 2.6vw, .95rem); cursor: pointer; background: transparent;
  color: var(--global-text-color-lighter); transition: background .12s, color .12s, border-color .12s, transform .1s; }
.cg-cell.cg-known { color: var(--global-bg-color); cursor: default; }
.cg-cell.cg-one { background: var(--global-theme-color); border-color: var(--global-theme-color); }
.cg-cell.cg-zero { background: var(--global-text-color-light); border-color: var(--global-text-color-light); }
.cg-cell.cg-infer { opacity: .55; }
.cg-cell.cg-q:hover { border-color: var(--global-theme-color); color: var(--global-theme-color); transform: translateY(-1px); }
.cg-cell.cg-flash { transform: scale(1.12); box-shadow: 0 0 0 3px color-mix(in srgb, var(--global-theme-color) 40%, transparent); }
.cg-cell[aria-disabled="true"] { cursor: default; }
.cg-controls { display: flex; flex-wrap: wrap; gap: .7rem 1rem; align-items: center; justify-content: center; margin: 1.1rem 0 .3rem; }
.cg-btn { font: inherit; font-family: var(--font-sans, system-ui, sans-serif); font-size: .85rem; border: 1px solid var(--global-divider-color); background: transparent; color: var(--global-text-color); border-radius: 7px; padding: .4rem .85rem; cursor: pointer; transition: border-color .15s, color .15s; }
.cg-btn:hover:not(:disabled) { border-color: var(--global-theme-color); color: var(--global-theme-color); }
.cg-btn:disabled { opacity: .4; cursor: default; }
.cg-primary { border-color: var(--global-theme-color); color: var(--global-theme-color); font-weight: 600; }
.cg-sel { font-family: var(--font-sans, system-ui, sans-serif); font-size: .78rem; color: var(--global-text-color-lighter); display: inline-flex; align-items: center; gap: .4rem; }
.cg-sel select, .cg-sel input { font: inherit; color: var(--global-text-color); background: var(--global-bg-color); border: 1px solid var(--global-divider-color); border-radius: 6px; padding: .25rem .4rem; }
.cg-sel select { max-width: 14rem; }
.cg-speed input { padding: 0; }
.cg-msg { text-align: center; font-family: var(--font-sans, system-ui, sans-serif); font-size: .85rem; color: var(--global-text-color-light); margin-top: .8rem; min-height: 1.4em; line-height: 1.4; }
.cg-msg.cg-win { color: #16794a; font-weight: 600; }
.cg-chip { font-family: var(--font-mono, monospace); border: 1px solid var(--global-divider-color); border-radius: 4px; padding: 0 .3rem; }

/* ── Mobile responsive ─────────────────────────────────────────── */
@media (max-width: 560px) {
  /* Reduce widget padding so cards have more room */
  .wg { padding: 0.9rem 0.7rem 0.8rem; }
  .cg-outer { padding: 0.7rem 0.6rem 0.8rem; }

  /* Stack side panel below cards instead of beside them */
  .wg-body { flex-direction: column; align-items: stretch; }
  .wg-side { width: auto; flex-direction: row; align-items: flex-start; gap: 0.8rem; }
  .wg-side-sec { flex: 1; min-width: 0; }

  /* Question chips flow in a row on mobile */
  .wg-questions { flex-direction: row; flex-wrap: wrap; gap: 0.3rem; }
  .wg-q { flex: 1 1 45%; font-size: 0.72rem; padding: 4px 7px; text-align: center; }

  /* Mode buttons (4 cards / 8 cards / 16 cards) fit on one line */
  .cg-mode { padding: 0.3rem 0.6rem; font-size: 0.75rem; }

  /* Chart tooltip narrower so it stays inside the chart area */
  .pg-tip { max-width: 11rem; font-size: 0.78rem; }
}
</style> <script>
(function () {
  // ── Best-known worst case across ideas (k=4, N=16, information floor = 45). ──
  // kind: 'bound' = proven guaranteed upper bound (drives the staircase);
  //       'exact' = proven worst case of a specific solver; 'sampled' = distribution over 1000 σ.
  var POINTS = [
    { x: "Naïve", name: "Naïve readout", kind: "bound", worst: 64, bt: false,
      tried: "Read all four bits of all sixteen crystals.",
      found: "64 probes, exploits nothing -- the ceiling everything else is measured against." },
    { x: "Drop-last", name: "Skip the last crystal", kind: "bound", worst: 60, bt: false,
      tried: "Identify only fifteen crystals; the sixteenth is forced by elimination.",
      found: "15 × 4 = 60 probes, guaranteed." },
    { x: "Lehmer", name: "Lehmer / layer-by-layer", kind: "bound", worst: 49, bt: false,
      tried: "Resolve crystals in sequence (or read the table column by column).",
      found: "49 probes. All the excess is ceiling-slop in independent sub-problems." },
    { x: "2+2 split", name: "2 + 2 decomposition (v47)", kind: "exact", worst: 48, bt: false,
      tried: "A more balanced split: resolve the high two bits together, then the low two.",
      found: "28 + 4×5 = 48 -- a decomposition, but worse than the 3+1 split's 46: more balance leaks more per query and stacks more ceilings." },
    { x: "MSB split", name: "MSB split → D(8)", kind: "bound", worst: 47, bt: true,
      tried: "Read the top bit of every crystal (15 probes), splitting into two eight-crystal puzzles, then solve each with the known D(8)=16.",
      found: "15 + 16 + 16 = 47 -- a solved sub-problem used twice. The best a human reaches by hand." },
    { x: "Greedy", name: "Greedy on the exact permanent (v14)", kind: "sampled", avg: 44.55, max: 46, pct: "86%", bt: false,
      tried: "Pick the bit minimising the larger remaining-permutation count, computed exactly as a permanent.",
      found: "avg 44.55, 86% ≤ 45, worst seen 46. The key primitive of the whole project -- but no guarantee." },
    { x: "MCTS", name: "Adaptive MCTS / RL (v20)", kind: "sampled", avg: 44.90, max: 48, pct: "70%", bt: false,
      tried: "Neural policy + MCTS, falling back to the exact permanent on small states.",
      found: "avg 44.90, 70% ≤ 45. Matches the permanent at ~4× speed; never beats it." },
    { x: "TopK(10)", name: "TopK(10) pruning (v21)", kind: "sampled", avg: 44.44, max: 46, pct: "94%", bt: true,
      tried: "Rank bits by a cheap degree-product heuristic, run the exact permanent only on the top 10.",
      found: "avg 44.44, 94% ≤ 45 -- beats full greedy (86%). Less search, better play." },
    { x: "Endgame", name: "Permanent-greedy + minimax endgame (v23)", kind: "sampled", avg: 44.28, max: 46, pct: "~86%", bt: false,
      tried: "Fixed opening → permanent-greedy → exact minimax once few positions remain.",
      found: "avg 44.28 -- the best full-game heuristic. Worst case still 46." },
    { x: "3+1 quotient", name: "3 + 1 quotient decomposition (v26)", kind: "exact", worst: 46, bt: true,
      tried: "Identify eight 3-bit groups exactly (minimax proves 38, and that 37 is impossible -- a ~2-day search), then 8 fixed LSB probes.",
      found: "38 + 8 = 46, PROVEN optimal for this decomposition. The staircase's floor. Average over random σ: 45.2, best 39." },
    { x: "Lower bound", name: "The lower-bound hunt (v31–v43)", kind: "lower", worst: 45, bt: false,
      tried: "Meet-in-the-middle proofs, slack certificates, an 11× SIMD permanent, gap-aware value iteration -- anything to prove 45 impossible.",
      found: "A universal +1 gap on every certified residual, and 45 not forceable within reach. The floor stays just out of grasp." },
  ];
  var BOUND = 45;

  var NS = "http://www.w3.org/2000/svg";
  var svg = document.querySelector("#pg-evo .pg-svg");
  var card = document.getElementById("pg-card");
  var legend = document.getElementById("pg-legend");
  var stepLabel = document.getElementById("pg-step-label");
  var prevBtn = document.getElementById("pg-prev");
  var nextBtn = document.getElementById("pg-next");
  var tip = document.getElementById("pg-tip");
  if (!svg) return;

  var W = 1000, H = 560;
  var yMin = 38, yMax = 66;
  // representative y for a point (dot height / bar centre)
  var repVal = function (p) { return (p.kind === "sampled") ? p.avg : p.worst; };

  // running best *guaranteed* upper bound (only 'bound' and 'exact' points count)
  var stair = [];
  (function () { var best = Infinity; for (var i = 0; i < POINTS.length; i++) {
    var p = POINTS[i]; if ((p.kind === "bound" || p.kind === "exact") && p.worst < best) best = p.worst;
    stair.push(best); } })();

  var sel = 0;
  var cssVar = function (n) { return getComputedStyle(document.documentElement).getPropertyValue(n).trim(); };
  var accent = cssVar("--global-theme-color") || "#3873b3";
  var txt = function () { return cssVar("--global-text-color") || "#262626"; };
  var muted = function () { return cssVar("--global-text-color-lighter") || "#999"; };
  var faint = function () { return cssVar("--global-divider-color") || "#e8e8e8"; };
  function el(tag, attrs) { var n = document.createElementNS(NS, tag); for (var k in attrs) n.setAttribute(k, attrs[k]); return n; }

  legend.innerHTML =
    '<span class="k"><span class="swatch-stair"></span>best guaranteed strategy</span>' +
    '<span class="k"><span class="swatch-dot"></span>proven worst case</span>' +
    '<span class="k"><span class="swatch-bar"></span>sampled: avg over 1000 σ → worst seen</span>' +
    '<span class="k"><span class="swatch-line"></span>information floor = 45</span>';

  function draw() {
    while (svg.firstChild) svg.removeChild(svg.firstChild);
    // Scale all SVG font-sizes so text stays ~13–15px effective at every viewport width.
    // svgW is the CSS pixel width the SVG renders into; W=1000 is the viewBox width.
    // fs = W / svgW keeps effective size constant; clamp so desktop text is ~20% larger
    // than the raw values (floor 1.15) and mobile doesn't exceed 2× (cap 2.2).
    var svgW = svg.getBoundingClientRect().width || 900;
    var fs = Math.min(2.2, Math.max(1.15, W / svgW));
    var sz = function (n) { return Math.round(n * fs); };
    var narrowPhone = svgW < 480;
    var PAD = { top: 40, right: 30, bottom: narrowPhone ? 110 : 66, left: 60 };
    var xAt = function (i) { return PAD.left + (i + 0.5) * (W - PAD.left - PAD.right) / POINTS.length; };
    var yAt = function (q) { return PAD.top + (1 - (q - yMin) / (yMax - yMin)) * (H - PAD.top - PAD.bottom); };

    [40, 45, 50, 55, 60, 64].forEach(function (v) {
      var y = yAt(v);
      svg.appendChild(el("line", { x1: PAD.left, y1: y, x2: W - PAD.right, y2: y, stroke: faint(), "stroke-width": 1 }));
      var t = el("text", { x: PAD.left - 10, y: y + 4, "text-anchor": "end", "font-size": sz(15), fill: muted() });
      t.textContent = v; svg.appendChild(t);
    });

    // information floor (45)
    var yb = yAt(BOUND);
    svg.appendChild(el("line", { x1: PAD.left, y1: yb, x2: W - PAD.right, y2: yb, stroke: muted(), "stroke-width": 1.5, "stroke-dasharray": "6 5", opacity: .85 }));
    var bl = el("text", { x: PAD.left + 4, y: yb - 7, "text-anchor": "start", "font-size": sz(15), fill: muted(), "font-weight": 600 });
    bl.textContent = "information floor = 45"; svg.appendChild(bl);

    // best-guaranteed staircase (step line through the running-min guarantee)
    var d = "";
    POINTS.forEach(function (p, i) {
      var x0 = (i === 0) ? PAD.left : xAt(i - 1);
      var x1 = xAt(i), y = yAt(stair[i]);
      d += (i === 0 ? "M" + x0 + " " + y : "L" + x0 + " " + y) + " L" + x1 + " " + y + " ";
    });
    svg.appendChild(el("path", { d: d.trim(), fill: "none", stroke: accent, "stroke-width": 3, "stroke-linejoin": "round", opacity: .9 }));

    POINTS.forEach(function (p, i) {
      var x = xAt(i), isSel = i === sel;
      var g = el("g", { style: "cursor:pointer" });
      g.appendChild(el("rect", { x: x - 34, y: PAD.top, width: 68, height: H - PAD.bottom - PAD.top, fill: "transparent" }));

      if (p.kind === "sampled") {
        var yA = yAt(p.avg), yMx = yAt(p.max);
        g.appendChild(el("line", { x1: x, y1: yA, x2: x, y2: yMx, stroke: isSel ? accent : muted(), "stroke-width": isSel ? 3 : 2 }));
        [yA, yMx].forEach(function (yy) { g.appendChild(el("line", { x1: x - 6, y1: yy, x2: x + 6, y2: yy, stroke: isSel ? accent : muted(), "stroke-width": isSel ? 3 : 2 })); });
        g.appendChild(el("circle", { cx: x, cy: yA, r: isSel ? 6 : 4.5, fill: "var(--global-bg-color)", stroke: isSel ? accent : muted(), "stroke-width": 2 }));
        // average label centered *below* the dot -- the whisker only goes upward, so this space is clear
        var lab = el("text", { x: x, y: yA + 22, "text-anchor": "middle", "font-size": isSel ? sz(18) : sz(15), fill: isSel ? accent : txt(), "font-weight": 600 });
        lab.textContent = p.avg; g.appendChild(lab);
      } else {
        var open = (p.kind === "lower");
        var y = yAt(p.worst);
        g.appendChild(el("circle", { cx: x, cy: y, r: isSel ? 8 : 6,
          fill: open ? "var(--global-bg-color)" : (isSel ? accent : "var(--global-bg-color)"),
          stroke: accent, "stroke-width": isSel ? 2.5 : 2, "stroke-dasharray": open ? "3 3" : "none" }));
        var lab2 = el("text", { x: x, y: y - 15, "text-anchor": "middle", "font-size": isSel ? sz(20) : sz(16), fill: isSel ? accent : txt(), "font-weight": 600 });
        lab2.textContent = open ? "45?" : p.worst; g.appendChild(lab2);
      }

      if (p.bt) {
        var ytop = yAt(p.kind === "sampled" ? p.max : p.worst);
        var byOff = sz(p.kind === "sampled" ? 30 : 38);
        var by = ytop - byOff;
        var cr = sz(10);
        g.appendChild(el("circle", { cx: x, cy: by - sz(5), r: cr, fill: "#fbe6c4", stroke: "#e8912b", "stroke-width": 1.5 }));
        var bt = el("text", { x: x, y: by, "text-anchor": "middle", "font-size": sz(16), fill: "#b4690e", "font-weight": 700 });
        bt.textContent = "⚡"; g.appendChild(bt);
      }

      var xlY = H - PAD.bottom + 28;
      var xlAttrs = { x: x, y: xlY, "font-size": sz(14), fill: isSel ? accent : muted(), "font-weight": isSel ? 600 : 400 };
      if (narrowPhone) {
        xlAttrs["text-anchor"] = "end";
        xlAttrs["transform"] = "rotate(-45 " + x + " " + xlY + ")";
      } else {
        xlAttrs["text-anchor"] = "middle";
      }
      var xl = el("text", xlAttrs);
      xl.textContent = p.x; g.appendChild(xl);

      g.addEventListener("click", function () { sel = i; render(); });
      svg.appendChild(g);
    });

    if (!narrowPhone) {
      var yt = el("text", { x: 16, y: (PAD.top + H - PAD.bottom) / 2, "text-anchor": "middle", "font-size": sz(16), fill: muted(),
        transform: "rotate(-90 16 " + ((PAD.top + H - PAD.bottom) / 2) + ")" });
      yt.textContent = "probes (worst case)"; svg.appendChild(yt);
    }
  }

  var wrap = document.querySelector("#pg-evo .pg-chart-wrap");
  function showTip(i, e) {
    var p = POINTS[i];
    var num = (p.kind === "sampled") ? (p.avg + " avg · worst " + p.max) : (p.kind === "lower" ? "45 still open" : p.worst + " worst case");
    tip.innerHTML = "<b>" + p.name + "</b> -- " + num + "<br><b>Tried:</b> " + p.tried + "<br><b>Found:</b> " + p.found;
    tip.hidden = false; moveTip(e);
  }
  function moveTip(e) {
    if (tip.hidden) return;
    var r = wrap.getBoundingClientRect();
    var x = e.clientX - r.left + 14, y = e.clientY - r.top + 14;
    if (x + tip.offsetWidth > r.width) x = e.clientX - r.left - tip.offsetWidth - 14;
    tip.style.left = Math.max(4, x) + "px"; tip.style.top = Math.max(4, y) + "px";
  }
  function hideTip() { tip.hidden = true; }

  // Robust hover: hit-test the nearest point by x at the SVG level (survives synthetic events
  // and the gaps between markers; on touch devices, tapping selects via the per-point click).
  function nearestIndex(e) {
    var r = svg.getBoundingClientRect();
    var fx = (e.clientX - r.left) / r.width * W;
    var bi = 0, bd = Infinity;
    for (var i = 0; i < POINTS.length; i++) { var d = Math.abs(xAt(i) - fx); if (d < bd) { bd = d; bi = i; } }
    var half = (W - PAD.left - PAD.right) / POINTS.length / 2 + 6;
    return bd <= half ? bi : -1;
  }
  svg.addEventListener("mousemove", function (e) { var i = nearestIndex(e); if (i < 0) { hideTip(); return; } showTip(i, e); });
  svg.addEventListener("mouseleave", hideTip);
  // Mobile: show tip on tap, dismiss when touching outside the chart
  svg.addEventListener("touchstart", function (e) {
    var t = e.changedTouches[0];
    var i = nearestIndex(t);
    if (i >= 0) { e.preventDefault(); showTip(i, t); } else hideTip();
  }, { passive: false });
  document.addEventListener("touchstart", function (e) {
    if (!wrap.contains(e.target)) hideTip();
  }, { passive: true });

  function renderCard() {
    var p = POINTS[sel];
    var badge = (p.kind === "sampled") ? '<span class="pg-badge sampled">sampled</span>'
      : (p.kind === "lower") ? '<span class="pg-badge sampled">open</span>'
      : '<span class="pg-badge exact">proven</span>';
    var num = (p.kind === "sampled")
      ? (p.avg + " <small>avg · worst " + p.max + " · " + p.pct + " ≤ 45</small>" + badge)
      : (p.kind === "lower")
        ? ("45? <small>not yet forceable</small>" + badge)
        : (p.worst + " <small>probes, worst case</small>" + badge);
    card.innerHTML =
      '<div class="pg-card-head"><span class="pg-card-title">' + p.name + '</span><span class="pg-card-num">' + num + '</span></div>' +
      '<p class="pg-card-desc"><b>Tried:</b> ' + p.tried + ' <b>Found:</b> ' + p.found + '</p>';
    stepLabel.textContent = "idea " + (sel + 1) + " / " + POINTS.length;
    prevBtn.disabled = sel === 0;
    nextBtn.disabled = sel === POINTS.length - 1;
  }

  function render() { draw(); renderCard(); }
  prevBtn.addEventListener("click", function () { if (sel > 0) { sel--; render(); } });
  nextBtn.addEventListener("click", function () { if (sel < POINTS.length - 1) { sel++; render(); } });
  document.addEventListener("keydown", function (e) {
    if (!svg.isConnected) return;
    if (e.key === "ArrowLeft" && sel > 0) { sel--; render(); }
    if (e.key === "ArrowRight" && sel < POINTS.length - 1) { sel++; render(); }
  });
  render();
})();
</script> <script>
(function () {
  // ── Explore the game: probe a hidden permutation; consistent-count via exact permanent. ──
  // K = bits per crystal, N = 2^K crystals, FLOOR = information floor ⌈log2 N!⌉.
  var N, K, FLOOR, ALLBITS;
  var grid = document.getElementById("cg-grid");
  if (!grid) return;
  var elProbes = document.getElementById("cg-probes");
  var elCons = document.getElementById("cg-cons");
  var elMsg = document.getElementById("cg-msg");
  var selStrat = document.getElementById("cg-strategy");
  var runBtn = document.getElementById("cg-run");
  var newBtn = document.getElementById("cg-new");
  var revealBtn = document.getElementById("cg-reveal");
  var speed = document.getElementById("cg-speed");

  var sigma, revealed, revealBit, probes, solved, seed, running, cells = [], valCells = [], ansCells = [], answerShown = false;

  // simple deterministic PRNG so "new shuffle" is reproducible-ish across a session
  function rng() { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }

  function shuffle() {
    sigma = []; for (var i = 0; i < N; i++) sigma.push(i);
    for (var j = N - 1; j > 0; j--) { var m = Math.floor(rng() * (j + 1)); var t = sigma[j]; sigma[j] = sigma[m]; sigma[m] = t; }
  }

  // popcount + Ryser permanent of the 0/1 compatibility matrix (rows = slots, cols = values).
  function popcount(x) { x = x - ((x >> 1) & 0x55555555); x = (x & 0x33333333) + ((x >> 2) & 0x33333333); return (((x + (x >> 4)) & 0x0f0f0f0f) * 0x01010101) >> 24; }
  function permanent(rows) {
    // rows[i] = bitmask over 16 values still compatible with slot i.
    var n = rows.length, row = new Int32Array(n), result = 0, i, k;
    for (k = 1; k < (1 << n); k++) {
      var g = k ^ (k >> 1), prevg = (k - 1) ^ ((k - 1) >> 1), bit = g ^ prevg;
      var j = 31 - Math.clz32(bit), add = (g & bit) !== 0, prod = 1;
      for (i = 0; i < n; i++) { row[i] += add ? ((rows[i] >> j) & 1) : -((rows[i] >> j) & 1); }
      for (i = 0; i < n; i++) { prod *= row[i]; if (prod === 0) break; }
      if (prod !== 0) result += (popcount(g) & 1 ? -1 : 1) * prod;
    }
    return (n & 1 ? -1 : 1) * result;
  }
  // Exact BigInt version -- used for the displayed count and win detection (no float drift).
  function permanentBig(rows) {
    var n = rows.length, row = new Array(n).fill(0), result = 0n, i, k;
    for (k = 1; k < (1 << n); k++) {
      var g = k ^ (k >> 1), bit = g ^ ((k - 1) ^ ((k - 1) >> 1));
      var j = 31 - Math.clz32(bit), add = (g & bit) !== 0, zero = false;
      for (i = 0; i < n; i++) { row[i] += add ? ((rows[i] >> j) & 1) : -((rows[i] >> j) & 1); }
      for (i = 0; i < n; i++) { if (row[i] === 0) { zero = true; break; } }
      if (!zero) { var prod = 1n; for (i = 0; i < n; i++) prod *= BigInt(row[i]); result += (popcount(g) & 1 ? -1n : 1n) * prod; }
    }
    return (n & 1 ? -1n : 1n) * result;
  }
  function liveCount(m) { var c = 0; for (var v = 0; v < N; v++) c += (m >> v) & 1; return c; }

  // compatibility mask for a slot given revealed bits
  function slotMask(i) {
    var mask = 0;
    for (var v = 0; v < N; v++) {
      var ok = true;
      for (var b = 0; b < K; b++) { if ((revealed[i] >> b) & 1) { if (((v >> b) & 1) !== ((revealBit[i] >> b) & 1)) { ok = false; break; } } }
      if (ok) mask |= (1 << v);
    }
    return mask;
  }
  function allRows() { var r = []; for (var i = 0; i < N; i++) r.push(slotMask(i)); return r; }
  function consistentCount() { return permanentBig(allRows()); } // exact BigInt

  function fmt(n) { return n.toLocaleString("en-US"); }

  function build() {
    grid.innerHTML = "";
    grid.style.gridTemplateColumns = "auto repeat(" + K + ", clamp(26px, 7vw, 36px)) auto auto";
    grid.appendChild(mk("div", "cg-h", ""));
    for (var hb = K - 1; hb >= 0; hb--) grid.appendChild(mk("div", "cg-h", "prop " + hb));
    grid.appendChild(mk("div", "cg-h", "value"));
    grid.appendChild(mk("div", "cg-h cg-sep", "answer"));
    cells = []; valCells = []; ansCells = [];
    for (var i = 0; i < N; i++) {
      grid.appendChild(mk("div", "cg-rowlab", "card " + (i + 1)));
      var row = [];
      for (var b = K - 1; b >= 0; b--) {
        var c = mk("div", "cg-cell cg-q", "?");
        c.setAttribute("role", "button");
        (function (ii, bb, cell) { cell.addEventListener("click", function () { if (!running && selStrat.value === "manual") probe(ii, bb, true); }); })(i, b, c);
        row[b] = c; grid.appendChild(c);
      }
      cells.push(row);
      var vc = mk("div", "cg-val", ""); valCells.push(vc); grid.appendChild(vc);
      var ac = mk("div", "cg-ans", "?"); ansCells.push(ac); grid.appendChild(ac);
    }
  }
  // The ground-truth hidden permutation column -- masked with "?" until the reader reveals it.
  function renderAnswer() {
    for (var i = 0; i < N; i++) {
      ansCells[i].textContent = answerShown ? sigma[i] : "?";
      ansCells[i].className = "cg-ans" + (answerShown ? " cg-ans-show" : "");
    }
  }
  function mk(tag, cls, txt) { var n = document.createElement(tag); n.className = cls; if (txt) n.textContent = txt; return n; }

  function reset(keepSigma) {
    revealed = new Int32Array(N); revealBit = new Int32Array(N); probes = 0; solved = false;
    if (!keepSigma) shuffle();
    for (var i = 0; i < N; i++) { for (var b = 0; b < K; b++) { var c = cells[i][b]; c.className = "cg-cell cg-q"; c.textContent = "?"; } valCells[i].textContent = ""; valCells[i].className = "cg-val"; }
    elProbes.textContent = "0";
    elCons.textContent = fmt(consistentCount());
    renderAnswer();
    setMsg(selStrat.value === "manual" ? 'Click any <span class="cg-chip">?</span> to check that property of that card.' : 'Press <b>Run ▶</b> to watch this strategy play.', false);
    syncControls();
  }

  function setMsg(html, win) { elMsg.innerHTML = html; elMsg.className = "cg-msg" + (win ? " cg-win" : ""); }

  function paint(i, b) {
    var bitval = (sigma[i] >> b) & 1, c = cells[i][b];
    c.className = "cg-cell cg-known " + (bitval ? "cg-one" : "cg-zero");
    c.textContent = bitval;
  }
  function flash(i, b) { var c = cells[i][b]; c.classList.add("cg-flash"); setTimeout(function () { c.classList.remove("cg-flash"); }, 240); }

  // reveal a single (slot,bit). Returns false if already known.
  function probe(i, b, doFlash) {
    if (solved || ((revealed[i] >> b) & 1)) return false;
    revealed[i] |= (1 << b); revealBit[i] |= (((sigma[i] >> b) & 1) << b);
    probes++; elProbes.textContent = probes;
    paint(i, b); if (doFlash) flash(i, b);
    var c = consistentCount();
    elCons.textContent = fmt(c);
    if (c === 1n) finish();
    return true;
  }

  function finish() {
    solved = true;
    // fill in any un-probed bits as inferred (greyed) so the full arrangement shows
    for (var i = 0; i < N; i++) {
      for (var b = 0; b < K; b++) {
        if (!((revealed[i] >> b) & 1)) { var bitval = (sigma[i] >> b) & 1, c = cells[i][b];
          c.className = "cg-cell cg-known cg-infer " + (bitval ? "cg-one" : "cg-zero"); c.textContent = bitval; }
      }
      valCells[i].textContent = sigma[i]; valCells[i].className = "cg-val cg-val-show";
    }
    setMsg("Solved in <b>" + probes + "</b> probes -- every card's value now sits on the right. (The floor of " + FLOOR + " is a <i>worst-case</i> promise; a typical shuffle folds sooner.)", true);
    syncControls();
  }

  // ── strategies ──────────────────────────────────────────────────────────
  // A probe is only worth spending if its answer isn't already forced -- i.e. if both
  // outcomes are still possible. If one child is empty, the bit is already determined by
  // elimination, so a good strategy skips it. This is exactly what turns a blind readout
  // into the tidy layer-by-layer and MSB-split counts.
  function isForced(i, b, rows) {
    var r0 = rows.slice(); r0[i] = rows[i] & maskBit(b, 0);
    if (permanent(r0) === 0) return true;
    var r1 = rows.slice(); r1[i] = rows[i] & maskBit(b, 1);
    return permanent(r1) === 0;
  }
  function nextLayer() {
    var rows = allRows();
    for (var b = K - 1; b >= 0; b--) for (var i = 0; i < N; i++)
      if (!((revealed[i] >> b) & 1) && !isForced(i, b, rows)) return [i, b];
    return null;
  }
  function nextMSB() {
    // Read the top bit of every slot first (skipping the forced one) -- the MSB partition into two
    // eight-crystal halves -- then solve the rest greedily, the way D(8) resolves each half.
    var rows = allRows();
    for (var i = 0; i < N; i++) if (!((revealed[i] >> (K - 1)) & 1) && !isForced(i, K - 1, rows)) return [i, K - 1];
    return nextGreedy();
  }
  // Rank every distinct candidate (crystal,bit) by the exact worst-child permanent -- the greedy
  // score. Dedup by (row-mask, bit): crystals with identical live-value sets give identical
  // worst-child permanents (the permanent is invariant under row swaps), collapsing the dense
  // early game from ~60 evals to a handful. `bits` restricts which detectors are eligible.
  // Uses the fast double permanent -- tiny float drift never flips an arg-min at these margins.
  function scoredCandidates(bits) {
    var rows = allRows(), seen = {}, list = [];
    for (var i = 0; i < N; i++) {
      if (liveCount(rows[i]) < 2) continue;
      for (var bi = 0; bi < bits.length; bi++) {
        var b = bits[bi];
        if ((revealed[i] >> b) & 1) continue;
        var key = rows[i] * 8 + b;
        if (seen[key]) continue; seen[key] = 1;
        var r0 = rows.slice(); r0[i] = rows[i] & maskBit(b, 0);
        var r1 = rows.slice(); r1[i] = rows[i] & maskBit(b, 1);
        list.push({ move: [i, b], score: Math.max(permanent(r0), permanent(r1)) });
      }
    }
    list.sort(function (a, c) { return a.score - c.score; });
    return list;
  }
  function nextGreedy() { var l = scoredCandidates(ALLBITS); return l.length ? l[0].move : null; }
  // Distilled network: trained to imitate the permanent-greedy. It reproduces the greedy move
  // most of the time and never out-plays it -- an occasional imitation slip takes the 2nd-best.
  function nextNN() {
    var l = scoredCandidates(ALLBITS); if (!l.length) return null;
    return (l.length > 1 && rng() < 0.15) ? l[1].move : l[0].move;
  }
  // The 46-query policy in miniature: first pin down each crystal's 3-bit GROUP (detectors 1–3) —
  // the phase the real solver drives from a 59.5M-state lookup table -- then one parity probe
  // (detector 0) per pair. Here the group phase is played greedily as a stand-in for that table.
  function nextQuotient() {
    var rows = allRows();
    var groups = scoredCandidates([1, 2, 3]);
    if (groups.length) { var mv = groups[0].move; if (!isForced(mv[0], mv[1], rows)) return mv; }
    for (var i = 0; i < N; i++) if (!((revealed[i] >> 0) & 1) && !isForced(i, 0, rows)) return [i, 0];
    return null;
  }
  // values whose bit b equals val
  function maskBit(b, val) { var m = 0; for (var v = 0; v < N; v++) if (((v >> b) & 1) === val) m |= (1 << v); return m; }

  // ── k=3 optimal decision tree (16 probes = the information floor, proven optimal) ──
  // Play-time minimax is exact but ~20s cold, so the optimal policy is precomputed offline and
  // embedded (window.CG_K3_POLICY), keyed by a canonical state under the 48 cube automorphisms
  // (3! bit-permutations × 8 XOR masks). The same table also drives the fixed-opening strategy —
  // both are verified over all 8! permutations to solve in ≤ 16.
  var OPENING_K3 = [[0, 2], [1, 2], [2, 2], [3, 1], [4, 1], [5, 0]]; // the documented [0,3,6,10,13,17]
  var AUTOS = null;
  function buildAutos() { // only for K=3
    var sigmas = [];
    (function pp(a) { if (a.length === K) { sigmas.push(a.slice()); return; } for (var i = 0; i < K; i++) if (a.indexOf(i) < 0) { a.push(i); pp(a); a.pop(); } })([]);
    AUTOS = [];
    for (var s = 0; s < sigmas.length; s++) for (var x = 0; x < N; x++) {
      var sig = sigmas[s], val = new Array(N);
      for (var v = 0; v < N; v++) { var r = 0; for (var i = 0; i < K; i++) r |= ((v >> sig[i]) & 1) << i; val[v] = r ^ x; }
      AUTOS.push({ sigma: sig, val: val });
    }
  }
  function remapMask(m, val) { var r = 0; for (var v = 0; v < N; v++) if ((m >> v) & 1) r |= (1 << val[v]); return r; }
  function canon(rows) { // canonical form under value automorphisms + crystal sorting
    var best = null;
    for (var a = 0; a < AUTOS.length; a++) {
      var val = AUTOS[a].val, rm = rows.map(function (m) { return remapMask(m, val); });
      var idx = rm.map(function (m, i) { return i; }).sort(function (p, q) { return rm[p] - rm[q]; });
      var kstr = idx.map(function (i) { return rm[i]; }).join(",");
      if (best === null || kstr < best.kstr) best = { kstr: kstr, a: AUTOS[a], idx: idx };
    }
    return best;
  }
  function nextOptimalK3() {
    var POL = window.CG_K3_POLICY;
    if (!POL) return nextLayer(); // asset not loaded → fall back to a valid (≤17) strategy
    var rows = allRows(), cc = canon(rows), code = POL[cc.kstr + "|" + (FLOOR - probes)];
    if (code === undefined) return nextLayer();
    var canonPos = Math.floor(code / K), canonBit = code % K;
    return [cc.idx[canonPos], cc.a.sigma[canonBit]];
  }
  // Fixed opening: six blind, non-adaptive probes committed up front, then the optimal policy.
  // Same worst case (16) -- the opening is provably part of an optimal strategy.
  function nextFixedK3() {
    if (probes < OPENING_K3.length) {
      var mv = OPENING_K3[probes];
      if (!((revealed[mv[0]] >> mv[1]) & 1)) return mv;
    }
    return nextOptimalK3();
  }

  function nextFor(strat) {
    if (strat === "layer") return nextLayer();
    if (strat === "msb") return nextMSB();
    if (strat === "greedy") return nextGreedy();
    if (strat === "nn") return nextNN();
    if (strat === "quot") return nextQuotient();
    if (strat === "rec17") return nextLayer();
    if (strat === "opt16") return nextOptimalK3();
    if (strat === "opt5") return nextGreedy();
    if (strat === "fixed") return nextFixedK3();
    return null;
  }

  function run() {
    if (running || solved) return;
    running = true; syncControls();
    var strat = selStrat.value, delay = 420 - Number(speed.value);
    (function tick() {
      if (!running) return;
      if (solved) { running = false; syncControls(); return; }
      var nb = nextFor(strat);
      if (!nb) { running = false; if (!solved) setMsg("Strategy exhausted its probes.", false); syncControls(); return; }
      probe(nb[0], nb[1], true);
      if (solved) { running = false; syncControls(); return; }
      setTimeout(tick, Math.max(0, delay));
    })();
  }

  function syncControls() {
    var manual = selStrat.value === "manual";
    runBtn.disabled = manual || running || solved;
    newBtn.disabled = running;
    selStrat.disabled = running;
  }

  // strategy menus per mode
  var STRAT_OPTS = {
    2: [
      ["manual", "Play it yourself"],
      ["opt5", "Optimal (5 -- meets the information floor)"]
    ],
    4: [
      ["manual", "Play it yourself"],
      ["greedy", "Exact-permanent greedy (near-optimal, ~45)"],
      ["nn", "Learned policy -- neural-net stand-in (imitates greedy)"],
      ["quot", "3 + 1 quotient decomposition (the 46 policy)"],
      ["msb", "MSB split → solve each half (the 47 idea)"],
      ["layer", "Layer-by-layer (a tidy ≤ 49)"]
    ],
    3: [
      ["manual", "Play it yourself"],
      ["opt16", "Optimal decision tree (16 -- proven best)"],
      ["fixed", "Fixed opening → optimal (16, blind first 6)"],
      ["rec17", "Recursion / layer-by-layer (17)"]
    ]
  };
  function populateStrat() {
    selStrat.innerHTML = "";
    STRAT_OPTS[K].forEach(function (o) { var op = document.createElement("option"); op.value = o[0]; op.textContent = o[1]; selStrat.appendChild(op); });
  }
  var modeBtns = document.querySelectorAll("#cg-modes .cg-mode");
  function setMode(k) {
    if (running) return;
    K = k; N = 1 << k; FLOOR = { 2: 5, 3: 16, 4: 45 }[k]; ALLBITS = []; for (var b = 0; b < K; b++) ALLBITS.push(b);
    if (k === 3 && !AUTOS) buildAutos();
    document.getElementById("cg-floor").textContent = FLOOR;
    answerShown = false; revealBtn.textContent = "Reveal answer";
    for (var mb = 0; mb < modeBtns.length; mb++) modeBtns[mb].classList.toggle("cg-mode-on", +modeBtns[mb].getAttribute("data-k") === k);
    populateStrat();
    build(); reset(false);
  }
  for (var mi = 0; mi < modeBtns.length; mi++) modeBtns[mi].addEventListener("click", function () { setMode(+this.getAttribute("data-k")); });

  selStrat.addEventListener("change", function () { reset(true); });
  runBtn.addEventListener("click", run);
  newBtn.addEventListener("click", function () { if (running) return; reset(false); });
  revealBtn.addEventListener("click", function () {
    answerShown = !answerShown;
    revealBtn.textContent = answerShown ? "Hide answer" : "Reveal answer";
    renderAnswer();
  });

  seed = 20260610; running = false;
  setMode(2);
})();
</script>]]></content><author><name></name></author><category term="research"/><category term="llm"/><category term="combinatorics"/><category term="search"/><category term="agents"/><summary type="html"><![CDATA[Sixteen cards, four binary properties, and an LLM agent left alone with a machine and its weekends -- chasing D(16), the fewest probes that always pin down a hidden arrangement. It walked the best-known strategy down 64 → 60 → 49 → 47 → 46, one probe above the information floor, where an old ghost from sorting is waiting.]]></summary></entry><entry><title type="html">Do the math where it’s easy</title><link href="https://snwagh.com/blog/2025/wave-hello-to-privacy/" rel="alternate" type="text/html" title="Do the math where it’s easy"/><published>2025-07-15T00:00:00+00:00</published><updated>2025-07-15T00:00:00+00:00</updated><id>https://snwagh.com/blog/2025/wave-hello-to-privacy</id><content type="html" xml:base="https://snwagh.com/blog/2025/wave-hello-to-privacy/"><![CDATA[<p>A surprising amount of secure computation comes down to <strong>looking something up in a table</strong>. A non-linear function — a sigmoid, a reciprocal, an S-box — is often easiest to express as “given index \(i\), return \(T[i]\).” Our <a href="https://eprint.iacr.org/2025/013">recent work</a> builds fast lookup-table protocols on one beautifully simple piece of math.</p> <p>Start with the observation that a table lookup is secretly an <strong>inner product</strong>. Encode the index as a <strong>one-hot vector</strong> \(e_i\) (all zeros except a single 1 in slot \(i\)) and then</p> \[\langle\, e_i,\; T \,\rangle = T[i].\] <p>The lookup <em>is</em> the dot product of a selector with the table. Now the key idea: $\textbf{Parseval’s theorem.}$ Under an orthonormal transform — Fourier, or here a <strong>wavelet</strong> transform — the inner product is <em>identical in both domains</em>:</p> \[\langle\, x,\; y \,\rangle \;=\; \langle\, \hat{x},\; \hat{y} \,\rangle.\] <figure class="post-fig" id="pg-parseval" aria-label="The same dot product computed in the signal domain (a one-hot selector against the table) and in the wavelet domain (the transformed selector against the transformed, now sparse, table) gives the same value."> <svg viewBox="0 0 660 340" role="img"></svg> <figcaption>The dot product is the same on both sides — but on the right the table is <em>sparse</em>, so it's far cheaper to evaluate securely.</figcaption> </figure> <p>So you’re free to compute the lookup <strong>in whichever domain is cheaper</strong>. The protocol moves the table into the wavelet domain, where the discrete wavelet transform concentrates its structure into a handful of large coefficients (the rest are tiny). The secure inner product against the transformed selector then costs far less work — and by Parseval it lands on exactly the same answer, \(T[i]\).</p> <p>That’s the whole trick: a lookup is an inner product, an inner product doesn’t care which domain you compute it in, and the wavelet domain is where it’s cheap. While the fundamental idea is simple, there is a lot of mathematics that makes DWT compress a lookup table well. The ability to be able to draw the higher order terms is what provides the compression. And in terms of bit length, this might translate into a lookup table of size $2^{29} \approx$ half a billion entries to a look up of aobut $2^{12} \approx 4000$ entries.</p> <p class="paper-links"> <a href="https://eprint.iacr.org/2025/013" target="_blank" rel="noopener noreferrer">PDF</a> <a href="https://github.com/NillionNetwork/WaveHelloToPrivacy" target="_blank" rel="noopener noreferrer">Code</a> <span>Wave Hello to Privacy · PETS 2025</span> </p> <script>
(function () {
  const svg = document.querySelector("#pg-parseval svg");
  if (!svg) return;
  const NS = "http://www.w3.org/2000/svg";
  const cssVar = n => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
  const accent = cssVar("--global-theme-color") || "#3873b3";
  const muted = cssVar("--global-text-color-lighter") || "#999";
  const txt = cssVar("--global-text-color-light") || "#666";
  const font = "var(--font-sans, system-ui, sans-serif)";
  const el = (t, a) => { const n = document.createElementNS(NS, t); for (const k in a) n.setAttribute(k, a[k]); return n; };
  const label = (x, y, s, opts = {}) => { const t = el("text", Object.assign({ x, y, "text-anchor": opts.anchor || "middle", "font-size": opts.size || 12, fill: opts.fill || txt, "font-family": font, "font-weight": opts.w || 400 }, {})); t.textContent = s; svg.appendChild(t); };

  // draw a vector as bars on a baseline; fill 'all' or per-bar accent
  function vec(startX, baseline, heights, color) {
    const w = 18, pitch = 24, maxH = 60;
    heights.forEach((h, j) => {
      const x = startX + j * pitch;
      svg.appendChild(el("rect", { x, y: baseline - 3, width: w, height: 3, fill: muted, opacity: 0.5 })); // slot tick
      if (h > 0) svg.appendChild(el("rect", { x, y: baseline - h, width: w, height: h, fill: color, rx: 1 }));
    });
  }
  const T = [30, 52, 22, 44, 38, 18, 48, 28];
  const e = [0, 0, 0, 60, 0, 0, 0, 0];
  const Th = [60, 50, 13, 8, 6, 5, 4, 3];   // sparse: few large coefficients
  const eh = [34, 28, 38, 30, 35, 25, 33, 29]; // spread

  // panel titles + transform arrow
  label(170, 26, "signal domain", { size: 13, w: 600, fill: txt });
  label(500, 26, "wavelet domain", { size: 13, w: 600, fill: txt });
  svg.appendChild(el("path", { d: "M300 20 H360", stroke: muted, "stroke-width": 1.5, "marker-end": "" }));
  svg.appendChild(el("path", { d: "M353 14 L362 20 L353 26", fill: "none", stroke: muted, "stroke-width": 1.5 }));
  label(330, 12, "DWT", { size: 11, fill: muted });

  // left (signal): selector e (one-hot) and table T
  label(60, 70, "selector eᵢ (one-hot)", { anchor: "start", size: 11.5, fill: muted });
  vec(60, 150, e, accent);
  label(60, 178, "table T", { anchor: "start", size: 11.5, fill: muted });
  vec(60, 250, T, muted);
  label(166, 290, "⟨ eᵢ , T ⟩ = T[i]", { size: 13, w: 600, fill: accent });

  // right (wavelet): transformed selector and sparse transformed table
  label(420, 70, "transformed selector", { anchor: "start", size: 11.5, fill: muted });
  vec(420, 150, eh, accent);
  label(420, 178, "transformed table (sparse)", { anchor: "start", size: 11.5, fill: muted });
  vec(420, 250, Th, muted);
  label(500, 290, "⟨ êᵢ , T̂ ⟩", { size: 13, w: 600, fill: accent });

  // equals (Parseval)
  label(330, 250, "=", { size: 26, w: 700, fill: txt });
  label(330, 322, "Parseval: same dot product, either domain", { size: 11.5, fill: txt });
})();
</script>]]></content><author><name></name></author><category term="research"/><category term="mpc"/><category term="cryptography"/><category term="signal-processing"/><summary type="html"><![CDATA[Fast private lookup tables, explained through one idea you already know — Parseval's theorem. A dot product is the same in the signal domain and the wavelet domain, so compute it wherever it's cheapest.]]></summary></entry><entry><title type="html">Secure enclaves for AI evaluation</title><link href="https://snwagh.com/blog/2025/secure-enclaves/" rel="alternate" type="text/html" title="Secure enclaves for AI evaluation"/><published>2025-04-15T00:00:00+00:00</published><updated>2025-04-15T00:00:00+00:00</updated><id>https://snwagh.com/blog/2025/secure-enclaves</id><content type="html" xml:base="https://snwagh.com/blog/2025/secure-enclaves/"><![CDATA[<p>This post lives on the OpenMined blog → <a href="https://openmined.org/blog/secure-enclaves-for-ai-evaluation/">Secure Enclaves for AI Evaluation</a>.</p>]]></content><author><name></name></author><category term="research"/><category term="privacy"/><category term="ai-safety"/><category term="enclaves"/><summary type="html"><![CDATA[Evaluating a frontier model on sensitive data when neither side wants to trust the other — work with Anthropic and the UK AI Safety Institute, on the OpenMined blog.]]></summary></entry><entry><title type="html">Should we swap numbers? A secret way to find out</title><link href="https://snwagh.com/blog/2023/card-based-crypto/" rel="alternate" type="text/html" title="Should we swap numbers? A secret way to find out"/><published>2023-10-18T00:00:00+00:00</published><updated>2023-10-18T00:00:00+00:00</updated><id>https://snwagh.com/blog/2023/card-based-crypto</id><content type="html" xml:base="https://snwagh.com/blog/2023/card-based-crypto/"><![CDATA[<script defer="" src="/assets/js/post-toc.js"></script> <blockquote> <p>“If you can’t explain something to a first-year student, then you haven’t really understood it.” — Richard Feynman</p> </blockquote> <p>I was recently asked to explain some of my research to a broad, non-technical audience, and this quote started nagging at me: did I really understand secure computation well enough to make it <em>that</em> simple? Thankfully, a delightful corner of the field came to my rescue.</p> <h2 id="a-modern-dilemma">A modern dilemma</h2> <p>You’ve just met someone wonderful at a party, and you’d each privately like to answer the same question: <em>would I want to swap numbers with this person?</em></p> <p>The trouble is that neither of you wants to say it out loud. Admitting “yes” to someone who would have said “no” is exactly the kind of face-losing moment you’d rather avoid. So what you really want is only the <em>combined</em> answer: <strong>do we both want to exchange numbers?</strong> You’ll swap only if both of you said yes — like a dating app’s match, but with no app (and no company) sitting in the middle. How do you pull that off?</p> <p>Mathematically, each of you holds a private bit — yes (<code class="language-plaintext highlighter-rouge">1</code>) or no (<code class="language-plaintext highlighter-rouge">0</code>) — and you want to compute one function of them: their logical <strong>AND</strong>. The hard part is the privacy requirement: you must learn the value of that AND and <em>nothing else</em>. If the answer comes out “no,” neither of you should be able to tell whether it was one no or two.</p> <p>Remarkably, you can do exactly this with nothing but a handful of playing cards. Scroll through it below — the cards in the panel respond as you read — and at the end, you can run the whole thing yourself.</p> <style>
.cc-scope{
  --cc-felt:#10241f; --cc-felt2:#0b1a17; --cc-line:rgba(255,255,255,.14);
  --cc-ink:#f3efe6; --cc-mut:#9fb3ad; --cc-red:#e5484d; --cc-black:#1d2b28;
  --cc-gold:#e3b341; --cc-alice:#ef7d57; --cc-bob:#5a7d9a; --cc-ref:#d4a02a;
  --cc-yes:#3fb27f; --cc-card:#faf7f0; --cc-back:#2b6b63;
  position:relative; margin:2.2rem 0; color:var(--cc-ink);
  font-family:inherit; line-height:1.5;
}
.cc-scope *{box-sizing:border-box;}
.cc-grid{display:block;}

/* ---- sticky full-width stage ---- */
.cc-stagewrap{position:sticky; top:5vh; z-index:1;}
.cc-stage{
  position:relative; width:100%; height:clamp(400px,64vh,540px); border-radius:18px;
  background:radial-gradient(120% 120% at 50% 18%, var(--cc-felt) 0%, var(--cc-felt2) 100%);
  box-shadow:0 18px 50px rgba(0,0,0,.35), inset 0 0 0 1px var(--cc-line);
  overflow:hidden;
}

/* ---- actors ---- */
.actor{position:absolute; width:15%; transform:translate(-50%,-50%);
  transition:opacity .5s, transform .5s; text-align:center;}
.actor svg{width:100%; height:auto; display:block; filter:drop-shadow(0 4px 8px rgba(0,0,0,.4));}
.actor .nm{font-size:.68rem; color:var(--cc-mut); margin-top:.2rem; font-weight:600;}
.actor.hide{opacity:0; pointer-events:none;}
#cc-alice{left:13%; top:24%;} #cc-bob{left:87%; top:24%;}
#cc-ref{left:50%; top:50%; width:13%;}
#cc-eve{left:50%; top:14%; width:12%;}
.actor.dim{opacity:.32;}

/* thought bubble */
.bubble{position:absolute; top:-44%; left:50%; transform:translateX(-50%) scale(.6);
  background:var(--cc-card); color:#16221f; border-radius:12px; padding:.2rem .55rem;
  font-weight:700; font-size:.8rem; white-space:nowrap; opacity:0;
  transition:opacity .45s, transform .45s; box-shadow:0 4px 10px rgba(0,0,0,.3);}
.bubble:after{content:""; position:absolute; bottom:-6px; left:50%; transform:translateX(-50%);
  border:6px solid transparent; border-top-color:var(--cc-card); border-bottom:0;}
.bubble .hi{color:var(--cc-yes);} .bubble .lo{color:var(--cc-red);}
.actor.think .bubble{opacity:1; transform:translateX(-50%) scale(1);}

/* ---- cards ---- */
.card{position:absolute; width:10%; aspect-ratio:5/7; transform:translate(-50%,-50%);
  transition:left .75s cubic-bezier(.5,0,.2,1), top .75s cubic-bezier(.5,0,.2,1),
             transform .75s cubic-bezier(.5,0,.2,1), opacity .45s;
  perspective:600px; opacity:0;}
.card.show{opacity:1;}
.card.spinning{transition:left .17s linear, top .17s linear, opacity .45s !important;}
.card .inner{position:relative; width:100%; height:100%;
  -webkit-transform-style:preserve-3d; transform-style:preserve-3d;
  transition:-webkit-transform .6s, transform .6s; border-radius:9px;}
.card.up .inner{-webkit-transform:rotateY(180deg); transform:rotateY(180deg);}
.card .face{position:absolute; inset:0;
  -webkit-backface-visibility:hidden; backface-visibility:hidden; border-radius:9px;
  display:flex; align-items:center; justify-content:center; font-size:1.5rem;
  box-shadow:0 5px 14px rgba(0,0,0,.4);
  transition:opacity 0s linear .3s;}   /* opacity fallback: flips the visible face at the 3D midpoint */
.card .back{background:
    repeating-linear-gradient(45deg, var(--cc-back) 0 6px, #225a53 6px 12px);
  border:2px solid #d8efe9; opacity:1;}
.card .back:before{content:""; position:absolute; inset:5px; border:1.5px solid rgba(255,255,255,.55); border-radius:5px;}
.card .front{background:var(--cc-card);
  -webkit-transform:rotateY(180deg); transform:rotateY(180deg); font-weight:700;
  border:1px solid #d9d2c2; opacity:0;}
.card.up .back{opacity:0;}
.card.up .front{opacity:1;}
.card.red  .front{color:var(--cc-red);}
.card.black .front{color:var(--cc-black);}
.card.lit .front{box-shadow:0 0 0 3px var(--cc-yes), 0 5px 14px rgba(0,0,0,.4);}
@media(max-width:560px){ .card .face{font-size:1.15rem;} .bubble{display:none;} }

/* ---- overlay panels (upper band, above the narration) ---- */
.panel{position:absolute; top:5%; left:6%; right:6%; bottom:42%; border-radius:14px;
  padding:.9rem 1.1rem; background:rgba(7,18,16,.78); border:1px solid var(--cc-line);
  backdrop-filter:blur(2px); opacity:0; pointer-events:none;
  transition:opacity .45s, transform .45s; transform:translateY(10px);
  display:flex; flex-direction:column; justify-content:center;}
.panel.on{opacity:1; transform:none;}
.panel h4{margin:0 0 .6rem; font-size:.92rem; color:var(--cc-ink); letter-spacing:.3px; text-align:center;}
/* truth table */
.tt{display:grid; grid-template-columns:repeat(3,1fr); gap:.3rem; font-size:.8rem; max-width:340px; margin:0 auto; width:100%;}
.tt div{padding:.4rem .2rem; text-align:center; border-radius:7px; background:rgba(255,255,255,.05);}
.tt .h{color:var(--cc-mut); background:transparent; font-weight:600; font-size:.72rem;}
.tt .y{color:var(--cc-yes); font-weight:700;} .tt .n{color:var(--cc-red); font-weight:700;}
.tt .row1>div{background:rgba(63,178,127,.14);}
/* encoding legend */
.enc{display:flex; gap:1.6rem; justify-content:center; align-items:center; flex-wrap:wrap;}
.enc .col{text-align:center;} .enc .pair{display:flex; gap:.3rem; justify-content:center; margin-bottom:.4rem;}
.enc .mini{width:32px; height:46px; border-radius:6px; background:var(--cc-card);
  display:flex; align-items:center; justify-content:center; font-size:1.15rem; font-weight:700;
  border:1px solid #d9d2c2; box-shadow:0 3px 8px rgba(0,0,0,.35);}
.enc .mini.red{color:var(--cc-red);} .enc .mini.black{color:var(--cc-black);}
.enc .lbl{font-size:.8rem; font-weight:700; color:var(--cc-yes);}
.enc .note{flex-basis:100%; text-align:center; font-size:.76rem; color:var(--cc-mut); margin-top:.2rem;}
/* vocab */
.vocab{display:flex; flex-direction:column; gap:.5rem;}
.vocab .v{display:flex; gap:.6rem; align-items:flex-start; font-size:.82rem;}
.vocab .ic{flex:0 0 28px; height:28px; border-radius:8px; display:flex; align-items:center;
  justify-content:center; font-size:1rem; background:rgba(255,255,255,.07);}
.vocab .v b{color:var(--cc-ink);} .vocab .v span{color:var(--cc-mut);}
/* cost chart */
.cost{display:flex; gap:1.8rem; justify-content:center; align-items:flex-end; height:70%;}
.cost .grp{display:flex; flex-direction:column; align-items:center; gap:.4rem; height:100%; justify-content:flex-end;}
.cost .bars{display:flex; gap:.5rem; align-items:flex-end; height:100%;}
.cost .bar{width:26px; border-radius:5px 5px 0 0; transition:height .8s cubic-bezier(.5,0,.2,1);}
.cost .bar.cmp{background:var(--cc-alice);} .cost .bar.com{background:var(--cc-bob);}
.cost .glab{font-size:.78rem; color:var(--cc-mut); font-weight:600;}
.cost .glab.priv{color:var(--cc-yes);}
.costkey{position:absolute; bottom:6%; left:0; right:0; text-align:center; font-size:.72rem; color:var(--cc-mut);}
.costkey i{font-style:normal; padding:0 .5rem;}
.costkey .d{display:inline-block; width:9px; height:9px; border-radius:2px; margin-right:4px; vertical-align:middle;}

/* result banner (top) */
.banner{position:absolute; left:50%; top:6%; transform:translateX(-50%) translateY(-8px);
  padding:.4rem 1rem; border-radius:999px; font-weight:700; font-size:.86rem; opacity:0;
  transition:opacity .5s, transform .5s; white-space:nowrap; box-shadow:0 6px 18px rgba(0,0,0,.4); z-index:3;}
.banner.on{opacity:1; transform:translateX(-50%);}
.banner.yes{background:var(--cc-yes); color:#06231a;}
.banner.no{background:#3a2a2c; color:var(--cc-red); border:1px solid var(--cc-red);}
.cross{position:absolute; left:50%; top:50%; width:18%; transform:translate(-50%,-50%);
  opacity:0; transition:opacity .4s;}
.cross.on{opacity:1;}
.cross svg{width:100%;}

/* ---- narration overlay at the bottom of the graphic ---- */
.cc-narr{position:absolute; left:0; right:0; bottom:0; padding:2.2rem 1.4rem 1.2rem;
  background:linear-gradient(to top, rgba(6,15,13,.97) 55%, rgba(6,15,13,.6) 85%, rgba(6,15,13,0));
  pointer-events:none; transition:opacity .3s; z-index:2;}
.cc-narr.hide{opacity:0;}
.cc-narr .nk{font-size:.68rem; letter-spacing:.14em; text-transform:uppercase;
  color:var(--cc-alice); font-weight:700; margin-bottom:.3rem;}
.cc-narr .nh{font-size:1.18rem; font-weight:600; line-height:1.25; margin-bottom:.35rem; color:var(--cc-ink);}
.cc-narr .np{font-size:.92rem; line-height:1.45; color:#d7e0dc; margin:0;}
@media(max-width:560px){ .cc-narr .nh{font-size:1.05rem;} .cc-narr .np{font-size:.85rem;} }

/* ---- scroll spacers: invisible, they just drive the story + feed the TOC ---- */
.cc-steps{position:relative; z-index:0;}
.cc-step{min-height:82vh;}
.cc-step:first-child{min-height:64vh;}
.cc-spacer{opacity:0; pointer-events:none; padding:1rem 0;}
.cc-step .k{font-size:.72rem; letter-spacing:.14em; text-transform:uppercase; color:var(--cc-alice);
  font-weight:700; margin-bottom:.5rem;}
.cc-step h3{margin:.1rem 0 .6rem; font-size:1.3rem; line-height:1.25;}
.cc-step p{margin:.5rem 0; font-size:1.02rem;}

/* ---- interactive tray (overlay on the final step) ---- */
.cc-tray{position:absolute; left:0; right:0; bottom:0; z-index:4;
  padding:.85rem 1.1rem 1rem; border-radius:0 0 18px 18px; color:var(--cc-ink);
  background:linear-gradient(to top, rgba(6,15,13,.98) 72%, rgba(6,15,13,0));
  opacity:0; pointer-events:none; transform:translateY(8px);
  transition:opacity .35s, transform .35s; max-height:80%; overflow:auto;}
.cc-tray.on{opacity:1; pointer-events:auto; transform:none;}
.cc-tray .tray-h{font-size:.7rem; letter-spacing:.13em; text-transform:uppercase;
  color:var(--cc-alice); font-weight:700; margin-bottom:.35rem;}
.cc-try .ctl{display:flex; gap:.45rem; flex-wrap:wrap; align-items:center; margin:.3rem 0;}
.cc-try .who{font-weight:700; min-width:64px; font-size:.85rem; color:var(--cc-mut);}
.cc-try button.opt{border:1px solid var(--cc-line); background:transparent; color:inherit;
  padding:.32rem .8rem; border-radius:999px; cursor:pointer; font:inherit; font-size:.88rem;
  transition:.15s;}
.cc-try button.opt.sel[data-v="1"]{background:var(--cc-yes); border-color:var(--cc-yes); color:#06231a;}
.cc-try button.opt.sel[data-v="0"]{background:var(--cc-red); border-color:var(--cc-red); color:#fff;}
.cc-try button.opt.mode.sel{background:var(--cc-bob); border-color:var(--cc-bob); color:#fff;}
.cc-try .secret{color:var(--cc-mut); font-size:.85rem; font-style:italic;}
.cc-try .run{margin-top:.7rem; padding:.5rem 1.2rem; border-radius:10px; border:0; cursor:pointer;
  font:inherit; font-weight:700; font-size:.95rem; background:var(--cc-gold); color:#241c00;}
.cc-try .run:disabled{opacity:.5; cursor:default;}
.cc-try .out{margin-top:.7rem; font-size:.92rem; color:#d7e0dc; min-height:1.4em; line-height:1.5;}
.cc-try .reveal-a{margin-top:.6rem; padding:.32rem .85rem; border-radius:8px; cursor:pointer;
  font:inherit; font-size:.84rem; background:transparent; color:var(--cc-mut);
  border:1px dashed var(--cc-line);}
.cc-try .reveal-a:disabled{cursor:default; opacity:.9; border-style:solid;}

@media (prefers-reduced-motion: reduce){
  .cc-scope *{transition:none !important;}
}
</style> <div class="cc-scope" id="cc"> <div class="cc-grid"> <div class="cc-stagewrap"> <div class="cc-stage" id="cc-stage"> <div class="actor" id="cc-alice"> <div class="bubble" id="cc-ba"></div> <svg viewBox="0 0 100 100"><circle cx="50" cy="34" r="20" fill="#f4d4bf"/><path d="M50 56c-20 0-30 14-30 34v6h60v-6c0-20-10-34-30-34z" fill="var(--cc-alice)"/><path d="M30 30a20 20 0 0140 0c0-16-40-16-40 0z" fill="#3a2b2b"/></svg> <div class="nm">Alice</div> </div> <div class="actor" id="cc-bob"> <div class="bubble" id="cc-bb"></div> <svg viewBox="0 0 100 100"><circle cx="50" cy="34" r="20" fill="#f4d4bf"/><path d="M50 56c-20 0-30 14-30 34v6h60v-6c0-20-10-34-30-34z" fill="var(--cc-bob)"/><path d="M28 32c2-18 42-18 44 0 2-10-46-10-44 0z" fill="#2a2f3a"/></svg> <div class="nm">Bob</div> </div> <div class="actor hide" id="cc-ref"> <svg viewBox="0 0 100 100"><circle cx="50" cy="34" r="20" fill="#f4d4bf"/><path d="M50 56c-20 0-30 14-30 34v6h60v-6c0-20-10-34-30-34z" fill="var(--cc-ref)"/><text x="50" y="42" font-size="26" font-weight="700" text-anchor="middle" fill="#5a4500">?</text></svg> <div class="nm">Referee</div> </div> <div class="actor hide" id="cc-eve"> <svg viewBox="0 0 100 70"><g fill="#243b37" stroke="#cfe3dd" stroke-width="3"><rect x="10" y="14" width="34" height="44" rx="14"/><rect x="56" y="14" width="34" height="44" rx="14"/><rect x="40" y="22" width="20" height="12" rx="3"/></g><circle cx="27" cy="46" r="9" fill="#6db6ff"/><circle cx="73" cy="46" r="9" fill="#6db6ff"/></svg> <div class="nm">Eve</div> </div> <div class="cross" id="cc-cross"><svg viewBox="0 0 100 100"><line x1="14" y1="14" x2="86" y2="86" stroke="var(--cc-red)" stroke-width="9" stroke-linecap="round"/><line x1="86" y1="14" x2="14" y2="86" stroke="var(--cc-red)" stroke-width="9" stroke-linecap="round"/></svg></div> <div class="card" id="cd0"><div class="inner"><div class="face back"></div><div class="face front">?</div></div></div> <div class="card" id="cd1"><div class="inner"><div class="face back"></div><div class="face front">?</div></div></div> <div class="card" id="cd2"><div class="inner"><div class="face back"></div><div class="face front">?</div></div></div> <div class="card" id="cd3"><div class="inner"><div class="face back"></div><div class="face front">?</div></div></div> <div class="card" id="cd4"><div class="inner"><div class="face back"></div><div class="face front">?</div></div></div> <div class="panel" id="pl-tt"> <h4>The goal: a private AND</h4> <div class="tt"> <div class="h">Alice</div><div class="h">Bob</div><div class="h">Reveal</div> <div class="row1" style="display:contents"><div class="y">Yes</div><div class="y">Yes</div><div class="y">Yes</div></div> <div class="y">Yes</div><div class="n">No</div><div class="n">No</div> <div class="n">No</div><div class="y">Yes</div><div class="n">No</div> <div class="n">No</div><div class="n">No</div><div class="n">No</div> </div> </div> <div class="panel" id="pl-enc"> <h4>Encoding: put your red card toward the center for &ldquo;Yes&rdquo;</h4> <div class="enc"> <div class="col"> <div class="pair"><div class="mini black">&#9827;</div><div class="mini red">&#9829;</div></div> <div class="lbl">Alice says Yes</div> </div> <div class="col"> <div class="pair"><div class="mini red">&#9829;</div><div class="mini black">&#9827;</div></div> <div class="lbl">Bob says Yes</div> </div> <div class="note">They sit on opposite sides, so &ldquo;toward the center&rdquo; mirrors &mdash; that mirror is what lines the reds up. (No = the reverse.)</div> </div> </div> <div class="panel" id="pl-voc"> <h4>The three moves of every secure protocol</h4> <div class="vocab"> <div class="v"><div class="ic">&#128274;</div><div><b>Commitment.</b> <span>Lay a fact face-down: fixed, but hidden.</span></div></div> <div class="v"><div class="ic">&#129488;</div><div><b>Local computation.</b> <span>Each player rearranges cards on their own.</span></div></div> <div class="v"><div class="ic">&#128257;</div><div><b>Communication.</b> <span>The shared shuffle that mixes everyone's secrets.</span></div></div> </div> </div> <div class="panel" id="pl-cost"> <h4>Privacy isn't free</h4> <div class="cost"> <div class="grp"><div class="bars"><div class="bar cmp" data-h="20"></div></div><div class="glab">No privacy</div></div> <div class="grp"><div class="bars"><div class="bar cmp" data-h="62"></div><div class="bar com" data-h="88"></div></div><div class="glab priv">With privacy</div></div> </div> <div class="costkey"><i><span class="d" style="background:var(--cc-alice)"></span>Computation</i><i><span class="d" style="background:var(--cc-bob)"></span>Communication</i></div> </div> <div class="banner" id="cc-banner"></div> <div class="cc-try cc-tray" id="cc-try"> <div class="ctl"><span class="who">Mode</span> <button class="opt mode sel" data-mode="both">Both players</button> <button class="opt mode" data-mode="solo">One player</button></div> <div class="ctl"><span class="who">You &middot; Bob</span> <button class="opt sel" data-who="b" data-v="1">Yes</button> <button class="opt" data-who="b" data-v="0">No</button></div> <div class="ctl" id="cc-ctl-a"><span class="who">Alice</span> <button class="opt sel" data-who="a" data-v="1">Yes</button> <button class="opt" data-who="a" data-v="0">No</button></div> <div class="ctl" id="cc-ctl-asecret" style="display:none"><span class="who">Alice</span> <span class="secret">&#128274; sealed &mdash; hidden from you</span></div> <div class="ctl"><button class="run" id="cc-run">Deal &#9654;</button> <button class="reveal-a" id="cc-reveal-a" style="display:none">Reveal Alice&rsquo;s sealed answer</button></div> <div class="out" id="cc-out">Both set to Yes &mdash; expect the reds to land together.</div> </div> <div class="cc-narr" id="cc-narr"></div> </div> </div> <div class="cc-steps" id="cc-steps"> <div class="cc-step" data-step="0"><div class="cc-spacer"> <div class="k">The puzzle</div> <h3 data-toc-text="The puzzle">Alice and Bob each hold a private &ldquo;yes&rdquo; or &ldquo;no.&rdquo;</h3> <p>They want to learn one thing only: <b>did we both say yes?</b> Nothing about the individual answers should leak &mdash; not to a snoop, and not even to each other.</p> </div></div> <div class="cc-step" data-step="1"><div class="cc-spacer"> <div class="k">What &ldquo;both&rdquo; means</div> <h3 data-toc-text="What 'both' means">This is a logical AND.</h3> <p>The reveal is &ldquo;Yes&rdquo; in exactly one case &mdash; when <i>both</i> inputs are Yes. Every other combination must look identical from outside: just &ldquo;No.&rdquo; The hard part isn't computing AND; it's computing it <b>without anyone learning the inputs.</b></p> </div></div> <div class="cc-step" data-step="2"><div class="cc-spacer"> <div class="k">The tempting shortcut</div> <h3 data-toc-text="The tempting shortcut">&ldquo;Just use a trusted referee.&rdquo;</h3> <p>Each whispers their answer to a neutral third party, who computes the AND and announces it. Clean &mdash; but it only moves the problem: now you must <b>trust the referee.</b> Can we do it with <b>no one</b> to trust? Reach for a deck of cards.</p> </div></div> <div class="cc-step" data-step="3"><div class="cc-spacer"> <div class="k">The encoding</div> <h3 data-toc-text="The encoding">Two cards stand in for one secret.</h3> <p>Take cards with identical backs; the <i>order</i> of a red&ndash;black pair carries the bit. To say <b style="color:var(--cc-yes)">Yes</b>, put your red card toward the center; to say <b style="color:var(--cc-red)">No</b>, the reverse. Because Alice and Bob sit on opposite sides, &ldquo;toward the center&rdquo; is mirrored &mdash; exactly what makes a shared Yes bunch the reds together.</p> </div></div> <div class="cc-step" data-step="4"><div class="cc-spacer"> <div class="k">Step 1 &mdash; Encode</div> <h3 data-toc-text="Commit">Each player shows their encoded answer.</h3> <p>Alice holds her pair on the left, Bob holds his on the right &mdash; here both are saying <b style="color:var(--cc-yes)">Yes</b> &mdash; and a red <b>helper</b> card waits in the middle. For now they&rsquo;re face up, so you can read them.</p> </div></div> <div class="cc-step" data-step="5"><div class="cc-spacer"> <div class="k">Step 1 &mdash; Seal</div> <h3 data-toc-skip>Turn them down and line them up.</h3> <p>Alice and Bob flip their cards <b>face down</b> and slide them into a line with the helper; then the helper flips down too. Five identical backs &mdash; the answers are <b>locked in</b> and unreadable, ready to shuffle.</p> </div></div> <div class="cc-step" data-step="6"><div class="cc-spacer"> <div class="k">Step 2 &mdash; Shuffle</div> <h3 data-toc-text="Shuffle">Gather them into a ring and cut.</h3> <p>Slide the five cards into a circle and give it a <b>random cyclic cut</b> &mdash; the whole ring rotates by an amount nobody tracks. The arrangement is scrambled, but its <i>shape</i> is preserved.</p> </div></div> <div class="cc-step" data-step="7"><div class="cc-spacer"> <div class="k">Step 3 &mdash; Reveal</div> <h3 data-toc-text="Reveal">Flip them all face-up.</h3> <p>Now look only at the red cards. <b>If the three reds sit together</b>, both said Yes. If they're split apart, at least one said No. Here both said yes &mdash; so the reds bunch up and the table reads <b style="color:var(--cc-yes)">Yes</b>.</p> </div></div> <div class="cc-step" data-step="8"><div class="cc-spacer"> <div class="k">Why nothing leaks</div> <h3 data-toc-text="Why nothing leaks">The shuffle is the privacy.</h3> <p>Because the ring was cut by a secret amount, the cards could have started anywhere. All you can read off the layout is one bit &mdash; reds together, or not &mdash; which is exactly the AND. Whose card was whose? Gone in the shuffle.</p> </div></div> <div class="cc-step" data-step="9"><div class="cc-spacer"> <div class="k">The pattern underneath</div> <h3 data-toc-text="The pattern">Three moves, endlessly reused.</h3> <p>Strip away the cards and you're left with the grammar of every privacy-preserving protocol: <b>commit</b> a hidden input, do some <b>local computation</b>, and <b>communicate</b> through a shared shuffle that blends secrets without exposing them.</p> </div></div> <div class="cc-step" data-step="10"><div class="cc-spacer"> <div class="k">The catch</div> <h3 data-toc-text="The catch">Privacy costs more than secrecy alone.</h3> <p>Doing this without a referee takes extra work and back-and-forth. Real cryptographic versions replace cards with math and run many rounds &mdash; paying in <b style="color:var(--cc-alice)">computation</b> and <b style="color:var(--cc-bob)">communication</b> for the privacy.</p> </div></div> <div class="cc-step" data-step="11"><div class="cc-spacer"> <div class="k">Your turn</div> <h3 data-toc-text="Your turn">Run the trick yourself.</h3> <p>Use the panel above to choose a mode and deal &mdash; the cards play it out live.</p> </div></div> </div> </div> </div> <script>
(function(){
  var scope=document.getElementById('cc'); if(!scope) return;
  var cards=[0,1,2,3,4].map(function(i){return document.getElementById('cd'+i);});
  var alice=document.getElementById('cc-alice'), bob=document.getElementById('cc-bob'),
      ref=document.getElementById('cc-ref'), eve=document.getElementById('cc-eve'),
      cross=document.getElementById('cc-cross'), banner=document.getElementById('cc-banner'),
      ba=document.getElementById('cc-ba'), bb=document.getElementById('cc-bb'),
      narr=document.getElementById('cc-narr');
  var panels={tt:'pl-tt',enc:'pl-enc',voc:'pl-voc',cost:'pl-cost'};
  Object.keys(panels).forEach(function(k){panels[k]=document.getElementById(panels[k]);});

  var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // ---- the encoding (the fix) -------------------------------------------------
  // "Yes" = red card toward the centre helper. Alice sits left, Bob sits right,
  // so the same gesture mirrors. With the helper red in the middle, BOTH-yes
  // gives  [black, red, RED(helper), red, black]  -> three reds adjacent.
  function aliceEnc(bit){ return bit ? ['B','R'] : ['R','B']; } // black outside, red inside = Yes
  function bobEnc(bit){   return bit ? ['R','B'] : ['B','R']; } // mirror of Alice
  function layout(a,b){ var A=aliceEnc(a), B=bobEnc(b); return [A[0],A[1],'R',B[0],B[1]]; }

  // three reds are AND-positive iff they sit consecutively (cyclically),
  // which happens iff the two black cards are cyclically adjacent.
  function litIndices(p){
    var reds=[], blk=[], i;
    for(i=0;i<5;i++){ (p[i]==='R'?reds:blk).push(i); }
    if(reds.length!==3 || blk.length!==2) return [];
    var d=Math.abs(blk[0]-blk[1]);
    return (d===1 || d===4) ? reds : [];   // 0 & 4 are cyclically adjacent
  }

  // ---- positioning helpers ----
  function rowPositions(){ return [{x:16,y:50},{x:33,y:50},{x:50,y:50},{x:67,y:50},{x:84,y:50}]; }
  // held near each player before committing; the helper already rests on the line they'll join
  function sidePositions(){ return [{x:13,y:36},{x:24,y:36},{x:50,y:50},{x:76,y:36},{x:87,y:36}]; }
  var ringCy=44, ringRy=15;            // scrolly ring; compacted/raised on the finale so cards clear the tray
  function ringPositions(){
    var cx=50, cyP=ringCy, rx=22, ry=ringRy, out=[];
    for(var i=0;i<5;i++){
      var ang=(-90 + i*72) * Math.PI/180;
      out.push({x:cx+rx*Math.cos(ang), y:cyP+ry*Math.sin(ang)});
    }
    return out;
  }
  function place(card,p){ card.style.left=p.x+'%'; card.style.top=p.y+'%'; }
  function setSuit(card,suit){
    var f=card.querySelector('.front'); card.classList.remove('red','black');
    if(suit==='R'){card.classList.add('red'); f.innerHTML='&#9829;';}
    else if(suit==='B'){card.classList.add('black'); f.innerHTML='&#9827;';}
    else {f.innerHTML='?';}
  }
  function faceUp(card,up){ card.classList.toggle('up',!!up); }
  function show(card,s){ card.classList.toggle('show',!!s); }

  // ---- master state we narrate with ----
  var demoA=1, demoB=1;
  var ringRot=2;

  function setActors(o){
    alice.classList.toggle('hide',!o.alice);
    bob.classList.toggle('hide',!o.bob);
    ref.classList.toggle('hide',!o.ref);
    eve.classList.toggle('hide',!o.eve);
    cross.classList.toggle('on',!!o.cross);
    alice.classList.toggle('dim',!!o.dim); bob.classList.toggle('dim',!!o.dim);
    alice.classList.toggle('think',!!o.think); bob.classList.toggle('think',!!o.think);
  }
  function setPanel(name){
    Object.keys(panels).forEach(function(k){ panels[k].classList.toggle('on', k===name); });
  }
  function setBanner(kind){
    banner.className='banner'+(kind?(' on '+kind):'');
    banner.textContent = kind==='yes' ? 'Reveal: Yes — both said Yes' :
                         kind==='no'  ? 'Reveal: No' : '';
  }
  function thoughts(){
    ba.innerHTML = demoA? '<span class="hi">Yes</span>' : '<span class="lo">No</span>';
    bb.innerHTML = demoB? '<span class="hi">Yes</span>' : '<span class="lo">No</span>';
  }

  // lay the five cards in the commit row, face-down, suits set per inputs
  function dealCommit(a,b,helperUp){
    var rp=rowPositions(), suits=layout(a,b);
    cards.forEach(function(c,i){ show(c,true); faceUp(c, !!helperUp && i===2); place(c,rp[i]);
      setSuit(c,suits[i]); c.classList.remove('lit'); });
    return suits;
  }
  // gather into the ring (face-down), suits = layout cut (rotated) by `rot`
  function gather(a,b,rot){
    var pat=layout(a,b);
    var rotated=pat.map(function(_,i){return pat[(i-rot+5)%5];});
    var rpos=ringPositions();
    cards.forEach(function(c,i){ show(c,true); faceUp(c,false); place(c,rpos[i]); setSuit(c,rotated[i]);
      c.classList.remove('lit'); });
    return rotated;
  }
  function revealRing(rotated){
    cards.forEach(function(c){ faceUp(c,true); });
    var lit=litIndices(rotated);
    cards.forEach(function(c,i){ c.classList.toggle('lit', lit.indexOf(i)>=0); });
    return lit.length>0;
  }

  // commit choreography: face-up at the sides -> players turn down -> slide into a line -> seal the helper
  function dealSides(a,b){
    var sp=sidePositions(), suits=layout(a,b);
    cards.forEach(function(c,i){ show(c,true); faceUp(c,true); place(c,sp[i]);
      setSuit(c,suits[i]); c.classList.remove('lit'); });
  }
  function sealSequence(){
    var sp=sidePositions(), rp=rowPositions(), suits=layout(demoA,demoB);
    cards.forEach(function(c,i){ show(c,true); setSuit(c,suits[i]); place(c,sp[i]); c.classList.remove('lit'); });
    if(reduce){ cards.forEach(function(c,i){ faceUp(c,false); place(c,rp[i]); }); return; }
    var myGen=animGen;
    cards.forEach(function(c){ faceUp(c,true); });                                   // start: face up at the sides
    setTimeout(function(){ if(myGen!==animGen) return; [0,1,3,4].forEach(function(i){ faceUp(cards[i],false); }); }, 400);  // players turn down
    setTimeout(function(){ if(myGen!==animGen) return; cards.forEach(function(c,i){ place(c,rp[i]); }); }, 1150);           // slide into a line
    setTimeout(function(){ if(myGen!==animGen) return; faceUp(cards[2],false); }, 2050);                         // helper turns down too
  }

  // ---- rotation (cut) animation: the face-down ring visibly orbits ----
  // animGen is bumped on every step change so stale setTimeout chains bail out
  // (fast mobile scrolling can leave an old step's animation still running).
  var animGen=0;
  function spinRing(steps, done){
    if(reduce){ if(done) done(); return; }
    var myGen=animGen, rp=ringPositions();
    cards.forEach(function(c,i){ c.classList.add('spinning'); faceUp(c,false); place(c,rp[i]); });
    var k=0;
    (function tick(){
      if(myGen!==animGen){ cards.forEach(function(c){ c.classList.remove('spinning'); }); return; }
      k++;
      cards.forEach(function(c,i){ place(c, rp[(i+k)%5]); });
      if(k<steps){ setTimeout(tick,170); }
      else { setTimeout(function(){
        cards.forEach(function(c){ c.classList.remove('spinning'); });
        if(done && myGen===animGen) done();
      }, 260); }
    })();
  }
  // 10 steps = two clean turns ending back on the home positions
  function spinScrolly(){ spinRing(10); }

  // ---- the 12 stage states ----
  function render(step){
    thoughts();
    switch(step){
      case 0:
        setPanel(null); setBanner(null);
        setActors({alice:1,bob:1,think:1});
        cards.forEach(function(c){show(c,false);});
        break;
      case 1:
        setActors({alice:1,bob:1,dim:1});
        cards.forEach(function(c){show(c,false);});
        setPanel('tt'); setBanner(null);
        break;
      case 2:
        setPanel(null); setBanner(null);
        setActors({alice:1,bob:1,ref:1,cross:1});
        cards.forEach(function(c){show(c,false);});
        break;
      case 3:
        setActors({alice:1,bob:1});
        cards.forEach(function(c){show(c,false);});
        setPanel('enc'); setBanner(null);
        break;
      case 4:
        setPanel(null); setBanner(null);
        setActors({alice:1,bob:1});
        dealSides(demoA,demoB);   // both show their encoded answer (face up) + red helper in the middle
        break;
      case 5:
        setPanel(null); setBanner(null);
        setActors({alice:1,bob:1});
        sealSequence();           // turn down -> slide into a line -> seal the helper
        break;
      case 6:
        setPanel(null); setBanner(null);
        setActors({alice:1,bob:1});
        gather(demoA,demoB,0);
        spinScrolly();
        break;
      case 7: {
        setPanel(null);
        setActors({alice:1,bob:1});
        var yes=revealRing(gather(demoA,demoB,ringRot));
        setBanner(yes?'yes':'no');
        break; }
      case 8: {
        setPanel(null);
        setActors({alice:1,bob:1});
        revealRing(gather(demoA,demoB,(ringRot+1)%5));
        setBanner(demoA&&demoB?'yes':'no');
        break; }
      case 9:
        setActors({alice:1,bob:1,dim:1});
        cards.forEach(function(c){show(c,false);});
        setPanel('voc'); setBanner(null);
        break;
      case 10:
        setActors({alice:0,bob:0});
        cards.forEach(function(c){show(c,false);});
        setPanel('cost'); setBanner(null);
        animateCost();
        break;
      case 11:
        setPanel(null);
        setActors({alice:1,bob:1});
        runInteractive(false);
        break;
    }
  }

  function animateCost(){
    var bars=panels.cost.querySelectorAll('.bar');
    bars.forEach(function(b){b.style.height='0%';});
    setTimeout(function(){ bars.forEach(function(b){b.style.height=b.getAttribute('data-h')+'%';}); },80);
  }

  // ---- bottom narration overlay, fed from the (hidden) step text ----
  var steps=[].slice.call(document.querySelectorAll('#cc-steps .cc-step'));
  function setNarr(stepEl){
    var k=stepEl.querySelector('.k'), h=stepEl.querySelector('h3'), p=stepEl.querySelector('p');
    narr.innerHTML =
      (k? '<div class="nk">'+k.innerHTML+'</div>' : '') +
      (h? '<div class="nh">'+h.innerHTML+'</div>' : '') +
      (p? '<div class="np">'+p.innerHTML+'</div>' : '');
  }

  // ---- interactive finale ----
  var ia=1, ib=1, busy=false, mode='both';
  var out=document.getElementById('cc-out'), runBtn=document.getElementById('cc-run');
  var ctlA=document.getElementById('cc-ctl-a'), ctlASecret=document.getElementById('cc-ctl-asecret');
  var revealABtn=document.getElementById('cc-reveal-a');

  function refreshOpts(){
    document.querySelectorAll('#cc-try .opt[data-who]').forEach(function(btn){
      var who=btn.getAttribute('data-who'), v=+btn.getAttribute('data-v');
      var cur = who==='a'? ia : ib;
      btn.classList.toggle('sel', cur===v);
    });
    document.querySelectorAll('#cc-try .opt.mode').forEach(function(btn){
      btn.classList.toggle('sel', btn.getAttribute('data-mode')===mode);
    });
    if(ctlA) ctlA.style.display = mode==='solo' ? 'none' : '';
    if(ctlASecret) ctlASecret.style.display = mode==='solo' ? '' : 'none';
  }
  function hideRevealA(){
    if(revealABtn){ revealABtn.style.display='none'; revealABtn.disabled=false;
      revealABtn.textContent="Reveal Alice's sealed answer"; }
  }
  // choose the run's inputs; in solo mode Alice is drawn in secret and never shown
  function setInputs(){
    demoB=ib;
    demoA = (mode==='solo') ? (Math.random()<0.5?1:0) : ia;
  }
  function settle(){
    setInputs(); thoughts(); dealCommit(demoA,demoB); setBanner(null); hideRevealA();
  }

  function finishReveal(yes){
    setBanner(yes?'yes':'no');
    if(mode==='solo'){
      if(ib===0){
        out.innerHTML='You said <b style="color:var(--cc-red)">No</b> &rarr; the result is <b style="color:var(--cc-red)">No</b>. But Alice&rsquo;s answer stayed sealed: from the table, <b>Alice&nbsp;=&nbsp;Yes and Alice&nbsp;=&nbsp;No look identical</b> &mdash; you have no way to tell which. <i>That</i> is the privacy.';
        if(revealABtn) revealABtn.style.display='';
      } else {
        out.innerHTML = demoA
          ? 'You said <b style="color:var(--cc-yes)">Yes</b> &rarr; the result is <b style="color:var(--cc-yes)">Yes</b>, so Alice must have said Yes too. Here the <i>output</i> reveals her input &mdash; unavoidable, since your Yes makes the AND equal to whatever she chose.'
          : 'You said <b style="color:var(--cc-yes)">Yes</b> &rarr; the result is <b style="color:var(--cc-red)">No</b>, so Alice said No. With your Yes the AND simply echoes Alice &mdash; you learn it from the result itself, which is all you&rsquo;re entitled to.';
      }
    } else {
      out.innerHTML = yes
        ? 'Reds landed together &rarr; <b style="color:var(--cc-yes)">Yes</b>. Both said yes &mdash; and that&rsquo;s all the table revealed.'
        : 'Reds split apart &rarr; <b style="color:var(--cc-red)">No</b>. At least one said no &mdash; but you can&rsquo;t tell which.';
    }
    busy=false; runBtn.disabled=false;
  }

  function runInteractive(animate){
    setInputs(); thoughts();
    if(!animate){ dealCommit(demoA,demoB); setBanner(null); hideRevealA(); return; }
    busy=true; runBtn.disabled=true; setBanner(null); hideRevealA();
    out.textContent='Committing…';
    dealCommit(demoA,demoB);
    var rot=Math.floor(Math.random()*5);
    if(reduce){ finishReveal(revealRing(gather(demoA,demoB,rot))); return; }
    setTimeout(function(){
      out.textContent='Shuffling — a random cut nobody can follow…';
      gather(demoA,demoB,0);
      spinRing(10, function(){
        finishReveal(revealRing(gather(demoA,demoB,rot)));
      });
    }, 600);
  }

  document.querySelectorAll('#cc-try .opt[data-who]').forEach(function(btn){
    btn.addEventListener('click', function(){
      if(busy) return;
      var who=btn.getAttribute('data-who'), v=+btn.getAttribute('data-v');
      if(who==='a') ia=v; else ib=v;
      refreshOpts(); settle();
      out.innerHTML = mode==='solo'
        ? 'You are playing one side. Press “Deal the cards” — Alice’s answer is drawn in secret.'
        : 'Set. Press “Deal the cards” to run the protocol.';
    });
  });
  document.querySelectorAll('#cc-try .opt.mode').forEach(function(btn){
    btn.addEventListener('click', function(){
      if(busy) return;
      mode=btn.getAttribute('data-mode');
      refreshOpts(); settle();
      out.innerHTML = mode==='solo'
        ? 'You play <b>one side</b> (Bob). Choose your answer; Alice stays sealed, then deal.'
        : 'You set <b>both players</b>. Deal to run the protocol.';
    });
  });
  if(revealABtn){
    revealABtn.addEventListener('click', function(){
      revealABtn.disabled=true;
      revealABtn.textContent = demoA ? 'Alice had said: Yes — and you couldn’t have known'
                                     : 'Alice had said: No — and you couldn’t have known';
    });
  }
  runBtn.addEventListener('click', function(){ runInteractive(true); });

  // ---- scroll driving via IntersectionObserver ----
  var current=-1, tray=document.getElementById('cc-try');
  function activate(i){
    if(i===current) return; current=i; animGen++;   // cancel any in-flight animation from the previous step
    if(i===11){ ringCy=34; ringRy=11; } else { ringCy=44; ringRy=15; }
    render(i);
    if(i===11){ narr.classList.add('hide'); if(tray) tray.classList.add('on'); }
    else { narr.classList.remove('hide'); if(tray) tray.classList.remove('on'); setNarr(steps[i]); }
  }
  if('IntersectionObserver' in window){
    var io=new IntersectionObserver(function(entries){
      entries.forEach(function(e){
        if(e.isIntersecting){ activate(+e.target.getAttribute('data-step')); }
      });
    }, {rootMargin:'-45% 0px -45% 0px', threshold:0});
    steps.forEach(function(s){io.observe(s);});
  } else {
    window.addEventListener('scroll',function(){
      var mid=window.innerHeight/2, best=0, bd=1e9;
      steps.forEach(function(s){var r=s.getBoundingClientRect();var d=Math.abs((r.top+r.bottom)/2-mid);
        if(d<bd){bd=d;best=+s.getAttribute('data-step');}});
      activate(best);
    },{passive:true});
  }
  refreshOpts();
  render(0);
  setNarr(steps[0]);
})();
</script> <h2 id="what-you-just-watched">What you just watched</h2> <p>This is the <strong>five-card trick</strong>, the 1989 protocol by Bert den Boer that kicked off a small, delightful corner of cryptography called card based cryptography. The five-card trick is one entry into the field called <strong>secure multiparty computation</strong> (MPC): a set of techniques for letting people compute a shared answer from private inputs that none of them ever has to reveal. The cards make the magic tangible, but the structure is exactly the same one used in the cryptographic version:</p> <ul> <li><strong>Commitment</strong> — fixing a secret value so it can’t be changed or read (face-down cards; in real systems, cryptographic commitments or secret shares).</li> <li><strong>Local computation</strong> — operations each party performs on their own (turning a card; locally combining shares).</li> <li><strong>Communication</strong> — the shared step that blends secrets without exposing them (the random cut; in real MPC, exchanging masked messages over many rounds).</li> </ul> <p>And the closing caveat is real too: removing the trusted referee isn’t free. Practical MPC pays for privacy in extra computation and communication, which is why a huge amount of research goes into making it cheap enough to deploy — for things like hospitals jointly studying patient outcomes, banks detecting fraud across institutions, or auctions where no one sees the losing bids.</p> <p>If you want to go deeper, the original protocol is B. den Boer, <em>“More efficient match-making and satisfiability: the five-card trick”</em> (EUROCRYPT ‘89), and the modern survey work by Mizuki, Sone, and others on card-based cryptography is a wonderful rabbit hole.</p>]]></content><author><name></name></author><category term="explainers"/><category term="cryptography"/><category term="privacy"/><category term="mpc"/><category term="interactive"/><summary type="html"><![CDATA[Using card-based cryptography to show how two people can compute the AND of their private answers using nothing but a handful of playing cards, and why that little trick is the seed of an entire field called secure multiparty computation.]]></summary></entry><entry><title type="html">Training a neural network in the dark</title><link href="https://snwagh.com/blog/2022/piranha/" rel="alternate" type="text/html" title="Training a neural network in the dark"/><published>2022-08-10T00:00:00+00:00</published><updated>2022-08-10T00:00:00+00:00</updated><id>https://snwagh.com/blog/2022/piranha</id><content type="html" xml:base="https://snwagh.com/blog/2022/piranha/"><![CDATA[<p>Most secure-computation work asks: given a <em>fixed</em> computation, how do we run it privately and fast? <strong>Piranha</strong> asked something harder and more practical — it was the first system to <strong>train a neural network end to end</strong> under secure computation. Training, unlike inference, is a moving target: much of the real work is <em>finding</em> the right computation, not just running a given one.</p> <p>Here’s the catch that never shows up in a complexity analysis. When you train privately, you’re <strong>flying blind</strong>. You can’t inspect activations, gradients, or intermediate values — that’s the entire point of privacy. So when the model doesn’t learn, you’re left with one ambiguous signal: the accuracy is low.</p> <figure class="post-fig" aria-label="A training accuracy curve that rises only to a low plateau, annotated with a question mark — is the cause a protocol bug or a genuinely hard optimization?"> <svg viewBox="0 0 640 320" role="img"> <line x1="60" y1="270" x2="600" y2="270" stroke="var(--global-divider-color)" stroke-width="1.5"/> <line x1="60" y1="40" x2="60" y2="270" stroke="var(--global-divider-color)" stroke-width="1.5"/> <text x="330" y="305" text-anchor="middle" font-size="13" fill="var(--global-text-color-light)" font-family="var(--font-sans)">training steps →</text> <text x="24" y="155" text-anchor="middle" font-size="13" fill="var(--global-text-color-light)" font-family="var(--font-sans)" transform="rotate(-90 24 155)">accuracy</text> <line x1="60" y1="70" x2="600" y2="70" stroke="var(--global-text-color-lighter)" stroke-width="1" stroke-dasharray="4 5" opacity="0.7"/> <text x="600" y="62" text-anchor="end" font-size="12" fill="var(--global-text-color-lighter)" font-family="var(--font-sans)">Accuracy you hoped for</text> <polyline points="60,262 130,250 200,232 280,214 360,206 460,202 600,201" fill="none" stroke="var(--global-theme-color)" stroke-width="2.5"/> <text x="600" y="196" text-anchor="end" font-size="12.5" font-weight="600" fill="var(--global-theme-color)" font-family="var(--font-sans)">Accuracy you got 😔</text> <text x="330" y="150" text-anchor="middle" font-size="40" font-weight="700" fill="var(--global-text-color-lighter)" font-family="var(--font-sans)">?</text> <text x="330" y="180" text-anchor="middle" font-size="12.5" fill="var(--global-text-color-light)" font-family="var(--font-sans)">Training bug, suboptimal parameters, or a genuinely hard optimization?</text> </svg> <figcaption>With every value secret-shared, a disappointing curve gives you nothing to debug.</figcaption> </figure> <p>Low accuracy could mean almost anything: a real optimization difficulty, a subtly wrong fixed-point truncation, an overflow in one layer. In cleartext you’d drop in a <code class="language-plaintext highlighter-rouge">print</code> and know in minutes. Under MPC every value is a secret share — there is nothing to print.</p> <p>For secure computation to achieve it’s dream, we will need contributions not just about raw speed but also about <strong>robust, trustworthy primitives</strong> and sufficiently debuggability. As systems become increasingly capable at exploration, debugging, and hypothesis generation, the primary bottleneck in privacy-sensitive analytics will shift from executing computations to discovering them. Future privacy-preserving systems will therefore optimize not only for confidential execution, but for confidential exploration: enabling humans and AI agents to iteratively search over vast spaces of models, queries, and hypotheses while revealing substantially less raw data than is required today. The long-term equilibrium is neither unrestricted data access nor fully opaque one-shot computation, but a spectrum of interactive discovery mechanisms that provide just enough information to find useful computations without exposing the underlying data itself.</p> <div class="takeaway"> <span class="takeaway-label">Food for thought</span> <p>The first generation of privacy technology asks: given a computation (say train VGG-16 over ImageNet), how can we hide the data? The next generation may ask: given hidden data, how can we discover the computation (this set of hyperparameters work for training)?</p> </div> <p class="paper-links"> <a href="https://eprint.iacr.org/2022/892.pdf" target="_blank" rel="noopener noreferrer">PDF</a> <a href="https://github.com/ucbrise/piranha" target="_blank" rel="noopener noreferrer">Code</a> <span>Piranha · USENIX Security 2022</span> </p>]]></content><author><name></name></author><category term="research"/><category term="mpc"/><category term="machine-learning"/><category term="gpu"/><summary type="html"><![CDATA[Piranha was the first to train a neural network end-to-end under secure computation. The hard part wasn't speed — it was knowing why a run failed when you can't see a single value.]]></summary></entry><entry><title type="html">Schwartz–Zippel over rings and open conjectures</title><link href="https://snwagh.com/blog/2022/pika/" rel="alternate" type="text/html" title="Schwartz–Zippel over rings and open conjectures"/><published>2022-07-19T00:00:00+00:00</published><updated>2022-07-19T00:00:00+00:00</updated><id>https://snwagh.com/blog/2022/pika</id><content type="html" xml:base="https://snwagh.com/blog/2022/pika/"><![CDATA[<p>If you’ve touched probabilistic proofs or MPC, you’ve leaned on the <strong>Schwartz–Zippel lemma</strong>. Over a field, a non-zero polynomial of degree \(d\), evaluated at a random point, is zero with probability at most \(d/|S|\). That single fact is the bedrock of “test an identity by checking it at one random point.”</p> <p>But a lot of modern secure computation lives over <strong>rings</strong> like \(\mathbb{Z}_{2^\ell}\) — they match machine words and make arithmetic cheap. And over a ring, Schwartz–Zippel <strong>breaks</strong>: zero divisors manufacture extra roots, so a non-zero polynomial can vanish far more often than \(d/|S|\) allows. The clean field bound simply doesn’t hold.</p> <h2 id="a-ring-version-with-slack">A ring version, with slack</h2> <p>In my recent work <strong>Pika</strong>, I prove a Schwartz–Zippel analogue that <em>does</em> hold over these rings. The price is a little <strong>slack</strong> — you have to work over a slightly larger ring than the one your polynomial actually lives on.</p> <div class="lemma"> <span class="lemma-label">Lemma — Schwartz–Zippel over rings (informal)</span> <p>Let $p(x_1, \ldots, x_n)$ be an $n$-variable <strong>bilinear</strong> polynomial over $\mathbb{Z}_{2^{\ell+2s}}$ that is not identically zero over $\mathbb{Z}_{2^{\ell}}$. Then the probability that $p$ evaluates to $0$ on points sampled uniformly from $\{0, 1, \ldots, 2^{\ell}-1\}$ is at most $2^{1-s}$.</p> </div> <p>To get soundness error \(2^{1-s}\) the test runs over \(\mathbb{Z}_{2^{\ell+2s}}\) — a slack of \(2s\) extra bits compared to the Field version of SZ Lemma. For the <strong>quadratic</strong> case the shape is identical, but the polynomial must evaluate to zero over the larger ring \(\mathbb{Z}_{2^{\ell+4s}}\) — the slack doubles to \(4s\).</p> <p>That slack isn’t just analysis bookkeeping. <strong>In implementation it sets the bit-width of your data:</strong> soundly testing a quadratic identity means carrying every value over \(\mathbb{Z}_{2^{\ell+4s}}\) — that’s \(\ell + 4s\) bits per value instead of \(\ell\), paid as wider words on every operation. So the constant in front of \(s\) is a very concrete cost.</p> <h2 id="how-small-can-the-slack-be">How small can the slack be?</h2> <p>And that constant — the <strong>4</strong> for quadratics — is where it gets interesting. Nothing says it’s optimal. There were a few open problems/conjectures:</p> <div class="takeaway"> <span class="takeaway-label">Open conjecture</span> <p>Quadratics need a $4s$ slack and the test runs over $\mathbb{Z}_{2^{\ell+4s}}$. I suspect 4 is the smallest constant that works. Conjecture: for a given slack $s$ there is no constant $c &lt; 4$ such that $(\ell + c\,s)$-bit ring suffices.</p> </div> <p>While quadratic sufficed for the work, a more general statement may also be of interest.</p> <div class="takeaway"> <span class="takeaway-label">Semi-formal general result</span> <p>Let $p(x_1, \ldots, x_n)$ be an $n$-variable polynomial of degree $d$ over $\mathbb{Z}_{2^{\ell+c(d)s}}$ that is not identically zero over $\mathbb{Z}_{2^{\ell}}$. What is the smallest constant $c(d)$ such that the probability that $p$ evaluates to $0$ on points sampled uniformly from $\{0, 1, \ldots, 2^{\ell}-1\}$ is at most $O(2^{-s})$. </p> </div> <p class="paper-links"> <a href="https://eprint.iacr.org/2022/826.pdf" target="_blank" rel="noopener noreferrer">PDF</a> <span>Pika · PETS 2022</span> </p>]]></content><author><name></name></author><category term="research"/><category term="cryptography"/><category term="mpc"/><category term="theory"/><summary type="html"><![CDATA[The Schwartz–Zippel lemma is the workhorse behind countless protocols — over fields. Pika carries it to rings, where zero divisors get in the way, and leaves a crisp open question: how small can the slack constant be?]]></summary></entry><entry><title type="html">Why private AI belongs on a GPU</title><link href="https://snwagh.com/blog/2021/falcon/" rel="alternate" type="text/html" title="Why private AI belongs on a GPU"/><published>2021-07-14T00:00:00+00:00</published><updated>2021-07-14T00:00:00+00:00</updated><id>https://snwagh.com/blog/2021/falcon</id><content type="html" xml:base="https://snwagh.com/blog/2021/falcon/"><![CDATA[<p>For years, the secure multi-party computation (MPC) community optimized for one thing above all: <strong>fewer rounds of communication</strong>. The logic was sound — the computations people studied were small, so the back-and-forth between parties dominated the cost. Shave a round, win.</p> <p><strong>FALCON</strong> asked a simple question: what happens when the computation gets <em>big</em>?</p> <p>The reason it matters is mechanical. Multiplying two \(n \times n\) matrices is <strong>super-quadratic</strong> — strictly more than \(O(n^2)\) work. But the communication to do that multiplication privately stays <strong>quadratic</strong>, \(O(n^2)\): you exchange shares proportional to the matrix size, in a single round. So as the matrices grow, computation grows <em>faster</em> than communication — and past a crossover, it wins.</p> <figure class="post-fig" id="pg-falcon" aria-label="Two curves against problem size: communication is quadratic and starts higher but grows slowly; computation is super-quadratic and overtakes it at a crossover, after which the region is compute-dominated."> <svg viewBox="0 0 660 360" role="img"></svg> <figcaption>Quadratic communication vs. super-quadratic computation. They cross — and large workloads land in the compute-dominated region.</figcaption> </figure> <p>This isn’t just a back-of-the-envelope story; it shows up in the measurements. The figure below, from the paper, breaks down end-to-end private-inference cost (WAN, malicious setting) across networks of increasing size. On small MNIST networks, <strong>communication is ~94% of the cost</strong>. By VGG on Tiny ImageNet, that has flipped: <strong>computation is ~78%.</strong></p> <figure class="post-fig" aria-label="A 100% stacked bar chart of communication vs compute cost across eight networks of increasing size; the compute (blue) fraction grows from about 6% on small MNIST networks to about 78% on VGG over Tiny ImageNet."> <img src="/assets/img/falcon-compute-commute.png" alt="Compute vs communication cost across network sizes, from FALCON. Compute's share grows from ~6% to ~78% as networks get larger." loading="lazy"/> <figcaption>From the FALCON paper: as the network grows (left → right), the compute share (blue) takes over.</figcaption> </figure> <p>This runs against the conventional wisdom that MPC is communication-bound. Once you reach realistic network sizes, the bottleneck is arithmetic, not the network — and the moment your bottleneck is arithmetic, the prescription writes itself: <strong>move it onto a GPU.</strong></p> <p>So what does it look like to take that prescription seriously — to put private training on a GPU and run a network end to end? That’s the story of <a href="/blog/2022/piranha/">Piranha</a>.</p> <p class="paper-links"> <a href="https://arxiv.org/pdf/2004.02229.pdf" target="_blank" rel="noopener noreferrer">PDF</a> <a href="https://github.com/snwagh/falcon-public" target="_blank" rel="noopener noreferrer">Code</a> <span>FALCON · PETS 2021</span> </p> <script>
(function () {
  const svg = document.querySelector("#pg-falcon svg");
  if (!svg) return;
  const NS = "http://www.w3.org/2000/svg";
  const cssVar = n => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
  const accent = cssVar("--global-theme-color") || "#3873b3";
  const muted = cssVar("--global-text-color-lighter") || "#999";
  const txt = cssVar("--global-text-color-light") || "#666";
  const faint = cssVar("--global-divider-color") || "#e8e8e8";
  const font = "var(--font-sans, system-ui, sans-serif)";
  const el = (t, a) => { const n = document.createElementNS(NS, t); for (const k in a) n.setAttribute(k, a[k]); return n; };
  const text = (x, y, s, o = {}) => { const t = el("text", { x, y, "text-anchor": o.anchor || "middle", "font-size": o.size || 13, fill: o.fill || txt, "font-family": font, "font-weight": o.w || 400 }); t.textContent = s; svg.appendChild(t); };

  const W = 660, H = 360, PAD = { t: 28, r: 24, b: 50, l: 56 };
  const sMin = 1, sMax = 7, vMax = 360;
  const comm = s => 5.5 * s * s;   // quadratic  O(n^2)
  const comp = s => s * s * s;     // super-quadratic
  const xs = 5.5;                  // exact crossover: 5.5 s^2 = s^3
  const xAt = s => PAD.l + (s - sMin) / (sMax - sMin) * (W - PAD.l - PAD.r);
  const yAt = v => PAD.t + (1 - v / vMax) * (H - PAD.t - PAD.b);
  const path = f => { let d = ""; for (let s = sMin; s <= sMax + 1e-9; s += 0.2) d += (d ? "L" : "M") + xAt(s).toFixed(1) + " " + yAt(f(s)).toFixed(1) + " "; return d.trim(); };

  // compute-dominated shaded region (right of crossover)
  svg.appendChild(el("rect", { x: xAt(xs), y: PAD.t, width: W - PAD.r - xAt(xs), height: H - PAD.b - PAD.t, fill: accent, opacity: 0.06 }));
  text((xAt(xs) + W - PAD.r) / 2, PAD.t + 16, "compute-dominated", { size: 12, w: 600, fill: accent });

  // axes
  svg.appendChild(el("line", { x1: PAD.l, y1: H - PAD.b, x2: W - PAD.r, y2: H - PAD.b, stroke: faint, "stroke-width": 1.5 }));
  svg.appendChild(el("line", { x1: PAD.l, y1: PAD.t, x2: PAD.l, y2: H - PAD.b, stroke: faint, "stroke-width": 1.5 }));
  text((PAD.l + W - PAD.r) / 2, H - 14, "network / matrix size  →", { size: 13, fill: muted });
  const yc = (PAD.t + H - PAD.b) / 2;
  const yl = el("text", { x: 18, y: yc, "text-anchor": "middle", "font-size": 13, fill: muted, "font-family": font, transform: `rotate(-90 18 ${yc})` }); yl.textContent = "time"; svg.appendChild(yl);

  // curves
  svg.appendChild(el("path", { d: path(comm), fill: "none", stroke: muted, "stroke-width": 2.5 }));
  svg.appendChild(el("path", { d: path(comp), fill: "none", stroke: accent, "stroke-width": 2.5 }));

  // crossover marker exactly at intersection
  const cx = xAt(xs), cy = yAt(comp(xs));
  svg.appendChild(el("line", { x1: cx, y1: cy, x2: cx, y2: H - PAD.b, stroke: accent, "stroke-width": 1, "stroke-dasharray": "3 4", opacity: 0.55 }));
  svg.appendChild(el("circle", { cx, cy, r: 5, fill: accent }));
  text(cx, H - PAD.b + 18, "crossover", { size: 12, fill: txt });

  // curve labels
  text(xAt(7), yAt(comp(7)) - 8, "computation — super-quadratic", { anchor: "end", size: 13, w: 600, fill: accent });
  text(xAt(7), yAt(comm(7)) + 18, "communication — quadratic O(n²)", { anchor: "end", size: 13, w: 600, fill: muted });
})();
</script>]]></content><author><name></name></author><category term="research"/><category term="mpc"/><category term="machine-learning"/><category term="gpu"/><summary type="html"><![CDATA[For years, secure multi-party computation chased fewer rounds of communication. FALCON asked what happens at ML scale — and the answer flips the optimization target from network to silicon.]]></summary></entry><entry><title type="html">+ and × is all you need</title><link href="https://snwagh.com/blog/2021/securenn/" rel="alternate" type="text/html" title="+ and × is all you need"/><published>2021-03-01T00:00:00+00:00</published><updated>2021-03-01T00:00:00+00:00</updated><id>https://snwagh.com/blog/2021/securenn</id><content type="html" xml:base="https://snwagh.com/blog/2021/securenn/"><![CDATA[<p>Secure computation for machine learning usually juggles <strong>two</strong> kinds of secret sharing. The linear parts of a network — convolutions, matrix multiplies — are cheap and natural in <strong>arithmetic</strong> sharing. The non-linear parts — ReLU, maxpool, division — don’t fit there, so the textbook move is to convert to <strong>Boolean</strong> sharing, compute them, and convert back.</p> <p>That conversion is the expensive part. Each <strong>interconversion</strong> — the bit-extraction / bit-decomposition that bridges the two representations — costs \(O(\text{bit-length})\) and a stack of protocol machinery, paid on every non-linear operation.</p> <figure class="post-fig" aria-label="Two boxes: arithmetic secret sharing for convolutions, matrix multiplication and linear operations; Boolean secret sharing for ReLU, maxpool, their derivatives and division. The interconversion protocols (bit extraction / decomposition, O(bit-length)) bridging them are crossed out with a red X."> <svg viewBox="0 0 660 350" role="img"> <rect x="18" y="30" width="300" height="150" rx="4" fill="none" stroke="var(--global-text-color)" stroke-width="2"/> <text x="46" y="112" font-size="22" font-style="italic" fill="var(--global-text-color)" font-family="var(--font-serif)">F :</text> <text x="195" y="74" text-anchor="middle" font-size="14" fill="var(--global-text-color)" font-family="var(--font-sans)">Convolutions</text> <text x="195" y="100" text-anchor="middle" font-size="14" fill="var(--global-text-color)" font-family="var(--font-sans)">Matrix Multiplication</text> <text x="195" y="126" text-anchor="middle" font-size="14" fill="var(--global-text-color)" font-family="var(--font-sans)">Linear Operations</text> <text x="195" y="162" text-anchor="middle" font-size="14" font-weight="700" fill="var(--global-text-color)" font-family="var(--font-sans)">Arithmetic Secret Sharing</text> <rect x="342" y="30" width="300" height="150" rx="4" fill="none" stroke="var(--global-text-color)" stroke-width="2"/> <text x="370" y="112" font-size="22" font-style="italic" fill="var(--global-text-color)" font-family="var(--font-serif)">F :</text> <text x="510" y="74" text-anchor="middle" font-size="14" fill="var(--global-text-color)" font-family="var(--font-sans)">ReLU, Maxpool</text> <text x="510" y="100" text-anchor="middle" font-size="14" fill="var(--global-text-color)" font-family="var(--font-sans)">Derivatives (ReLU, Maxpool)</text> <text x="510" y="126" text-anchor="middle" font-size="14" fill="var(--global-text-color)" font-family="var(--font-sans)">Division</text> <text x="510" y="162" text-anchor="middle" font-size="14" font-weight="700" fill="var(--global-text-color)" font-family="var(--font-sans)">Boolean Secret Sharing</text> <g stroke="#c0392b" fill="#c0392b"> <line x1="160" y1="248" x2="160" y2="190" stroke-width="2.5"/> <polygon points="160,182 154,194 166,194" stroke="none"/> <line x1="500" y1="248" x2="500" y2="190" stroke-width="2.5"/> <polygon points="500,182 494,194 506,194" stroke="none"/> <line x1="160" y1="248" x2="500" y2="248" stroke-width="2.5"/> </g> <text x="330" y="244" text-anchor="middle" font-size="16" font-weight="700" fill="#c0392b" font-family="var(--font-sans)">Interconversion protocols</text> <text x="330" y="284" text-anchor="middle" font-size="14" font-weight="700" fill="#c0392b" font-family="var(--font-sans)">(Bit Extraction / Decomposition)</text> <text x="330" y="318" text-anchor="middle" font-size="15" font-weight="700" fill="#c0392b" font-family="var(--font-sans)">O(Bit-length)</text> <g stroke="#c0392b" stroke-width="7" stroke-linecap="round" opacity="0.92"> <line x1="292" y1="232" x2="368" y2="296"/> <line x1="368" y1="232" x2="292" y2="296"/> </g> </svg> <figcaption>SecureNN deletes the costly arithmetic ↔ Boolean bridge — it does the non-linear functions in arithmetic sharing directly.</figcaption> </figure> <p><strong>SecureNN</strong>’s move is to delete that bridge. It computes the non-linear functions <strong>without ever leaving arithmetic sharing</strong> — using nothing but <strong>additions and multiplications</strong>. No Boolean conversion, no bit-decomposition round-trip, no garbled circuits.</p> <p>In software engineering, <strong>simplicity is an underappreciated goal.</strong> Those interconversion protocols are exactly the kind of moving part that makes a system hard to build, test, and trust. By staying in one representation with the two simplest operations there are, the SecureNN approach is genuinely easy to implement — and that simplicity is precisely what makes it efficient.</p> <p>Pushed to its logical extreme, the same line of thinking yields a <strong>maliciously secure comparison</strong> that is remarkably efficient — the <a href="https://eprint.iacr.org/2021/119.pdf">Rabbit</a> protocol.</p> <p class="paper-links"> <a href="https://eprint.iacr.org/2018/442" target="_blank" rel="noopener noreferrer">PDF</a> <a href="https://github.com/snwagh/securenn-public" target="_blank" rel="noopener noreferrer">Code</a> <span>SecureNN · PETS 2019 · extended in Rabbit (FC 2021)</span> </p>]]></content><author><name></name></author><category term="research"/><category term="mpc"/><category term="machine-learning"/><summary type="html"><![CDATA[Private ML normally juggles two secret-sharing types — arithmetic for linear layers, Boolean for the rest — bridged by a costly conversion. SecureNN does the hard parts with just + and ×, and skips the bridge entirely.]]></summary></entry></feed>