<?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-09-30T13:28:16+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">The mathematics of perfect speed dating</title><link href="https://snwagh.com/blog/2026/speed-dating/" rel="alternate" type="text/html" title="The mathematics of perfect speed dating"/><published>2026-09-27T00:00:00+00:00</published><updated>2026-09-27T00:00:00+00:00</updated><id>https://snwagh.com/blog/2026/speed-dating</id><content type="html" xml:base="https://snwagh.com/blog/2026/speed-dating/"><![CDATA[<script defer="" src="/assets/js/post-toc.js"></script> <style>
  .post-content strong, .post-content b { font-weight: 700; }
  mjx-container[display="true"] { overflow-x: auto; overflow-y: hidden; max-width: 100%; }
  mjx-container:not([display="true"]) { display: inline-block; vertical-align: middle; }

  .sd-hook {
    margin: 1.4rem 0 1.8rem; padding: 1rem 1.1rem;
    border-left: 3px solid var(--global-theme-color); border-radius: 0 10px 10px 0;
    background: color-mix(in srgb, var(--global-theme-color) 7%, transparent);
    font-size: .94rem; line-height: 1.65;
  }
  .sd-hook p:last-child { margin-bottom: 0; }
  .sd-lemma {
    margin: 1.35rem 0; padding: .9rem 1rem;
    border: 1px solid var(--global-divider-color); border-radius: 10px;
    background: var(--global-card-bg-color);
  }
  .sd-lemma-kicker {
    display: block; margin-bottom: .28rem; color: var(--global-theme-color);
    font-size: .69rem; font-weight: 800; letter-spacing: .07em; text-transform: uppercase;
  }
  .sd-lemma p { margin: 0; line-height: 1.6; }
  details.sd-proof { margin-top: .7rem; padding-top: .65rem; border-top: 1px solid var(--global-divider-color); }
  details.sd-proof summary { color: var(--global-text-color-light); cursor: pointer; font-size: .76rem; font-weight: 750; list-style: none; }
  details.sd-proof summary::before { content: "▸ "; color: var(--global-theme-color); }
  details.sd-proof[open] summary::before { content: "▾ "; }
  details.sd-proof > div { padding-top: .55rem; font-size: .86rem; line-height: 1.6; }

  .sd-result-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 1.1rem 0; }
  .sd-result-card { padding: .8rem; border: 1px solid var(--global-divider-color); border-radius: 10px; background: var(--global-card-bg-color); }
  .sd-result-card strong { display: block; margin-bottom: .2rem; color: var(--global-theme-color); font-size: 1.05rem; }
  .sd-result-card span { color: var(--global-text-color-light); font-size: .75rem; line-height: 1.45; }

  #sd-mixer {
    --sd-man: #2878b8; --sd-woman: #d94f79;
    --sd-theme: var(--global-theme-color, #168aad);
    --sd-ink: var(--global-text-color, #18202a);
    --sd-muted: var(--global-text-color-light, #667085);
    --sd-line: var(--global-divider-color, #d7dde5);
    --sd-card: var(--global-card-bg-color, #fff);
    --sd-bg: var(--global-bg-color, #f7f9fb);
    margin: 1.6rem 0 2rem; padding: 12px; border: 1px solid var(--sd-line);
    border-radius: 16px; background: var(--global-card-bg-color); box-shadow: 0 4px 18px rgba(0,0,0,.06);
  }
  #sd-mixer * { box-sizing: border-box; }
  #sd-mixer button, #sd-mixer select { font: inherit; }
  #sd-mixer button:focus-visible, #sd-mixer select:focus-visible { outline: 3px solid color-mix(in srgb, var(--sd-theme) 45%, transparent); outline-offset: 2px; }
  .sd-mixer-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; padding: .25rem .3rem .8rem; }
  .sd-mixer-title { font-size: .92rem; font-weight: 800; }
  .sd-mixer-sub { margin-top: .15rem; color: var(--sd-muted); font-size: .7rem; line-height: 1.45; }
  .sd-mode-tabs { display: flex; justify-content: center; margin: 0 .3rem .8rem; }
  .sd-mode-tab {
    min-height: 32px; padding: 0 .7rem; border: 1px solid var(--sd-line);
    background: var(--sd-card); color: var(--sd-muted); cursor: pointer;
    font-size: .66rem; font-weight: 800;
  }
  .sd-mode-tab:first-child { border-radius: 8px 0 0 8px; }
  .sd-mode-tab:last-child { border-left: 0; border-radius: 0 8px 8px 0; }
  .sd-mode-tab[aria-selected="true"] { border-color: var(--sd-theme); background: var(--sd-theme); color: #fff; }
  .sd-round-chip { flex: 0 0 auto; padding: .32rem .62rem; border-radius: 999px; background: color-mix(in srgb, var(--sd-theme) 10%, transparent); color: var(--sd-theme); font-size: .68rem; font-weight: 800; }
  .sd-mixer-grid { display: grid; grid-template-columns: minmax(0, 1fr) 205px; gap: 10px; }
  .sd-stage { min-width: 0; display: flex; flex-direction: column; padding: 12px 10px 10px; border: 1px solid var(--sd-line); border-radius: 13px; background: color-mix(in srgb, var(--sd-ink) 2%, var(--sd-bg)); }
  .sd-tables { flex: 1 1 auto; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-content: center; gap: 14px 12px; }
  .sd-table {
    position: relative; width: min(100%, 184px); aspect-ratio: 1.36;
    display: grid; grid-template-columns: repeat(2, 1fr); place-items: center; justify-self: center;
    padding: 16px 21px; border: 2px solid color-mix(in srgb, var(--sd-theme) 45%, var(--sd-line));
    border-radius: 50%; background: var(--sd-card);
    transition: transform .22s ease, box-shadow .22s ease;
  }
  .sd-table.is-traced { border-color: var(--sd-theme); box-shadow: 0 0 0 3px color-mix(in srgb, var(--sd-theme) 14%, transparent); transform: scale(1.025); }
  /* Number the physical tables clockwise so the original rotation travels
     around the perimeter instead of cutting diagonally across the room. */
  .sd-table:nth-child(1) { grid-column: 1; grid-row: 1; }
  .sd-table:nth-child(2) { grid-column: 2; grid-row: 1; }
  .sd-table:nth-child(3) { grid-column: 2; grid-row: 2; }
  .sd-table:nth-child(4) { grid-column: 1; grid-row: 2; }
  .sd-table-label { position: absolute; top: -8px; left: 50%; transform: translateX(-50%); padding: 1px 7px; border: 1px solid var(--sd-line); border-radius: 999px; background: var(--sd-card); color: var(--sd-muted); font-size: .55rem; font-weight: 800; text-transform: uppercase; }
  .sd-human {
    position: relative; width: 52px; min-height: 56px; display: grid; grid-template-rows: 38px auto; place-items: center;
    border: 0; border-radius: 9px; padding: 2px; background: transparent; color: var(--sd-ink);
    --sd-scale: 1; --sd-dx: 0px; --sd-dy: 0px;
    cursor: pointer; line-height: 1;
    transform: translate(var(--sd-dx), var(--sd-dy)) scale(var(--sd-scale));
    transition: transform .72s cubic-bezier(.22,.8,.24,1), background .15s ease, box-shadow .15s ease;
    will-change: transform;
  }
  .sd-human:hover { background: color-mix(in srgb, var(--sd-theme) 8%, transparent); }
  .sd-human.is-selected { --sd-scale: 1.06; }
  .sd-human-emoji { width: 38px; height: 38px; display: inline-flex; align-items: center; justify-content: center; font-size: 1.82rem; line-height: 1; filter: saturate(.9); }
  .sd-human.is-selected::after { content: ""; position: absolute; top: 0; left: 50%; width: 38px; height: 38px; border: 2px solid var(--sd-theme); border-radius: 10px; background: color-mix(in srgb, var(--sd-theme) 10%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--sd-theme) 15%, transparent); transform: translateX(-50%); pointer-events: none; }
  .sd-human-name { font-size: .65rem; font-weight: 850; }
  .sd-human.man .sd-human-name, .sd-met-person.man { color: #238636; }
  .sd-human.woman .sd-human-name, .sd-met-person.woman { color: #c9364f; }
  .sd-playback { flex: 0 0 auto; display: grid; grid-template-columns: auto auto auto 1fr; gap: 6px; align-items: center; margin-top: auto; padding-top: 14px; }
  .sd-control { min-height: 31px; border: 1px solid var(--sd-line); border-radius: 8px; padding: 0 .62rem; background: var(--sd-card); color: var(--sd-ink); cursor: pointer; font-size: .68rem; font-weight: 800; }
  .sd-control.sd-play { min-width: 82px; border-color: var(--sd-theme); background: var(--sd-theme); color: #fff; }
  .sd-control:disabled { opacity: .35; cursor: default; }
  .sd-round-dots { display: flex; justify-content: flex-end; gap: 5px; }
  .sd-round-dot { width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 24px; border: 1px solid var(--sd-line); border-radius: 50%; padding: 0; background: transparent; color: var(--sd-muted); cursor: pointer; font-size: .58rem; font-weight: 800; line-height: 1; text-align: center; }
  .sd-round-dot.is-current { border-color: var(--sd-theme); background: var(--sd-theme); color: #fff; }

  .sd-trace { padding: 10px; border: 1px solid var(--sd-line); border-radius: 13px; background: var(--sd-bg); }
  .sd-trace-title { font-size: .69rem; font-weight: 850; }
  .sd-trace-copy { margin: .18rem 0 .55rem; color: var(--sd-muted); font-size: .61rem; line-height: 1.4; }
  .sd-trace label { display: block; margin-bottom: 3px; color: var(--sd-muted); font-size: .56rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; }
  .sd-person-select { width: 100%; min-height: 32px; border: 1px solid var(--sd-line); border-radius: 7px; padding: 0 7px; background: var(--sd-card); color: var(--sd-ink); font-size: .68rem; font-weight: 700; }
  .sd-met-heading { margin: .7rem 0 .3rem; font-size: .59rem; font-weight: 850; }
  .sd-met-heading span { color: var(--sd-muted); font-weight: 600; }
  .sd-met-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; }
  .sd-met-person { position: relative; min-height: 34px; display: grid; place-items: center; overflow: hidden; border: 1px solid var(--sd-line); border-radius: 7px; color: var(--sd-muted); font-size: .57rem; font-weight: 800; }
  .sd-met-person.is-met { background: color-mix(in srgb, var(--sd-theme) 9%, transparent); color: var(--sd-ink); }
  .sd-met-person.is-new { border-color: #d59b19; box-shadow: 0 0 0 2px color-mix(in srgb, #f0b429 28%, transparent); }
  .sd-scratch { position: absolute; left: 8%; top: calc(42% + var(--scratch) * 3px); width: 84%; height: 2px; border-radius: 2px; background: var(--sd-theme); transform: rotate(-31deg); opacity: .82; }
  .sd-meet-count { position: absolute; right: 2px; bottom: 1px; color: var(--sd-theme); font-size: .46rem; line-height: 1; }
  .sd-trace-key { margin-top: .4rem; color: var(--sd-muted); font-size: .52rem; line-height: 1.35; }
  .sd-trace-status { min-height: 4.5em; margin-top: .65rem; color: var(--sd-muted); font-size: .62rem; line-height: 1.45; }
  .sd-figure-caption { margin: .7rem .3rem 0; color: var(--sd-muted); font-size: .67rem; line-height: 1.5; }
  .sd-hist-table { width: 100%; font-size: .78rem; }
  .sd-hist-table td:first-child { font-family: var(--font-mono, monospace); }
  .sd-hist-table td:last-child, .sd-hist-table th:last-child { text-align: right; }
  .sd-question-list li { margin-bottom: .45rem; }

  #sc-lab { margin: 1.4rem 0 2rem; padding: 14px; border: 1px solid var(--global-divider-color); border-radius: 16px; background: var(--global-card-bg-color); }
  #sc-lab * { box-sizing: border-box; }
  .sc-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
  .sc-title { font-size: .82rem; font-weight: 850; }
  .sc-tabs { display: flex; }
  .sc-tab { min-height: 32px; padding: 0 .65rem; border: 1px solid var(--global-divider-color); background: var(--global-bg-color); color: var(--global-text-color-light); font: inherit; font-size: .62rem; font-weight: 800; cursor: pointer; }
  .sc-tab:first-child { border-radius: 8px 0 0 8px; }
  .sc-tab:last-child { margin-left: -1px; border-radius: 0 8px 8px 0; }
  .sc-tab[aria-selected="true"] { position: relative; border-color: var(--global-theme-color); background: var(--global-theme-color); color: #fff; }
  .sc-layout { display: grid; grid-template-columns: minmax(0,1fr); gap: 16px; }
  .sc-picker { display: grid; grid-template-columns: repeat(8,minmax(0,1fr)); gap: 5px; }
  .sc-person { min-height: 38px; border: 2px solid transparent; border-radius: 8px; background: var(--sc-bg); color: var(--sc-fg); font: inherit; font-size: .61rem; font-weight: 850; cursor: pointer; }
  .sc-person.is-selected { border-color: var(--global-theme-color); box-shadow: 0 0 0 2px color-mix(in srgb, var(--global-theme-color) 20%, transparent); transform: scale(1.05); }
  .sc-hint { margin-top: 7px; color: var(--global-text-color-light); font-size: .58rem; line-height: 1.4; }
  .sc-rounds { display: grid; gap: 5px; margin-bottom: 10px; }
  .sc-round-row { display: grid; grid-template-columns: 48px repeat(4,minmax(0,1fr)); gap: 5px; align-items: center; padding: 4px; border: 1px solid transparent; border-radius: 10px; opacity: .34; transition: opacity .2s ease, background .2s ease, border-color .2s ease; }
  .sc-round-row:hover { opacity: .7; }
  .sc-round-row.is-current { border-color: color-mix(in srgb, var(--global-theme-color) 32%, var(--global-divider-color)); background: color-mix(in srgb, var(--global-theme-color) 6%, transparent); opacity: 1; }
  .sc-round-label { color: var(--global-text-color-light); font-size: .55rem; font-weight: 850; line-height: 1.15; text-align: center; text-transform: uppercase; }
  .sc-round-group { min-width: 0; min-height: 45px; display: flex; align-items: center; justify-content: center; gap: 2px; border: 1px solid var(--global-divider-color); border-radius: 999px; padding: 3px; background: var(--global-bg-color); color: var(--global-text-color); font: inherit; cursor: pointer; }
  .sc-round-group:hover { border-color: var(--global-theme-color); }
  .sc-round-group.has-selected { border-color: var(--global-theme-color); box-shadow: inset 0 0 0 1px var(--global-theme-color); }
  .sc-mini-person { display: grid; grid-template-rows: auto auto; place-items: center; row-gap: 3px; min-width: 19px; color: var(--sc-fg); font-size: .46rem; font-weight: 850; line-height: 1; }
  .sc-mini-person b { display: block; font-size: .9rem; font-weight: 400; line-height: 1; }
  .sc-circle { min-height: 112px; display: grid; align-content: center; padding: 16px 18px; border: 2px solid var(--global-theme-color); border-radius: 56px; text-align: center; }
  .sc-circle-label, .sc-tracker-label { margin-bottom: 9px; color: var(--global-text-color-light); font-size: .62rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; }
  .sc-circle-people { display: flex; justify-content: center; gap: 12px; }
  .sc-pill { min-width: 68px; padding: 9px 10px; border: 1px solid var(--sc-border); border-radius: 10px; background: var(--sc-bg); color: var(--sc-fg); font-size: .78rem; font-weight: 850; line-height: 1.25; }
  .sc-pill.is-self { box-shadow: 0 0 0 2px var(--global-theme-color); }
  .sc-tracker-label { margin-top: 18px; }
  .sc-tracker { display: grid; grid-template-columns: repeat(5,1fr); gap: 7px; }
  .sc-contact { position: relative; min-height: 64px; display: grid; place-content: center; gap: 5px; padding: 8px 5px; border: 1px solid var(--global-divider-color); border-radius: 9px; background: var(--global-bg-color); color: var(--sc-fg); cursor: pointer; font-size: .74rem; font-weight: 850; line-height: 1.1; text-align: center; }
  .sc-contact small { display: block; color: var(--global-text-color-light); font-size: .54rem; line-height: 1.2; }
  .sc-contact.is-past { opacity: .48; text-decoration: line-through; }
  .sc-contact.is-current { border-color: var(--global-theme-color); box-shadow: inset 0 0 0 1px var(--global-theme-color); }
  .sc-progress { height: 12px; display: grid; grid-template-columns: repeat(5,1fr); gap: 3px; margin-top: 14px; }
  .sc-progress-step { min-width: 0; height: 12px; border: 0; border-radius: 999px; padding: 0; background: var(--global-divider-color); cursor: pointer; transition: background .18s ease, transform .18s ease, box-shadow .18s ease; }
  .sc-progress-step.is-past { background: color-mix(in srgb, var(--global-theme-color) 55%, var(--global-divider-color)); }
  .sc-progress-step.is-current { background: var(--global-theme-color); box-shadow: 0 0 0 2px color-mix(in srgb, var(--global-theme-color) 22%, transparent); transform: scaleY(1.22); }
  .sc-progress-step:hover { background: color-mix(in srgb, var(--global-theme-color) 78%, var(--global-divider-color)); transform: scaleY(1.22); }
  .sc-progress-step:focus-visible { outline: 3px solid color-mix(in srgb, var(--global-theme-color) 35%, transparent); outline-offset: 2px; }
  .sc-progress-copy { margin-top: 6px; color: var(--global-text-color-light); font-size: .63rem; text-align: center; }

  @media (max-width: 720px) {
    .sd-result-grid { grid-template-columns: 1fr; }
    .sd-mixer-head { display: block; }
    .sd-round-chip { display: inline-block; margin-top: .55rem; }
    .sd-mixer-grid { grid-template-columns: 1fr; }
    .sd-table { width: min(100%, 150px); padding: 12px 15px; }
    .sd-human { width: 42px; min-height: 43px; }
    .sd-human-emoji { font-size: 1.35rem; }
    .sd-met-grid { grid-template-columns: repeat(8, 1fr); }
    .sc-picker { grid-template-columns: repeat(8,1fr); }
    .sc-round-row { grid-template-columns: 42px repeat(4,minmax(74px,1fr)); min-width: 390px; }
    .sc-rounds { overflow-x: auto; padding-bottom: 8px; scrollbar-color: var(--global-theme-color) transparent; scrollbar-width: thin; }
  }
  @media (max-width: 430px) {
    .sd-tables { gap: 8px; }
    .sd-table { width: min(100%, 132px); padding: 10px 12px; }
    .sd-human { width: 37px; min-height: 40px; }
    .sd-playback { grid-template-columns: repeat(3, 1fr); }
    .sd-round-dots { grid-column: 1 / -1; justify-content: center; }
    .sc-tabs { width: 100%; }
    .sc-tab { flex: 1; white-space: normal; }
    .sc-tracker { grid-template-columns: repeat(3,1fr); }
  }
  @media (prefers-reduced-motion: reduce) { #sd-mixer * { animation: none !important; transition: none !important; } }
</style> <p>My friend <a href="https://www.linkedin.com/in/shruti-tajne">Shruti</a> recently went to a speed-dating event with about a hundred people. The organizers seated ten people at each table: five men and five women. Everyone gave a one-minute introduction; then the men moved as a group clockwise to the next table. It does what it promises: every woman eventually meets every man.</p> <p>But my friend pointed out a small cruelty in the design: each pack of five women travels together. Every woman hears the same four introductions again. And again. And again. The men have the same problem.</p> <div class="sd-hook"> <p>That complaint turned into this puzzle: <strong>can everyone keep moving, meet every possible date exactly once, and never drag the same little entourage from table to table?</strong></p> </div> <h3 id="an-interactive-exploration">An interactive exploration</h3> <p>Here is a scaled-down version with 8 men and 8 women at four tables. Each table seats 2 of each. Compare the original rotation with the improved schedule, then select anyone to trace their evening.</p> <div id="sd-mixer"> <div class="sd-mixer-head"> <div><div class="sd-mixer-title">Four tables, four rounds</div><div class="sd-mixer-sub" id="sd-mixer-sub">The men stay put while each pair of women moves to the next table.</div></div> <div class="sd-round-chip" id="sd-round-chip">Round 1 of 4</div> </div> <div class="sd-mode-tabs" role="tablist" aria-label="Compare dating schedules"> <button class="sd-mode-tab" type="button" role="tab" aria-selected="true" data-mode="original">Original rotation</button> <button class="sd-mode-tab" type="button" role="tab" aria-selected="false" data-mode="perfect">Perfect mixing</button> </div> <div class="sd-mixer-grid"> <div class="sd-stage"> <div class="sd-tables" id="sd-tables" aria-live="polite"></div> <div class="sd-playback"> <button class="sd-control" id="sd-prev" type="button">← Back</button> <button class="sd-control sd-play" id="sd-play" type="button">Play</button> <button class="sd-control" id="sd-next" type="button">Next →</button> <div class="sd-round-dots" id="sd-round-dots" aria-label="Choose a round"></div> </div> </div> <div class="sd-trace"> <div class="sd-trace-title">Trace one person</div> <div class="sd-trace-copy">Each slash marks one shared table. A gold outline means this person is new in the current round.</div> <label for="sd-person-select">Person to trace</label> <select class="sd-person-select" id="sd-person-select"></select> <div class="sd-met-heading">Potential dates <span id="sd-date-count"></span></div> <div class="sd-met-grid" id="sd-dates"></div> <div class="sd-met-heading">Same-gender introductions <span id="sd-peer-count"></span></div> <div class="sd-met-grid" id="sd-peers"></div> <div class="sd-trace-key">Gold = new now · each diagonal slash = one introduction</div> <div class="sd-trace-status" id="sd-trace-status" aria-live="polite"></div> </div> </div> <div class="sd-figure-caption" id="sd-figure-caption">The original rotation covers every possible date, but each woman travels with the same partner and hears her introduction in all four rounds.</div> </div> <p>Both schedules cover every possible man-woman pairing. The difference is what happens <em>along the way</em>.</p> <ol> <li>In the <strong>original rotation</strong>, the men remain at fixed tables and each pair of women moves together. Every woman meets all eight men, but hears the same woman’s introduction four times.</li> <li>In <strong>perfect mixing</strong>, everyone moves. Every person still meets all eight potential dates, but now also meets four different people of their own gender thus avoiding any repeated introduction.</li> </ol> <p>The full mathematical design has a fifth round. It would place the men at two all-male tables and the women at two all-female tables, so the dating version simply drops it. The rest of this post is about the mathematics of designing such a schedule.</p> <h2 id="problem-simplification-drop-gender">Problem Simplification: Drop gender</h2> <p>Let us first make the problem more demanding and, paradoxically, easier to understand. Suppose there are \(N\) people. In every round they split into tables of \(m\) people each. Can we arrange the rounds so that <strong>every pair of people shares a table once and only once</strong>?</p> <p>Combinatorialists call this a <em>resolvable Steiner \(2\)-design</em>, or a resolvable block design. In our case, each round partitions the room, and the full evening covers every pair exactly once. Think of this as <strong>speed socializing.</strong></p> <h3 id="the-unavoidable-constraints">The unavoidable constraints</h3> <p>Before searching for a schedule, we can rule out most values of \(N\) and \(m\) with two simple observations.</p> <div class="sd-lemma"> <span class="sd-lemma-kicker">Lemma 1 · the divisibility test</span> <p>A perfect schedule can exist only if \(m\mid N\) and \(m-1\mid N-1\). Equivalently, \(N\equiv m\pmod{m(m-1)}\). If it exists, the number of rounds is forced to be \(R=(N-1)/(m-1)\).</p> <details class="sd-proof"><summary>Show the counting proof</summary><div> Every round must divide all \(N\) participants into groups of \(m\), so \(m\mid N\). Now follow one person. They meet \(m-1\) new people per round and must meet the other \(N-1\) people exactly once. Thus \(R(m-1)=N-1\). Combining the congruences gives \(N\equiv m\pmod{m(m-1)}\). The event uses \(B=NR/m=N(N-1)/(m(m-1))\) tables in total. </div></details> </div> <p>For tables of four, the possible room sizes begin \(N=4,16,28,40,\ldots\). For instance, twenty people cannot work: one person would need \(19/3\) rounds.</p> <h3 id="passing-the-test-does-not-guarantee-a-schedule">Passing the test does not guarantee a schedule</h3> <p>This is the first delightful trap: the conditions are necessary, but they are not sufficient. For example, \((m,N)=(6,36)\) passes both tests, yet no schedule exists (see for instance <a href="#ref-bjl">[Beth–Jungnickel–Lenz]</a>). Some parts of the existence story are completely understood:</p> <div class="sd-result-grid"> <div class="sd-result-card"><strong>\(m=2\)</strong><span>Every even \(N\) works: this is the familiar round-robin pairing schedule.</span></div> <div class="sd-result-card"><strong>\(m=3\)</strong><span>Exactly \(N\equiv3\pmod6\) works—the resolved Kirkman schoolgirl problem <a href="#ref-kirkman">[Ray-Chaudhuri–Wilson]</a>.</span></div> <div class="sd-result-card"><strong>\(m=4\)</strong><span>Exactly \(N\equiv4\pmod{12}\) works <a href="#ref-hrw">[Hanani–Ray-Chaudhuri–Wilson]</a>.</span></div> </div> <p>Interestingly, for every fixed \(m\), all sufficiently large \(N\) that pass the divisibility test do work <a href="#ref-rcw">[Ray-Chaudhuri–Wilson]</a>. What remains difficult is the finite territory before “sufficiently large.” There is no known one-line classification for arbitrary \(m\).</p> <h2 id="simple-construction-for-specific-parameters">Simple construction for specific parameters</h2> <p>The cleanest construction occurs when \(m=q\) is a prime power and \(N=q^2\). Imagine labeling people by the \(q^2\) points of a \(q\times q\) grid but doing arithmetic in the finite field \(\mathbb F_q\). Each round is characterized by a line of a given slope.</p> <ul> <li>One round uses the vertical lines.</li> <li>The other \(q\) rounds use the lines of slope \(a\): \(y=ax+b,\ \mathrm{where}\ a,b\in\mathbb F_q\).</li> </ul> <p>Lines of one slope partition the grid, so they form a round. Any two points determine one and only one line, so any two people meet exactly once. For prime-power \(q\), finite fields give this construction immediately. This is what is used in the interactive schedule above (\(q=4\)).</p> <p>For a general candidate \((m,N)\), there is still a universal <em>search method</em>: encode every possible table as a binary choice and require every participant to appear once per round and every pair once overall. This is an exact-cover or integer-programming problem. It can decide modest instances, but it is not a proof that all arithmetically admissible inputs work. At scale, design theory uses recursive constructions—group-divisible designs, frames, and difference families <a href="#ref-bjl">[Beth–Jungnickel–Lenz]</a>.</p> <h2 id="add-gender-back-into-the-picture">Add Gender back into the picture</h2> <p>Now color every participant one of two types—say women and men (suppose there are \(c\) women and \(N-c\) men). A table of size \(m\) can contain \(0,1,\ldots,m\) women. Let</p> <p>\[ h_j=\#\{\text{tables containing exactly }j\text{ women}\}. \]</p> <p>The vector \(h=(h_0,h_1,\ldots,h_m)\) is a histogram of the entire event. It lets us ask questions the ungendered version of the problem cannot ask:</p> <ul> <li>Can we make \(h_0=h_m=0\), so no table is all men or all women?</li> <li>If \(m\) is even, can most tables sit near the balanced value \(j=m/2\)?</li> <li>Which histograms are numerically plausible but geometrically impossible?</li> </ul> <p>The first one would be interesting from a dating point of view where you would like to avoid tables of all men or all women. The second is just a stronger version of that, which states can be avoided. Highly biased table where all but one are men, or all but one are women. The third one is just the full mathematical complexity of this problem. The first three moments of these are fixed based on the parameters but there is interesting geometry past these. In mathematical language, asking for \(h_0=h_m=0\) is asking for a weak two-coloring or a two-sided blocking set. This connects our scheduling puzzle to block-design colorings <a href="#ref-colorings">[Rosa–Colbourn]</a>, asymptotic weak colorability <a href="#ref-horsley">[Horsley–Pike]</a>, and blocking sets in affine planes <a href="#ref-jamison">[Jamison]</a>, <a href="#ref-brouwer">[Brouwer–Schrijver]</a>.</p> <div class="sd-lemma"> <span class="sd-lemma-kicker">Lemma 3 · three checks on every histogram</span> <p>If \(c\) of the \(N\) people are women, every histogram must satisfy</p> <p>\[\sum_jh_j=B,\qquad \sum_j jh_j=cR,\qquad \sum_j\binom{j}{2}h_j=\binom{c}{2}.\]</p> <details class="sd-proof"><summary>Show the three double counts</summary><div> The first sum counts tables. The second counts woman–table appearances: each of the \(c\) women appears in all \(R\) rounds. The third counts pairs of women: a table with \(j\) women contains \(\binom{j}{2}\) such pairs, and every pair shares exactly one table. These are classical intersection-number equations <a href="#ref-mendelsohn">[Mendelsohn]</a>, <a href="#ref-trung">[van Trung–Wu–Mesner]</a>. </div></details> </div> <p>These equations are necessary, but they forget how tables overlap. That missing geometry is where the interesting questions live.</p> <h3 id="the-complete-m4n16-experiment">The complete \(m=4,N=16\) experiment</h3> <p>With 8 women and 8 men, there are \(\binom{16}{8}=12{,}870\) ways to assign the two types to the schedule. This is because we can fix the table arrangements and only permute the gender across the sixteen participants. Exhaustive enumeration produces exactly seven histograms:</p> <div style="overflow-x:auto; margin:1.1rem 0"> <table class="sd-hist-table"> <thead><tr><th>Histogram \((h_0,h_1,h_2,h_3,h_4)\)</th><th>Colorings</th></tr></thead> <tbody> <tr><td>(0, 6, 10, 2, 2)</td><td>1,440</td></tr> <tr><td>(0, 7, 7, 5, 1)</td><td>2,880</td></tr> <tr><td><strong>(0, 8, 4, 8, 0)</strong></td><td><strong>120</strong></td></tr> <tr><td>(1, 4, 10, 4, 1)</td><td>4,080</td></tr> <tr><td>(1, 5, 7, 7, 0)</td><td>2,880</td></tr> <tr><td><strong>(2, 0, 16, 0, 2)</strong></td><td><strong>30</strong></td></tr> <tr><td>(2, 2, 10, 6, 0)</td><td>1,440</td></tr> </tbody> </table> </div> <p>The two bold rows are the ones that I found interesting. The first has no single-gender table anywhere. The second has sixteen perfectly balanced tables and four single-gender tables. The interactive figure uses the latter construction and drops its four single-gender tables. What remains is unusually clean: sixteen mixed tables over four rounds, every one split \(2+2\), and every possible man–woman pair covered exactly once. <strong>That right there is the perfect speed dating schedule!</strong></p> <h3 id="explore-the-full-five-round-design">Explore the full five-round design</h3> <p>Here is the original construction explorer. Unlike the dating view above, it keeps the fifth round because the full design is trying to make <strong>every pair</strong> meet exactly once. Switch between the two histograms at the top, choose any person, and click a round—or click someone in the tracker to jump directly to the round in which the pair meets.</p> <div id="sc-lab"> <div class="sc-head"> <div class="sc-title">The complete \(m=4\), \(N=16\) schedule</div> <div class="sc-tabs" role="tablist" aria-label="Choose a red-blue construction"> <button class="sc-tab" type="button" role="tab" aria-selected="true" data-construction="A">\((2,0,16,0,2)\)</button> <button class="sc-tab" type="button" role="tab" aria-selected="false" data-construction="B">\((0,8,4,8,0)\)</button> </div> </div> <div class="sc-layout"> <div> <div class="sc-circle-label">Choose a person to trace</div> <div class="sc-picker" id="sc-picker"></div> <div class="sc-hint" id="sc-hint">The colors encode the selected histogram construction.</div> </div> <div> <div class="sc-rounds" id="sc-rounds" aria-label="Choose a round"></div> <div class="sc-circle"> <div class="sc-circle-label" id="sc-circle-label">Person 1's circle in round 1</div> <div class="sc-circle-people" id="sc-circle-people"></div> </div> <div class="sc-tracker-label">Everyone else · click anyone to jump to your shared round</div> <div class="sc-tracker" id="sc-tracker"></div> <div class="sc-progress" id="sc-progress" aria-label="Jump to a round"></div> <div class="sc-progress-copy" id="sc-progress-copy"></div> </div> </div> </div> <h2 id="the-questions-i-was-left-with">The questions I was left with</h2> <p>While there are some obvious mathematical questions, some of which are still open in the academic literature, here’s a short list that opens up:</p> <ul class="sd-question-list"> <li><strong>Schedule existence.</strong> For which \((m,N)\) does a perfect pairwise-meeting schedule exist?</li> <li><strong>Balanced events.</strong> When can a schedule be colored with equal-sized types and no monochromatic table?</li> <li><strong>Histogram spectrum.</strong> Which vectors \(h\) satisfying the counting equations actually come from a schedule?</li> <li><strong>Practical variants.</strong> What changes if we care only about cross-type meetings, allow uneven tables, or permit a small number of repeats?</li> </ul> <p>Like many puzzles, I love that something that sounds almost petty (having to hear the same introduction four times) and opens into large bodies of mathematics. Here’s to improved speed dating!</p> <h2 id="references">References</h2> <ol> <li><span id="ref-kirkman"></span>D. K. Ray-Chaudhuri and R. M. Wilson, “Solution of Kirkman’s schoolgirl problem,” <em>Proceedings of Symposia in Pure Mathematics</em> 19 (1971), 187–203.</li> <li><span id="ref-hrw"></span>H. Hanani, D. K. Ray-Chaudhuri, and R. M. Wilson, “On resolvable designs,” <em>Discrete Mathematics</em> 3 (1972), 343–357. <a href="https://doi.org/10.1016/0012-365X(72)90091-X">doi:10.1016/0012-365X(72)90091-X</a>.</li> <li><span id="ref-rcw"></span>D. K. Ray-Chaudhuri and R. M. Wilson, “The existence of resolvable block designs,” in <em>A Survey of Combinatorial Theory</em> (1973), 361–375. <a href="https://doi.org/10.1016/B978-0-7204-2262-7.50035-1">doi:10.1016/B978-0-7204-2262-7.50035-1</a>.</li> <li><span id="ref-bjl"></span>T. Beth, D. Jungnickel, and H. Lenz, <em>Design Theory</em>, 2nd ed., 2 vols., Cambridge University Press, 1999. <a href="https://assets.cambridge.org/97805214/44323/sample/9780521444323wsc00.pdf">Publisher preview</a>.</li> <li><span id="ref-mendelsohn"></span>N. S. Mendelsohn, “Intersection numbers of \(t\)-designs,” in L. Mirsky, ed., <em>Studies in Pure Mathematics (Presented to Richard Rado)</em>, Academic Press, 1971, 145–150.</li> <li><span id="ref-trung"></span>T. van Trung, Q.-R. Wu, and D. M. Mesner, “High order intersection numbers of \(t\)-designs,” <em>Journal of Statistical Planning and Inference</em> 56 (1996), 257–268. <a href="https://doi.org/10.1016/S0378-3758(96)00022-5">doi:10.1016/S0378-3758(96)00022-5</a>.</li> <li><span id="ref-colorings"></span>A. Rosa and C. J. Colbourn, “Colorings of block designs,” in J. H. Dinitz and D. R. Stinson, eds., <em>Contemporary Design Theory: A Collection of Surveys</em>, Wiley, 1992, 401–430. <a href="https://www.wiley-vch.de/en?isbn=9780471531418&amp;option=com_eshop&amp;title=Contemporary+Design+Theory&amp;view=product">Publisher page</a>.</li> <li><span id="ref-horsley"></span>D. Horsley and D. A. Pike, “On balanced incomplete block designs with specified weak chromatic number,” <em>Journal of Combinatorial Theory, Series A</em> 123 (2014), 123–153. <a href="https://doi.org/10.1016/j.jcta.2013.12.004">doi:10.1016/j.jcta.2013.12.004</a>.</li> <li><span id="ref-jamison"></span>R. E. Jamison, “Covering finite fields with cosets of subspaces,” <em>Journal of Combinatorial Theory, Series A</em> 22 (1977), 253–266. <a href="https://doi.org/10.1016/0097-3165(77)90001-2">doi:10.1016/0097-3165(77)90001-2</a>.</li> <li><span id="ref-brouwer"></span>A. E. Brouwer and A. Schrijver, “The blocking number of an affine space,” <em>Journal of Combinatorial Theory, Series A</em> 24 (1978), 251–253. <a href="https://doi.org/10.1016/0097-3165(78)90013-4">doi:10.1016/0097-3165(78)90013-4</a>.</li> </ol> <script>
(() => {
  const lab = document.getElementById('sc-lab');
  if (!lab) return;
  const schedule = [
    [[1,2,3,4],[5,6,7,8],[9,10,11,12],[13,14,15,16]],
    [[1,5,9,13],[2,6,10,14],[3,7,11,15],[4,8,12,16]],
    [[1,6,11,16],[2,5,12,15],[3,8,9,14],[4,7,10,13]],
    [[1,7,12,14],[2,8,11,13],[3,5,10,16],[4,6,9,15]],
    [[1,8,10,15],[2,7,9,16],[3,6,12,13],[4,5,11,14]]
  ];
  const constructions = {
    A: new Set([1,2,3,4,5,6,7,8]),
    B: new Set([1,3,6,8,10,12,14,16])
  };
  const faces = ['👨🏻','👨🏼','👨🏽','👨🏾','🧔🏻','🧔🏼','🧔🏽','🧔🏾','👩🏻','👩🏼','👩🏽','👩🏾','👩‍🦱','👩‍🦰','👩‍🦳','👩‍🦱'];
  const picker = document.getElementById('sc-picker');
  const rounds = document.getElementById('sc-rounds');
  const circle = document.getElementById('sc-circle-people');
  const circleLabel = document.getElementById('sc-circle-label');
  const tracker = document.getElementById('sc-tracker');
  const progress = document.getElementById('sc-progress');
  const progressCopy = document.getElementById('sc-progress-copy');
  let construction = 'A';
  let selected = 1;
  let round = 0;

  const group = (person, ri) => schedule[ri].find(members => members.includes(person));
  const sharedRound = (a, b) => schedule.findIndex((_, ri) => group(a, ri).includes(b));
  const personStyle = person => constructions[construction].has(person)
    ? '--sc-bg:#fee2e2;--sc-fg:#b42318;--sc-border:#fca5a5'
    : '--sc-bg:#dbeafe;--sc-fg:#1d4ed8;--sc-border:#93c5fd';
  const personLabel = person => `${faces[person - 1]} ${person}`;

  function render() {
    picker.innerHTML = Array.from({ length: 16 }, (_, index) => index + 1).map(person =>
      `<button class="sc-person ${person === selected ? 'is-selected' : ''}" style="${personStyle(person)}" type="button" data-person="${person}" aria-label="Trace person ${person}">${personLabel(person)}</button>`
    ).join('');
    picker.querySelectorAll('button').forEach(button => button.addEventListener('click', () => { selected = Number(button.dataset.person); round = 0; render(); }));

    rounds.innerHTML = schedule.map((groups, index) => `
      <div class="sc-round-row ${index === round ? 'is-current' : ''}">
        <div class="sc-round-label">Round<br>${index + 1}</div>
        ${groups.map((members, groupIndex) => `
          <button class="sc-round-group ${members.includes(selected) ? 'has-selected' : ''}" type="button" data-round="${index}" aria-label="Round ${index + 1}, group ${groupIndex + 1}: people ${members.join(', ')}">
            ${members.map(person => `<span class="sc-mini-person" style="${personStyle(person)}"><b aria-hidden="true">${faces[person - 1]}</b>${person}</span>`).join('')}
          </button>`).join('')}
      </div>`).join('');
    rounds.querySelectorAll('.sc-round-group').forEach(button => button.addEventListener('click', () => { round = Number(button.dataset.round); render(); }));

    const current = group(selected, round);
    circleLabel.textContent = `Person ${selected}'s circle in round ${round + 1}`;
    circle.innerHTML = current.map(person => `<span class="sc-pill ${person === selected ? 'is-self' : ''}" style="${personStyle(person)}">${personLabel(person)}</span>`).join('');

    const contacts = Array.from({ length: 16 }, (_, index) => index + 1).filter(person => person !== selected);
    tracker.innerHTML = contacts.map(person => {
      const meetingRound = sharedRound(selected, person);
      const state = meetingRound < round ? 'is-past' : meetingRound === round ? 'is-current' : 'is-future';
      const note = meetingRound < round ? `met · R${meetingRound + 1}` : meetingRound === round ? `meeting now` : `meet · R${meetingRound + 1}`;
      return `<button class="sc-contact ${state}" style="${personStyle(person)}" type="button" data-round="${meetingRound}">${personLabel(person)}<small>${note}</small></button>`;
    }).join('');
    tracker.querySelectorAll('button').forEach(button => button.addEventListener('click', () => { round = Number(button.dataset.round); render(); }));

    const alreadyMet = round * 3;
    progress.innerHTML = schedule.map((_, index) => `<button class="sc-progress-step ${index < round ? 'is-past' : ''} ${index === round ? 'is-current' : ''}" type="button" data-round="${index}" aria-label="Jump to round ${index + 1}" ${index === round ? 'aria-current="step"' : ''}></button>`).join('');
    progress.querySelectorAll('.sc-progress-step').forEach(button => button.addEventListener('click', () => { round = Number(button.dataset.round); render(); }));
    progressCopy.textContent = round === 4
      ? 'All 15 people have now been met exactly once.'
      : `${alreadyMet} met earlier · 3 meeting now · ${12 - alreadyMet} still ahead`;
  }

  lab.querySelectorAll('.sc-tab').forEach(button => button.addEventListener('click', () => {
    construction = button.dataset.construction;
    lab.querySelectorAll('.sc-tab').forEach(tab => tab.setAttribute('aria-selected', String(tab === button)));
    render();
  }));
  render();
})();
</script> <script>
(() => {
  const root = document.getElementById('sd-mixer');
  if (!root) return;
  const schedules = {
    original: [
      [[1,2,9,10], [3,4,11,12], [5,6,13,14], [7,8,15,16]],
      [[1,2,15,16], [3,4,9,10], [5,6,11,12], [7,8,13,14]],
      [[1,2,13,14], [3,4,15,16], [5,6,9,10], [7,8,11,12]],
      [[1,2,11,12], [3,4,13,14], [5,6,15,16], [7,8,9,10]]
    ],
    perfect: [
      [[1,5,9,13], [2,6,10,14], [3,7,11,15], [4,8,12,16]],
      [[1,6,11,16], [2,5,12,15], [3,8,9,14], [4,7,10,13]],
      [[1,7,12,14], [2,8,11,13], [3,5,10,16], [4,6,9,15]],
      [[1,8,10,15], [2,7,9,16], [3,6,12,13], [4,5,11,14]]
    ]
  };
  const tablesBox = document.getElementById('sd-tables');
  const chip = document.getElementById('sd-round-chip');
  const subtitle = document.getElementById('sd-mixer-sub');
  const caption = document.getElementById('sd-figure-caption');
  const dots = document.getElementById('sd-round-dots');
  const select = document.getElementById('sd-person-select');
  const datesBox = document.getElementById('sd-dates');
  const peersBox = document.getElementById('sd-peers');
  const dateCount = document.getElementById('sd-date-count');
  const peerCount = document.getElementById('sd-peer-count');
  const status = document.getElementById('sd-trace-status');
  const prev = document.getElementById('sd-prev');
  const next = document.getElementById('sd-next');
  const play = document.getElementById('sd-play');
  let round = 0;
  let selected = 1;
  let mode = 'original';
  let timer = null;
  const isMan = person => person <= 8;
  const faces = ['👨🏻','👨🏼','👨🏽','👨🏾','🧔🏻','🧔🏼','🧔🏽','🧔🏾','👩🏻','👩🏼','👩🏽','👩🏾','👩‍🦱','👩‍🦰','👩‍🦳','👩‍🦱'];
  const colors = ['#2364aa','#3a7d44','#7b2cbf','#b45309','#a61e4d','#007f73','#5f3dc4','#9c2f2f','#c2255c','#d9480f','#087f5b','#5c7cfa','#862e9c','#0b7285','#e67700','#495057'];
  const name = person => `${isMan(person) ? 'M' : 'W'}${isMan(person) ? person : person - 8}`;
  const emoji = person => faces[person - 1];
  const groupFor = (person, ri) => schedules[mode][ri].find(group => group.includes(person));

  function meetingsThrough(ri) {
    const counts = new Map();
    for (let i = 0; i <= ri; i += 1) groupFor(selected, i).forEach(person => {
      if (person !== selected) counts.set(person, (counts.get(person) || 0) + 1);
    });
    return counts;
  }
  function stop() { if (timer) window.clearInterval(timer); timer = null; play.textContent = '▶ Play'; play.setAttribute('aria-pressed', 'false'); }
  function choose(person) { selected = person; select.value = String(person); stop(); render(); }
  function renderList(container, people, counts, previousCounts) {
    container.innerHTML = people.map(person => {
      const count = counts.get(person) || 0;
      const isNew = count > 0 && !(previousCounts.get(person) || 0);
      const scratches = Array.from({ length: count }, (_, i) => `<i class="sd-scratch" style="--scratch:${i - (count - 1) / 2}" aria-hidden="true"></i>`).join('');
      return `<span class="sd-met-person ${isMan(person) ? 'man' : 'woman'} ${count ? 'is-met' : ''} ${isNew ? 'is-new' : ''}">${emoji(person)} ${name(person)}${scratches}${count > 1 ? `<small class="sd-meet-count">×${count}</small>` : ''}</span>`;
    }).join('');
  }
  function render() {
    const oldPositions = new Map(Array.from(tablesBox.querySelectorAll('.sd-human')).map(button => [button.dataset.person, button.getBoundingClientRect()]));
    const currentGroup = groupFor(selected, round);
    const meetings = meetingsThrough(round);
    const previousMeetings = round ? meetingsThrough(round - 1) : new Map();
    const rounds = schedules[mode];
    tablesBox.innerHTML = rounds[round].map((group, ti) => `
      <div class="sd-table ${group.includes(selected) ? 'is-traced' : ''}">
        <span class="sd-table-label">Table ${ti + 1}</span>
        ${group.map(person => `<button class="sd-human ${isMan(person) ? 'man' : 'woman'} ${person === selected ? 'is-selected' : ''}" type="button" data-person="${person}" aria-label="Trace ${isMan(person) ? 'man' : 'woman'} ${name(person).slice(1)}"><span class="sd-human-emoji" aria-hidden="true">${emoji(person)}</span><span class="sd-human-name">${name(person)}</span></button>`).join('')}
      </div>`).join('');
    if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      tablesBox.querySelectorAll('.sd-human').forEach(button => {
        const before = oldPositions.get(button.dataset.person);
        if (!before) return;
        const after = button.getBoundingClientRect();
        button.style.transition = 'none';
        button.style.setProperty('--sd-dx', `${before.left - after.left}px`);
        button.style.setProperty('--sd-dy', `${before.top - after.top}px`);
        window.requestAnimationFrame(() => window.requestAnimationFrame(() => {
          button.style.transition = '';
          button.style.setProperty('--sd-dx', '0px');
          button.style.setProperty('--sd-dy', '0px');
        }));
      });
    }
    tablesBox.querySelectorAll('.sd-human').forEach(button => button.addEventListener('click', () => choose(Number(button.dataset.person))));
    chip.textContent = `Round ${round + 1} of 4`;
    dots.innerHTML = rounds.map((_, i) => `<button class="sd-round-dot ${i === round ? 'is-current' : ''}" type="button" data-round="${i}" aria-label="Go to round ${i + 1}">${i + 1}</button>`).join('');
    dots.querySelectorAll('.sd-round-dot').forEach(button => button.addEventListener('click', () => { round = Number(button.dataset.round); stop(); render(); }));
    const dates = Array.from({ length: 8 }, (_, i) => isMan(selected) ? i + 9 : i + 1);
    const peers = Array.from({ length: 8 }, (_, i) => isMan(selected) ? i + 1 : i + 9).filter(person => person !== selected);
    renderList(datesBox, dates, meetings, previousMeetings); renderList(peersBox, peers, meetings, previousMeetings);
    dateCount.textContent = `· ${dates.filter(person => meetings.has(person)).length}/8 met`;
    peerCount.textContent = `· ${peers.filter(person => meetings.has(person)).length}/7 met`;
    const tableNumber = rounds[round].findIndex(group => group.includes(selected)) + 1;
    if (round === 3 && mode === 'original') {
      const repeatedPeer = peers.find(person => meetings.has(person));
      status.textContent = `${name(selected)} met every potential date—but shared all four rounds with ${name(repeatedPeer)}.`;
    } else if (round === 3) {
      status.textContent = `${name(selected)} met every potential date exactly once, plus four different same-group peers.`;
    } else {
      status.textContent = `At table ${tableNumber}, ${name(selected)} meets ${currentGroup.filter(person => person !== selected).map(name).join(', ')}.`;
    }
    prev.disabled = round === 0; next.disabled = round === 3;
  }
  select.innerHTML = Array.from({ length: 16 }, (_, i) => i + 1).map(person => `<option value="${person}">${emoji(person)} ${name(person)}</option>`).join('');
  select.addEventListener('change', () => choose(Number(select.value)));
  root.querySelectorAll('.sd-mode-tab').forEach(button => button.addEventListener('click', () => {
    mode = button.dataset.mode;
    round = 0;
    stop();
    root.querySelectorAll('.sd-mode-tab').forEach(tab => tab.setAttribute('aria-selected', String(tab === button)));
    subtitle.textContent = mode === 'original'
      ? 'The men stay put while each pair of women moves to the next table.'
      : 'Everyone moves so that companions do not repeat from round to round.';
    caption.textContent = mode === 'original'
      ? 'The original rotation covers every possible date, but each woman travels with the same partner and hears her introduction in all four rounds.'
      : 'Perfect mixing covers the same dates while replacing the repeated companion with four different same-gender introductions.';
    render();
  }));
  prev.addEventListener('click', () => { round = Math.max(0, round - 1); stop(); render(); });
  next.addEventListener('click', () => { round = Math.min(3, round + 1); stop(); render(); });
  play.addEventListener('click', () => {
    if (timer) { stop(); return; }
    const restarting = round === 3;
    if (restarting) round = 0;
    play.textContent = '❚❚ Pause';
    play.setAttribute('aria-pressed', 'true');
    if (restarting) render();
    timer = window.setInterval(() => { if (round === 3) { stop(); return; } round += 1; render(); }, 1900);
  });
  render();
  if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
    const autoplayObserver = new IntersectionObserver(entries => {
      if (!entries.some(entry => entry.isIntersecting)) return;
      autoplayObserver.disconnect();
      window.setTimeout(() => { if (!timer && round === 0) play.click(); }, 450);
    }, { threshold: 0.35 });
    autoplayObserver.observe(root);
  }
})();
</script>]]></content><author><name></name></author><category term="research"/><category term="combinatorics"/><category term="design-theory"/><category term="finite-fields"/><category term="puzzles"/><summary type="html"><![CDATA[A speed-dating puzzle about moving sixteen people between four tables leads to finite fields, affine planes, and a surprisingly rich scheduling problem.]]></summary></entry><entry><title type="html">Piloting the world’s first double-blind AI evaluations</title><link href="https://snwagh.com/blog/2026/double-blind-ai-evaluations/" rel="alternate" type="text/html" title="Piloting the world’s first double-blind AI evaluations"/><published>2026-08-27T00:00:00+00:00</published><updated>2026-08-27T00:00:00+00:00</updated><id>https://snwagh.com/blog/2026/double-blind-ai-evaluations</id><content type="html" xml:base="https://snwagh.com/blog/2026/double-blind-ai-evaluations/"><![CDATA[<p>This post lives on the Google DeepMind blog → <a href="https://deepmind.google/blog/piloting-the-worlds-first-double-blind-ai-evaluations/">Piloting the world’s first double-blind AI evaluations</a>.</p>]]></content><author><name></name></author><category term="research"/><category term="ai-safety"/><category term="evaluation"/><summary type="html"><![CDATA[Designing evaluation protocols where neither the model developer nor the evaluator can game the results — work with Google DeepMind.]]></summary></entry><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">Open source vs frontier models</title><link href="https://snwagh.com/blog/2026/open-source-vs-frontier/" rel="alternate" type="text/html" title="Open source vs frontier models"/><published>2026-07-19T00:00:00+00:00</published><updated>2026-07-19T00:00:00+00:00</updated><id>https://snwagh.com/blog/2026/open-source-vs-frontier</id><content type="html" xml:base="https://snwagh.com/blog/2026/open-source-vs-frontier/"><![CDATA[<script defer="" src="/assets/js/post-toc.js"></script> <script>
/* This post has dollar amounts in prose. Remove $ as MathJax inline delimiter
   so amounts like $33.15 render as text instead of triggering math mode. */
(function() {
  var mj = window.MathJax = window.MathJax || {};
  mj.tex = Object.assign(mj.tex || {}, { inlineMath: [['\\(', '\\)']] });
})();
</script> <style>
  /* Dollar amounts in prose — code tag prevents MathJax scanning, inherit styles hide it */
  code.cost { font: inherit; background: none; padding: 0; border-radius: 0; color: inherit; }

  /* ── Theme overrides for embedded figures ──────────────────────────────
     The widgets use inline CSS with hardcoded colors. These ID-scoped rules
     override them for dark-mode compatibility without touching the figures. */
  #oss-story { color: var(--global-text-color); }
  #oss-story .osst-caption {
    background: var(--global-card-bg-color);
    border-color: var(--global-divider-color);
  }
  #oss-story .osst-body { color: var(--global-text-color); }
  #oss-story .osst-nav button {
    background: var(--global-text-color);
    color: var(--global-bg-color);
    border-color: var(--global-divider-color);
  }
  #oss-story .osst-nav .osst-prev {
    background: var(--global-bg-color);
    color: var(--global-text-color);
  }
  #oss-story .osst-dots i { background: var(--global-divider-color); }
  #oss-story .osst-dots i.on { background: var(--global-text-color); }
  #oss-story .osst-progress { background: var(--global-text-color); }
  #oss-story .osst-tip {
    background: var(--global-text-color);
    color: var(--global-bg-color);
  }

  #oss-tradeoff { color: var(--global-text-color); }
  #oss-tradeoff .oss-sub { color: var(--global-text-color-light); }
  #oss-tradeoff .oss-tabs button {
    background: var(--global-card-bg-color);
    color: var(--global-text-color);
    border-color: var(--global-divider-color);
  }
  #oss-tradeoff .oss-tabs button:hover { border-color: var(--global-text-color-light); }
  #oss-tradeoff .oss-tabs button.active {
    background: var(--global-text-color);
    color: var(--global-bg-color);
    border-color: var(--global-text-color);
  }
  #oss-tradeoff .oss-axis button.active {
    background: #2563eb;
    border-color: #2563eb;
    color: #fff;
  }
  #oss-tradeoff .oss-legend { color: var(--global-text-color-light); }
  #oss-tradeoff .oss-note { color: var(--global-text-color-light); opacity: .75; }
  #oss-tradeoff .oss-tip {
    background: var(--global-text-color);
    color: var(--global-bg-color);
  }
</style> <p>Privacy regulation and AI sovereignty create an incentive towards open-weights-only stacks. I wanted to explore how close is the frontier of open-source only models in comparison to closed sourced frontier models (Opus and ChatGPT). I ran a small benchmark study to measure three axes at once: accuracy, cost, and speed.</p> <h3 id="evaluation-setup">Evaluation setup</h3> <p>In my open open source setup models, I use the following set <code class="language-plaintext highlighter-rouge">DeepSeek-V4-Pro</code>, <code class="language-plaintext highlighter-rouge">Qwen3-235B-Thinking</code>, <code class="language-plaintext highlighter-rouge">GLM-5.2</code>. I used four ensembling strategies:</p> <ul> <li>Synthesizer: Several OSS models answer concurrently, then an OSS synthesizer model reads every member’s answer and its reasoning and writes the final answer by deciding which line of reasoning to trust</li> <li>Consensus adjudicator: This is shown the vote tally of the responses of multiple OSS models, it defaults to the majority answer and overturns it only when it can name a concrete, specific error in the consensus reasoning thereby fixing the synthesizer’s habit of talking itself into a lone wrong answer</li> <li>Self-consistency: Each OSS model answers k=3 times and the final answer is the plurality vote, with no aggregator model reading anything (OpenRouter Fusion also used this)</li> <li>OpenRouter Fusion: OpenRouter’s own hosted mixture-of-models router that blends multiple providers behind a single endpoint (turns out to be the cheapest option on all five benchmarks).</li> </ul> <p>No closed model was used anywhere in the ensembling stack, not even as judge. For frontier models, I used <code class="language-plaintext highlighter-rouge">Claude Opus 4.8</code> and <code class="language-plaintext highlighter-rouge">ChatGPT 5.5</code>. For the benchmarks, I used GPQA-Diamond (198 questions), AIME 2025 (30), SuperGPQA (500), HLE-math (128), SimpleQA (1000). Finally, I used <a href="https://openrouter.ai">OpenRouter</a> via <a href="https://inspect.aisi.org.uk">Inspect</a> to run the evaluation pipelines, with live per-token pricing for cost.</p> <h3 id="comparison-with-claude-opus-48">Comparison with Claude Opus 4.8</h3> <p>I used Claude Opus 4.8 as the reference bar. ChatGPT was generally strictly better on these benchmarks but the ensembles had mixed performance comparison. Scroll through this visual to see the results in a nutshell.</p> <div id="oss-story" class="osst"> <div class="osst-stage"> <div class="osst-progress"></div> <svg class="osst-svg" viewBox="0 0 720 420" preserveAspectRatio="xMidYMid meet"></svg> </div> <div class="osst-caption"> <div class="osst-title"></div> <div class="osst-body"></div> </div> <div class="osst-nav"> <button class="osst-prev">← Back</button> <div class="osst-dots"></div> <button class="osst-next">Next →</button> </div> <div class="osst-tip" hidden=""></div> </div> <style>
  .osst{max-width:760px;margin:1.5rem auto;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
  .osst-stage{position:relative;overflow-x:auto;-webkit-overflow-scrolling:touch}
  .osst-progress{height:3px;background:#1d2330;width:0;border-radius:3px;transition:width .4s ease;margin-bottom:.4rem}
  .osst-svg{min-width:520px;width:100%;height:auto;display:block}
  .osst-caption{background:#f7f8fa;border:1px solid #e6e9ef;border-radius:12px;padding:.9rem 1.1rem;margin:.6rem 0}
  .osst-title{font-weight:700;font-size:1.05rem;margin-bottom:.25rem}
  .osst-body{font-size:.93rem;line-height:1.5;color:#3a4252}
  .osst-body b{color:#16a34a}.osst-body .f{color:#2563eb}
  .osst-nav{display:flex;align-items:center;justify-content:space-between;gap:1rem}
  .osst-nav button{border:1px solid #d6dae2;background:#1d2330;color:#fff;border-radius:999px;padding:.4rem 1.1rem;font-size:.9rem;cursor:pointer}
  .osst-nav button:disabled{opacity:.35;cursor:default}
  .osst-nav .osst-prev{background:#fff;color:#3a4252}
  .osst-dots{display:flex;gap:6px}
  .osst-dots i{width:8px;height:8px;border-radius:50%;background:#d6dae2;display:inline-block}
  .osst-dots i.on{background:#1d2330}
  .osst-tip{position:fixed;pointer-events:none;background:#10141c;color:#fff;padding:.45rem .6rem;border-radius:8px;font-size:.78rem;z-index:9999}
</style> <script>
(function(){
  var NS="http://www.w3.org/2000/svg";
  var BENCH=[
    {k:"simpleqa",label:"SimpleQA",opus:46.3,cg:18.5,oss:14.7,ossName:"self-consistency",open:83,rAcc:48.9},
    {k:"gpqa",label:"GPQA-Diamond",opus:86.9,cg:5.6,oss:2.0,ossName:"self-consistency",open:67,rAcc:82.3},
    {k:"supergpqa",label:"SuperGPQA",opus:65.0,cg:3.4,oss:1.0,ossName:"self-consistency",open:91,rAcc:63.4},
    {k:"aime",label:"AIME 2025",opus:100.0,cg:0.0,oss:-6.7,ossName:"adjudicator",open:17,rAcc:96.7},
    {k:"hle",label:"HLE-math",opus:46.1,cg:11.7,oss:-9.4,ossName:"adjudicator",open:30,rAcc:41.4}
  ];
  var ECON={chatgptTok:0.45,ossTok:17.25,chatgptCost:12.12,ossCost:23.26,fusionCost:2.61,hypo:469};
  var STEPS=[
    {mode:"vs",series:[],hi:[],title:"The bar to beat: Claude Opus 4.8",
     body:"Every bar below is measured <i>relative to Claude Opus 4.8</i> — the center line is Claude Opus 4.8. To the right = beats Claude Opus 4.8; to the left = loses to it. Claude Opus 4.8 scores: SimpleQA 46%, GPQA 87%, SuperGPQA 65%, AIME 100%, HLE 46%."},
    {mode:"vs",series:["cg"],hi:[],title:"Even the frontier disagrees with itself",
     body:"<span class='f'>ChatGPT 5.5</span> beats Claude Opus 4.8 on every benchmark (up to +18.5 pts). So Claude Opus 4.8 is the <i>weaker</i> of the two frontier models, which makes &ldquo;can open source beat Claude Opus 4.8?&rdquo; a fair, concrete bar."},
    {mode:"vs",series:["cg","oss"],hi:["simpleqa","gpqa","supergpqa"],title:"Open source clears the bar — on 3 of 5",
     body:"A <b>tool-free, open-weights-only ensemble</b> (DeepSeek + Qwen + GLM, no closed model in the loop) beats Claude Opus 4.8 on <b>SimpleQA (+14.7)</b>, <b>GPQA (+2.0)</b> and <b>SuperGPQA (+1.0)</b>."},
    {mode:"vs",series:["cg","oss"],hi:["aime","hle"],title:"Where the frontier still earns its keep",
     body:"The two it loses are the hardest: <span class='f'>competition math (AIME, −6.7)</span> and <span class='f'>frontier-difficulty reasoning (HLE, −9.4)</span>. The frontier premium is real at the top of the difficulty curve."},
    {mode:"econ",series:[],hi:[],title:"Scary token count, ordinary bill",
     body:"On GPQA the naive open ensemble emits <b>38× more tokens</b> than ChatGPT. At ChatGPT's per-token price that'd be <b>~$469</b> but open tokens are ~20× cheaper, so the real bill is <b>$23</b> (≈2× ChatGPT, not 38×). And the <i>smart</i> ensemble, <b>Fusion (by OpenRouter), costs just $2.61</b> — cheaper than either frontier model. Tokens are the scary number; dollars are the real one."},
    {mode:"split",series:[],hi:[],title:"So route, don't pick",
     body:"A cheap <b>router</b> (brain: GLM-5.2, ~$0.0001/decision) reads each question and sends it to an open model or the frontier. On easy & factual tasks it keeps <b>83–91% of traffic open</b> thus remaining cheap and self-hosted. At scale, this will be significant cost reduction."},
    {mode:"split",series:[],hi:["aime","hle"],title:"…and it escalates exactly when it should",
     body:"On <b>AIME (17% open)</b> and <b>HLE (30% open)</b> the same router flips to the <span class='f'>frontier</span> automatically (no hard coded rules). It spends frontier dollars only where open models are likely to fail."},
    {mode:"end",series:[],hi:[],title:"The takeaway",
     body:"<b>Open-weights-only ensembles already beat Claude Opus 4.8 on most benchmarks.</b> The frontier's edge survives on the hardest math & reasoning and a cheap router buys it there while keeping the easy majority open, private, and inexpensive."}
  ];

  var root=document.getElementById("oss-story"),svg=root.querySelector(".osst-svg"),tip=root.querySelector(".osst-tip");
  var elTitle=root.querySelector(".osst-title"),elBody=root.querySelector(".osst-body");
  var elProg=root.querySelector(".osst-progress"),elDots=root.querySelector(".osst-dots");
  var prev=root.querySelector(".osst-prev"),next=root.querySelector(".osst-next");
  STEPS.forEach(function(_,i){var d=document.createElement("i");elDots.appendChild(d);});
  var idx=0;
  function el(t,a){var e=document.createElementNS(NS,t);for(var k in a)e.setAttribute(k,a[k]);return e;}
  function txt(x,y,s,o){var t=el("text",Object.assign({x:x,y:y},o||{}));t.textContent=s;svg.appendChild(t);return t;}

  function draw(){
    var st=STEPS[idx];
    while(svg.firstChild)svg.removeChild(svg.firstChild);
    var W=720,H=420,m={t:30,r:60,b:20,l:118};
    var rowH=(H-m.t-m.b)/BENCH.length;
    if(st.mode==="end"){
      txt(W/2,150,"Beat Opus 4.8 on 3 of 5 benchmarks",{"text-anchor":"middle","font-size":22,"font-weight":700,fill:"#16a34a"});
      txt(W/2,188,"(SimpleQA, GPQA, SuperGPQA — tool-free, open weights only)",{"text-anchor":"middle","font-size":13,fill:"#5b636f"});
      txt(W/2,236,"Frontier still leads on AIME & HLE",{"text-anchor":"middle","font-size":18,"font-weight":700,fill:"#2563eb"});
      txt(W/2,272,"A cheap router keeps 83–91% of easy traffic open,",{"text-anchor":"middle","font-size":14,fill:"#3a4252"});
      txt(W/2,292,"and escalates the hard 70–83% of AIME/HLE to the frontier.",{"text-anchor":"middle","font-size":14,fill:"#3a4252"});
      finishNav();return;
    }
    if(st.mode==="vs"){
      var dom=22,cx=m.l+(W-m.l-m.r)/2,half=(W-m.l-m.r)/2;
      var sx=function(v){return cx+v/dom*half;};
      txt((m.l+W-m.r)/2,18,"Accuracy vs. Claude Opus 4.8 (percentage points)",{"text-anchor":"middle","font-size":12,fill:"#5b636f"});
      svg.appendChild(el("line",{x1:cx,y1:m.t-6,x2:cx,y2:H-m.b,stroke:"#1d2330","stroke-width":1.5}));
      txt(cx,H-6,"Opus 4.8",{"text-anchor":"middle","font-size":10,fill:"#1d2330"});
      [-20,-10,10,20].forEach(function(g){var x=sx(g);svg.appendChild(el("line",{x1:x,y1:m.t-2,x2:x,y2:H-m.b,stroke:"#eef0f4"}));
        txt(x,m.t-10,(g>0?"+":"")+g,{"text-anchor":"middle","font-size":9,fill:"#aab1bd"});});
      BENCH.forEach(function(bn,i){
        var y0=m.t+i*rowH,dim=(st.hi.length&&st.hi.indexOf(bn.k)<0);
        txt(m.l-10,y0+rowH/2+4,bn.label,{"text-anchor":"end","font-size":12,fill:dim?"#aeb4bf":"#1d2330","font-weight":dim?400:600});
        var bars=[];
        if(st.series.indexOf("cg")>=0)bars.push({v:bn.cg,c:"#2563eb",nm:"ChatGPT 5.5"});
        if(st.series.indexOf("oss")>=0)bars.push({v:bn.oss,c:"#16a34a",nm:"OSS ensemble ("+bn.ossName+")"});
        var bh=Math.min(16,(rowH-10)/Math.max(bars.length,1));
        bars.forEach(function(b,j){
          var yy=y0+rowH/2-(bars.length*bh)/2+j*bh+1;
          var x1=Math.min(cx,sx(b.v)),w=Math.abs(sx(b.v)-cx);
          var rect=el("rect",{x:x1,y:yy,width:Math.max(w,1.5),height:bh-2,rx:2,fill:b.c,"fill-opacity":dim?0.18:0.9});
          svg.appendChild(rect);
          if(!dim){txt(b.v>=0?sx(b.v)+4:sx(b.v)-4,yy+bh-4,(b.v>0?"+":"")+b.v.toFixed(1),{"font-size":10,fill:b.c,"text-anchor":b.v>=0?"start":"end","font-weight":600});}
          rect.addEventListener("mousemove",function(ev){tip.hidden=false;tip.innerHTML="<b>"+bn.label+"</b><br>"+b.nm+": "+(b.v>0?"+":"")+b.v.toFixed(1)+" pts vs Claude Opus 4.8";
            tip.style.left=Math.min(ev.clientX+12,window.innerWidth-200)+"px";tip.style.top=(ev.clientY+12)+"px";});
          rect.addEventListener("mouseleave",function(){tip.hidden=true;});
        });
      });
    } else if(st.mode==="econ"){
      var pad={t:50,r:48,b:24,l:212},lo=1,hi=1000,x0=pad.l,ww=W-pad.l-pad.r;
      var sx=function(v){return x0+(Math.log10(v)-Math.log10(lo))/(Math.log10(hi)-Math.log10(lo))*ww;};
      txt((x0+W-pad.r)/2,18,"Cost to answer all 198 GPQA-Diamond questions",{"text-anchor":"middle","font-size":12.5,fill:"#1d2330","font-weight":600});
      txt((x0+W-pad.r)/2,33,"the open ensemble generates 38× more tokens than ChatGPT 5.5 — but tokens ≠ dollars",{"text-anchor":"middle","font-size":10.5,fill:"#9aa3b2"});
      [1,10,100,1000].forEach(function(g){var x=sx(g);svg.appendChild(el("line",{x1:x,y1:pad.t-6,x2:x,y2:H-pad.b,stroke:"#eef0f4"}));
        txt(x,H-pad.b+14,"$"+g,{"text-anchor":"middle","font-size":10,fill:"#aab1bd"});});
      var bars=[
        {lab:"Those tokens at ChatGPT 5.5 prices",v:ECON.hypo,c:"#d97706",ghost:true},
        {lab:"Open ensemble (actual)",v:ECON.ossCost,c:"#16a34a"},
        {lab:"ChatGPT 5.5 (actual)",v:ECON.chatgptCost,c:"#2563eb"},
        {lab:"Fusion — smart OSS (actual)",v:ECON.fusionCost,c:"#0ea5e9"}
      ];
      var rH=(H-pad.t-pad.b)/bars.length;
      bars.forEach(function(b,i){
        var y0=pad.t+i*rH,yy=y0+rH/2-12,w=Math.max(sx(b.v)-x0,2);
        txt(pad.l-10,y0+rH/2+4,b.lab,{"text-anchor":"end","font-size":11.5,fill:b.ghost?"#b08900":"#1d2330","font-weight":b.ghost?400:600});
        svg.appendChild(el("rect",{x:x0,y:yy,width:w,height:24,rx:3,fill:b.c,"fill-opacity":b.ghost?0.16:0.9,stroke:b.ghost?b.c:"none","stroke-dasharray":b.ghost?"5 3":"0"}));
        txt(sx(b.v)+7,y0+rH/2+4,"$"+b.v.toFixed(b.v>=100?0:2),{"font-size":11.5,fill:b.c,"font-weight":700});
      });
      var xa=sx(ECON.hypo),xb=sx(ECON.ossCost),ya=pad.t+rH/2+12,yb=pad.t+rH+rH/2-12;
      svg.appendChild(el("path",{d:"M"+xa+","+ya+" C"+xa+","+(ya+22)+" "+xb+","+(yb-22)+" "+xb+","+yb,fill:"none",stroke:"#d97706","stroke-width":1.5,"stroke-dasharray":"4 3"}));
      svg.appendChild(el("path",{d:"M"+xb+","+yb+" l-4,-7 l8,0 z",fill:"#d97706"}));
      txt(xb+8,(ya+yb)/2+3,"÷20 — open tokens are ~20× cheaper",{"font-size":10.5,fill:"#b08900","font-style":"italic","font-weight":600});
    } else if(st.mode==="split"){
      txt((m.l+W-m.r)/2,18,"Where the router sends each question (brain: GLM-5.2)",{"text-anchor":"middle","font-size":12,fill:"#5b636f"});
      var x0=m.l,ww=W-m.l-m.r;
      BENCH.forEach(function(bn,i){
        var y0=m.t+i*rowH,dim=(st.hi.length&&st.hi.indexOf(bn.k)<0);
        txt(m.l-10,y0+rowH/2+4,bn.label,{"text-anchor":"end","font-size":12,fill:dim?"#aeb4bf":"#1d2330","font-weight":dim?400:600});
        var bh=Math.min(22,rowH-14),yy=y0+rowH/2-bh/2;
        var wOpen=ww*bn.open/100;
        var gOpen=el("rect",{x:x0,y:yy,width:wOpen,height:bh,rx:3,fill:"#16a34a","fill-opacity":dim?0.2:0.85});svg.appendChild(gOpen);
        var gCl=el("rect",{x:x0+wOpen,y:yy,width:ww-wOpen,height:bh,rx:3,fill:"#2563eb","fill-opacity":dim?0.2:0.85});svg.appendChild(gCl);
        if(!dim){
          if(bn.open>14)txt(x0+6,yy+bh/2+4,bn.open+"% open",{"font-size":11,fill:"#fff","font-weight":600});
          if(100-bn.open>14)txt(x0+ww-6,yy+bh/2+4,(100-bn.open)+"% frontier",{"font-size":11,fill:"#fff","text-anchor":"end","font-weight":600});
        }
      });
      svg.appendChild(el("rect",{x:m.l,y:H-14,width:10,height:10,fill:"#16a34a"}));txt(m.l+15,H-5,"open models",{"font-size":10,fill:"#5b636f"});
      svg.appendChild(el("rect",{x:m.l+110,y:H-14,width:10,height:10,fill:"#2563eb"}));txt(m.l+125,H-5,"frontier",{"font-size":10,fill:"#5b636f"});
    }
    finishNav();
  }
  function finishNav(){
    elTitle.innerHTML=STEPS[idx].title;elBody.innerHTML=STEPS[idx].body;
    elProg.style.width=((idx)/(STEPS.length-1)*100)+"%";
    Array.prototype.forEach.call(elDots.children,function(d,i){d.classList.toggle("on",i===idx);});
    prev.disabled=(idx===0);next.disabled=(idx===STEPS.length-1);
    next.textContent=(idx===STEPS.length-2)?"See takeaway →":"Next →";
  }
  prev.onclick=function(){if(idx>0){idx--;draw();}};
  next.onclick=function(){if(idx<STEPS.length-1){idx++;draw();}};

  function calibrateCaption(){
    var cap=root.querySelector(".osst-caption");
    cap.style.minHeight="0";
    var savedTitle=elTitle.innerHTML,savedBody=elBody.innerHTML;
    var maxH=0;
    STEPS.forEach(function(s){
      elTitle.innerHTML=s.title;
      elBody.innerHTML=s.body;
      maxH=Math.max(maxH,cap.scrollHeight);
    });
    cap.style.minHeight=maxH+"px";
    elTitle.innerHTML=savedTitle;
    elBody.innerHTML=savedBody;
  }

  draw();
  calibrateCaption();
  window.addEventListener("resize",function(){calibrateCaption();draw();});
})();
</script> <p>The accuracy finding is more nuanced than a single number lets on: open source wins where benchmarks are dense enough for ensembling to help, and loses where the questions are genuinely at the frontier of difficulty. GPQA-Diamond and SuperGPQA fall in the first category; HLE-math and AIME fall in the second. Ensembling can also have big impact on costs with simple tasks being delegated to local models.</p> <h3 id="cost-and-speed-tradeoff">Cost and Speed tradeoff</h3> <p>Accuracy parity is only half the story. The chart below shows the full trade-off space for the models with costs in $ also included.</p> <div id="oss-tradeoff" class="oss-fig"> <div class="oss-head"> <strong>Open-source ensembles vs. the frontier</strong> <span class="oss-sub">Up &amp; left is better — higher accuracy, lower cost.</span> </div> <div class="oss-controls"> <div class="oss-tabs" data-group="bench"></div> <div class="oss-tabs oss-axis" data-group="axis"></div> </div> <svg class="oss-svg" viewBox="0 0 720 440" preserveAspectRatio="xMidYMid meet" role="img"></svg> <div class="oss-legend"> <span><i class="dot frontier"></i> Frontier (closed)</span> <span><i class="dot oss"></i> Open-source ensemble</span> <span class="oss-note">bubble size ∝ tokens used</span> </div> <div class="oss-tip" hidden=""></div> </div> <style>
  .oss-fig{max-width:760px;margin:1.5rem auto;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:#1d2330}
  .oss-head{display:flex;flex-direction:column;gap:2px;margin-bottom:.5rem;align-items:center;text-align:center}
  .oss-head strong{font-size:1.05rem}
  .oss-sub{color:#7a8190;font-size:.85rem}
  .oss-controls{display:flex;flex-direction:column;align-items:center;gap:.45rem;margin:.4rem 0 .2rem;padding:0 1.5rem}
  .oss-tabs{display:flex;flex-wrap:wrap;gap:.25rem;justify-content:center}
  .oss-tabs button{border:1px solid #d6dae2;background:#f6f7f9;color:#3a4252;border-radius:999px;padding:.25rem .7rem;font-size:.8rem;cursor:pointer;transition:.15s}
  .oss-tabs button:hover{border-color:#9aa3b2}
  .oss-tabs button.active{background:#1d2330;color:#fff;border-color:#1d2330}
  .oss-axis button.active{background:#2563eb;border-color:#2563eb}
  .oss-svg{width:100%;height:auto;display:block;touch-action:none}
  .oss-legend{display:flex;flex-wrap:wrap;gap:1rem;align-items:center;font-size:.8rem;color:#5b636f;margin-top:.3rem}
  .oss-legend .dot{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:4px;vertical-align:middle}
  .dot.frontier{background:#2563eb}.dot.oss{background:#16a34a}
  .oss-note{color:#9aa3b2}
  .oss-tip{position:fixed;pointer-events:none;background:#10141c;color:#fff;padding:.5rem .65rem;border-radius:8px;font-size:.78rem;line-height:1.35;box-shadow:0 6px 22px rgba(0,0,0,.25);z-index:9999;max-width:230px}
  .oss-tip b{color:#fff}
  .oss-dot{cursor:pointer;transition:opacity .15s}
  .oss-dot:hover{opacity:.85}
  text{font-family:inherit}
</style> <script>
(function(){
  var OSSDATA = {
    gpqa:{label:"GPQA-Diamond (198)",rows:[
      {n:"Claude Opus 4.8",k:"frontier",acc:86.9,cost:3.00,tok:0.18,lat:6.0},
      {n:"ChatGPT 5.5",k:"frontier",acc:92.4,cost:12.12,tok:0.45,lat:19.6},
      {n:"OSS synthesizer",k:"oss",acc:87.9,cost:9.40,tok:6.64,lat:206},
      {n:"OSS adjudicator",k:"oss",acc:85.4,cost:10.15,tok:6.90,lat:242},
      {n:"OSS self-consistency",k:"oss",acc:88.9,cost:23.26,tok:17.25,lat:211},
      {n:"OSS Fusion",k:"oss",acc:85.9,cost:2.61,tok:1.24,lat:157}
    ]},
    aime:{label:"AIME 2025 (30)",rows:[
      {n:"Claude Opus 4.8",k:"frontier",acc:100,cost:1.02,tok:0.05,lat:7.3},
      {n:"ChatGPT 5.5",k:"frontier",acc:100,cost:2.57,tok:0.09,lat:31.4},
      {n:"OSS synthesizer",k:"oss",acc:80.0,cost:2.16,tok:1.81,lat:434},
      {n:"OSS adjudicator",k:"oss",acc:93.3,cost:1.71,tok:1.65,lat:488},
      {n:"OSS self-consistency",k:"oss",acc:93.3,cost:4.06,tok:4.23,lat:626},
      {n:"OSS Fusion",k:"oss",acc:96.7,cost:0.64,tok:0.29,lat:322}
    ]},
    supergpqa:{label:"SuperGPQA (500)",rows:[
      {n:"Claude Opus 4.8",k:"frontier",acc:65.0,cost:5.26,tok:0.35,lat:3.9},
      {n:"ChatGPT 5.5",k:"frontier",acc:68.4,cost:17.02,tok:0.67,lat:10.3},
      {n:"OSS synthesizer",k:"oss",acc:63.2,cost:12.96,tok:10.68,lat:159},
      {n:"OSS adjudicator",k:"oss",acc:62.6,cost:13.82,tok:10.67,lat:147},
      {n:"OSS self-consistency",k:"oss",acc:66.0,cost:31.63,tok:27.23,lat:153},
      {n:"OSS Fusion",k:"oss",acc:64.6,cost:4.47,tok:2.71,lat:105}
    ]},
    hle:{label:"HLE-math (128)",rows:[
      {n:"Claude Opus 4.8",k:"frontier",acc:46.1,cost:17.28,tok:1.08,lat:32},
      {n:"ChatGPT 5.5",k:"frontier",acc:57.8,cost:23.92,tok:1.19,lat:118},
      {n:"OSS synthesizer",k:"oss",acc:35.0,cost:14.29,tok:11.36,lat:1156},
      {n:"OSS adjudicator",k:"oss",acc:36.7,cost:16.66,tok:12.22,lat:1179},
      {n:"OSS self-consistency",k:"oss",acc:18.8,cost:35.64,tok:25.02,lat:750},
      {n:"OSS Fusion",k:"oss",acc:42.2,cost:2.79,tok:1.46,lat:1218}
    ]},
    simpleqa:{label:"SimpleQA (1000)",rows:[
      {n:"Claude Opus 4.8",k:"frontier",acc:46.3,cost:2.04,tok:0.50,lat:5.0},
      {n:"ChatGPT 5.5",k:"frontier",acc:64.8,cost:33.15,tok:1.47,lat:18.3},
      {n:"OSS self-consistency",k:"oss",acc:61.0,cost:65.68,tok:27.56,lat:40},
      {n:"OSS Fusion*",k:"oss",acc:97.0,cost:1.33,tok:5.35,lat:81}
    ]}
  };
  var AXES = {
    cost:{label:"Cost (US$, log)",key:"cost",fmt:function(v){return "$"+v.toFixed(2)},log:true},
    tok:{label:"Tokens (millions, log)",key:"tok",fmt:function(v){return v.toFixed(2)+"M"},log:true},
    lat:{label:"Median latency / query (s, log)",key:"lat",fmt:function(v){return v+"s"},log:true}
  };
  var root=document.getElementById("oss-tradeoff");
  var svg=root.querySelector(".oss-svg");
  var tip=root.querySelector(".oss-tip");
  var benchKeys=Object.keys(OSSDATA),axisKeys=Object.keys(AXES);
  var state={bench:"gpqa",axis:"cost"};
  var NS="http://www.w3.org/2000/svg";

  function mkTabs(group,keys,labels){
    var box=root.querySelector('[data-group="'+group+'"]');
    box.innerHTML="";
    keys.forEach(function(k){
      var b=document.createElement("button");
      b.textContent=labels[k];b.dataset.k=k;
      b.onclick=function(){state[group==="bench"?"bench":"axis"]=k;render();syncTabs()};
      box.appendChild(b);
    });
  }
  function syncTabs(){
    root.querySelectorAll('[data-group="bench"] button').forEach(function(b){b.classList.toggle("active",b.dataset.k===state.bench)});
    root.querySelectorAll('[data-group="axis"] button').forEach(function(b){b.classList.toggle("active",b.dataset.k===state.axis)});
  }
  function el(tag,attrs){var e=document.createElementNS(NS,tag);for(var a in attrs)e.setAttribute(a,attrs[a]);return e;}

  function render(){
    while(svg.firstChild)svg.removeChild(svg.firstChild);
    var W=720,H=440,m={t:24,r:24,b:54,l:48};
    var data=OSSDATA[state.bench],rows=data.rows,ax=AXES[state.axis];
    var xs=rows.map(function(r){return r[ax.key]});
    var xmin=Math.min.apply(null,xs),xmax=Math.max.apply(null,xs);
    var lx=function(v){var a=Math.log10(Math.max(xmin*0.8,1e-3)),b=Math.log10(xmax*1.25);
      return m.l+(Math.log10(v)-a)/(b-a)*(W-m.l-m.r);};
    var accMin=Math.max(0,Math.min.apply(null,rows.map(function(r){return r.acc}))-10);
    var ly=function(v){return H-m.b-(v-accMin)/(100-accMin)*(H-m.t-m.b);};

    svg.appendChild(el("line",{x1:m.l,y1:H-m.b,x2:W-m.r,y2:H-m.b,stroke:"#cdd2db"}));
    svg.appendChild(el("line",{x1:m.l,y1:m.t,x2:m.l,y2:H-m.b,stroke:"#cdd2db"}));
    for(var a=Math.ceil(accMin/10)*10;a<=100;a+=10){
      var y=ly(a);
      svg.appendChild(el("line",{x1:m.l,y1:y,x2:W-m.r,y2:y,stroke:"#eef0f4"}));
      var t=el("text",{x:m.l-8,y:y+4,"text-anchor":"end","font-size":11,fill:"#9aa3b2"});t.textContent=a+"%";svg.appendChild(t);
    }
    var ticks=[];var decadeLo=Math.floor(Math.log10(xmin*0.8)),decadeHi=Math.ceil(Math.log10(xmax*1.25));
    for(var d=decadeLo;d<=decadeHi;d++){[1,2,5].forEach(function(mul){var v=mul*Math.pow(10,d);if(v>=xmin*0.8&&v<=xmax*1.25)ticks.push(v);});}
    ticks.forEach(function(v){var x=lx(v);
      svg.appendChild(el("line",{x1:x,y1:m.t,x2:x,y2:H-m.b,stroke:"#f3f5f8"}));
      var t=el("text",{x:x,y:H-m.b+16,"text-anchor":"middle","font-size":10,fill:"#9aa3b2"});t.textContent=ax.fmt(v);svg.appendChild(t);
    });
    var xl=el("text",{x:(m.l+W-m.r)/2,y:H-12,"text-anchor":"middle","font-size":12,fill:"#5b636f"});xl.textContent=ax.label;svg.appendChild(xl);
    var yl=el("text",{x:14,y:(m.t+H-m.b)/2,"text-anchor":"middle","font-size":12,fill:"#5b636f",transform:"rotate(-90 14 "+((m.t+H-m.b)/2)+")"});yl.textContent="Accuracy";svg.appendChild(yl);
    var ar=el("text",{x:W-m.r-4,y:m.t+12,"text-anchor":"end","font-size":11,fill:"#16a34a","font-style":"italic"});ar.textContent="← cheaper · more accurate ↑";svg.appendChild(ar);

    var tokMax=Math.max.apply(null,rows.map(function(r){return r.tok}));
    rows.forEach(function(r){
      var cx=lx(r[ax.key]),cy=ly(r.acc);
      var rad=6+10*Math.sqrt(r.tok/(tokMax||1));
      var c=el("circle",{cx:cx,cy:cy,r:rad,class:"oss-dot",fill:r.k==="frontier"?"#2563eb":"#16a34a","fill-opacity":.22,stroke:r.k==="frontier"?"#2563eb":"#16a34a","stroke-width":1.6});
      svg.appendChild(c);
      var core=el("circle",{cx:cx,cy:cy,r:3,fill:r.k==="frontier"?"#2563eb":"#16a34a"});svg.appendChild(core);
      var lbl=el("text",{x:cx+rad+4,y:cy+3,"font-size":10.5,fill:"#3a4252"});lbl.textContent=r.n;svg.appendChild(lbl);
      [c,core].forEach(function(node){
        node.addEventListener("mousemove",function(ev){showTip(ev,r,ax)});
        node.addEventListener("mouseleave",function(){tip.hidden=true});
      });
    });
  }
  function showTip(ev,r,ax){
    tip.hidden=false;
    tip.innerHTML="<b>"+r.n+"</b><br>Accuracy: "+r.acc.toFixed(1)+"%<br>Cost: $"+r.cost.toFixed(2)+
      "<br>Tokens: "+r.tok.toFixed(2)+"M<br>Latency: "+r.lat+"s/query";
    tip.style.left=Math.min(ev.clientX+14,window.innerWidth-240)+"px";
    tip.style.top=(ev.clientY+14)+"px";
  }
  mkTabs("bench",benchKeys,(function(){var o={};benchKeys.forEach(function(k){o[k]=OSSDATA[k].label.split(" (")[0]});return o})());
  mkTabs("axis",axisKeys,{cost:"vs Cost",tok:"vs Tokens",lat:"vs Speed"});
  syncTabs();render();
  window.addEventListener("resize",render);
})();
</script> <p><strong>On cost, open source can undercut the frontier substantially.</strong> This to me was the largest insight. Fusion (by OpenRouter) was the cheapest contestant on all five benchmarks: <code class="cost">$1.33</code> vs ChatGPT’s <code class="cost">$33.15</code> on SimpleQA,<sup>†</sup> <code class="cost">$2.79</code> vs <code class="cost">$23.92</code> on HLE. The catch is that naive ensembling goes the other direction: self-consistency, which samples every model several times, was the most expensive option of all (up to <code class="cost">$66</code> on SimpleQA). How you ensemble matters more than whether you ensemble.</p> <p><strong>On speed, open source loses.</strong> Flip to <em>vs Speed</em>. A single frontier call returns in seconds to minutes; open ensembles ran ten times longer or more (HLE: ~20 minutes per question for some strategies). Fanning out to three reasoning models and synthesizing is inherently slow. No amount of accuracy parity changes this, and it is the honest weak spot of the approach.</p> <p style="font-size:.82rem;color:var(--global-text-color-light);margin-top:-.5rem"><sup>†</sup> SimpleQA Fusion uses OpenRouter's built-in web search, not a tool-free setup — which explains the accuracy spike to 97%.</p> <h3 id="closing-thoughts">Closing thoughts</h3> <p>Open source models have truly come a long way. However, frontier models certainly are worth their premium because as the envelope of capabilities increases, the bar of problems users want to tackle also raises higher.</p>]]></content><author><name></name></author><category term="research"/><category term="llm"/><category term="open-source"/><category term="benchmarks"/><category term="ai"/><summary type="html"><![CDATA[Open-weights-only ensembles beat Claude Opus 4.8 on three of five benchmarks. The frontier's edge survives only on the hardest reasoning tasks — and a cheap router buys it there while keeping the rest private and inexpensive.]]></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></feed>