<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
 <title>Julian Henry's Blog</title>
 <link href="http://juleshenry.github.io/blog/atom.xml" rel="self"/>
 <link href="http://juleshenry.github.io/blog"/>
 <updated>2026-05-03T15:45:30+00:00</updated>
 <id>http://juleshenry.github.io/blog</id>
 <author>
   <name>Julian Henry</name>
 </author>

 
 <entry>
   <title>touch_keeper: Mass-Personalized New Year's Texts</title>
   <link href="http://hankquinlan.github.io/blog/2026/05/04/touch-keeper"/>
   <updated>2026-05-04T00:00:00+00:00</updated>
   <id>http://juleshenry.github.io//blog/2026/05/04/touch-keeper</id>
   <content type="html">&lt;p&gt;&lt;a href=&quot;https://github.com/juleshenry/touch_keeper&quot;&gt;View on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;New Year’s Eve. 11:58 PM. You want to text “Happy New Year” to everyone in your contacts. All 200+ of them. Personalized by name. Before midnight.&lt;/p&gt;

&lt;p&gt;You could type fast. Or you could automate it.&lt;/p&gt;

&lt;p&gt;touch_keeper is a Python CLI tool that parses your phone contacts (VCF file), sends a personalized SMS to each contact via the Twilio API, runs a webhook server that auto-replies when people text back, and then analyzes the response data with matplotlib visualizations.&lt;/p&gt;

&lt;p&gt;The project is on &lt;a href=&quot;https://github.com/juleshenry/touchkeeper&quot;&gt;GitHub&lt;/a&gt;. Apache 2.0 licensed.&lt;/p&gt;

&lt;div id=&quot;nye-viz&quot; style=&quot;width: 100%; height: 350px; margin: 2em 0; border-radius: 8px; overflow: hidden; background: #020617;&quot;&gt;&lt;/div&gt;

&lt;script&gt;
(function() {
  function initNYE() {
    if (typeof THREE === &apos;undefined&apos;) { setTimeout(initNYE, 100); return; }
    const container = document.getElementById(&apos;nye-viz&apos;);
    if (!container) return;
    const w = container.clientWidth, h = 350;
    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x020617);
    const camera = new THREE.PerspectiveCamera(60, w / h, 0.1, 100);
    camera.position.set(0, 0, 20);
    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(w, h);
    renderer.setPixelRatio(window.devicePixelRatio);
    container.appendChild(renderer.domElement);

    // Central phone
    const phoneGeo = new THREE.BoxGeometry(1.2, 2.2, 0.15);
    const phoneMat = new THREE.MeshStandardMaterial({ color: 0x1e293b, metalness: 0.8, roughness: 0.2 });
    const phone = new THREE.Mesh(phoneGeo, phoneMat);
    scene.add(phone);
    // Screen
    const screenGeo = new THREE.PlaneGeometry(1.0, 1.8);
    const screenMat = new THREE.MeshStandardMaterial({ color: 0x22d3ee, emissive: 0x22d3ee, emissiveIntensity: 0.3 });
    const screen = new THREE.Mesh(screenGeo, screenMat);
    screen.position.z = 0.08;
    phone.add(screen);

    // Message particles radiating outward
    const msgs = [];
    const msgColors = [0x22d3ee, 0x6366f1, 0xec4899, 0xf59e0b, 0x10b981, 0xa78bfa, 0xfbbf24, 0x34d399];
    for (let i = 0; i &lt; 60; i++) {
      const geo = new THREE.SphereGeometry(0.08 + Math.random() * 0.08, 8, 8);
      const mat = new THREE.MeshStandardMaterial({ color: msgColors[i % msgColors.length], emissive: msgColors[i % msgColors.length], emissiveIntensity: 0.5 });
      const m = new THREE.Mesh(geo, mat);
      const angle = Math.random() * Math.PI * 2;
      const speed = 1.5 + Math.random() * 3;
      const ySpeed = (Math.random() - 0.5) * 2;
      m._vx = Math.cos(angle) * speed;
      m._vy = ySpeed;
      m._vz = Math.sin(angle) * speed * 0.3;
      m._life = Math.random() * 3;
      m._maxLife = 2.5 + Math.random() * 1.5;
      m.position.set(0, 0, 0);
      scene.add(m);
      msgs.push(m);
    }

    scene.add(new THREE.AmbientLight(0xffffff, 0.4));
    const pl = new THREE.PointLight(0x22d3ee, 1.5, 20);
    pl.position.set(0, 0, 5);
    scene.add(pl);

    function animate() {
      requestAnimationFrame(animate);
      const dt = 0.016;
      const t = Date.now() * 0.001;
      phone.rotation.y = Math.sin(t * 0.3) * 0.15;
      phone.rotation.x = Math.sin(t * 0.2) * 0.05;
      screenMat.emissiveIntensity = 0.2 + Math.sin(t * 2) * 0.15;
      for (const m of msgs) {
        m._life += dt;
        if (m._life &gt; m._maxLife) {
          m._life = 0;
          m.position.set(0, 0, 0);
          const angle = Math.random() * Math.PI * 2;
          const speed = 1.5 + Math.random() * 3;
          m._vx = Math.cos(angle) * speed;
          m._vy = (Math.random() - 0.5) * 2;
          m._vz = Math.sin(angle) * speed * 0.3;
        }
        m.position.x += m._vx * dt;
        m.position.y += m._vy * dt;
        m.position.z += m._vz * dt;
        const fade = 1 - (m._life / m._maxLife);
        m.material.opacity = fade;
        m.material.transparent = true;
        m.scale.setScalar(fade);
      }
      renderer.render(scene, camera);
    }
    window.addEventListener(&apos;resize&apos;, function() {
      const nw = container.clientWidth;
      camera.aspect = nw / h;
      camera.updateProjectionMatrix();
      renderer.setSize(nw, h);
    });
    animate();
  }
  if (document.readyState === &apos;loading&apos;) document.addEventListener(&apos;DOMContentLoaded&apos;, initNYE);
  else initNYE();
})();
&lt;/script&gt;

&lt;p style=&quot;text-align:center; color:#64748b; font-size:0.85em; margin-top:-1em;&quot;&gt;200+ personalized SMS messages radiating out at midnight.&lt;/p&gt;

&lt;h2 id=&quot;the-three-stages&quot;&gt;The Three Stages&lt;/h2&gt;

&lt;svg viewBox=&quot;0 0 700 110&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;width:100%;max-width:700px;display:block;margin:1.5em auto;&quot;&gt;
  &lt;rect width=&quot;700&quot; height=&quot;110&quot; rx=&quot;8&quot; fill=&quot;#0f172a&quot; /&gt;
  &lt;!-- Stage 1: Send --&gt;
  &lt;rect x=&quot;20&quot; y=&quot;20&quot; width=&quot;180&quot; height=&quot;70&quot; rx=&quot;10&quot; fill=&quot;#0c2d48&quot; stroke=&quot;#22d3ee&quot; stroke-width=&quot;2&quot; /&gt;
  &lt;text x=&quot;110&quot; y=&quot;48&quot; text-anchor=&quot;middle&quot; fill=&quot;#22d3ee&quot; font-size=&quot;14&quot; font-weight=&quot;bold&quot; font-family=&quot;monospace&quot;&gt;1. SEND&lt;/text&gt;
  &lt;text x=&quot;110&quot; y=&quot;68&quot; text-anchor=&quot;middle&quot; fill=&quot;#64748b&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;VCF -&amp;gt; Twilio SMS&lt;/text&gt;
  &lt;text x=&quot;110&quot; y=&quot;80&quot; text-anchor=&quot;middle&quot; fill=&quot;#475569&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;200+ personalized texts&lt;/text&gt;
  &lt;!-- Arrow --&gt;
  &lt;polygon points=&quot;210,55 225,48 225,52 250,52 250,58 225,58 225,62&quot; fill=&quot;#fbbf24&quot; /&gt;
  &lt;!-- Stage 2: Serve --&gt;
  &lt;rect x=&quot;255&quot; y=&quot;20&quot; width=&quot;180&quot; height=&quot;70&quot; rx=&quot;10&quot; fill=&quot;#2d1a0a&quot; stroke=&quot;#f59e0b&quot; stroke-width=&quot;2&quot; /&gt;
  &lt;text x=&quot;345&quot; y=&quot;48&quot; text-anchor=&quot;middle&quot; fill=&quot;#fbbf24&quot; font-size=&quot;14&quot; font-weight=&quot;bold&quot; font-family=&quot;monospace&quot;&gt;2. SERVE&lt;/text&gt;
  &lt;text x=&quot;345&quot; y=&quot;68&quot; text-anchor=&quot;middle&quot; fill=&quot;#64748b&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;Flask webhook /sms&lt;/text&gt;
  &lt;text x=&quot;345&quot; y=&quot;80&quot; text-anchor=&quot;middle&quot; fill=&quot;#475569&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;Auto-reply + logging&lt;/text&gt;
  &lt;!-- Arrow --&gt;
  &lt;polygon points=&quot;445,55 460,48 460,52 485,52 485,58 460,58 460,62&quot; fill=&quot;#fbbf24&quot; /&gt;
  &lt;!-- Stage 3: Analyze --&gt;
  &lt;rect x=&quot;490&quot; y=&quot;20&quot; width=&quot;190&quot; height=&quot;70&quot; rx=&quot;10&quot; fill=&quot;#1a0c2d&quot; stroke=&quot;#a855f7&quot; stroke-width=&quot;2&quot; /&gt;
  &lt;text x=&quot;585&quot; y=&quot;48&quot; text-anchor=&quot;middle&quot; fill=&quot;#c084fc&quot; font-size=&quot;14&quot; font-weight=&quot;bold&quot; font-family=&quot;monospace&quot;&gt;3. ANALYZE&lt;/text&gt;
  &lt;text x=&quot;585&quot; y=&quot;68&quot; text-anchor=&quot;middle&quot; fill=&quot;#64748b&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;response.log -&amp;gt; charts&lt;/text&gt;
  &lt;text x=&quot;585&quot; y=&quot;80&quot; text-anchor=&quot;middle&quot; fill=&quot;#475569&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;173 replies visualized&lt;/text&gt;
&lt;/svg&gt;

&lt;h3 id=&quot;1-send&quot;&gt;1. Send&lt;/h3&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;touch-keeper send &lt;span class=&quot;nt&quot;&gt;--contacts&lt;/span&gt; contacts.vcf &lt;span class=&quot;nt&quot;&gt;--sender&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Jules&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Parses the VCF file, extracts names and phone numbers, normalizes numbers to E.164 format (handling the chaos of inconsistent phone number formatting – parentheses, dashes, spaces, country codes, no country codes), and fires off a personalized SMS to each contact via the Twilio API:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Happy New Year, Maria!!! (~‾⌣‾)~
Cheers, Jules&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Messages are spaced with a configurable delay (default 1 second) to avoid hitting Twilio’s rate limits. A dry-run mode previews all messages without sending.&lt;/p&gt;

&lt;h3 id=&quot;2-serve&quot;&gt;2. Serve&lt;/h3&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;touch-keeper serve
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Launches a Flask webhook server at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/sms&lt;/code&gt;. When Twilio receives a reply from one of your contacts, it forwards the message to your webhook. The server handles replies differently based on whether the person has replied before:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;First-time replier&lt;/strong&gt;: Gets a warm, personal-sounding response: &lt;em&gt;“May the new decade find you great happiness and prosperity.”&lt;/em&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Repeat replier&lt;/strong&gt;: Gets a random celebration emoji from a curated set – party poppers, champagne glasses, fireworks, confetti balls.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All incoming messages are logged to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;response.log&lt;/code&gt; with the sender’s phone number and timestamp.&lt;/p&gt;

&lt;p&gt;The auto-reply is the part that creates the most entertainment. People think they are texting a person. They are texting a Flask server. The responses are just plausible enough to sustain a back-and-forth for 2-3 messages before suspicion sets in.&lt;/p&gt;

&lt;h3 id=&quot;3-analyze&quot;&gt;3. Analyze&lt;/h3&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;touch-keeper analyze &lt;span class=&quot;nt&quot;&gt;--contacts&lt;/span&gt; contacts.vcf &lt;span class=&quot;nt&quot;&gt;--log&lt;/span&gt; response.log
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Reads the response log, maps phone numbers back to contact names using the VCF file, and generates:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A text summary of all replies per contact&lt;/li&gt;
  &lt;li&gt;A bar chart histogram of response frequency (sorted descending – who replied the most?)&lt;/li&gt;
  &lt;li&gt;A time-series scatter plot of when replies came in, with quartile lines showing the distribution of reply times&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;the-response-log-a-time-capsule&quot;&gt;The Response Log: A Time Capsule&lt;/h2&gt;

&lt;svg viewBox=&quot;0 0 500 140&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;width:100%;max-width:500px;display:block;margin:1.5em auto;&quot;&gt;
  &lt;rect width=&quot;500&quot; height=&quot;140&quot; rx=&quot;8&quot; fill=&quot;#0f172a&quot; /&gt;
  &lt;text x=&quot;250&quot; y=&quot;22&quot; text-anchor=&quot;middle&quot; fill=&quot;#64748b&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;NYE 2019/2020 Response Stats&lt;/text&gt;
  &lt;!-- Bar: sent --&gt;
  &lt;rect x=&quot;40&quot; y=&quot;35&quot; width=&quot;400&quot; height=&quot;28&quot; rx=&quot;5&quot; fill=&quot;#1e293b&quot; /&gt;
  &lt;rect x=&quot;40&quot; y=&quot;35&quot; width=&quot;400&quot; height=&quot;28&quot; rx=&quot;5&quot; fill=&quot;#22d3ee&quot; opacity=&quot;0.7&quot; /&gt;
  &lt;text x=&quot;250&quot; y=&quot;54&quot; text-anchor=&quot;middle&quot; fill=&quot;#0f172a&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot; font-family=&quot;monospace&quot;&gt;200+ sent&lt;/text&gt;
  &lt;!-- Bar: replied --&gt;
  &lt;rect x=&quot;40&quot; y=&quot;70&quot; width=&quot;346&quot; height=&quot;28&quot; rx=&quot;5&quot; fill=&quot;#1e293b&quot; /&gt;
  &lt;rect x=&quot;40&quot; y=&quot;70&quot; width=&quot;346&quot; height=&quot;28&quot; rx=&quot;5&quot; fill=&quot;#10b981&quot; opacity=&quot;0.7&quot; /&gt;
  &lt;text x=&quot;213&quot; y=&quot;89&quot; text-anchor=&quot;middle&quot; fill=&quot;#0f172a&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot; font-family=&quot;monospace&quot;&gt;173 replied (86.5%)&lt;/text&gt;
  &lt;!-- Bar: reconnections --&gt;
  &lt;rect x=&quot;40&quot; y=&quot;105&quot; width=&quot;60&quot; height=&quot;28&quot; rx=&quot;5&quot; fill=&quot;#1e293b&quot; /&gt;
  &lt;rect x=&quot;40&quot; y=&quot;105&quot; width=&quot;60&quot; height=&quot;28&quot; rx=&quot;5&quot; fill=&quot;#f59e0b&quot; opacity=&quot;0.7&quot; /&gt;
  &lt;text x=&quot;130&quot; y=&quot;124&quot; fill=&quot;#fbbf24&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;real meetups after&lt;/text&gt;
&lt;/svg&gt;

&lt;p&gt;The included &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;response.log&lt;/code&gt; contains &lt;strong&gt;173 real replies&lt;/strong&gt; from what appears to be a New Year’s Eve 2019/2020 deployment. I am going to quote some of these because they are a genuine cross-section of human reaction to receiving an automated-but-personalized text at midnight:&lt;/p&gt;

&lt;p&gt;The grateful:&lt;/p&gt;
&lt;blockquote&gt;
  &lt;p&gt;&lt;em&gt;“Love you juju”&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The suspicious:&lt;/p&gt;
&lt;blockquote&gt;
  &lt;p&gt;&lt;em&gt;“Is this some auto response shit”&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The testing:&lt;/p&gt;
&lt;blockquote&gt;
  &lt;p&gt;&lt;em&gt;“Did you get a new phone??”&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The wholesome:&lt;/p&gt;
&lt;blockquote&gt;
  &lt;p&gt;&lt;em&gt;“Happy New Year!! Miss you man”&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The pragmatic:&lt;/p&gt;
&lt;blockquote&gt;
  &lt;p&gt;&lt;em&gt;“Coffee or a drink… or a blunt whatever works”&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The recursive:&lt;/p&gt;
&lt;blockquote&gt;
  &lt;p&gt;Someone figured out the auto-reply and kept texting to see how many different emojis they could get.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;173 replies out of ~200 contacts is a remarkable response rate. Turns out, people appreciate being remembered at midnight even if the remembering was automated. The personalization (including their name) makes each message feel intentional, and the auto-reply sustains the illusion just long enough for the warmth to land.&lt;/p&gt;

&lt;h2 id=&quot;technical-notes&quot;&gt;Technical Notes&lt;/h2&gt;

&lt;p&gt;The codebase is clean modern Python. Frozen dataclasses for immutable data models (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Contact&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Settings&lt;/code&gt;). Type annotations throughout. Strict mypy. ruff for linting. pytest for testing. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;src/&lt;/code&gt; layout convention. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pyproject.toml&lt;/code&gt; with Hatchling build system.&lt;/p&gt;

&lt;p&gt;The VCF parser handles the various vCard format quirks – multiple phone numbers per contact (picks the first mobile number), names stored as “Last;First” or “First Last”, phone numbers with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tel:&lt;/code&gt; URI prefixes. Phone number normalization to E.164 strips all formatting characters and prepends the country code if missing.&lt;/p&gt;

&lt;p&gt;The analysis module uses pandas for data manipulation and matplotlib for the visualizations. The quartile lines on the time-series plot are a nice touch – they show that most replies cluster in the first 30 minutes after midnight, with a long tail of late-night stragglers responding hours later.&lt;/p&gt;

&lt;h2 id=&quot;why-twilio&quot;&gt;Why Twilio?&lt;/h2&gt;

&lt;p&gt;Twilio is the path of least resistance for programmatic SMS. The Python SDK (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;twilio&amp;gt;=9.0&lt;/code&gt;) wraps the REST API cleanly, and the webhook integration with Flask is trivial – Twilio sends a POST request to your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/sms&lt;/code&gt; endpoint with the sender’s number and message body. You respond with TwiML (Twilio Markup Language), which is XML that specifies the reply message. It is not elegant, but it works, and you can have the whole thing running in 20 lines of Flask.&lt;/p&gt;

&lt;p&gt;The cost is roughly $0.0075 per SMS in the US. 200 contacts costs about $1.50 in Twilio credits. A dollar fifty to text your entire phone book a personalized New Year’s message. That is cheaper than a greeting card.&lt;/p&gt;

&lt;h2 id=&quot;the-philosophical-bit&quot;&gt;The Philosophical Bit&lt;/h2&gt;

&lt;p&gt;There is something slightly absurd about automating personal connection. The whole point of a New Year’s text is that someone thought of you. If a script thought of you, does it count?&lt;/p&gt;

&lt;p&gt;I think it does, actually. The automation handles the logistics – the typing, the timing, the 200 repetitions of the same sentiment. But the decision to send the text was human. The contact list was curated by a human over years of real relationships. The message template was written by a human who wanted those specific people to feel remembered. The automation scales the intention without diluting it.&lt;/p&gt;

&lt;p&gt;173 people replied. Several led to actual meetups in the following weeks. One person I had not spoken to in three years texted back and we reconnected. The script did not create the connection. It removed the friction that was preventing it.&lt;/p&gt;

&lt;p&gt;Keep in touch.&lt;/p&gt;
</content>
 </entry>
 
 <entry>
   <title>Hacker de Teclado: Acoustic Side-Channel Attack</title>
   <link href="http://hankquinlan.github.io/blog/2026/04/27/Hacker-de-Teclado"/>
   <updated>2026-04-27T00:00:00+00:00</updated>
   <id>http://juleshenry.github.io//blog/2026/04/27/Hacker-de-Teclado</id>
   <content type="html">&lt;p&gt;&lt;a href=&quot;https://github.com/juleshenry/hacker-de-audio-de-teclado&quot;&gt;View on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every key on your keyboard makes a slightly different sound when pressed. The spacebar sounds different from the Enter key. The ‘A’ key sounds different from the ‘S’ key – subtly, but measurably. If you record the audio of someone typing and feed it through a trained neural network, you can reconstruct what they typed.&lt;/p&gt;

&lt;p&gt;This project is a from-scratch reimplementation of Harrison, Toreini, and Mehrnezhad’s 2023 paper “A Practical Deep Learning-Based Acoustic Side Channel Attack on Keyboards” (&lt;a href=&quot;https://arxiv.org/abs/2308.01074&quot;&gt;arXiv:2308.01074&lt;/a&gt;). Written in Portuguese-flavored Python, built on PyTorch and librosa.&lt;/p&gt;

&lt;p&gt;The project is on &lt;a href=&quot;https://github.com/juleshenry/hacker-de-audio-de-teclado&quot;&gt;GitHub&lt;/a&gt;. MIT licensed.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://github.com/juleshenry/hacker-de-audio-de-teclado/blob/main/rms.png?raw=1&quot; alt=&quot;Keystroke RMS Energy Detection&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;the-pipeline&quot;&gt;The Pipeline&lt;/h2&gt;

&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;graph TD
    A[Raw Audio Recording] --&amp;gt;|librosa onset detection| B[Individual Keystroke Chunks]
    B --&amp;gt;|Mel Spectrogram| C[64x64 Spectral Images]
    C --&amp;gt;|Time-Shift Augmentation| D[Augmented Dataset]
    D --&amp;gt;|SpecAugment Masking| E[Masked Spectrograms]
    E --&amp;gt;|CoAtNet| F[36-Class Prediction: a-z, 0-9]
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Three stages: collect data, train a classifier, run the attack.&lt;/p&gt;

&lt;h3 id=&quot;stage-1-data-collection&quot;&gt;Stage 1: Data Collection&lt;/h3&gt;

&lt;p&gt;The interactive recorder (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;quickstart.py&lt;/code&gt;) walks you through pressing each key on your keyboard. For each of the 36 alphanumeric keys (a-z, 0-9), it prompts you, records a short clip via your microphone using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sounddevice&lt;/code&gt;, auto-detects the keystroke onset in the audio, and saves the isolated keystroke as a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.wav&lt;/code&gt; file in a per-key directory.&lt;/p&gt;

&lt;p&gt;For quick experimentation without a physical keyboard, a synthetic data generator (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gerar_exemplo_zorro.py&lt;/code&gt; – “generate fox example”) creates fake keystroke audio using decaying sine waves at distinct frequencies. Each key gets a unique base frequency, so the spectrograms are distinguishable even though the sounds are artificial. The full pipeline (train, predict) works on synthetic data, letting you test end-to-end in under a minute.&lt;/p&gt;

&lt;p&gt;The demo phrase is &lt;strong&gt;“o zorro e gris”&lt;/strong&gt; – Portuguese for “the fox is grey.”&lt;/p&gt;

&lt;h3 id=&quot;stage-2-training&quot;&gt;Stage 2: Training&lt;/h3&gt;

&lt;p&gt;The training pipeline faithfully reproduces the paper’s methodology.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Onset Detection.&lt;/strong&gt; librosa detects keystroke onsets in the audio using energy-based peak detection with a 300ms minimum distance filter (to prevent detecting the same keypress twice).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mel Spectrogram Extraction.&lt;/strong&gt; Each keystroke chunk is converted into a 64-band Mel spectrogram with 1024-point FFT and 225 hop length, producing a 64x64 spectral image. This is the feature representation: a 2D image where the x-axis is time, the y-axis is frequency (mel-scaled), and pixel intensity represents energy at that time-frequency bin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data Augmentation.&lt;/strong&gt; Two techniques from the paper:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Time-shift augmentation&lt;/strong&gt; – each keystroke is speed-distorted by +/- 40% using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;librosa.effects.time_stretch&lt;/code&gt;, producing 2 augmented copies per sample. This teaches the model to be invariant to typing speed.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;SpecAugment&lt;/strong&gt; – at training time, random rectangular masks are applied to the spectrogram (2 frequency masks and 2 time masks, each spanning 10% of the axis width). This is the same augmentation technique used in speech recognition (Park et al. 2019), and it prevents the model from overfitting to specific time-frequency patterns.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The Model: CoAtNet.&lt;/strong&gt; The classifier is a CoAtNet (Convolutional + Attention Network) – a hybrid architecture that combines the local feature extraction of convolutions with the global context modeling of self-attention:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Stem.&lt;/strong&gt; Conv2D (1 -&amp;gt; 32 channels, stride 2) with BatchNorm and GELU activation. Halves spatial dimensions.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;MBConv Phase.&lt;/strong&gt; Two Mobile Inverted Bottleneck Convolution blocks, expanding to 64 then 128 channels. These are depth-wise separable convolutions with squeeze-and-excitation – the same building blocks used in EfficientNet. They capture local spectral patterns: the frequency distribution of a single keystroke.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Transformer Phase.&lt;/strong&gt; Two Transformer Encoder layers with 4-head self-attention (d_model=128, feedforward dimension 512). These capture global dependencies across the entire spectrogram: the temporal shape of the keystroke’s decay envelope, the relationship between the initial attack and the subsequent resonance.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Classification Head.&lt;/strong&gt; Adaptive 2D Average Pooling -&amp;gt; Fully Connected Linear layer -&amp;gt; 36-class output.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The MBConv blocks say “what does this keystroke look like locally?” The Transformer blocks say “what does the overall shape tell us?” Together, they classify each 64x64 spectrogram as one of 36 alphanumeric characters.&lt;/p&gt;

&lt;svg viewBox=&quot;0 0 700 320&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;width:100%;max-width:700px;display:block;margin:1.5em auto;&quot;&gt;
  &lt;rect width=&quot;700&quot; height=&quot;320&quot; rx=&quot;8&quot; fill=&quot;#0f172a&quot; /&gt;
  &lt;text x=&quot;350&quot; y=&quot;25&quot; text-anchor=&quot;middle&quot; fill=&quot;#64748b&quot; font-size=&quot;12&quot; font-family=&quot;monospace&quot;&gt;CoAtNet Architecture&lt;/text&gt;
  &lt;!-- Input --&gt;
  &lt;rect x=&quot;20&quot; y=&quot;40&quot; width=&quot;90&quot; height=&quot;50&quot; rx=&quot;8&quot; fill=&quot;#1e293b&quot; stroke=&quot;#475569&quot; /&gt;
  &lt;text x=&quot;65&quot; y=&quot;60&quot; text-anchor=&quot;middle&quot; fill=&quot;#94a3b8&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;64x64&lt;/text&gt;
  &lt;text x=&quot;65&quot; y=&quot;75&quot; text-anchor=&quot;middle&quot; fill=&quot;#94a3b8&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;Mel Spec&lt;/text&gt;
  &lt;!-- Arrow --&gt;
  &lt;line x1=&quot;110&quot; y1=&quot;65&quot; x2=&quot;135&quot; y2=&quot;65&quot; stroke=&quot;#475569&quot; stroke-width=&quot;2&quot; marker-end=&quot;url(#arrowhead)&quot; /&gt;
  &lt;!-- Stem --&gt;
  &lt;rect x=&quot;135&quot; y=&quot;40&quot; width=&quot;90&quot; height=&quot;50&quot; rx=&quot;8&quot; fill=&quot;#1e3a5f&quot; stroke=&quot;#3b82f6&quot; /&gt;
  &lt;text x=&quot;180&quot; y=&quot;60&quot; text-anchor=&quot;middle&quot; fill=&quot;#60a5fa&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;Stem&lt;/text&gt;
  &lt;text x=&quot;180&quot; y=&quot;75&quot; text-anchor=&quot;middle&quot; fill=&quot;#475569&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;Conv2D 32ch&lt;/text&gt;
  &lt;!-- Arrow --&gt;
  &lt;line x1=&quot;225&quot; y1=&quot;65&quot; x2=&quot;250&quot; y2=&quot;65&quot; stroke=&quot;#475569&quot; stroke-width=&quot;2&quot; /&gt;
  &lt;!-- MBConv 1 --&gt;
  &lt;rect x=&quot;250&quot; y=&quot;40&quot; width=&quot;90&quot; height=&quot;50&quot; rx=&quot;8&quot; fill=&quot;#1a2e1a&quot; stroke=&quot;#22c55e&quot; /&gt;
  &lt;text x=&quot;295&quot; y=&quot;60&quot; text-anchor=&quot;middle&quot; fill=&quot;#4ade80&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;MBConv&lt;/text&gt;
  &lt;text x=&quot;295&quot; y=&quot;75&quot; text-anchor=&quot;middle&quot; fill=&quot;#475569&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;64ch SE&lt;/text&gt;
  &lt;!-- Arrow --&gt;
  &lt;line x1=&quot;340&quot; y1=&quot;65&quot; x2=&quot;365&quot; y2=&quot;65&quot; stroke=&quot;#475569&quot; stroke-width=&quot;2&quot; /&gt;
  &lt;!-- MBConv 2 --&gt;
  &lt;rect x=&quot;365&quot; y=&quot;40&quot; width=&quot;90&quot; height=&quot;50&quot; rx=&quot;8&quot; fill=&quot;#1a2e1a&quot; stroke=&quot;#22c55e&quot; /&gt;
  &lt;text x=&quot;410&quot; y=&quot;60&quot; text-anchor=&quot;middle&quot; fill=&quot;#4ade80&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;MBConv&lt;/text&gt;
  &lt;text x=&quot;410&quot; y=&quot;75&quot; text-anchor=&quot;middle&quot; fill=&quot;#475569&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;128ch SE&lt;/text&gt;
  &lt;!-- Arrow --&gt;
  &lt;line x1=&quot;455&quot; y1=&quot;65&quot; x2=&quot;480&quot; y2=&quot;65&quot; stroke=&quot;#475569&quot; stroke-width=&quot;2&quot; /&gt;
  &lt;!-- Transformer 1 --&gt;
  &lt;rect x=&quot;480&quot; y=&quot;40&quot; width=&quot;90&quot; height=&quot;50&quot; rx=&quot;8&quot; fill=&quot;#2d1a3a&quot; stroke=&quot;#a855f7&quot; /&gt;
  &lt;text x=&quot;525&quot; y=&quot;60&quot; text-anchor=&quot;middle&quot; fill=&quot;#c084fc&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;Transformer&lt;/text&gt;
  &lt;text x=&quot;525&quot; y=&quot;75&quot; text-anchor=&quot;middle&quot; fill=&quot;#475569&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;4-Head Attn&lt;/text&gt;
  &lt;!-- Arrow --&gt;
  &lt;line x1=&quot;570&quot; y1=&quot;65&quot; x2=&quot;595&quot; y2=&quot;65&quot; stroke=&quot;#475569&quot; stroke-width=&quot;2&quot; /&gt;
  &lt;!-- Transformer 2 --&gt;
  &lt;rect x=&quot;595&quot; y=&quot;40&quot; width=&quot;85&quot; height=&quot;50&quot; rx=&quot;8&quot; fill=&quot;#2d1a3a&quot; stroke=&quot;#a855f7&quot; /&gt;
  &lt;text x=&quot;637&quot; y=&quot;60&quot; text-anchor=&quot;middle&quot; fill=&quot;#c084fc&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;Transformer&lt;/text&gt;
  &lt;text x=&quot;637&quot; y=&quot;75&quot; text-anchor=&quot;middle&quot; fill=&quot;#475569&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;4-Head Attn&lt;/text&gt;
  &lt;!-- Arrow down to output --&gt;
  &lt;line x1=&quot;637&quot; y1=&quot;90&quot; x2=&quot;637&quot; y2=&quot;110&quot; stroke=&quot;#475569&quot; stroke-width=&quot;2&quot; /&gt;
  &lt;!-- Output --&gt;
  &lt;rect x=&quot;570&quot; y=&quot;110&quot; width=&quot;120&quot; height=&quot;40&quot; rx=&quot;8&quot; fill=&quot;#3a1a1a&quot; stroke=&quot;#ef4444&quot; /&gt;
  &lt;text x=&quot;630&quot; y=&quot;135&quot; text-anchor=&quot;middle&quot; fill=&quot;#f87171&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;36-class output&lt;/text&gt;
  &lt;!-- Legend --&gt;
  &lt;rect x=&quot;30&quot; y=&quot;120&quot; width=&quot;12&quot; height=&quot;12&quot; rx=&quot;3&quot; fill=&quot;#1e3a5f&quot; stroke=&quot;#3b82f6&quot; /&gt;
  &lt;text x=&quot;48&quot; y=&quot;131&quot; fill=&quot;#60a5fa&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;Convolution&lt;/text&gt;
  &lt;rect x=&quot;30&quot; y=&quot;140&quot; width=&quot;12&quot; height=&quot;12&quot; rx=&quot;3&quot; fill=&quot;#1a2e1a&quot; stroke=&quot;#22c55e&quot; /&gt;
  &lt;text x=&quot;48&quot; y=&quot;151&quot; fill=&quot;#4ade80&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;MBConv (local features)&lt;/text&gt;
  &lt;rect x=&quot;30&quot; y=&quot;160&quot; width=&quot;12&quot; height=&quot;12&quot; rx=&quot;3&quot; fill=&quot;#2d1a3a&quot; stroke=&quot;#a855f7&quot; /&gt;
  &lt;text x=&quot;48&quot; y=&quot;171&quot; fill=&quot;#c084fc&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;Transformer (global context)&lt;/text&gt;
  &lt;!-- Spectrogram visualization --&gt;
  &lt;text x=&quot;350&quot; y=&quot;210&quot; text-anchor=&quot;middle&quot; fill=&quot;#64748b&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;Sample Keystroke Spectrogram Heatmap&lt;/text&gt;
  &lt;g transform=&quot;translate(100,220)&quot;&gt;
    &lt;!-- Simulated mel spectrogram grid --&gt;
    &lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;500&quot; height=&quot;80&quot; rx=&quot;4&quot; fill=&quot;#0a0e1a&quot; stroke=&quot;#1e293b&quot; /&gt;
    &lt;!-- Frequency bands (rows of varying intensity) --&gt;
    &lt;rect x=&quot;2&quot; y=&quot;2&quot; width=&quot;496&quot; height=&quot;5&quot; fill=&quot;#0f172a&quot; opacity=&quot;0.6&quot; /&gt;&lt;rect x=&quot;60&quot; y=&quot;2&quot; width=&quot;40&quot; height=&quot;5&quot; fill=&quot;#1e40af&quot; opacity=&quot;0.7&quot; /&gt;
    &lt;rect x=&quot;2&quot; y=&quot;8&quot; width=&quot;496&quot; height=&quot;5&quot; fill=&quot;#0f172a&quot; opacity=&quot;0.5&quot; /&gt;&lt;rect x=&quot;55&quot; y=&quot;8&quot; width=&quot;50&quot; height=&quot;5&quot; fill=&quot;#2563eb&quot; opacity=&quot;0.8&quot; /&gt;
    &lt;rect x=&quot;2&quot; y=&quot;14&quot; width=&quot;496&quot; height=&quot;5&quot; fill=&quot;#0f172a&quot; opacity=&quot;0.4&quot; /&gt;&lt;rect x=&quot;50&quot; y=&quot;14&quot; width=&quot;60&quot; height=&quot;5&quot; fill=&quot;#3b82f6&quot; opacity=&quot;0.9&quot; /&gt;
    &lt;rect x=&quot;2&quot; y=&quot;20&quot; width=&quot;496&quot; height=&quot;5&quot; fill=&quot;#0f172a&quot; opacity=&quot;0.3&quot; /&gt;&lt;rect x=&quot;45&quot; y=&quot;20&quot; width=&quot;70&quot; height=&quot;5&quot; fill=&quot;#60a5fa&quot; /&gt;
    &lt;rect x=&quot;2&quot; y=&quot;26&quot; width=&quot;496&quot; height=&quot;5&quot; fill=&quot;#0f172a&quot; opacity=&quot;0.3&quot; /&gt;&lt;rect x=&quot;40&quot; y=&quot;26&quot; width=&quot;80&quot; height=&quot;5&quot; fill=&quot;#93c5fd&quot; /&gt;
    &lt;rect x=&quot;2&quot; y=&quot;32&quot; width=&quot;496&quot; height=&quot;5&quot; fill=&quot;#0f172a&quot; opacity=&quot;0.4&quot; /&gt;&lt;rect x=&quot;42&quot; y=&quot;32&quot; width=&quot;75&quot; height=&quot;5&quot; fill=&quot;#bfdbfe&quot; /&gt;
    &lt;rect x=&quot;2&quot; y=&quot;38&quot; width=&quot;496&quot; height=&quot;5&quot; fill=&quot;#0f172a&quot; opacity=&quot;0.5&quot; /&gt;&lt;rect x=&quot;45&quot; y=&quot;38&quot; width=&quot;65&quot; height=&quot;5&quot; fill=&quot;#93c5fd&quot; opacity=&quot;0.8&quot; /&gt;
    &lt;rect x=&quot;2&quot; y=&quot;44&quot; width=&quot;496&quot; height=&quot;5&quot; fill=&quot;#0f172a&quot; opacity=&quot;0.5&quot; /&gt;&lt;rect x=&quot;48&quot; y=&quot;44&quot; width=&quot;55&quot; height=&quot;5&quot; fill=&quot;#60a5fa&quot; opacity=&quot;0.7&quot; /&gt;
    &lt;rect x=&quot;2&quot; y=&quot;50&quot; width=&quot;496&quot; height=&quot;5&quot; fill=&quot;#0f172a&quot; opacity=&quot;0.6&quot; /&gt;&lt;rect x=&quot;50&quot; y=&quot;50&quot; width=&quot;45&quot; height=&quot;5&quot; fill=&quot;#3b82f6&quot; opacity=&quot;0.6&quot; /&gt;
    &lt;rect x=&quot;2&quot; y=&quot;56&quot; width=&quot;496&quot; height=&quot;5&quot; fill=&quot;#0f172a&quot; opacity=&quot;0.7&quot; /&gt;&lt;rect x=&quot;52&quot; y=&quot;56&quot; width=&quot;35&quot; height=&quot;5&quot; fill=&quot;#2563eb&quot; opacity=&quot;0.5&quot; /&gt;
    &lt;rect x=&quot;2&quot; y=&quot;62&quot; width=&quot;496&quot; height=&quot;5&quot; fill=&quot;#0f172a&quot; opacity=&quot;0.8&quot; /&gt;&lt;rect x=&quot;55&quot; y=&quot;62&quot; width=&quot;25&quot; height=&quot;5&quot; fill=&quot;#1e40af&quot; opacity=&quot;0.4&quot; /&gt;
    &lt;rect x=&quot;2&quot; y=&quot;68&quot; width=&quot;496&quot; height=&quot;5&quot; fill=&quot;#0f172a&quot; opacity=&quot;0.9&quot; /&gt;&lt;rect x=&quot;58&quot; y=&quot;68&quot; width=&quot;15&quot; height=&quot;5&quot; fill=&quot;#1e3a8a&quot; opacity=&quot;0.3&quot; /&gt;
    &lt;!-- Second keystroke --&gt;
    &lt;rect x=&quot;200&quot; y=&quot;2&quot; width=&quot;35&quot; height=&quot;5&quot; fill=&quot;#1e40af&quot; opacity=&quot;0.6&quot; /&gt;
    &lt;rect x=&quot;195&quot; y=&quot;8&quot; width=&quot;45&quot; height=&quot;5&quot; fill=&quot;#7c3aed&quot; opacity=&quot;0.7&quot; /&gt;
    &lt;rect x=&quot;190&quot; y=&quot;14&quot; width=&quot;55&quot; height=&quot;5&quot; fill=&quot;#8b5cf6&quot; opacity=&quot;0.8&quot; /&gt;
    &lt;rect x=&quot;188&quot; y=&quot;20&quot; width=&quot;60&quot; height=&quot;5&quot; fill=&quot;#a78bfa&quot; /&gt;
    &lt;rect x=&quot;185&quot; y=&quot;26&quot; width=&quot;65&quot; height=&quot;5&quot; fill=&quot;#c4b5fd&quot; /&gt;
    &lt;rect x=&quot;187&quot; y=&quot;32&quot; width=&quot;60&quot; height=&quot;5&quot; fill=&quot;#a78bfa&quot; opacity=&quot;0.8&quot; /&gt;
    &lt;rect x=&quot;190&quot; y=&quot;38&quot; width=&quot;50&quot; height=&quot;5&quot; fill=&quot;#8b5cf6&quot; opacity=&quot;0.7&quot; /&gt;
    &lt;rect x=&quot;193&quot; y=&quot;44&quot; width=&quot;40&quot; height=&quot;5&quot; fill=&quot;#7c3aed&quot; opacity=&quot;0.6&quot; /&gt;
    &lt;rect x=&quot;196&quot; y=&quot;50&quot; width=&quot;30&quot; height=&quot;5&quot; fill=&quot;#6d28d9&quot; opacity=&quot;0.5&quot; /&gt;
    &lt;rect x=&quot;199&quot; y=&quot;56&quot; width=&quot;20&quot; height=&quot;5&quot; fill=&quot;#5b21b6&quot; opacity=&quot;0.4&quot; /&gt;
    &lt;!-- Third keystroke --&gt;
    &lt;rect x=&quot;340&quot; y=&quot;2&quot; width=&quot;30&quot; height=&quot;5&quot; fill=&quot;#065f46&quot; opacity=&quot;0.5&quot; /&gt;
    &lt;rect x=&quot;335&quot; y=&quot;8&quot; width=&quot;40&quot; height=&quot;5&quot; fill=&quot;#059669&quot; opacity=&quot;0.6&quot; /&gt;
    &lt;rect x=&quot;330&quot; y=&quot;14&quot; width=&quot;50&quot; height=&quot;5&quot; fill=&quot;#10b981&quot; opacity=&quot;0.7&quot; /&gt;
    &lt;rect x=&quot;328&quot; y=&quot;20&quot; width=&quot;55&quot; height=&quot;5&quot; fill=&quot;#34d399&quot; opacity=&quot;0.9&quot; /&gt;
    &lt;rect x=&quot;325&quot; y=&quot;26&quot; width=&quot;60&quot; height=&quot;5&quot; fill=&quot;#6ee7b7&quot; /&gt;
    &lt;rect x=&quot;328&quot; y=&quot;32&quot; width=&quot;55&quot; height=&quot;5&quot; fill=&quot;#34d399&quot; opacity=&quot;0.8&quot; /&gt;
    &lt;rect x=&quot;330&quot; y=&quot;38&quot; width=&quot;45&quot; height=&quot;5&quot; fill=&quot;#10b981&quot; opacity=&quot;0.6&quot; /&gt;
    &lt;rect x=&quot;333&quot; y=&quot;44&quot; width=&quot;35&quot; height=&quot;5&quot; fill=&quot;#059669&quot; opacity=&quot;0.5&quot; /&gt;
    &lt;text x=&quot;70&quot; y=&quot;92&quot; fill=&quot;#475569&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;key &apos;o&apos;&lt;/text&gt;
    &lt;text x=&quot;210&quot; y=&quot;92&quot; fill=&quot;#475569&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;key &apos;z&apos;&lt;/text&gt;
    &lt;text x=&quot;345&quot; y=&quot;92&quot; fill=&quot;#475569&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;key &apos;r&apos;&lt;/text&gt;
  &lt;/g&gt;
&lt;/svg&gt;

&lt;p&gt;&lt;strong&gt;Training Hyperparameters.&lt;/strong&gt; Adam optimizer with max LR 5e-4 and linear annealing over 1100 epochs. Batch size 16. 80/20 train/val split. Early stopping with patience of 50 epochs. Best model checkpointed to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;keystroke_model_best.pth&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The model supports Apple MPS (Metal), CUDA, and CPU fallback. On an M-series Mac, training completes in minutes.&lt;/p&gt;

&lt;h3 id=&quot;stage-3-the-attack&quot;&gt;Stage 3: The Attack&lt;/h3&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;python hacker_de_teclado.py &lt;span class=&quot;nt&quot;&gt;--prever&lt;/span&gt; recording.wav
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Given a recording of someone typing, the system:&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;Detects each keystroke onset using the same librosa pipeline&lt;/li&gt;
  &lt;li&gt;Extracts the 64x64 Mel spectrogram for each detected keystroke&lt;/li&gt;
  &lt;li&gt;Runs each spectrogram through the trained CoAtNet&lt;/li&gt;
  &lt;li&gt;Outputs the predicted text: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;o z o r r o e g r i s&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;why-this-works&quot;&gt;Why This Works&lt;/h2&gt;

&lt;p&gt;It seems implausible that a microphone across the room could distinguish ‘A’ from ‘S’. But the acoustic differences are real and arise from physical properties:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key position.&lt;/strong&gt; Keys in different positions on the keyboard produce different resonance patterns because the mechanical structure beneath them varies. A key near the edge of the keyboard plate has different vibrational modes than a key near the center.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Finger angle.&lt;/strong&gt; Different keys are typically struck by different fingers at different angles. The ring finger hitting ‘A’ produces a different impact profile than the index finger hitting ‘J’.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Travel distance.&lt;/strong&gt; Adjacent keys have slightly different travel distances and spring tensions due to manufacturing tolerances, wear patterns, and the geometry of the underlying switch mechanism.&lt;/p&gt;

&lt;p&gt;These differences are tiny – inaudible to a human listener comparing two keystrokes – but they are consistent and repeatable. A Mel spectrogram captures them as subtle variations in the frequency distribution of the keystroke’s initial attack and subsequent decay. The CoAtNet learns to detect these patterns.&lt;/p&gt;

&lt;p&gt;The Harrison et al. paper reports 95% accuracy on a MacBook Pro keyboard using a smartphone microphone placed nearby. The accuracy degrades with distance, ambient noise, and different keyboard models (the model trained on one keyboard does not transfer well to another, because the acoustic properties are hardware-specific).&lt;/p&gt;

&lt;h2 id=&quot;the-security-implications&quot;&gt;The Security Implications&lt;/h2&gt;

&lt;p&gt;This is a real attack vector. If someone can record the audio of your typing – via a compromised microphone, a nearby phone, or even a video call where keyboard sounds leak through – they can potentially reconstruct your keystrokes. Passwords, emails, code, messages.&lt;/p&gt;

&lt;p&gt;Defenses include:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Acoustic noise injection&lt;/strong&gt; – playing white noise near the keyboard to mask keystroke sounds&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Silent keyboards&lt;/strong&gt; – membrane keyboards produce weaker acoustic signatures than mechanical ones&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Software-based keystroke randomization&lt;/strong&gt; – introducing random delays between keystrokes to disrupt the temporal patterns&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Awareness&lt;/strong&gt; – muting your microphone during video calls when typing sensitive information&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The project is educational. It demonstrates that the attack is accessible (PyTorch + librosa + a microphone), reproducible (the synthetic data generator lets anyone test the pipeline), and frighteningly effective on controlled setups. The paper it reimplements is publicly available. The code is MIT licensed. The fox is grey.&lt;/p&gt;
</content>
 </entry>
 
 <entry>
   <title>The Swiss-Mexican Auction: Combinatorial Bidding</title>
   <link href="http://hankquinlan.github.io/blog/2026/04/20/Swiss-Mexican-Auction"/>
   <updated>2026-04-20T00:00:00+00:00</updated>
   <id>http://juleshenry.github.io//blog/2026/04/20/Swiss-Mexican-Auction</id>
   <content type="html">&lt;p&gt;&lt;a href=&quot;https://github.com/juleshenry/swiss-mexican-auction&quot;&gt;View on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What if eBay let you bid on bundles?&lt;/p&gt;

&lt;p&gt;You want a camera body, a specific lens, and a tripod – but only if you can get all three. Buying the lens without the body is useless. Buying the body without the lens is expensive paperweight acquisition. You want the bundle, all or nothing.&lt;/p&gt;

&lt;p&gt;This is the &lt;strong&gt;exposure problem&lt;/strong&gt; in auction theory, and solving it optimally is NP-complete. The Swiss-Mexican Auction is a dual-layer framework that says: forget optimality. Get close enough, fast enough, at the scale of a real marketplace.&lt;/p&gt;

&lt;p&gt;The project is on &lt;a href=&quot;https://github.com/juleshenry/swiss-mexican-auction&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;div id=&quot;auction-viz&quot; style=&quot;width: 100%; height: 420px; margin: 2em 0; border-radius: 8px; overflow: hidden; background: #0a0e1a;&quot;&gt;&lt;/div&gt;

&lt;script&gt;
(function() {
  function initAuctionViz() {
    if (typeof THREE === &apos;undefined&apos;) { setTimeout(initAuctionViz, 100); return; }
    const container = document.getElementById(&apos;auction-viz&apos;);
    if (!container) return;
    const w = container.clientWidth, h = 420;
    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x0a0e1a);
    const camera = new THREE.PerspectiveCamera(60, w / h, 0.1, 100);
    camera.position.set(0, 0, 14);
    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(w, h);
    renderer.setPixelRatio(window.devicePixelRatio);
    container.appendChild(renderer.domElement);
    scene.add(new THREE.AmbientLight(0xffffff, 0.6));
    const dl = new THREE.DirectionalLight(0xffffff, 0.8);
    dl.position.set(5, 5, 5);
    scene.add(dl);

    // Bidder nodes in a ring
    const bidders = [];
    const N = 12;
    const colors = [0x22d3ee, 0x6366f1, 0xec4899, 0xf59e0b, 0x10b981, 0x8b5cf6,
                    0xf43f5e, 0x06b6d4, 0xa78bfa, 0xfbbf24, 0x34d399, 0xe879f9];
    for (let i = 0; i &lt; N; i++) {
      const a = (i / N) * Math.PI * 2;
      const r = 5;
      const geo = new THREE.SphereGeometry(0.35 + Math.random() * 0.25, 24, 24);
      const mat = new THREE.MeshStandardMaterial({ color: colors[i % colors.length], metalness: 0.5, roughness: 0.3 });
      const m = new THREE.Mesh(geo, mat);
      m.position.set(Math.cos(a) * r, Math.sin(a) * r, 0);
      m._angle = a; m._radius = r; m._speed = 0.08 + Math.random() * 0.04;
      m._phase = Math.random() * Math.PI * 2;
      scene.add(m);
      bidders.push(m);
    }

    // Conflict edges (random subset)
    const edges = [];
    const edgePairs = [[0,1],[1,2],[2,5],[3,4],[4,7],[5,6],[6,9],[7,8],[8,11],[0,3],[2,9],[5,10],[1,6],[3,8],[10,11],[0,7]];
    for (const [a,b] of edgePairs) {
      const pts = [bidders[a].position.clone(), bidders[b].position.clone()];
      const geo = new THREE.BufferGeometry().setFromPoints(pts);
      const mat = new THREE.LineBasicMaterial({ color: 0x334155, transparent: true, opacity: 0.4 });
      const line = new THREE.Line(geo, mat);
      line._a = a; line._b = b;
      scene.add(line);
      edges.push(line);
    }

    // Center label sphere (the &quot;auctioneer&quot;)
    const cGeo = new THREE.IcosahedronGeometry(0.7, 1);
    const cMat = new THREE.MeshStandardMaterial({ color: 0xfbbf24, metalness: 0.7, roughness: 0.2, wireframe: true });
    const center = new THREE.Mesh(cGeo, cMat);
    scene.add(center);

    function animate() {
      requestAnimationFrame(animate);
      const t = Date.now() * 0.001;
      for (let i = 0; i &lt; N; i++) {
        const b = bidders[i];
        const a = b._angle + t * b._speed;
        const zOff = Math.sin(t * 0.5 + b._phase) * 1.5;
        b.position.set(Math.cos(a) * b._radius, Math.sin(a) * b._radius, zOff);
      }
      for (const e of edges) {
        const posArr = e.geometry.attributes.position.array;
        const pa = bidders[e._a].position, pb = bidders[e._b].position;
        posArr[0]=pa.x; posArr[1]=pa.y; posArr[2]=pa.z;
        posArr[3]=pb.x; posArr[4]=pb.y; posArr[5]=pb.z;
        e.geometry.attributes.position.needsUpdate = true;
      }
      center.rotation.x = t * 0.3;
      center.rotation.y = t * 0.5;
      renderer.render(scene, camera);
    }
    window.addEventListener(&apos;resize&apos;, function() {
      const nw = container.clientWidth;
      camera.aspect = nw / h;
      camera.updateProjectionMatrix();
      renderer.setSize(nw, h);
    });
    animate();
  }
  if (document.readyState === &apos;loading&apos;) document.addEventListener(&apos;DOMContentLoaded&apos;, initAuctionViz);
  else initAuctionViz();
})();
&lt;/script&gt;

&lt;p style=&quot;text-align:center; color:#64748b; font-size:0.85em; margin-top:-1em;&quot;&gt;Interactive conflict graph: bidder nodes orbit the auctioneer. Edges represent item conflicts between bundles.&lt;/p&gt;

&lt;h2 id=&quot;the-problem-satisfiability-and-expenditure&quot;&gt;The Problem: Satisfiability and Expenditure&lt;/h2&gt;

&lt;p&gt;Call it the SEP: Satisfiability and Expenditure Problem. You have a marketplace with $N$ items and $M$ bidders. Each bidder $i$ wants a specific bundle $O_i \subseteq {1, …, N}$ (all or nothing) and has a hard budget ceiling $B_i$. The auctioneer wants to maximize total revenue while respecting three constraints:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Budget&lt;/strong&gt;: No bidder pays more than $B_i$&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Indivisibility&lt;/strong&gt;: Either bidder $i$ gets the entire bundle $O_i$ or nothing&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Exclusivity&lt;/strong&gt;: Each item can be allocated to at most one bidder&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is simultaneously a Set Packing problem (maximize the number of non-overlapping bundles) and a Multi-Dimensional Knapsack problem (maximize revenue subject to capacity constraints). Both are NP-hard individually. Combined, the ILP formulation is:&lt;/p&gt;

\[\max \sum_{i=1}^{M} B_i \cdot x_i\]

&lt;p&gt;subject to:&lt;/p&gt;

\[\sum_{i: j \in O_i} x_i \leq 1 \quad \forall j \in \{1, ..., N\}\]

\[x_i \in \{0, 1\} \quad \forall i\]

&lt;p&gt;At eBay’s scale – 1.7 billion items, 134 million buyers – exact ILP solutions are computationally impossible. The Swiss-Mexican Auction does not even try.&lt;/p&gt;

&lt;h2 id=&quot;the-two-layers&quot;&gt;The Two Layers&lt;/h2&gt;

&lt;svg viewBox=&quot;0 0 700 260&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;width:100%;max-width:700px;display:block;margin:1.5em auto;&quot;&gt;
  &lt;defs&gt;
    &lt;linearGradient id=&quot;swiss-grad&quot; x1=&quot;0&quot; y1=&quot;0&quot; x2=&quot;0&quot; y2=&quot;1&quot;&gt;
      &lt;stop offset=&quot;0%&quot; stop-color=&quot;#1e3a5f&quot; /&gt;
      &lt;stop offset=&quot;100%&quot; stop-color=&quot;#0f1b2d&quot; /&gt;
    &lt;/linearGradient&gt;
    &lt;linearGradient id=&quot;mex-grad&quot; x1=&quot;0&quot; y1=&quot;0&quot; x2=&quot;0&quot; y2=&quot;1&quot;&gt;
      &lt;stop offset=&quot;0%&quot; stop-color=&quot;#4a1a0a&quot; /&gt;
      &lt;stop offset=&quot;100%&quot; stop-color=&quot;#1a0a04&quot; /&gt;
    &lt;/linearGradient&gt;
  &lt;/defs&gt;
  &lt;!-- Swiss Layer --&gt;
  &lt;rect x=&quot;20&quot; y=&quot;15&quot; width=&quot;660&quot; height=&quot;100&quot; rx=&quot;12&quot; fill=&quot;url(#swiss-grad)&quot; stroke=&quot;#3b82f6&quot; stroke-width=&quot;2&quot; /&gt;
  &lt;text x=&quot;350&quot; y=&quot;42&quot; text-anchor=&quot;middle&quot; fill=&quot;#60a5fa&quot; font-size=&quot;15&quot; font-weight=&quot;bold&quot; font-family=&quot;monospace&quot;&gt;SWISS LAYER (Constraints)&lt;/text&gt;
  &lt;text x=&quot;350&quot; y=&quot;65&quot; text-anchor=&quot;middle&quot; fill=&quot;#94a3b8&quot; font-size=&quot;12&quot; font-family=&quot;monospace&quot;&gt;ILP Formulation | LP Relaxation | Budget Ceilings B_i | Bundle Integrity O_i&lt;/text&gt;
  &lt;text x=&quot;350&quot; y=&quot;85&quot; text-anchor=&quot;middle&quot; fill=&quot;#475569&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;Rigid. Formal. Provides theoretical upper bounds.&lt;/text&gt;
  &lt;text x=&quot;350&quot; y=&quot;102&quot; text-anchor=&quot;middle&quot; fill=&quot;#334155&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;x_i in {0,1} | sum x_i &amp;lt;= 1 per item | NP-Complete&lt;/text&gt;
  &lt;!-- Arrow --&gt;
  &lt;polygon points=&quot;350,120 340,130 345,130 345,145 355,145 355,130 360,130&quot; fill=&quot;#fbbf24&quot; /&gt;
  &lt;!-- Mexican Layer --&gt;
  &lt;rect x=&quot;20&quot; y=&quot;150&quot; width=&quot;660&quot; height=&quot;100&quot; rx=&quot;12&quot; fill=&quot;url(#mex-grad)&quot; stroke=&quot;#f59e0b&quot; stroke-width=&quot;2&quot; /&gt;
  &lt;text x=&quot;350&quot; y=&quot;177&quot; text-anchor=&quot;middle&quot; fill=&quot;#fbbf24&quot; font-size=&quot;15&quot; font-weight=&quot;bold&quot; font-family=&quot;monospace&quot;&gt;MEXICAN LAYER (Execution)&lt;/text&gt;
  &lt;text x=&quot;350&quot; y=&quot;200&quot; text-anchor=&quot;middle&quot; fill=&quot;#94a3b8&quot; font-size=&quot;12&quot; font-family=&quot;monospace&quot;&gt;Value Density rho_i = B_i / |O_i| | Greedy Sort | O(N log N)&lt;/text&gt;
  &lt;text x=&quot;350&quot; y=&quot;220&quot; text-anchor=&quot;middle&quot; fill=&quot;#475569&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;Fast. Fluid. Good enough. Clears 50K items in 0.26s.&lt;/text&gt;
  &lt;text x=&quot;350&quot; y=&quot;237&quot; text-anchor=&quot;middle&quot; fill=&quot;#334155&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;Anti-Whale Effect | Tequila-Snow Phase Transition&lt;/text&gt;
&lt;/svg&gt;

&lt;h3 id=&quot;the-swiss-layer-constraints&quot;&gt;The Swiss Layer (Constraints)&lt;/h3&gt;

&lt;p&gt;The “Swiss” layer is the rigid, formal mathematical scaffolding. It defines the ILP, establishes the feasibility space, and provides theoretical upper bounds via LP relaxation (relax $x_i \in {0,1}$ to $x_i \in [0,1]$ and solve the continuous problem with a linear solver). The LP relaxation gives you a ceiling: “no feasible allocation can exceed this revenue.” You cannot achieve it, but you can measure how close your heuristic gets.&lt;/p&gt;

&lt;h3 id=&quot;the-mexican-layer-execution&quot;&gt;The Mexican Layer (Execution)&lt;/h3&gt;

&lt;p&gt;The “Mexican” layer is the heuristic. Fast, fluid, good enough. The algorithm:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;table&gt;
      &lt;tbody&gt;
        &lt;tr&gt;
          &lt;td&gt;Compute each bidder’s &lt;strong&gt;value density&lt;/strong&gt;: $\rho_i = B_i /&lt;/td&gt;
          &lt;td&gt;O_i&lt;/td&gt;
          &lt;td&gt;$ (budget per item in the bundle)&lt;/td&gt;
        &lt;/tr&gt;
      &lt;/tbody&gt;
    &lt;/table&gt;
  &lt;/li&gt;
  &lt;li&gt;Sort all bidders by $\rho_i$ in descending order&lt;/li&gt;
  &lt;li&gt;Iterate: for each bidder, check if all items in their bundle $O_i$ are still available. If yes, allocate. If any item is taken, skip.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is it. $O(N \log N)$ time (dominated by the sort). The greedy heuristic favors bidders who pay the most per item – the efficient bidders – and allocates first-come-first-served among non-conflicting bundles.&lt;/p&gt;

&lt;h2 id=&quot;results-50000-items-20000-bidders-026-seconds&quot;&gt;Results: 50,000 Items, 20,000 Bidders, 0.26 Seconds&lt;/h2&gt;

&lt;svg viewBox=&quot;0 0 600 200&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;width:100%;max-width:600px;display:block;margin:1.5em auto;&quot;&gt;
  &lt;!-- Background --&gt;
  &lt;rect width=&quot;600&quot; height=&quot;200&quot; rx=&quot;8&quot; fill=&quot;#0f172a&quot; /&gt;
  &lt;!-- Bars --&gt;
  &lt;rect x=&quot;60&quot; y=&quot;45&quot; width=&quot;130&quot; height=&quot;40&quot; rx=&quot;6&quot; fill=&quot;#22d3ee&quot; opacity=&quot;0.85&quot; /&gt;
  &lt;text x=&quot;200&quot; y=&quot;70&quot; fill=&quot;#e2e8f0&quot; font-size=&quot;13&quot; font-family=&quot;monospace&quot;&gt;36% bidders satisfied&lt;/text&gt;
  &lt;rect x=&quot;60&quot; y=&quot;95&quot; width=&quot;165&quot; height=&quot;40&quot; rx=&quot;6&quot; fill=&quot;#6366f1&quot; opacity=&quot;0.85&quot; /&gt;
  &lt;text x=&quot;235&quot; y=&quot;120&quot; fill=&quot;#e2e8f0&quot; font-size=&quot;13&quot; font-family=&quot;monospace&quot;&gt;45% items cleared&lt;/text&gt;
  &lt;rect x=&quot;60&quot; y=&quot;145&quot; width=&quot;200&quot; height=&quot;40&quot; rx=&quot;6&quot; fill=&quot;#f59e0b&quot; opacity=&quot;0.85&quot; /&gt;
  &lt;text x=&quot;270&quot; y=&quot;170&quot; fill=&quot;#e2e8f0&quot; font-size=&quot;13&quot; font-family=&quot;monospace&quot;&gt;$945K revenue in 0.26s&lt;/text&gt;
  &lt;!-- Title --&gt;
  &lt;text x=&quot;300&quot; y=&quot;25&quot; text-anchor=&quot;middle&quot; fill=&quot;#64748b&quot; font-size=&quot;12&quot; font-family=&quot;monospace&quot;&gt;Greedy Heuristic Market Clearance&lt;/text&gt;
&lt;/svg&gt;

&lt;p&gt;The simulation generates a realistic marketplace:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;50,000 items with log-normal price distributions&lt;/li&gt;
  &lt;li&gt;20,000 bidders with bundle sizes ranging from 1 to 15 items&lt;/li&gt;
  &lt;li&gt;Budgets drawn from a distribution centered around the sum of desired item prices (with noise)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The greedy algorithm clears this market in &lt;strong&gt;0.26 seconds&lt;/strong&gt;. It satisfies 36% of bidders (7,200 out of 20,000) with zero item conflicts, zero budget violations, and extracts ~$945,000 in revenue.&lt;/p&gt;

&lt;h3 id=&quot;the-lp-guided-hybrid&quot;&gt;The LP-Guided Hybrid&lt;/h3&gt;

&lt;p&gt;The “Swiss Fallback” improves on pure greedy. It solves the LP relaxation first (using SciPy’s HiGHS solver), extracts the fractional solution weights, and uses them to re-rank bidders before running the greedy pass. Bidders that the LP heavily weights get priority in the greedy allocation.&lt;/p&gt;

&lt;p&gt;On a smaller test market (500 items, 1,000 bidders), the hybrid closes &lt;strong&gt;92.8%&lt;/strong&gt; of the gap between pure greedy and the theoretical LP upper bound, achieving an 8.8% revenue improvement. The LP solve adds computational cost, but for markets where the revenue stakes justify it, the hybrid is the clear winner.&lt;/p&gt;

&lt;h2 id=&quot;two-emergent-phenomena&quot;&gt;Two Emergent Phenomena&lt;/h2&gt;

&lt;h3 id=&quot;the-anti-whale-effect&quot;&gt;The Anti-Whale Effect&lt;/h3&gt;

&lt;p&gt;The greedy algorithm naturally favors small, targeted bidders over large-bundle “whales.” A bidder wanting 2 items with a $500 budget has $\rho = 250$ per item. A bidder wanting 15 items with a $2,000 budget has $\rho = 133$ per item. The small bidder ranks higher. By the time we reach the whale, several of their desired items are already allocated to smaller bidders, and the whale is skipped.&lt;/p&gt;

&lt;p&gt;This produces a more &lt;strong&gt;democratic&lt;/strong&gt; marketplace. The algorithm does not discriminate by total wealth – it discriminates by efficiency. A buyer willing to pay a premium for a small, specific bundle is prioritized over a deep-pocketed buyer making a speculative grab at a large bundle. This is not a designed feature. It is an emergent property of sorting by value density.&lt;/p&gt;

&lt;h3 id=&quot;the-tequila-snow-phase-transition&quot;&gt;The Tequila-Snow Phase Transition&lt;/h3&gt;

&lt;p&gt;Market liquidity collapses non-linearly as desired bundle sizes grow. This is the “Tequila-Snow” phase transition, named by analogy to statistical mechanics.&lt;/p&gt;

&lt;p&gt;When average bundle size is 1-2 items, the market is &lt;strong&gt;liquid&lt;/strong&gt; – most bidders can be satisfied because their bundles rarely conflict. When average bundle size exceeds 8-10 items, the market &lt;strong&gt;freezes&lt;/strong&gt; – almost no one gets their bundle because the probability of at least one item conflict approaches 1.&lt;/p&gt;

&lt;p&gt;The critical insight: there is a &lt;strong&gt;Viscous Goldilocks Zone&lt;/strong&gt; around bundle size 3-4 where the product of satisfaction rate and revenue per bidder is maximized. Smaller bundles have high satisfaction but low per-bidder revenue. Larger bundles have high per-bidder revenue but near-zero satisfaction. The sweet spot is in the middle. This has practical implications for marketplace design: if you are building an auction platform that supports bundling, you should nudge bidders toward bundles of 3-4 items for maximum overall market efficiency.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;xychart-beta
    title &quot;Tequila-Snow Phase Transition&quot;
    x-axis &quot;Average Bundle Size&quot; [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15]
    y-axis &quot;Satisfaction Rate (%)&quot; 0 --&amp;gt; 100
    line [95, 72, 54, 41, 31, 24, 18, 14, 11, 8, 2]
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;future-horizons&quot;&gt;Future Horizons&lt;/h2&gt;

&lt;p&gt;The paper sketches several extensions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quantum Annealing.&lt;/strong&gt; The ILP can be reformulated as a QUBO (Quadratic Unconstrained Binary Optimization) problem, which maps naturally onto an Ising model. D-Wave’s quantum annealers natively solve QUBO problems. For a 50,000-item market, the Ising model would have 20,000 qubits (one per bidder) with pairwise couplings encoding item conflicts. Current quantum hardware cannot handle this scale, but the formulation is ready for when it can.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Neural Mechanism Design.&lt;/strong&gt; Train a Graph Neural Network on the conflict graph (bidders as nodes, item conflicts as edges) to learn allocation strategies that generalize across market structures. The GNN could learn market-type-specific heuristics that outperform the generic value-density sort.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero-Knowledge Proofs.&lt;/strong&gt; Bidders currently reveal their budgets to the auctioneer. With zk-SNARKs, bidders could prove “my budget exceeds the price of my bundle” without revealing the actual budget. Cryptographic privacy for auction participants.&lt;/p&gt;

&lt;p&gt;The Swiss-Mexican Auction is not optimal. By construction, it cannot be – the optimal solution is NP-complete. But it is fast, it is principled, it closes 93% of the optimality gap, and it clears a 50,000-item market in a quarter of a second. Sometimes good enough, fast enough, is the right answer.&lt;/p&gt;
</content>
 </entry>
 
 <entry>
   <title>git-goblin: 195+ Shortcuts and a Pure Bash TUI</title>
   <link href="http://hankquinlan.github.io/blog/2026/04/13/git-goblin"/>
   <updated>2026-04-13T00:00:00+00:00</updated>
   <id>http://juleshenry.github.io//blog/2026/04/13/git-goblin</id>
   <content type="html">&lt;p&gt;&lt;a href=&quot;https://github.com/juleshenry/git-goblin&quot;&gt;View on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://github.com/juleshenry/git-goblin/blob/main/ggob.jpeg?raw=1&quot; alt=&quot;ggob&quot; /&gt;&lt;/p&gt;

&lt;p&gt;git-goblin is a shell productivity toolkit that replaces verbose CLI commands with short, mnemonic shortcuts. 195+ aliases and functions covering Git, Docker, Kubernetes, AWS, GCP, Terraform, Helm, Ansible, and general shell utilities. One-command setup. Source it into your bash or zsh, and your terminal becomes a different animal.&lt;/p&gt;

&lt;p&gt;The project is on &lt;a href=&quot;https://github.com/juleshenry/git-goblin&quot;&gt;GitHub&lt;/a&gt;. MIT licensed.&lt;/p&gt;

&lt;h2 id=&quot;the-greatest-hits&quot;&gt;The Greatest Hits&lt;/h2&gt;

&lt;p&gt;Before we get to the interesting stuff, here is the practical pitch. These are commands I use every single day:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;gg &lt;span class=&quot;s2&quot;&gt;&quot;fix the auth bug&quot;&lt;/span&gt;     &lt;span class=&quot;c&quot;&gt;# git add -A &amp;amp;&amp;amp; git commit -m &quot;...&quot; &amp;amp;&amp;amp; git push&lt;/span&gt;
gs                        &lt;span class=&quot;c&quot;&gt;# git status&lt;/span&gt;
gd                        &lt;span class=&quot;c&quot;&gt;# git diff&lt;/span&gt;
gb                        &lt;span class=&quot;c&quot;&gt;# git branch&lt;/span&gt;
gco feature               &lt;span class=&quot;c&quot;&gt;# git checkout feature&lt;/span&gt;
gpr                       &lt;span class=&quot;c&quot;&gt;# create GitHub PR via gh cli&lt;/span&gt;
gsquash 3                 &lt;span class=&quot;c&quot;&gt;# squash last 3 commits&lt;/span&gt;
gwip                      &lt;span class=&quot;c&quot;&gt;# commit everything as WIP&lt;/span&gt;
gunwip                    &lt;span class=&quot;c&quot;&gt;# undo the last WIP commit&lt;/span&gt;
presto                    &lt;span class=&quot;c&quot;&gt;# nuclear option: wipe git history (with confirmation)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Docker:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;dps                       &lt;span class=&quot;c&quot;&gt;# docker ps&lt;/span&gt;
dcu                       &lt;span class=&quot;c&quot;&gt;# docker compose up -d&lt;/span&gt;
dcd                       &lt;span class=&quot;c&quot;&gt;# docker compose down&lt;/span&gt;
dex container bash        &lt;span class=&quot;c&quot;&gt;# docker exec -it container bash&lt;/span&gt;
drmi                      &lt;span class=&quot;c&quot;&gt;# docker rmi (remove image)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Kubernetes:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;kgp                       &lt;span class=&quot;c&quot;&gt;# kubectl get pods&lt;/span&gt;
kgs                       &lt;span class=&quot;c&quot;&gt;# kubectl get services&lt;/span&gt;
kl pod-name               &lt;span class=&quot;c&quot;&gt;# kubectl logs pod-name&lt;/span&gt;
kex pod-name bash          &lt;span class=&quot;c&quot;&gt;# kubectl exec -it pod-name -- bash&lt;/span&gt;
ksc context                &lt;span class=&quot;c&quot;&gt;# kubectl config use-context&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;AWS, GCP, Terraform, Helm, Ansible – all have similar shortcut families. The full list is 195+ commands. You do not memorize them all. You memorize the ones you use, and for the rest, there is the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;G&lt;/code&gt; command.&lt;/p&gt;

&lt;h2 id=&quot;the-g-command&quot;&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;G&lt;/code&gt; Command&lt;/h2&gt;

&lt;p&gt;This is the centerpiece of the project, and the reason it is more than a dotfiles repo.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;G&lt;/code&gt; is a “hot-doc autofiller” that operates in three modes:&lt;/p&gt;

&lt;h3 id=&quot;mode-1-best-guess&quot;&gt;Mode 1: Best-Guess&lt;/h3&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;G &lt;span class=&quot;s1&quot;&gt;&apos;git status&apos;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;You type any raw CLI command – or even a vague description – and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;G&lt;/code&gt; fuzzy-matches it to the closest git-goblin shortcut. It displays the documentation and copies the shortcut to your clipboard. You typed &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git status&lt;/code&gt;? &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;G&lt;/code&gt; tells you the shortcut is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gs&lt;/code&gt;, shows you what it does, and copies &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gs&lt;/code&gt; to your clipboard. Done.&lt;/p&gt;

&lt;p&gt;The matching uses a scoring algorithm that weights exact substring matches highest, then token overlap, then edit distance. It is not perfect. It does not need to be. It needs to be faster than grepping through a 200-line alias file, and it is.&lt;/p&gt;

&lt;h3 id=&quot;mode-2-add&quot;&gt;Mode 2: Add&lt;/h3&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;G &lt;span class=&quot;nt&quot;&gt;-a&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;mycommand&apos;&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;what it does&apos;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Register your own custom shortcuts. They persist across sessions in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.git-goblin-custom&lt;/code&gt; and are searchable through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;G&lt;/code&gt; just like the built-in commands. Your team has a weird deploy script? Add it. Your CI pipeline has a 40-character incantation? Add it.&lt;/p&gt;

&lt;h3 id=&quot;mode-3-interactive-tui&quot;&gt;Mode 3: Interactive TUI&lt;/h3&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;G
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;No arguments. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;G&lt;/code&gt; launches a full-screen fuzzy-find interface. Type to filter. Arrow keys to navigate. Enter to select and copy. It looks like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fzf&lt;/code&gt;, but it is built entirely in pure Bash.&lt;/p&gt;

&lt;p&gt;No dependencies. No &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fzf&lt;/code&gt;. No &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ncurses&lt;/code&gt;. No Python. The TUI uses raw terminal escape sequences (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;\033[&lt;/code&gt;), &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;read -rsn1&lt;/code&gt; for character-by-character input handling, and ANSI color codes for syntax highlighting. The entire interactive mode is a bash function that manages cursor position, screen clearing, and input parsing by hand.&lt;/p&gt;

&lt;p&gt;I built it this way because I wanted &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;G&lt;/code&gt; to work on any machine with bash. No installation step beyond sourcing the file. No &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;brew install fzf&lt;/code&gt; prerequisite. You &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ssh&lt;/code&gt; into a bare-bones production server, source git-goblin, and the TUI works.&lt;/p&gt;

&lt;h2 id=&quot;setup&quot;&gt;Setup&lt;/h2&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;./setup-git-goblin
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;One command. It auto-detects your shell (bash or zsh), backs up your RC file, sources the function files, and makes scripts executable. Takes about 3 seconds.&lt;/p&gt;

&lt;h2 id=&quot;the-python-utilities&quot;&gt;The Python Utilities&lt;/h2&gt;

&lt;p&gt;git-goblin also ships with a handful of Python tools:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;kash&lt;/code&gt;&lt;/strong&gt; – A file-based function cache decorator. Decorate a Python function with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@kash&lt;/code&gt;, and its return value gets cached to a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.kash&lt;/code&gt; JSON file on disk, keyed by the arguments. Next time you call the function with the same arguments, it reads from the file instead of recomputing. It is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;functools.lru_cache&lt;/code&gt; but persistent across process restarts. Useful for expensive API calls during development.&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;kash&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;kash&lt;/span&gt;

&lt;span class=&quot;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;kash&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;expensive_api_call&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;query&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;requests&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;https://api.example.com/&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;query&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;autosave-git-recursively.py&lt;/code&gt;&lt;/strong&gt; – Walks a directory tree and auto-commits/pushes every git repository it finds. I run this as a cron job on my dev machine. It is an autosave for all my projects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;repoinspector.py&lt;/code&gt;&lt;/strong&gt; – Fetches all of a GitHub user’s public repositories and aggregates their open issues into a single summary. Useful for triaging across many repos without clicking through GitHub’s web UI.&lt;/p&gt;

&lt;h2 id=&quot;why-not-just-use-oh-my-zsh&quot;&gt;Why Not Just Use Oh My Zsh?&lt;/h2&gt;

&lt;p&gt;Oh My Zsh has git aliases. They are fine. git-goblin differs in three ways:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Scope.&lt;/strong&gt; OMZ aliases are git-only. git-goblin covers Git, Docker, Kubernetes, AWS, GCP, Terraform, Helm, Ansible, and shell utilities.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;G&lt;/code&gt; command.&lt;/strong&gt; OMZ does not have a command finder. You memorize the aliases or you grep through the source. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;G&lt;/code&gt; is the difference between a reference card and a searchable database.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Portability.&lt;/strong&gt; git-goblin works on bash and zsh with no framework dependency. OMZ is a zsh framework that you install. git-goblin is a file that you source.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The built-in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gg-help&lt;/code&gt; command provides a searchable, section-filterable reference table:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;gg-help              &lt;span class=&quot;c&quot;&gt;# show everything&lt;/span&gt;
gg-help docker       &lt;span class=&quot;c&quot;&gt;# show only Docker shortcuts&lt;/span&gt;
gg-help kubernetes   &lt;span class=&quot;c&quot;&gt;# show only Kubernetes shortcuts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;195 shortcuts. A fuzzy-find TUI in pure Bash. Zero dependencies. One goblin.&lt;/p&gt;
</content>
 </entry>
 
 <entry>
   <title>README Rosetta: Open Source in Every Language</title>
   <link href="http://hankquinlan.github.io/blog/2026/04/06/README-Rosetta"/>
   <updated>2026-04-06T00:00:00+00:00</updated>
   <id>http://juleshenry.github.io//blog/2026/04/06/README-Rosetta</id>
   <content type="html">&lt;p&gt;&lt;a href=&quot;https://github.com/juleshenry/readme_rosetta&quot;&gt;View on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most open source projects have English-only documentation. This is a barrier. A significant percentage of the global developer population reads English as a second language, and for many, the friction of parsing a technical README in a non-native language is enough to keep them from adopting a tool they would otherwise love.&lt;/p&gt;

&lt;p&gt;README Rosetta automates the translation of README files and Sphinx documentation into 70+ languages using locally-running LLMs via Ollama. No API keys. No cloud costs. No data leaving your machine. One command:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;readme-rosetta &lt;span class=&quot;nt&quot;&gt;--langs&lt;/span&gt; es fr de ja zh ar hi
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The project is on &lt;a href=&quot;https://pypi.org/project/readme-rosetta/&quot;&gt;PyPI&lt;/a&gt; (v0.1.7) and &lt;a href=&quot;https://github.com/juleshenry/readme_rosetta&quot;&gt;GitHub&lt;/a&gt;. MIT licensed.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://github.com/juleshenry/readme_rosetta/blob/main/readme_image.png?raw=1&quot; alt=&quot;Rosetta Stone&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;the-problem-with-llm-translation-of-structured-text&quot;&gt;The Problem with LLM Translation of Structured Text&lt;/h2&gt;

&lt;p&gt;Raw LLMs are notoriously unreliable for translating Markdown. I learned this the hard way. Here is a non-exhaustive list of things that go wrong when you naively ask an LLM to translate a README:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Code block corruption.&lt;/strong&gt; The LLM helpfully “translates” your Python code. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;import requests&lt;/code&gt; becomes &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;importar solicitudes&lt;/code&gt;. My God.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Hallucinated links.&lt;/strong&gt; The original has &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[docs](https://example.com/docs)&lt;/code&gt;. The translation has &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[documentos](https://ejemplo.com/documentos)&lt;/code&gt;. That URL does not exist.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Conversational preambles.&lt;/strong&gt; Instead of returning the translation, the model starts with “Sure! Here’s the translation of your README into Spanish:” and then provides the translation. Now your translated README begins with an English sentence from the LLM.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Line count explosion.&lt;/strong&gt; A single-sentence bullet point becomes a three-sentence paragraph because the LLM decided to elaborate.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Placeholder mangling.&lt;/strong&gt; If you use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{variable}&lt;/code&gt; template syntax, the LLM either translates the variable name or drops the braces.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;README Rosetta addresses all of this with a multi-layered defense system.&lt;/p&gt;

&lt;h2 id=&quot;code-block-protection&quot;&gt;Code Block Protection&lt;/h2&gt;

&lt;p&gt;Before sending any text to the LLM, README Rosetta replaces all fenced code blocks with numbered placeholders:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;```python
def hello():
    print(&quot;world&quot;)
```
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;becomes &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ROSETTA_CB_0&lt;/code&gt;. The LLM never sees the code. After translation, the placeholders are swapped back in. The code is untouched. This is the single most important feature in the entire tool, and it is embarrassingly simple.&lt;/p&gt;

&lt;p&gt;For Sphinx documentation, the same approach extends to reStructuredText syntax: directives, roles, and inline literals are replaced with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ROSETTA_RST_N&lt;/code&gt; placeholders. The LLM translates the prose. The markup survives.&lt;/p&gt;

&lt;h2 id=&quot;hallucination-detection-and-retry-logic&quot;&gt;Hallucination Detection and Retry Logic&lt;/h2&gt;

&lt;p&gt;After each translation, README Rosetta validates the output:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Placeholder count check.&lt;/strong&gt; If the original had 5 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ROSETTA_CB_N&lt;/code&gt; placeholders and the translation has 4, something was dropped. Retry.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Link verification.&lt;/strong&gt; Compare URLs in the original and translation. If the translation contains URLs not present in the original, the LLM hallucinated them. Retry.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Conversational response detection.&lt;/strong&gt; If the translation starts with patterns like “Here is,” “Sure!”, “Of course,” or “I’d be happy to,” the LLM prefixed its response with preamble. Strip it or retry.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Line count sanity.&lt;/strong&gt; If a single input line produced 5+ output lines, the LLM elaborated instead of translating. Retry.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each retry uses an increasingly strict system prompt. The first attempt is polite: “Please translate the following Markdown content.” The retry is firm: “Translate ONLY the text. Do NOT add any explanations, preambles, or commentary. Do NOT modify URLs, code blocks, or formatting.”&lt;/p&gt;

&lt;p&gt;Up to 2 retries per chunk. If all fail, the original text is preserved with a comment noting the failed translation.&lt;/p&gt;

&lt;h2 id=&quot;translation-caching&quot;&gt;Translation Caching&lt;/h2&gt;

&lt;p&gt;Translations are cached in a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.rosetta_cache.json&lt;/code&gt; file using MD5 hashes of the source content. If you re-run the tool after editing only one section of your README, only that section gets re-translated. The rest is served from cache. This matters when you are translating into 20+ languages – you do not want to re-translate 500 chunks because you fixed a typo in one paragraph.&lt;/p&gt;

&lt;h2 id=&quot;output-modes&quot;&gt;Output Modes&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Split mode&lt;/strong&gt; (default): Generates separate files – &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;README.es.md&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;README.fr.md&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;README.ja.md&lt;/code&gt;, etc. Each file is self-contained and includes a navigation table at the top (the “Rosetta stone”) with links to all translated versions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unified mode&lt;/strong&gt; (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--no-split&lt;/code&gt;): Appends all translations into a single &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;README.md&lt;/code&gt;, separated by HTML comment markers. Useful for projects that want everything in one file.&lt;/p&gt;

&lt;p&gt;The navigation table itself is auto-generated:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;| [English](README.md) | [Español](README.es.md) | [Français](README.fr.md) | [日本語](README.ja.md) | [中文](README.zh.md) | ...
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;sphinx-integration&quot;&gt;Sphinx Integration&lt;/h2&gt;

&lt;p&gt;For larger projects with Sphinx documentation, README Rosetta can set up the entire i18n pipeline:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;readme-rosetta &lt;span class=&quot;nt&quot;&gt;--sphinx&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--langs&lt;/span&gt; es fr de ja &lt;span class=&quot;nt&quot;&gt;--docs-dir&lt;/span&gt; ./docs
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This runs &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sphinx-build gettext&lt;/code&gt; to generate &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.pot&lt;/code&gt; files, creates locale directories, translates the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.po&lt;/code&gt; files (with rST syntax protection), and builds localized HTML. The output is a complete set of translated Sphinx docs ready for hosting.&lt;/p&gt;

&lt;h2 id=&quot;github-actions&quot;&gt;GitHub Actions&lt;/h2&gt;

&lt;p&gt;A workflow template is included that installs Ollama on a GitHub Actions runner, pulls the model, and auto-translates on every push to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt;. Internationalization that happens automatically in CI rather than being an aspirational backlog item that never gets done.&lt;/p&gt;

&lt;h2 id=&quot;usage&quot;&gt;Usage&lt;/h2&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;pip &lt;span class=&quot;nb&quot;&gt;install &lt;/span&gt;readme-rosetta
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;# Translate to 5 languages&lt;/span&gt;
readme-rosetta &lt;span class=&quot;nt&quot;&gt;--langs&lt;/span&gt; es fr de ja zh

&lt;span class=&quot;c&quot;&gt;# Use a specific model&lt;/span&gt;
readme-rosetta &lt;span class=&quot;nt&quot;&gt;--model&lt;/span&gt; llama3.2 &lt;span class=&quot;nt&quot;&gt;--langs&lt;/span&gt; ar hi ko

&lt;span class=&quot;c&quot;&gt;# Dry run (preview without writing)&lt;/span&gt;
readme-rosetta &lt;span class=&quot;nt&quot;&gt;--dry-run&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--langs&lt;/span&gt; es

&lt;span class=&quot;c&quot;&gt;# Sphinx docs&lt;/span&gt;
readme-rosetta &lt;span class=&quot;nt&quot;&gt;--sphinx&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--langs&lt;/span&gt; es fr de ja &lt;span class=&quot;nt&quot;&gt;--docs-dir&lt;/span&gt; ./docs
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Configuration can also live in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pyproject.toml&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-toml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nn&quot;&gt;[tool.readme-rosetta]&lt;/span&gt;
&lt;span class=&quot;py&quot;&gt;model&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;llama3.2&quot;&lt;/span&gt;
&lt;span class=&quot;py&quot;&gt;languages&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;es&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;fr&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;de&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;ja&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;zh&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;the-technical-core&quot;&gt;The Technical Core&lt;/h2&gt;

&lt;p&gt;The source is 7 files. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;translator.py&lt;/code&gt; handles the Ollama integration, caching, and retry logic. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;markdown_handler.py&lt;/code&gt; handles code block protection/restoration and the navigation table. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sphinx_handler.py&lt;/code&gt; handles the Sphinx i18n pipeline. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lang_codes.py&lt;/code&gt; is a mapping of 70+ ISO language codes to language names. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cli.py&lt;/code&gt; orchestrates everything.&lt;/p&gt;

&lt;p&gt;The tool defaults to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;llama3.2&lt;/code&gt; via Ollama because it is small enough to run on a laptop, fast enough for batch translation, and accurate enough for technical prose. You can swap in any Ollama-compatible model.&lt;/p&gt;

&lt;p&gt;The hallucination detection is regex-heavy by design. I considered using a second LLM call to validate the first LLM’s output, but that doubles the cost and introduces a new failure mode (what if the validator hallucinates?). Pattern matching is deterministic, fast, and does not argue with you.&lt;/p&gt;

&lt;p&gt;Making documentation accessible should not be hard. It should not require a translation team or a localization budget. It should be one command. That is what README Rosetta does.&lt;/p&gt;
</content>
 </entry>
 
 <entry>
   <title>Mr. Worldwide: Hello in 80 Languages as a GIF</title>
   <link href="http://hankquinlan.github.io/blog/2026/03/30/Mr-Worldwide"/>
   <updated>2026-03-30T00:00:00+00:00</updated>
   <id>http://juleshenry.github.io//blog/2026/03/30/Mr-Worldwide</id>
   <content type="html">&lt;p&gt;&lt;a href=&quot;https://github.com/juleshenry/mr.worldwide&quot;&gt;View on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Mr. Worldwide generates animated GIFs of a single word – “Hello,” “Love,” whatever you want – translated into 80+ languages, with each translation rendered as a frame. Each frame can optionally display the translated word over a culturally relevant photograph from that country. The result is a looping, globe-trotting GIF that says your word in English, then Spanish, then Japanese, then Amharic, then Tibetan, then Yoruba, and on and on through 80+ languages.&lt;/p&gt;

&lt;p&gt;The project is on &lt;a href=&quot;https://github.com/juleshenry/mr.worldwide&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://github.com/juleshenry/mr.worldwide/blob/main/examples/demos/worldwide_demo.gif?raw=1&quot; alt=&quot;Demo&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;the-hard-problem-rendering-every-script-on-earth&quot;&gt;The Hard Problem: Rendering Every Script on Earth&lt;/h2&gt;

&lt;p&gt;Generating a GIF is easy. Pillow does that in five lines. The hard problem is rendering text correctly in 80+ writing systems.&lt;/p&gt;

&lt;p&gt;“Hello” in English uses Latin characters. “こんにちは” uses CJK ideographs. “مرحبا” uses Arabic script (right-to-left). “สวัสดี” uses Thai. “ᓱᓇᑦᓯᐊᖅ” uses Canadian Aboriginal Syllabics. “བཀྲ་ཤིས་བདེ་ལེགས” uses Tibetan. Each of these scripts requires a different font, and many system fonts support only a handful of scripts.&lt;/p&gt;

&lt;p&gt;Mr. Worldwide bundles 25+ Google Noto fonts to cover the full Unicode range. The font selection is automatic: for each character in the translated word, the tool checks which Unicode code point range it falls into and selects the corresponding Noto font. Arabic characters get NotoSansArabic, Devanagari gets NotoSansDevanagari, CJK gets NotoSansCJK, and so on.&lt;/p&gt;

&lt;p&gt;The font sizing is also automatic. Each translation has a different string length and character width. “Hello” in English is 5 compact Latin characters. “Привет” in Russian is 6 Cyrillic characters of similar width. “こんにちは” in Japanese is 5 wide CJK characters. Mr. Worldwide measures the rendered text width for each translation and scales the font size to fit within the frame. No text gets cut off. No frames have tiny unreadable text in one corner.&lt;/p&gt;

&lt;h2 id=&quot;smart-contrast-coloring&quot;&gt;Smart Contrast Coloring&lt;/h2&gt;

&lt;p&gt;When overlaying text on a country photograph (say, “Bonjour” over a picture of the Eiffel Tower), the text needs to be legible. White text on a bright sky is invisible. Black text on a dark scene is invisible.&lt;/p&gt;

&lt;p&gt;Mr. Worldwide solves this with k-means color clustering. For each background image:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Cluster the image pixels into dominant colors using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scipy.cluster.vq.kmeans&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;Identify the most prominent background hues&lt;/li&gt;
  &lt;li&gt;Select a text color that maximizes both contrast (distance from the dominant colors) and vibrancy (saturation)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result is text that pops against any background – bright cyan over a dark forest, deep navy over a sunlit beach, vivid magenta over a gray cityscape.&lt;/p&gt;

&lt;h2 id=&quot;flag-color-text&quot;&gt;Flag-Color Text&lt;/h2&gt;

&lt;p&gt;An alternative to smart contrast: paint each character using the colors of the corresponding country’s flag. “Hola” rendered in the red, yellow, and red of the Spanish flag. “Bonjour” in blue, white, and red. “Hallo” in the black, red, and gold of Germany.&lt;/p&gt;

&lt;p&gt;The flag colors are extracted from SVG files stored in a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;banderas/&lt;/code&gt; directory (banderas is Spanish for “flags”). The tool parses the fill attributes from each SVG and maps them to character indices. Character 1 gets color 1, character 2 gets color 2, and so on, cycling through the palette.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://github.com/juleshenry/mr.worldwide/blob/main/examples/demos/flag_hello.gif?raw=1&quot; alt=&quot;Flag Hello&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;the-full-cli&quot;&gt;The Full CLI&lt;/h2&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;python mr_worldwide.py &lt;span class=&quot;nt&quot;&gt;--word&lt;/span&gt; Hello &lt;span class=&quot;nt&quot;&gt;--use-icons&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--smart-colors&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--size&lt;/span&gt; 800x600
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;python mr_worldwide.py &lt;span class=&quot;nt&quot;&gt;--word&lt;/span&gt; Love &lt;span class=&quot;nt&quot;&gt;--flag-colors&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--delay&lt;/span&gt; 150 &lt;span class=&quot;nt&quot;&gt;--output&lt;/span&gt; love_flags.gif
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;python mr_worldwide.py &lt;span class=&quot;nt&quot;&gt;--text-array&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Hola,Bonjour,Ciao,Hallo&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--rainbow&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--sine-delay&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Options include:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--use-icons&lt;/code&gt; – overlay text on country photographs&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--smart-colors&lt;/code&gt; – k-means contrast text coloring&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--flag-colors&lt;/code&gt; – paint text with country flag colors&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--rainbow&lt;/code&gt; – hue-shift the text color across frames&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--sine-delay&lt;/code&gt; – a sinusoidal timing effect that “dwells” on each frame in sequence, creating a wave-like viewing rhythm&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--langs&lt;/code&gt; – filter to specific ISO language codes (e.g., &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--langs es fr de ja&lt;/code&gt;)&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--size&lt;/code&gt; – output dimensions&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--delay&lt;/code&gt; – milliseconds per frame&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The example gallery includes 14 pre-built scripts covering every permutation of these options, each with a pre-generated demo GIF.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://github.com/juleshenry/mr.worldwide/blob/main/examples/demos/test_rainbow.gif?raw=1&quot; alt=&quot;Rainbow Demo&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;design-decisions&quot;&gt;Design Decisions&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why local translations instead of an API?&lt;/strong&gt; The 80+ translations are hardcoded in a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;translations.json&lt;/code&gt; file rather than fetched from Google Translate at runtime. This was deliberate. Machine translation APIs are rate-limited, cost money at scale, and produce inconsistent results for single-word translations (context-free translation of “Love” can yield wildly different results depending on the API’s mood). By curating the translations manually, every output is verified and stable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deduplication.&lt;/strong&gt; Many languages share the same word. “Hello” is “Halo” in both Indonesian and Malay. Rather than showing two identical frames, Mr. Worldwide deduplicates translations before rendering. The GIF only shows unique strings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Geographic ordering.&lt;/strong&gt; The translations are sorted by region (Europe, Asia, Africa, Americas, Oceania) with priority languages (English, Spanish, Italian, French) first. This gives the GIF a natural geographic flow rather than a random jumble.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Background image sourcing.&lt;/strong&gt; The country photographs are fetched from the Pexels API and Wikimedia Commons, organized into per-country directories. The tool selects a random image from each country’s directory, so regenerating the GIF produces visual variety even with the same parameters.&lt;/p&gt;

&lt;p&gt;A fun intersection of internationalization, image processing, and creative coding. The kind of project that is both technically interesting (font detection across Unicode ranges, k-means color analysis) and produces something you can actually send to your friends.&lt;/p&gt;
</content>
 </entry>
 
 <entry>
   <title>SuperWand: Magic Wand for Image Retheming</title>
   <link href="http://hankquinlan.github.io/blog/2026/03/23/SuperWand"/>
   <updated>2026-03-23T00:00:00+00:00</updated>
   <id>http://juleshenry.github.io//blog/2026/03/23/SuperWand</id>
   <content type="html">&lt;p&gt;&lt;a href=&quot;https://github.com/juleshenry/superwand&quot;&gt;View on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;SuperWand takes an image, identifies its dominant color regions using KMeans clustering, and replaces those colors with any of 18 curated aesthetic themes – Vaporwave, Cyberpunk, Tropical, Arctic, and so on. It is a magic wand for recoloring. Point it at a posterized Charizard, pick “Midnight,” and out comes a moonlit dragon.&lt;/p&gt;

&lt;p&gt;The project is on &lt;a href=&quot;https://pypi.org/project/superwand/&quot;&gt;PyPI&lt;/a&gt; (v0.2.10) and &lt;a href=&quot;https://github.com/juleshenry/superwand&quot;&gt;GitHub&lt;/a&gt;. Licensed under Apache 2.0.&lt;/p&gt;

&lt;h2 id=&quot;how-it-works&quot;&gt;How It Works&lt;/h2&gt;

&lt;p&gt;The pipeline has three stages.&lt;/p&gt;

&lt;h3 id=&quot;1-region-identification&quot;&gt;1. Region Identification&lt;/h3&gt;

&lt;p&gt;Given an image, SuperWand uses scikit-learn’s KMeans to cluster all pixels into &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;k&lt;/code&gt; regions (default 4) by color similarity. For large images, it downsamples before clustering (because running KMeans on a 4000x3000 image with 12 million pixels is a good way to watch your laptop overheat) and then applies the cluster assignments at full resolution.&lt;/p&gt;

&lt;p&gt;Each pixel gets a label: region 0, region 1, region 2, region 3. These regions correspond roughly to the dominant color areas – the sky, the ground, the subject, the shadows.&lt;/p&gt;

&lt;h3 id=&quot;2-theme-injection&quot;&gt;2. Theme Injection&lt;/h3&gt;

&lt;p&gt;Each region gets mapped to a color from the chosen theme. The 18 themes are hand-curated palettes:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Theme&lt;/th&gt;
      &lt;th&gt;Vibe&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Tropical&lt;/td&gt;
      &lt;td&gt;Warm greens, coral, turquoise&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Cyberpunk&lt;/td&gt;
      &lt;td&gt;Neon pink, electric blue, dark purple&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Vaporwave&lt;/td&gt;
      &lt;td&gt;Pastel pink, lavender, mint&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Arctic&lt;/td&gt;
      &lt;td&gt;Ice blue, white, steel gray&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Retro80s&lt;/td&gt;
      &lt;td&gt;Hot pink, electric cyan, chrome yellow&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Volcano&lt;/td&gt;
      &lt;td&gt;Deep red, orange, obsidian black&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;…&lt;/td&gt;
      &lt;td&gt;(12 more)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The mapping is straightforward: the brightest region gets the lightest theme color, the darkest region gets the darkest theme color, and intermediates are matched accordingly. The result is a recolored image that preserves the original structure (edges, shapes, textures) but wears an entirely different color palette.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://github.com/juleshenry/superwand/blob/main/examples/charizards/Cyberpunk_charizard.png?raw=1&quot; alt=&quot;Charizard Themed Examples&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;3-gradient-application&quot;&gt;3. Gradient Application&lt;/h3&gt;

&lt;p&gt;Flat color replacement looks… flat. So SuperWand supports five gradient styles that can be applied per region:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Bottom-up&lt;/strong&gt; / &lt;strong&gt;Top-down&lt;/strong&gt; – vertical gradient across the region&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Left-right&lt;/strong&gt; / &lt;strong&gt;Right-left&lt;/strong&gt; – horizontal gradient&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Radial&lt;/strong&gt; – gradient radiating from the region’s centroid outward&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each gradient has a configurable polarity/bias parameter that controls where the midpoint sits. The gradient is computed as a NumPy array and multiplied element-wise against the region’s pixels. The result is smooth color transitions within each region instead of uniform blocks.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://github.com/juleshenry/superwand/blob/main/examples/charizards/gradient_radial_charizard.png?raw=1&quot; alt=&quot;Gradient Examples&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;superwand-studio&quot;&gt;SuperWand Studio&lt;/h2&gt;

&lt;p&gt;The CLI is fine for scripting, but the real fun is the Studio – a Flask-based web UI served locally at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;http://127.0.0.1:5001&lt;/code&gt;. Upload an image, and you get an interactive workspace:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Pick a theme or set custom per-region colors&lt;/li&gt;
  &lt;li&gt;Adjust the number of KMeans clusters (more clusters = finer region detection)&lt;/li&gt;
  &lt;li&gt;Apply different gradient styles to different regions&lt;/li&gt;
  &lt;li&gt;Toggle morphological flood-fill smoothing (uses SciPy’s binary dilation/closing to smooth jagged region boundaries)&lt;/li&gt;
  &lt;li&gt;See the result instantly as a live preview&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;img src=&quot;https://github.com/juleshenry/superwand/blob/main/examples/studio-preview.png?raw=1&quot; alt=&quot;Studio Preview&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The Studio also handles CSS retheming. Upload a stylesheet, and SuperWand parses all hex color codes, clusters them with KMeans, and maps the clusters to your chosen theme. The before/after is surprisingly dramatic:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://github.com/juleshenry/superwand/blob/main/examples/css/before.png?raw=1&quot; alt=&quot;CSS Before&quot; /&gt;
&lt;img src=&quot;https://github.com/juleshenry/superwand/blob/main/examples/css/after_tropical.png?raw=1&quot; alt=&quot;CSS After Tropical&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;cli-usage&quot;&gt;CLI Usage&lt;/h2&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;pip &lt;span class=&quot;nb&quot;&gt;install &lt;/span&gt;superwand
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;superwand zebra.png &lt;span class=&quot;nt&quot;&gt;-theme&lt;/span&gt; Urban
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;superwand charizard.png &lt;span class=&quot;nt&quot;&gt;-theme&lt;/span&gt; Vaporwave &lt;span class=&quot;nt&quot;&gt;-k&lt;/span&gt; 6 &lt;span class=&quot;nt&quot;&gt;-gradient&lt;/span&gt; radial
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-k&lt;/code&gt; flag controls the number of color regions. More regions means finer-grained recoloring. For simple posterized art (like the Charizard), 4 regions works well. For photographs with subtle gradients, 6-8 regions capture more detail.&lt;/p&gt;

&lt;h2 id=&quot;the-interesting-parts&quot;&gt;The Interesting Parts&lt;/h2&gt;

&lt;p&gt;The NumPy optimization was worth the effort. The naive approach – iterating over each pixel in a Python loop to check its cluster assignment and replace its color – is painfully slow for large images. The vectorized approach uses boolean mask indexing: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;image[labels == k] = theme_color[k]&lt;/code&gt; replaces an entire region in one operation. The gradient computation is similarly vectorized: generate the gradient array for the bounding box, mask it to the region shape, and multiply. No Python loops touch individual pixels.&lt;/p&gt;

&lt;p&gt;The morphological flood filling (optional, via SciPy) addresses a visual artifact of KMeans clustering: jagged region boundaries. KMeans assigns each pixel independently, so the boundary between two regions can be noisy – a few pixels of region 1 embedded in region 2 because their color was ambiguous. Binary dilation followed by closing smooths these boundaries, producing regions with cleaner edges. It is a post-processing step borrowed from medical image segmentation, applied here to make Charizards look better.&lt;/p&gt;

&lt;p&gt;The CSS retheming feature came from a practical itch. I was reskinning a web project and manually hunting for every hex code in the stylesheets. SuperWand automates this: it parses hex patterns from the CSS file, clusters them (because many similar shades should map to the same theme color), replaces each cluster with the nearest theme color, and writes the new stylesheet. It is the kind of thing that saves you two hours of find-and-replace.&lt;/p&gt;

&lt;p&gt;Eighteen themes. Five gradient styles. One wand.&lt;/p&gt;
</content>
 </entry>
 
 <entry>
   <title>ZIT: Zooplankton Image Tool</title>
   <link href="http://hankquinlan.github.io/blog/2026/03/16/ZIT"/>
   <updated>2026-03-16T00:00:00+00:00</updated>
   <id>http://juleshenry.github.io//blog/2026/03/16/ZIT</id>
   <content type="html">&lt;p&gt;&lt;a href=&quot;https://github.com/juleshenry/zooplankton-image-tool&quot;&gt;View on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;ZIT turns underwater plankton video into composite images that reveal animal locomotion patterns. Think of it as computational long-exposure photography for marine biology. You feed it a video of tiny creatures swimming around, and it produces a single image showing everywhere they went – crisp trails on a stable background, like light painting but for zooplankton.&lt;/p&gt;

&lt;p&gt;The project is on &lt;a href=&quot;https://pypi.org/p/zooplankton-imaging-tool&quot;&gt;PyPI&lt;/a&gt; and &lt;a href=&quot;https://github.com/juleshenry/zooplankton-image-tool&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;the-problem&quot;&gt;The Problem&lt;/h2&gt;

&lt;p&gt;You have a video of plankton swimming in a petri dish under a microscope. You want a single image that shows the motion paths. The naive approach – averaging all frames together – produces a blurry mess where the background dominates and the organisms become faint ghosts. Overlaying frames with alpha blending is slightly better but still noisy. The issue is that the organisms are tiny, the background is dominant, and naive compositing does not discriminate between “this pixel is a plankton” and “this pixel is a speck of sediment.”&lt;/p&gt;

&lt;h2 id=&quot;two-compositing-modes&quot;&gt;Two Compositing Modes&lt;/h2&gt;

&lt;h3 id=&quot;mode-1-pixel-difference&quot;&gt;Mode 1: Pixel Difference&lt;/h3&gt;

&lt;p&gt;The basic approach. Take a reference background frame, compare each subsequent frame pixel-by-pixel using Euclidean distance in RGB space. If a pixel differs from the background by more than an epsilon threshold, transfer it to the composite. If the difference is below a noise delta, ignore it. Simple, fast, and okay for clean videos with high contrast.&lt;/p&gt;

&lt;p&gt;The problem: it picks up noise. Slight lighting variations, camera jitter, sediment particles drifting through the frame – all of these exceed the epsilon threshold and get composited as artifacts. For messy real-world microscope footage, this mode produces something that looks like a composite of plankton trails plus television static.&lt;/p&gt;

&lt;h3 id=&quot;mode-2-entity-recognition-the-good-one&quot;&gt;Mode 2: Entity Recognition (The Good One)&lt;/h3&gt;

&lt;p&gt;This is where OpenCV earns its keep. Instead of pixel-level comparison, we use MOG2 – Mixture of Gaussians background subtraction. MOG2 builds a statistical model of the background over time, learning which pixels are “normally” part of the static scene and which are transient foreground objects. Each frame gets a foreground mask: white where something is moving, black where the background is stable.&lt;/p&gt;

&lt;p&gt;But the raw mask is still noisy. So we clean it up with morphological operations:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Opening&lt;/strong&gt; (erosion then dilation) – removes small noise speckles&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Closing&lt;/strong&gt; (dilation then erosion) – fills small holes in detected organisms&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Additional dilation&lt;/strong&gt; – slightly expands the detected regions to capture the full organism body&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then contour filtering. We find all contours in the cleaned mask and reject any that are too small (noise) or have extreme aspect ratios (scan lines, horizontal artifacts from the camera). What remains are the actual organisms.&lt;/p&gt;

&lt;p&gt;The result is dramatically cleaner. The background is pristine, and you see only the plankton trails – exactly where each organism swam during the recording period.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://github.com/juleshenry/zooplankton-image-tool/blob/main/assets/mari_comp.png?raw=1&quot; alt=&quot;Mariposa Example&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;the-parameter-sweep-grid&quot;&gt;The Parameter Sweep Grid&lt;/h2&gt;

&lt;p&gt;Different videos need different parameters. Water turbidity, lighting, camera resolution, organism size – all of these affect what threshold and minimum area values produce the cleanest composite. Tuning by trial and error is tedious.&lt;/p&gt;

&lt;p&gt;So I built a sweep tool. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sweep_grid.py&lt;/code&gt; generates a 5x5 grid of composites, sweeping across two axes: MinArea (the minimum contour area to count as a real organism) and Thresh (the background subtraction threshold). You look at the grid, find the cell that looks best, and use those parameters. Visual parameter search.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://github.com/juleshenry/zooplankton-image-tool/blob/main/assets/sweep_grid_184368-873181589_small.mp4.png?raw=1&quot; alt=&quot;Sweep Grid Example&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;usage&quot;&gt;Usage&lt;/h2&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;pip &lt;span class=&quot;nb&quot;&gt;install &lt;/span&gt;zooplankton-imaging-tool
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;zit &lt;span class=&quot;nt&quot;&gt;--video&lt;/span&gt; plankton_video.mp4 &lt;span class=&quot;nt&quot;&gt;--interval&lt;/span&gt; 1.0 &lt;span class=&quot;nt&quot;&gt;--epsilon&lt;/span&gt; 30 &lt;span class=&quot;nt&quot;&gt;--mode&lt;/span&gt; entity
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--interval&lt;/code&gt; flag controls how many seconds between frame captures (default 1.0). Lower intervals give you denser trails but slower processing. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--mode entity&lt;/code&gt; flag selects the MOG2 pipeline.&lt;/p&gt;

&lt;h2 id=&quot;what-i-learned&quot;&gt;What I Learned&lt;/h2&gt;

&lt;p&gt;The interesting technical takeaway is that background subtraction is not just for security cameras. MOG2 was designed for surveillance – detecting people walking through a scene – but it works beautifully for microscopy. The statistical background model adapts to gradual lighting changes (a problem with fixed-threshold approaches), and the morphological filtering pipeline transfers directly from “person detection” to “plankton detection” because the underlying math does not care what the foreground object is. It cares about the statistics of pixel variation over time.&lt;/p&gt;

&lt;p&gt;The contour aspect-ratio filter was a late addition born from frustration. Some microscope cameras produce horizontal scan-line artifacts – thin horizontal stripes that MOG2 correctly identifies as “not background” but that are obviously not organisms. Filtering by aspect ratio (rejecting contours wider than they are tall by a factor of 10) eliminated these artifacts completely.&lt;/p&gt;

&lt;p&gt;Computer vision for marine biology. Not a combination I expected to work this well. But the plankton do not care what the algorithm was designed for, and neither does the math.&lt;/p&gt;
</content>
 </entry>
 
 <entry>
   <title>Language Notes Repos</title>
   <link href="http://hankquinlan.github.io/blog/2026/03/09/notes-repos"/>
   <updated>2026-03-09T00:00:00+00:00</updated>
   <id>http://juleshenry.github.io//blog/2026/03/09/notes-repos</id>
   <content type="html">&lt;p&gt;It’s my personal passion to learn new languages, and it matters to me because it keeps me curious, humble, and connected to people I would otherwise never really meet. I like to keep my new words with me on the go. You can find them here for your perusing, and I’m curious how others do it too. Are Anki cards the most popular? Should I switch?&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/juleshenry/castellano_notes&quot;&gt;castellano_notes&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/juleshenry/french_notes&quot;&gt;french_notes&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/juleshenry/korean_notes&quot;&gt;korean_notes&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/juleshenry/portuguese_notes&quot;&gt;portuguese_notes&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’re working through a language and want to compare approaches, feel free to browse or fork. These are living scratchpads, not polished textbooks, but that’s part of the fun.&lt;/p&gt;
</content>
 </entry>
 
 <entry>
   <title>한글 낙서: Hangul Graffiti</title>
   <link href="http://hankquinlan.github.io/blog/2026/03/09/Hangul-Graffiti"/>
   <updated>2026-03-09T00:00:00+00:00</updated>
   <id>http://juleshenry.github.io//blog/2026/03/09/Hangul-Graffiti</id>
   <content type="html">&lt;p&gt;There is something disarming about a language that bows before it speaks.&lt;/p&gt;

&lt;p&gt;Korean does not merely encode information. It encodes relationships. Every verb ending is a social contract – a declaration of how you see the person standing in front of you. In English, “please sit down” works for your boss and your dog. In Korean, you’d better know the difference between 앉으세요 and 앉아, or you will insult one and confuse the other.&lt;/p&gt;

&lt;p&gt;I have been collecting notes on Korean for a few years now. What follows is a distillation of those notes, organized not as a textbook would but as a learner actually encounters the language: in gyms, in novels, in text messages, and in the strange space between what is said and what is meant.&lt;/p&gt;

&lt;h2 id=&quot;the-architecture-of-hangul&quot;&gt;The Architecture of Hangul&lt;/h2&gt;

&lt;p&gt;King Sejong the Great (세종대왕) invented Hangul in 1443, and the story is almost too good to be true. The consonant shapes are modeled after the physical position of the tongue and mouth when you pronounce them:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;ㄱ (g/k)&lt;/strong&gt;: the back of the tongue rising toward the soft palate&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;ㄴ (n)&lt;/strong&gt;: the tongue touching the upper gum ridge&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;ㅁ (m)&lt;/strong&gt;: the shape of closed lips&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;ㅅ (s)&lt;/strong&gt;: the shape of a tooth&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;ㅇ (ng/silent)&lt;/strong&gt;: the shape of the throat&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Vowels are built from three elements: a dot (representing the sun/heaven), a horizontal line (the earth), and a vertical line (a person standing). From these three primitives, the entire vowel system unfolds:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Vowel&lt;/th&gt;
      &lt;th&gt;Sound&lt;/th&gt;
      &lt;th&gt;Construction&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;ㅏ&lt;/td&gt;
      &lt;td&gt;a&lt;/td&gt;
      &lt;td&gt;vertical + right dot (bright)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;ㅓ&lt;/td&gt;
      &lt;td&gt;eo&lt;/td&gt;
      &lt;td&gt;vertical + left dot (dark)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;ㅗ&lt;/td&gt;
      &lt;td&gt;o&lt;/td&gt;
      &lt;td&gt;horizontal + top dot (bright)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;ㅜ&lt;/td&gt;
      &lt;td&gt;u&lt;/td&gt;
      &lt;td&gt;horizontal + bottom dot (dark)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;ㅡ&lt;/td&gt;
      &lt;td&gt;eu&lt;/td&gt;
      &lt;td&gt;horizontal line alone&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;ㅣ&lt;/td&gt;
      &lt;td&gt;i&lt;/td&gt;
      &lt;td&gt;vertical line alone&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;This is a writing system designed by committee – but the good kind, the kind where the committee included phonologists. The result is that Korean is arguably the most rationally designed script in active use anywhere on Earth.&lt;/p&gt;

&lt;h2 id=&quot;pronunciation-the-rules-they-dont-teach-first&quot;&gt;Pronunciation: The Rules They Don’t Teach First&lt;/h2&gt;

&lt;p&gt;Two rules I picked up early that cleared up a lot of confusion:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. ㅅ before ㅣ or ㅑ/ㅕ/ㅛ/ㅠ becomes “sh”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The consonant ㅅ is normally an “s,” but place it before any “i” or “y” vowel and it palatalizes to “sh.” This is why 시 sounds like “shi” and 신문 (newspaper) is “shin-mun,” not “sin-mun.” The word 시작 (beginning) is “shi-jak,” and 식당 (restaurant) is “shik-dang.”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The silent 받침&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When ㄹ sits in the 받침 (bottom consonant position), it behaves differently than you’d expect. Korean syllable blocks stack consonants and vowels into squares, and the bottom slot – the 받침 – follows its own rules of liaison and assimilation. The consonant at the bottom of one syllable bleeds into the top of the next, creating pronunciation chains that make spoken Korean sound nothing like its spelling suggests.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;독립 (independence) is pronounced “dong-nip” not “dok-lip”&lt;/li&gt;
  &lt;li&gt;한국어 (Korean language) is pronounced “han-gu-geo” – the ㄱ 받침 links to the next syllable’s vowel&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;grammar-hierarchy-in-the-verb&quot;&gt;Grammar: Hierarchy in the Verb&lt;/h2&gt;

&lt;p&gt;Korean has seven speech levels, though modern usage mostly collapses these into four. The critical insight is that the verb ending changes based on your relationship to the listener – not the subject, the &lt;em&gt;listener&lt;/em&gt;:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Level&lt;/th&gt;
      &lt;th&gt;Ending&lt;/th&gt;
      &lt;th&gt;When to Use&lt;/th&gt;
      &lt;th&gt;Example (to go)&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Formal polite (합쇼체)&lt;/td&gt;
      &lt;td&gt;-ㅂ니다 / -습니다&lt;/td&gt;
      &lt;td&gt;Business, news, strangers&lt;/td&gt;
      &lt;td&gt;갑니다&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Informal polite (해요체)&lt;/td&gt;
      &lt;td&gt;-아요 / -어요&lt;/td&gt;
      &lt;td&gt;Default safe choice&lt;/td&gt;
      &lt;td&gt;가요&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Casual (해체)&lt;/td&gt;
      &lt;td&gt;-아 / -어&lt;/td&gt;
      &lt;td&gt;Close friends, younger people&lt;/td&gt;
      &lt;td&gt;가&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Formal plain (해라체)&lt;/td&gt;
      &lt;td&gt;-ㄴ다 / -는다&lt;/td&gt;
      &lt;td&gt;Writing, narration, diaries&lt;/td&gt;
      &lt;td&gt;간다&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The same verb 가다 (to go) is four different social acts depending on the ending. Get it wrong and you’ve made a statement about the relationship, not about where you’re going.&lt;/p&gt;

&lt;h3 id=&quot;conjugation-in-practice&quot;&gt;Conjugation in Practice&lt;/h3&gt;

&lt;p&gt;Unlike European languages with their tables of person and number, Korean verbs don’t conjugate for &lt;em&gt;who&lt;/em&gt; is doing the action. They conjugate for &lt;em&gt;how you feel about the person you’re talking to&lt;/em&gt;. The subject is often dropped entirely. Context carries it.&lt;/p&gt;

&lt;p&gt;Here is 먹다 (to eat) across several constructions:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Form&lt;/th&gt;
      &lt;th&gt;Korean&lt;/th&gt;
      &lt;th&gt;Literal&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Formal polite&lt;/td&gt;
      &lt;td&gt;먹습니다&lt;/td&gt;
      &lt;td&gt;(one) eats [sir/ma’am]&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Informal polite&lt;/td&gt;
      &lt;td&gt;먹어요&lt;/td&gt;
      &lt;td&gt;(one) eats [politely]&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Casual&lt;/td&gt;
      &lt;td&gt;먹어&lt;/td&gt;
      &lt;td&gt;eat / eats&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Past (polite)&lt;/td&gt;
      &lt;td&gt;먹었어요&lt;/td&gt;
      &lt;td&gt;ate&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Future (polite)&lt;/td&gt;
      &lt;td&gt;먹을 거예요&lt;/td&gt;
      &lt;td&gt;will eat&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Negative&lt;/td&gt;
      &lt;td&gt;안 먹어요&lt;/td&gt;
      &lt;td&gt;doesn’t eat&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Want to&lt;/td&gt;
      &lt;td&gt;먹고 싶어요&lt;/td&gt;
      &lt;td&gt;wants to eat&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Can&lt;/td&gt;
      &lt;td&gt;먹을 수 있어요&lt;/td&gt;
      &lt;td&gt;can eat&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Progressive&lt;/td&gt;
      &lt;td&gt;먹고 있어요&lt;/td&gt;
      &lt;td&gt;is eating&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Notice how the stem 먹 stays constant while the endings do all the work. This is the agglutinative nature of Korean: you stack suffixes like LEGO bricks.&lt;/p&gt;

&lt;h2 id=&quot;the-particles-small-words-heavy-lifting&quot;&gt;The Particles: Small Words, Heavy Lifting&lt;/h2&gt;

&lt;p&gt;Korean particles are postpositions – they attach &lt;em&gt;after&lt;/em&gt; the noun, not before it. And they do everything.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Particle&lt;/th&gt;
      &lt;th&gt;Function&lt;/th&gt;
      &lt;th&gt;Example&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;은/는&lt;/td&gt;
      &lt;td&gt;Topic marker&lt;/td&gt;
      &lt;td&gt;저&lt;strong&gt;는&lt;/strong&gt; 학생이에요 (As for me, I’m a student)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;이/가&lt;/td&gt;
      &lt;td&gt;Subject marker&lt;/td&gt;
      &lt;td&gt;비&lt;strong&gt;가&lt;/strong&gt; 와요 (Rain is coming)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;을/를&lt;/td&gt;
      &lt;td&gt;Object marker&lt;/td&gt;
      &lt;td&gt;커피&lt;strong&gt;를&lt;/strong&gt; 마셔요 (I drink coffee)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;에&lt;/td&gt;
      &lt;td&gt;Location / time&lt;/td&gt;
      &lt;td&gt;학교&lt;strong&gt;에&lt;/strong&gt; 가요 (I go to school)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;에서&lt;/td&gt;
      &lt;td&gt;Location of action&lt;/td&gt;
      &lt;td&gt;집&lt;strong&gt;에서&lt;/strong&gt; 공부해요 (I study at home)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;의&lt;/td&gt;
      &lt;td&gt;Possession&lt;/td&gt;
      &lt;td&gt;나&lt;strong&gt;의&lt;/strong&gt; 책 (my book)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;도&lt;/td&gt;
      &lt;td&gt;Also/too&lt;/td&gt;
      &lt;td&gt;저&lt;strong&gt;도&lt;/strong&gt; 가요 (I’m going too)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;한테/에게&lt;/td&gt;
      &lt;td&gt;To (a person)&lt;/td&gt;
      &lt;td&gt;친구&lt;strong&gt;한테&lt;/strong&gt; 줘요 (I give it to a friend)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The distinction between 은/는 (topic) and 이/가 (subject) is one of the deepest rabbit holes in Korean linguistics. Roughly: 은/는 sets the frame (“as for X…”), while 이/가 identifies (“it is X that…”). The sentence 제가 학생이에요 emphasizes that &lt;em&gt;I&lt;/em&gt; am the student (maybe someone asked “who’s the student?”), while 저는 학생이에요 simply states the fact about me.&lt;/p&gt;

&lt;h2 id=&quot;at-the-gym-헬스장에서&quot;&gt;At the Gym: 헬스장에서&lt;/h2&gt;

&lt;p&gt;Some of the most useful Korean I’ve picked up comes from the gym. The phrasebook doesn’t prepare you for wanting to ask someone if they’re done with the squat rack:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Korean&lt;/th&gt;
      &lt;th&gt;English&lt;/th&gt;
      &lt;th&gt;Context&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;몇 세트 남았어요?&lt;/td&gt;
      &lt;td&gt;How many sets do you have left?&lt;/td&gt;
      &lt;td&gt;Politely waiting for equipment&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;좀 보조 해주실 수 있나요?&lt;/td&gt;
      &lt;td&gt;Can you spot me?&lt;/td&gt;
      &lt;td&gt;Asking for help on bench press&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;아직 쓰시고 있나요?&lt;/td&gt;
      &lt;td&gt;Are you still using this?&lt;/td&gt;
      &lt;td&gt;Gesturing at a machine&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;하루에 몇 끼 먹나요?&lt;/td&gt;
      &lt;td&gt;How many meals do you eat a day?&lt;/td&gt;
      &lt;td&gt;Gym small talk&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;And the vocabulary that comes with the culture:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;헬창&lt;/strong&gt; – literally an abbreviation meaning something like “health fiend.” Used as a playful, almost affectionate compliment among gym-goers, though it sounds crude to outsiders. Think “gym rat” but with more edge.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;빵빵하다&lt;/strong&gt; – describes big, pumped muscles. 근육이 빵빵! (“Muscles are poppin’!”)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;오운완&lt;/strong&gt; (abbreviation of 오늘도 운동 완료했다) – “Finished my workout for today.” The hashtag of Korean fitness Instagram. Sometimes abbreviated further to just ㅇㅇㅇ, because even abbreviations get abbreviated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The counter system reveals itself here too: &lt;strong&gt;끼&lt;/strong&gt; is the counter for meals (하루에 몇 끼?), &lt;strong&gt;세트&lt;/strong&gt; borrows the English “set,” and &lt;strong&gt;체지방&lt;/strong&gt; (body fat) and &lt;strong&gt;체중&lt;/strong&gt; (body weight) share the hanja character 체 (body, 體).&lt;/p&gt;

&lt;h2 id=&quot;reading-korean-literature-선화-by-김이&quot;&gt;Reading Korean Literature: 선화 by 김이&lt;/h2&gt;

&lt;p&gt;The jump from textbook Korean to literary Korean is a canyon. I tried reading 선화 by 김이, published by 은행나무, and the opening pages alone were a vocabulary tsunami. But the prose was beautiful:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;나는 타인의 흉터를 빤히 쳐다보는 버릇이 있었다.&lt;/p&gt;

  &lt;p&gt;“I had a habit of staring intently at other people’s scars.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;blockquote&gt;
  &lt;p&gt;누구든 상처가 있다. 상처에서 흐르던 피가 굳고 딱지가 내려앉고, 딱지가 떨어진 자리에 솟은 새살이 바로 상처를 반추하게 하는 흉터였다.&lt;/p&gt;

  &lt;p&gt;“Everyone carries wounds. The blood that flowed from wounds dries, scabs settle, and the new flesh that rises where scabs have fallen – that is the scar that makes you ruminate on the wound.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;blockquote&gt;
  &lt;p&gt;세상에 나만 흉터가 있는 게 아니었으니까.&lt;/p&gt;

  &lt;p&gt;“Because I wasn’t the only one in the world with scars.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The vocabulary of wounds and healing in Korean is evocative:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Korean&lt;/th&gt;
      &lt;th&gt;English&lt;/th&gt;
      &lt;th&gt;Notes&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;흉터&lt;/td&gt;
      &lt;td&gt;scar&lt;/td&gt;
      &lt;td&gt;흉 (ugly) + 터 (site) – the site of ugliness&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;상처&lt;/td&gt;
      &lt;td&gt;wound&lt;/td&gt;
      &lt;td&gt;from Hanja 傷處 – place of injury&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;딱지&lt;/td&gt;
      &lt;td&gt;scab&lt;/td&gt;
      &lt;td&gt;also means “ticket” or “tag” – the body’s parking ticket&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;새살&lt;/td&gt;
      &lt;td&gt;new flesh&lt;/td&gt;
      &lt;td&gt;새 (new) + 살 (flesh) – beautifully literal&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;반추하다&lt;/td&gt;
      &lt;td&gt;to ruminate&lt;/td&gt;
      &lt;td&gt;from 反芻 – what cows do, applied to thought&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;아물다&lt;/td&gt;
      &lt;td&gt;to heal (a wound)&lt;/td&gt;
      &lt;td&gt;no hanja, pure Korean&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;빤히 쳐다보다&lt;/td&gt;
      &lt;td&gt;to stare intently&lt;/td&gt;
      &lt;td&gt;빤히 (fixedly) + 쳐다보다 (to gaze up at)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;수고하세요-the-farewell-that-means-work-hard&quot;&gt;수고하세요: The Farewell That Means “Work Hard”&lt;/h2&gt;

&lt;p&gt;I wrote about this in &lt;a href=&quot;/blog/2025/06/21/Shtetl-Length&quot;&gt;Shtetl Length&lt;/a&gt;, but it deserves elaboration here.&lt;/p&gt;

&lt;p&gt;수고하세요 is a casual farewell rooted in Korean work culture. It literally means something like “please labor/exert yourself,” but in practice it functions as “good work, see you later” or “keep it up.” The upper politeness register manifests as 수고하셨어요 or 수고하셨습니다, used when:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;A colleague has finished a full day of work: “Great job today + goodbye”&lt;/li&gt;
  &lt;li&gt;Someone (like a cashier) has done effort on your behalf: “Thank you for your effort”&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;As the customer, you could say 수고하셨습니다 as a gesture of gratitude – acknowledging the work the other person has done.&lt;/p&gt;

&lt;p&gt;The grammar is revealing:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;수고&lt;/strong&gt; (苦勞): labor, exertion, toil&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;하세요&lt;/strong&gt;: polite imperative of 하다 (to do) – “please do”&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;하셨어요&lt;/strong&gt;: past tense honorific – “you did (honorably)”&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;하셨습니다&lt;/strong&gt;: past tense formal honorific – the most deferential form&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the farewell literally commands someone to work hard, and the thank-you literally praises them for having worked hard. Working hard is as common a social invocation in Korean culture as invoking God in Latin cultures, or saying “take care” in English. Except 수고하세요 is more specific – it does not wish you wellness, it wishes you productive suffering.&lt;/p&gt;

&lt;h2 id=&quot;the-korean-keyboard-두벌식&quot;&gt;The Korean Keyboard: 두벌식&lt;/h2&gt;

&lt;p&gt;Learning to type in Korean is its own adventure. The standard Korean keyboard layout (두벌식, “two-set”) splits consonants to the left hand and vowels to the right:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;ㅂ ㅈ ㄷ ㄱ ㅅ  ㅛ ㅕ ㅑ ㅐ ㅔ
 ㅁ ㄴ ㅇ ㄹ ㅎ  ㅗ ㅓ ㅏ ㅣ
  ㅋ ㅌ ㅊ ㅍ   ㅠ ㅜ ㅡ
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Hold Shift for tense consonants (ㅃ ㅉ ㄸ ㄲ ㅆ) and compound vowels (ㅒ ㅖ). The layout is phonetically organized: consonants on the left, vowels on the right. Your hands alternate with almost every keystroke, which makes Korean typing surprisingly rhythmic once you internalize it.&lt;/p&gt;

&lt;p&gt;My early keyboard practice files are pure gibberish – mashing keys to build muscle memory. The word 산화 (oxidation) sits at the bottom of one such file, a lone recognizable word in a sea of random jamo. Progress, I suppose, is measured in the ratio of intelligible words to noise.&lt;/p&gt;

&lt;h2 id=&quot;what-korean-teaches-you-about-language&quot;&gt;What Korean Teaches You About Language&lt;/h2&gt;

&lt;p&gt;Every language you learn restructures how you think. Spanish taught me that objects can have gender. Portuguese taught me that the subjunctive is not optional. French taught me that spelling and pronunciation exist in separate universes. But Korean taught me something more fundamental: that grammar can encode social relationships, that the verb is not just an action but a posture.&lt;/p&gt;

&lt;p&gt;The language forces you to decide, before you open your mouth, who you are in relation to the person you’re speaking to. There is no neutral register. Every sentence is a tiny act of social positioning. And once you internalize this, you start noticing how English accomplishes the same thing through different mechanisms – tone, word choice, the presence or absence of “please” – all the implicit hierarchy that Korean makes explicit.&lt;/p&gt;

&lt;p&gt;한국어를 배우는 것은 끝이 없는 여행입니다. 하지만, 그 여행이 제일 재미있는 부분이에요.&lt;/p&gt;

&lt;p&gt;Learning Korean is a journey without end. But the journey is the best part.&lt;/p&gt;
</content>
 </entry>
 
 <entry>
   <title>Ráfaga: Log-Mean Reversion of VIX</title>
   <link href="http://hankquinlan.github.io/blog/2026/03/09/rafaga"/>
   <updated>2026-03-09T00:00:00+00:00</updated>
   <id>http://juleshenry.github.io//blog/2026/03/09/rafaga</id>
   <content type="html">&lt;p&gt;&lt;a href=&quot;https://github.com/juleshenry/rafaga&quot;&gt;View on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ráfaga – Spanish for “gust” – began as a gust of hubris, a conviction that I could model the CBOE Volatility Index with a handful of characteristic functions, some complex integration, and a prayer. The source material: Qunfang Bao’s 2013 PhD thesis, &lt;em&gt;Mean-Reverting Logarithmic Modeling of VIX&lt;/em&gt;, a paper I found sitting in the MPRA archives like a loaded weapon waiting for someone dumb enough to pick it up.&lt;/p&gt;

&lt;p&gt;I picked it up.&lt;/p&gt;

&lt;p&gt;The idea is seductive in its clarity. The logarithm of the VIX follows an Ornstein-Uhlenbeck process. Mean-reverting. Elegant. You bolt on Poisson jumps with exponential size distributions to capture the fat tails, and suddenly you have a model that prices VIX futures and options with ~97% accuracy. The theory is beautiful. The implementation is where Python tried to kill me.&lt;/p&gt;

&lt;p&gt;But before we get to the carnage, let me build the math from scratch. If you survived a semester of calculus and remember what an integral is, you can follow this. I will take you from a rubber band to a jump-diffusion characteristic function, one layer at a time.&lt;/p&gt;

&lt;h2 id=&quot;part-i-the-rubber-band--what-mean-reversion-means&quot;&gt;Part I: The Rubber Band – What Mean Reversion Means&lt;/h2&gt;

&lt;h3 id=&quot;ordinary-differential-equations-and-springs&quot;&gt;Ordinary Differential Equations and Springs&lt;/h3&gt;

&lt;p&gt;Imagine you have a number $Y$ that represents, say, the log of a stock’s volatility. Right now $Y$ is at some value, and it has a “home” it wants to return to, which we call $\theta$. The simplest model of this homing behavior is an ordinary differential equation:&lt;/p&gt;

\[\frac{dY}{dt} = \kappa(\theta - Y)\]

&lt;p&gt;This says: the rate of change of $Y$ is proportional to how far $Y$ is from home. If $Y$ is above $\theta$, the quantity $(\theta - Y)$ is negative, so $Y$ decreases. If $Y$ is below $\theta$, the quantity is positive, so $Y$ increases. The constant $\kappa &amp;gt; 0$ controls the speed. Big $\kappa$, fast return. Small $\kappa$, slow drift.&lt;/p&gt;

&lt;p&gt;You solved this in your first ODE course. Separate variables:&lt;/p&gt;

\[\frac{dY}{\theta - Y} = \kappa\,dt\]

&lt;p&gt;Integrate both sides:&lt;/p&gt;

\[-\ln\lvert\theta - Y\rvert = \kappa t + C\]

&lt;p&gt;Exponentiate, apply the initial condition $Y(0) = Y_0$, and you get:&lt;/p&gt;

\[Y(t) = \theta + (Y_0 - \theta)\,e^{-\kappa t}\]

&lt;p&gt;This is exponential decay toward $\theta$. A rubber band. A spring. Nothing stochastic yet, just a deterministic pull toward home. The “half-life” of a displacement from $\theta$ is $t_{1/2} = \frac{\ln 2}{\kappa}$. If $\kappa = 5$ (annualized), the half-life is about 50 trading days. If the VIX spikes from 15 to 40, you expect it to be halfway back to its long-term mean in roughly two months. This is not a guess. This is what 20 years of daily VIX data confirms when you fit an AR(1) regression to the log-VIX time series.&lt;/p&gt;

&lt;h3 id=&quot;adding-noise-the-stochastic-differential-equation&quot;&gt;Adding Noise: The Stochastic Differential Equation&lt;/h3&gt;

&lt;p&gt;The deterministic model is too clean. The VIX does not slide back to $\theta$ along a smooth exponential curve. It wobbles. It jitters. It sometimes mean-reverts in a day and sometimes takes six months. We need randomness.&lt;/p&gt;

&lt;p&gt;In calculus you learned about the Riemann integral. In stochastic calculus, there is an analogous object called a &lt;em&gt;Wiener process&lt;/em&gt; (or Brownian motion), denoted $W_t$. The key properties:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;$W_0 = 0$&lt;/li&gt;
  &lt;li&gt;Increments $W_{t+\Delta t} - W_t$ are normally distributed with mean 0 and variance $\Delta t$&lt;/li&gt;
  &lt;li&gt;Non-overlapping increments are independent&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Think of $W_t$ as the cumulative sum of infinitely many infinitesimal coin flips. At any finite time $t$, $W_t \sim \mathcal{N}(0, t)$. The paths are continuous but nowhere differentiable – a fractal jitter.&lt;/p&gt;

&lt;p&gt;We bolt this onto our rubber band:&lt;/p&gt;

\[dY_t = \kappa(\theta - Y_t)\,dt + \sigma\,dW_t\]

&lt;p&gt;This is the &lt;strong&gt;Ornstein-Uhlenbeck (OU) process&lt;/strong&gt;, and it is the foundation of everything that follows. The first term is the deterministic pull toward $\theta$. The second term is random noise scaled by $\sigma$, the “volatility of volatility” (or vol-of-vol). Every instant, the process gets tugged toward home and simultaneously kicked by a random perturbation.&lt;/p&gt;

&lt;p&gt;What does the solution look like? Apply the integrating factor $e^{\kappa t}$ (just like you would for a first-order linear ODE) and integrate:&lt;/p&gt;

\[Y_t = \theta + (Y_0 - \theta)e^{-\kappa t} + \sigma\int_0^t e^{-\kappa(t-s)}\,dW_s\]

&lt;p&gt;The first two terms are the deterministic solution you already know. The third term is a stochastic integral – a weighted sum of all the random shocks from time 0 to $t$, where recent shocks (small $t - s$) are weighted heavily and old shocks (large $t - s$) are exponentially forgotten. This is the mean-reverting memory of the process.&lt;/p&gt;

&lt;p&gt;Since the stochastic integral is a sum of normals (with deterministic weights), $Y_t$ is itself normally distributed:&lt;/p&gt;

\[Y_t \sim \mathcal{N}\left(\theta + (Y_0 - \theta)e^{-\kappa t},\;\frac{\sigma^2}{2\kappa}(1 - e^{-2\kappa t})\right)\]

&lt;p&gt;The mean decays exponentially toward $\theta$. The variance saturates at $\frac{\sigma^2}{2\kappa}$ as $t \to \infty$. This is the stationary distribution. No matter where the process starts, it eventually forgets its initial condition and fluctuates around $\theta$ with a fixed spread determined by the ratio $\sigma^2 / \kappa$. High noise and low reversion means wide fluctuations. High reversion and low noise means the process hugs $\theta$ tightly.&lt;/p&gt;

&lt;p&gt;This is the &lt;strong&gt;MRLR model&lt;/strong&gt;: Mean-Reverting Logarithmic. We set $Y_t = \ln(\text{VIX}_t)$, so the VIX itself is log-normally distributed around $e^\theta$. The model has three parameters: $\kappa$ (reversion speed), $\theta$ (long-term log-VIX level), and $\sigma$ (vol-of-vol). Estimate them from data, and you can price VIX futures and options.&lt;/p&gt;

&lt;h3 id=&quot;but-why-mean-reverting-why-not-a-random-walk&quot;&gt;But Why Mean-Reverting? Why Not a Random Walk?&lt;/h3&gt;

&lt;p&gt;This is worth pausing on, because the choice of mean reversion is not cosmetic. It is the single most consequential modeling decision in the entire project, and if you get it wrong, everything downstream is garbage.&lt;/p&gt;

&lt;p&gt;Stock prices are typically modeled as geometric Brownian motion – a random walk with drift. The idea is that Apple’s stock price today does not “want” to return to any particular level. There is no gravitational home. If AAPL is at 200, it is just as likely to wander to 250 as to drift back to 150. The efficient market hypothesis says the current price already reflects all information, so there is no force pulling it anywhere. You model it as a random walk and that is a reasonable first approximation.&lt;/p&gt;

&lt;p&gt;The VIX is not a stock price. The VIX is a &lt;em&gt;measure of fear&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Fear has a baseline. Humans cannot sustain panic indefinitely. Markets cannot sustain 80-vol indefinitely. When the VIX is at 40, traders are terrified, hedging is expensive, and the entire options market is priced for Armageddon. But Armageddon, as a rule, does not last. The panic subsides. The hedges get unwound. Implied volatility collapses back toward its long-term average. Conversely, when the VIX is at 10, complacency reigns. Everyone is short vol. Everyone is selling puts. And then some exogenous shock – a pandemic, a war, a bank failure, a leveraged fund blowing up – arrives, and the VIX spikes back up. The floor is soft but real.&lt;/p&gt;

&lt;p&gt;This is mean reversion. The VIX has a home. It wanders away from home – sometimes violently – but it always comes back. Twenty-two years of daily data confirm this: fit an AR(1) regression to log-VIX, and the coefficient $\beta$ is significantly less than 1. If $\beta = 1$, you have a random walk. If $\beta &amp;lt; 1$, you have mean reversion. For the VIX, $\beta \approx 0.98$ at a daily frequency, which implies an annualized $\kappa$ of roughly 5. The VIX is not a random walk. It is a rubber band anchored to a long-term mean of about $e^{2.8} \approx 16$.&lt;/p&gt;

&lt;p&gt;If you modeled the VIX as a random walk, your futures prices would be wrong (they would not converge to a long-term level), your option prices would be wrong (you would misprice the term structure), and your hedging ratios would be wrong (you would ignore the gravitational pull). Mean reversion is not an assumption. It is a fact about what the VIX &lt;em&gt;is&lt;/em&gt; – a fear gauge with a home address.&lt;/p&gt;

&lt;p&gt;The Ornstein-Uhlenbeck process is the simplest continuous-time model that captures this fact. It is exactly a random walk plus gravity. That is why Bao chose it, and that is why it works.&lt;/p&gt;

&lt;h2 id=&quot;part-ii-pricing--from-process-to-product&quot;&gt;Part II: Pricing – From Process to Product&lt;/h2&gt;

&lt;h3 id=&quot;vix-futures&quot;&gt;VIX Futures&lt;/h3&gt;

&lt;p&gt;A VIX futures contract is an agreement to exchange cash based on the VIX at some future date $T$. Under risk-neutral pricing, the futures price $F(t, T)$ is the expected value of $\text{VIX}_T$ given today’s information:&lt;/p&gt;

\[F(t, T) = \mathbb{E}^{\mathbb{Q}}[\text{VIX}_T \,\vert\, \mathcal{F}_t]\]

&lt;p&gt;Since $Y_T = \ln(\text{VIX}_T)$ is normal under MRLR, $\text{VIX}_T = e^{Y_T}$ is log-normal, and the expected value of a log-normal random variable $e^X$ where $X \sim \mathcal{N}(\mu, \sigma^2)$ is $e^{\mu + \sigma^2/2}$. Plug in our expressions for the mean and variance of $Y_T$:&lt;/p&gt;

\[F(t, T) = \exp\!\left(e^{-\kappa\tau}\ln(\text{VIX}_t) + \theta(1 - e^{-\kappa\tau}) + \frac{\sigma^2}{4\kappa}(1 - e^{-2\kappa\tau})\right)\]

&lt;p&gt;where $\tau = T - t$. Written more compactly:&lt;/p&gt;

\[F(t, T) = \text{VIX}_t^{\,\phi} \cdot M\]

&lt;p&gt;where $\phi = e^{-\kappa\tau}$ is the mean-reversion factor and $M$ absorbs the rest. As $\tau \to \infty$, $\phi \to 0$, and the futures price converges to a constant determined solely by $\theta$ and $\sigma/\kappa$. The current VIX level becomes irrelevant for long-dated futures. This is the term structure of VIX futures, and it is why VIX futures are almost always in &lt;em&gt;contango&lt;/em&gt; (upward-sloping) – the long-term expected VIX sits above the current spot when VIX is low.&lt;/p&gt;

&lt;h3 id=&quot;vix-options-black-scholes-variant&quot;&gt;VIX Options (Black-Scholes Variant)&lt;/h3&gt;

&lt;p&gt;A European call option on the VIX with strike $K$ and expiry $T$ pays $\max(\text{VIX}_T - K, 0)$ at expiry. Under risk-neutral pricing:&lt;/p&gt;

\[C(t, T, K) = e^{-r\tau}\,\mathbb{E}^{\mathbb{Q}}[\max(\text{VIX}_T - K, 0)]\]

&lt;p&gt;Since $\text{VIX}_T$ is log-normal under MRLR, this expectation has a closed-form solution identical in structure to the Black-Scholes formula. Define:&lt;/p&gt;

\[\sigma_{\text{eff}}^2 = \frac{\sigma^2}{2\kappa}(1 - e^{-2\kappa\tau})\]

&lt;p&gt;This is the total variance of $\ln(\text{VIX}_T)$ over the interval $[t, T]$. Then:&lt;/p&gt;

\[d_1 = \frac{\ln(F/K) + \frac{1}{2}\sigma_{\text{eff}}^2}{\sigma_{\text{eff}}}, \qquad d_2 = d_1 - \sigma_{\text{eff}}\]

\[C(t, T, K) = e^{-r\tau}\left(F\,\Phi(d_1) - K\,\Phi(d_2)\right)\]

&lt;p&gt;where $\Phi$ is the standard normal CDF. This is it. The MRLR option price. It looks like Black-Scholes because it &lt;em&gt;is&lt;/em&gt; Black-Scholes – just with a mean-reverting forward and an effective volatility that accounts for the OU dynamics instead of geometric Brownian motion.&lt;/p&gt;

&lt;p&gt;The problem: this works fine for at-the-money options but fails on the wings. The log-normal distribution from pure diffusion does not have fat enough tails to explain the prices of deep out-of-the-money VIX calls. The market prices those calls as though the VIX can suddenly spike 10 points in a day – because it can. The MRLR model, with its smooth Gaussian increments, says this is vanishingly unlikely. The market disagrees.&lt;/p&gt;

&lt;p&gt;We need jumps. Jump like Jordan – the VIX does not get to 80 by dribbling.&lt;/p&gt;

&lt;h2 id=&quot;part-iii-the-jumps--making-the-model-honest&quot;&gt;Part III: The Jumps – Making the Model Honest&lt;/h2&gt;

&lt;h3 id=&quot;what-is-a-jump-process&quot;&gt;What Is a Jump Process?&lt;/h3&gt;

&lt;p&gt;In the OU process, the path of $Y_t$ is continuous. It wiggles, but it never teleports. In reality, the VIX can leap from 15 to 30 overnight (February 5, 2018, anyone?). A continuous process cannot produce this behavior without an absurdly large $\sigma$, which would then overestimate volatility the rest of the time.&lt;/p&gt;

&lt;p&gt;Enter the &lt;strong&gt;Poisson process&lt;/strong&gt; $N_t$. This is a counting process: $N_t$ counts the number of “events” (jumps) that have occurred by time $t$. The jumps arrive randomly at rate $\lambda$ – on average, $\lambda$ jumps per unit time. The probability of exactly $k$ jumps in an interval of length $\Delta t$ is:&lt;/p&gt;

\[P(N_{t+\Delta t} - N_t = k) = \frac{(\lambda \Delta t)^k}{k!}\,e^{-\lambda \Delta t}\]

&lt;p&gt;For small $\Delta t$, there is at most one jump (probability $\approx \lambda \Delta t$) or no jump (probability $\approx 1 - \lambda \Delta t$). The Poisson process is the simplest model of rare, discrete events.&lt;/p&gt;

&lt;p&gt;Now, each time a jump arrives, the VIX does not just nudge – it leaps by a random amount $J$. In Bao’s model, the jump sizes are exponentially distributed with parameter $\eta &amp;gt; 0$:&lt;/p&gt;

\[J \sim \text{Exp}(\eta), \qquad f_J(j) = \eta\,e^{-\eta j}, \quad j \geq 0\]

&lt;p&gt;The mean jump size is $1/\eta$. The jumps are always positive – the VIX jumps &lt;em&gt;up&lt;/em&gt;. This is physically correct: volatility spikes are sudden upward moves driven by panic. Volatility does not “spike” downward; it &lt;em&gt;decays&lt;/em&gt; downward, which the mean-reversion term already handles. The asymmetry between jumps (sudden, upward) and decay (gradual, gravitational) is the fundamental dynamic of the VIX, and the MRLRJ model captures both.&lt;/p&gt;

&lt;h3 id=&quot;the-mrlrj-stochastic-differential-equation&quot;&gt;The MRLRJ Stochastic Differential Equation&lt;/h3&gt;

&lt;p&gt;Bolt the Poisson jumps onto the OU process:&lt;/p&gt;

\[dY_t = \kappa(\theta - Y_t)\,dt + \sigma\,dW_t + J_t\,dN_t\]

&lt;p&gt;Three terms:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;$\kappa(\theta - Y_t)\,dt$ – the deterministic pull toward home&lt;/li&gt;
  &lt;li&gt;$\sigma\,dW_t$ – continuous random noise&lt;/li&gt;
  &lt;li&gt;$J_t\,dN_t$ – discrete random jumps: at each Poisson event (on average $\lambda$ times per year), $Y_t$ increases by a random amount $J_t \sim \text{Exp}(\eta)$&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the &lt;strong&gt;MRLRJ model&lt;/strong&gt;: Mean-Reverting Logarithmic with Random Jumps. Five parameters now: $\kappa$, $\theta$, $\sigma$, $\lambda$ (jump frequency), and $\eta$ (inverse mean jump size).&lt;/p&gt;

&lt;h3 id=&quot;why-jumps-break-closed-form-pricing&quot;&gt;Why Jumps Break Closed-Form Pricing&lt;/h3&gt;

&lt;p&gt;Under MRLR, $Y_T$ was normally distributed, so $\text{VIX}_T$ was log-normal, and we used the Black-Scholes formula. Under MRLRJ, $Y_T$ is &lt;em&gt;not&lt;/em&gt; normally distributed. The jump component adds a mixture of shifted exponentials to the Gaussian base, creating a distribution with a heavier right tail – exactly the fat tail the market is pricing.&lt;/p&gt;

&lt;p&gt;There is no closed-form expression for the CDF of this distribution. You cannot write down $d_1$ and $d_2$ and call it a day. But there is another way in.&lt;/p&gt;

&lt;h3 id=&quot;the-characteristic-function&quot;&gt;The Characteristic Function&lt;/h3&gt;

&lt;p&gt;Here is where we need one idea from analysis that you may not have seen in a standard calculus sequence, but which is straightforward once stated.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;characteristic function&lt;/strong&gt; of a random variable $X$ is:&lt;/p&gt;

\[\psi_X(u) = \mathbb{E}[e^{iuX}] = \int_{-\infty}^{\infty} e^{iux}\,f_X(x)\,dx\]

&lt;p&gt;This is the Fourier transform of the probability density $f_X$. The function $\psi_X(u)$ encodes &lt;em&gt;all&lt;/em&gt; distributional information about $X$. If you know $\psi_X$, you can recover $f_X$ by inverse Fourier transform. You can compute any moment by differentiating $\psi_X$ at $u = 0$. And crucially, you can compute option prices.&lt;/p&gt;

&lt;p&gt;Why use characteristic functions instead of the density? Because for many processes – including jump-diffusions – the characteristic function has a &lt;em&gt;closed-form expression&lt;/em&gt; even when the density does not.&lt;/p&gt;

&lt;p&gt;For the MRLRJ model, after considerable algebra (Bao’s paper works through it in detail), the characteristic function of $Y_T = \ln(\text{VIX}_T)$ given today’s state is:&lt;/p&gt;

\[\psi(u) = \exp\!\Big(iu\phi\ln(\text{VIX}_t) + iu\theta(1-\phi) - \frac{u^2\sigma^2}{4\kappa}(1 - e^{-2\kappa\tau}) + \frac{\lambda}{\kappa}\ln\frac{\eta - iu\phi}{\eta - iu}\Big)\]

&lt;p&gt;where $\phi = e^{-\kappa\tau}$ as before. Let me walk through each term so you see where it comes from:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Term 1&lt;/strong&gt;: $iu\phi\ln(\text{VIX}_t)$. This is the initial condition, decayed by mean reversion. The factor $\phi = e^{-\kappa\tau}$ tells you how much the current VIX level matters at expiry. For short-dated options ($\tau$ small), $\phi \approx 1$ and the current VIX dominates. For long-dated options, $\phi \to 0$ and the initial condition fades.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Term 2&lt;/strong&gt;: $iu\theta(1-\phi)$. This is the mean reversion target. As $\tau \to \infty$, $(1-\phi) \to 1$, and this term becomes $iu\theta$ – the characteristic function converges to one centered at $\theta$.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Term 3&lt;/strong&gt;: $-\frac{u^2\sigma^2}{4\kappa}(1 - e^{-2\kappa\tau})$. This is the diffusion variance. It is the $-u^2\sigma^2/2$ term you see in the characteristic function of any Gaussian, modified by the mean-reversion-adjusted total variance $\frac{\sigma^2}{2\kappa}(1 - e^{-2\kappa\tau})$.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Term 4&lt;/strong&gt;: $\frac{\lambda}{\kappa}\ln\frac{\eta - iu\phi}{\eta - iu}$. This is the jump contribution, and it is the term that makes everything interesting and difficult. It arises from integrating the Poisson-exponential jump kernel against the mean-reverting dynamics. The $\lambda/\kappa$ prefactor reflects the interplay between jump frequency and reversion speed: if $\kappa$ is large, the process reverts so fast that jumps are quickly absorbed, reducing their cumulative impact. The logarithm of the complex ratio introduces branch cuts in the complex plane, which is exactly why Python’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;float64&lt;/code&gt; arithmetic could not handle the numerical integration. More on that shortly.&lt;/p&gt;

&lt;p&gt;If you set $\lambda = 0$ (no jumps), term 4 vanishes and you recover the pure MRLR characteristic function, which is Gaussian. The jump term deforms the Gaussian into a heavier-tailed distribution.&lt;/p&gt;

&lt;h3 id=&quot;gil-pelaez-inversion-from-characteristic-function-to-option-price&quot;&gt;Gil-Pelaez Inversion: From Characteristic Function to Option Price&lt;/h3&gt;

&lt;p&gt;We have $\psi(u)$ in closed form. We need option prices. The bridge is the &lt;strong&gt;Gil-Pelaez inversion theorem&lt;/strong&gt; (1951), which expresses cumulative probabilities directly in terms of the characteristic function:&lt;/p&gt;

\[\Pi = \frac{1}{2} + \frac{1}{\pi}\int_0^{\infty} \frac{\text{Im}\!\left[\psi(u)\,e^{-iu\ln K}\right]}{u}\,du\]

&lt;p&gt;This integral recovers the probability that $\text{VIX}_T &amp;gt; K$ (or a risk-adjusted variant thereof). The option price is:&lt;/p&gt;

\[C(t, T, K) = e^{-r\tau}\left(F\,\Pi_1 - K\,\Pi_2\right)\]

&lt;p&gt;where $\Pi_1$ and $\Pi_2$ use slightly modified characteristic functions (one shifted to account for the asset-or-nothing payoff, one unshifted for the cash-or-nothing payoff). This is structurally identical to the Black-Scholes decomposition $C = e^{-r\tau}(F\,\Phi(d_1) - K\,\Phi(d_2))$, except $\Phi(d_1)$ and $\Phi(d_2)$ are replaced by $\Pi_1$ and $\Pi_2$ computed via numerical integration of the characteristic function.&lt;/p&gt;

&lt;p&gt;The integral converges because $\lvert\psi(u)\rvert \to 0$ as $u \to \infty$ (the variance term $-u^2\sigma^2/(\cdots)$ in the exponent dominates). In practice, we truncate the integration at some upper limit ($u = 100$ for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BigFloat&lt;/code&gt;, $u = 50$ for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Float64&lt;/code&gt;) and use adaptive Gauss-Kronrod quadrature (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;QuadGK&lt;/code&gt; in Julia).&lt;/p&gt;

&lt;p&gt;This is the entire pricing pipeline. Three layers of math: (1) the SDE defines the dynamics, (2) the characteristic function encodes the distribution in closed form, (3) Gil-Pelaez inversion recovers option prices via a single numerical integral. No Monte Carlo. No PDE solvers. One integral per option price.&lt;/p&gt;

&lt;h2 id=&quot;part-iv-the-python-disaster&quot;&gt;Part IV: The Python Disaster&lt;/h2&gt;

&lt;p&gt;The original codebase was Python. I wrote it in October 2022 under files named, with escalating confidence, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cool.py&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cooler.py&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;coolest.py&lt;/code&gt;. The naming convention should tell you everything about the trajectory of the project.&lt;/p&gt;

&lt;p&gt;The MRLR model worked fine. Black-Scholes with a mean-reverting forward. Python handles this. Python is happy.&lt;/p&gt;

&lt;p&gt;Then I introduced jumps.&lt;/p&gt;

&lt;p&gt;The characteristic function’s jump term – $\frac{\lambda}{\kappa}\ln\frac{\eta - iu\phi}{\eta - iu}$ – involves a logarithm of a ratio of complex numbers. In Python’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;complex128&lt;/code&gt; (which is just two &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;float64&lt;/code&gt; values glued together), this term is numerically treacherous. When the integration variable $u$ is large, the imaginary parts of $\eta - iu\phi$ and $\eta - iu$ dominate the real part $\eta$, and the logarithm of their ratio develops branch cut sensitivity. The exponential of the full characteristic function then involves $e^z$ where the real part of $z$ can be $-200$ or worse. Python’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;float64&lt;/code&gt; maps this to exactly zero – underflow – and the integrand becomes discontinuous in ways that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scipy.integrate.quad&lt;/code&gt; silently integrates through, occasionally returning a probability greater than 1.0.&lt;/p&gt;

&lt;p&gt;A probability. Greater than one. I stared at that output for an hour before I understood what was happening.&lt;/p&gt;

&lt;p&gt;I suppressed the warnings. I actually wrote this with my own hands:&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;warnings&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;simplefilter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;action&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;ignore&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;category&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;scipy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ComplexWarning&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;warnings&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;simplefilter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;action&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;ignore&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;category&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;integrate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;IntegrationWarning&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;If you are suppressing integration warnings in a project whose entire purpose is numerical integration, you have lost the plot. I had lost the plot.&lt;/p&gt;

&lt;p&gt;The calibration optimizer would converge to parameters that made no physical sense. Negative jump intensities. Mean reversion speeds of $10^{12}$. The Nelder-Mead simplex would wander into regions of parameter space where the characteristic function returned &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NaN&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scipy.optimize&lt;/code&gt; would shrug and keep going. I spent weeks debugging numerical issues that were not bugs in my code but fundamental limitations of double-precision arithmetic applied to equations that demand better.&lt;/p&gt;

&lt;p&gt;My God.&lt;/p&gt;

&lt;h2 id=&quot;part-v-julia&quot;&gt;Part V: Julia&lt;/h2&gt;

&lt;p&gt;Julia solved the problem in a weekend.&lt;/p&gt;

&lt;p&gt;Not because Julia is magic. Because Julia has &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BigFloat&lt;/code&gt; as a first-class citizen. You declare your struct fields as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BigFloat&lt;/code&gt;, and the entire computation – every intermediate exponential, every complex logarithm, every term in the characteristic function – propagates at arbitrary precision. No wrapper libraries. No &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mpmath&lt;/code&gt; imported from some dusty corner of PyPI. It is just… how the language works.&lt;/p&gt;

&lt;p&gt;The core module is 122 lines. Here is the characteristic function, the thing that Python could not compute without lying to me:&lt;/p&gt;

&lt;div class=&quot;language-julia highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;function&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt; characteristic_function&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;m&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MRLRJ&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;t&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;T&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;s&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;VIX_t&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;τ&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;BigFloat&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;T&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;t&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;ϕ&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;exp&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;m&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;κ&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;τ&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;s_bf&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Complex&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;BigFloat&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;}(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;s&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;)&lt;/span&gt;
    
    &lt;span class=&quot;n&quot;&gt;term1&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;im&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;s_bf&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ϕ&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;log&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;BigFloat&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;VIX_t&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;))&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;term2&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;im&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;s_bf&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;m&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;θ&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;x&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ϕ&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;term3&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;s_bf&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;^&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;m&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;σ&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;^&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;x&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;exp&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;m&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;κ&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;τ&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;))&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;4&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;m&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;κ&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;term4&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;x&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;m&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;λ&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;m&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;κ&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;log&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;m&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;η&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;im&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;s_bf&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ϕ&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;m&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;η&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;im&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;s_bf&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;))&lt;/span&gt;
    
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ComplexF64&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;exp&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;term1&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;term2&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;term3&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;term4&lt;/span&gt;&lt;span class=&quot;x&quot;&gt;))&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Every term computed in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BigFloat&lt;/code&gt;. The final result cast back to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ComplexF64&lt;/code&gt; for the integrator. No underflow. No overflow. No lies. The Gil-Pelaez inversion integrates cleanly with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;QuadGK&lt;/code&gt; at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rtol=1e-6&lt;/code&gt;, and the option prices come out correct.&lt;/p&gt;

&lt;p&gt;Julia’s type dispatch also meant I could define separate &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vix_option&lt;/code&gt; methods for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MRLR&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MRLRJ&lt;/code&gt; without any inheritance boilerplate. The pure diffusion model uses the analytic Black-Scholes formula. The jump model uses Fourier inversion. Same function name. The compiler figures it out. It is the kind of thing that makes you wonder why you spent years writing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;if isinstance(model, MRLRJ)&lt;/code&gt; branches in Python like some kind of animal.&lt;/p&gt;

&lt;p&gt;I also built a fast &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Float64&lt;/code&gt; variant for calibration loops where you are evaluating the objective function thousands of times and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BigFloat&lt;/code&gt; arithmetic is too slow. Same math, narrower integration range (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;50.0&lt;/code&gt; vs &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;100.0&lt;/code&gt;), looser tolerance (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rtol=1e-4&lt;/code&gt; vs &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1e-6&lt;/code&gt;). Use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Float64&lt;/code&gt; for parameter search, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BigFloat&lt;/code&gt; for final pricing. The two modules coexist. It works brilliantly.&lt;/p&gt;

&lt;h2 id=&quot;part-vi-calibration--fitting-the-model-to-reality&quot;&gt;Part VI: Calibration – Fitting the Model to Reality&lt;/h2&gt;

&lt;p&gt;A model with five free parameters is a toy until you show it can reproduce market prices. Calibration is the process of choosing $(\kappa, \theta, \sigma, \lambda, \eta)$ so that the model’s theoretical option prices match the observed market prices as closely as possible.&lt;/p&gt;

&lt;h3 id=&quot;time-series-calibration-the-mathbbp-measure&quot;&gt;Time-Series Calibration (the $\mathbb{P}$-measure)&lt;/h3&gt;

&lt;p&gt;The simplest approach: fit an AR(1) regression to daily log-VIX levels. If $y_t = \ln(\text{VIX}_t)$, then the discrete-time version of the OU process is:&lt;/p&gt;

\[y_{t+1} = \alpha + \beta\,y_t + \varepsilon_t\]

&lt;p&gt;where $\varepsilon_t \sim \mathcal{N}(0, \sigma_\varepsilon^2)$. The relationship to the continuous parameters is:&lt;/p&gt;

\[\beta = e^{-\kappa\,\Delta t}, \quad \alpha = \theta\,\kappa\,\Delta t, \quad \sigma = \frac{\sigma_\varepsilon}{\sqrt{\Delta t}}\]

&lt;p&gt;with $\Delta t = 1/252$ (one trading day). Run OLS on 20 years of daily VIX data (5,500+ observations), and you get the historical $\kappa$, $\theta$, $\sigma$ under the real-world $\mathbb{P}$-measure.&lt;/p&gt;

&lt;p&gt;For the jump parameters, I separate diffusion from jumps by identifying residuals that exceed 2.5 standard deviations from the AR(1) fit. These outliers are classified as jump days. The jump intensity $\lambda_{\mathbb{P}}$ is estimated as the fraction of jump days per year, and $\eta_{\mathbb{P}}$ is estimated from the mean size of the detected jumps. It is crude. It works. The filtered AR(1) (excluding jump days) gives cleaner estimates of the diffusion parameters.&lt;/p&gt;

&lt;h3 id=&quot;options-chain-calibration-the-mathbbq-measure&quot;&gt;Options-Chain Calibration (the $\mathbb{Q}$-measure)&lt;/h3&gt;

&lt;p&gt;The more powerful approach: fit the model to live option prices. Take a snapshot of the VIX options chain – say, April 16, 2021, with 33 days to May 19 expiry and spot VIX at 16.25. You have 20-30 strikes with observed last prices. Define the objective function:&lt;/p&gt;

\[\text{MAPE}(\kappa, \theta, \sigma, \lambda, \eta) = \frac{1}{N}\sum_{i=1}^N \frac{\lvert C_{\text{model}}(K_i) - C_{\text{market}}(K_i)\rvert}{C_{\text{market}}(K_i)}\]

&lt;p&gt;Minimize this using Nelder-Mead (a derivative-free simplex method, appropriate because the objective is non-smooth and the characteristic function integration makes autodiff impractical). Starting guess: $\kappa = 5$, $\theta = 2.8$, $\sigma = 1$, $\lambda = 2$, $\eta = 2$. The optimizer converges to physically meaningful parameters. The MAPE settles under 3%.&lt;/p&gt;

&lt;p&gt;The critical subtlety: the parameters recovered from options calibration are $\mathbb{Q}$-measure (risk-neutral) parameters, not $\mathbb{P}$-measure (historical) parameters. The two sets of parameters are different because investors are risk-averse. They pay a premium for protection, which inflates the implied jump intensity $\lambda_{\mathbb{Q}}$ relative to the historical $\lambda_{\mathbb{P}}$. This gap between $\mathbb{P}$ and $\mathbb{Q}$ is the volatility risk premium, and it is where the money lives. More on this in the trading section.&lt;/p&gt;

&lt;h2 id=&quot;part-vii-results&quot;&gt;Part VII: Results&lt;/h2&gt;

&lt;p&gt;The Julia implementation verifies and confirms Bao’s primary findings:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Jump impact&lt;/strong&gt;: The addition of the jump component significantly improves pricing accuracy for deep out-of-the-money options. Without jumps, the MRLR model systematically underprices OTM VIX calls because the log-normal distribution cannot generate the fat right tail that the market expects. With jumps, the MRLRJ model captures the skewness of the VIX options surface. The jump intensity $\lambda$ and mean jump size $1/\eta$ together control the weight of the right tail.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Mean reversion dominance&lt;/strong&gt;: The parameters $\kappa$ and $\theta$ remain stable and physically interpretable across different calibration windows. The implied long-term VIX level $e^\theta$ sits consistently in the 16-20 range, matching the historical unconditional mean. The reversion speed $\kappa$ sits in the 3-8 range, implying a half-life of 30-80 trading days. This is not a fitted artifact. It is the dominant dynamic of the VIX.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Characteristic function integrity&lt;/strong&gt;: The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BigFloat&lt;/code&gt; implementation solves the numerical instability that plagues standard-precision routines. The probabilities $\Pi_1$ and $\Pi_2$ integrate cleanly. No warnings suppressed. No probabilities greater than one.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Cross-temporal validation&lt;/strong&gt;: The model was calibrated against the April 16, 2021 VIX options chain (33 days to May 19 expiry, spot VIX at 16.25) and, independently, against live-scraped March 2026 data (spot VIX at 29.49, 40 strikes from 11 to 95). Both calibrations converge to plausible parameters. Both produce sub-3% MAPE. The model works in low-vol and high-vol regimes.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;From a 2013 paper. In 122 lines of Julia. After Python tried to gaslight me with probabilities greater than one.&lt;/p&gt;

&lt;h2 id=&quot;part-viii-how-to-actually-make-money-with-this&quot;&gt;Part VIII: How to Actually Make Money With This&lt;/h2&gt;

&lt;p&gt;Fitting the market to 97% accuracy does not give you a crystal ball. What it gives you is a highly precise mathematical map of how the market &lt;em&gt;currently&lt;/em&gt; values risk across different strikes and expirations. The gap between what the model implies and what the market does is where the edge lives. Let me be specific.&lt;/p&gt;

&lt;h3 id=&quot;strategy-1-harvesting-the-volatility-risk-premium-mathbbp-vs-mathbbq&quot;&gt;Strategy 1: Harvesting the Volatility Risk Premium ($\mathbb{P}$ vs. $\mathbb{Q}$)&lt;/h3&gt;

&lt;p&gt;There are two probability worlds. The real world ($\mathbb{P}$, estimated from historical data) and the risk-neutral world ($\mathbb{Q}$, implied by option prices). Investors are structurally terrified of VIX spikes – volatility is the one asset class where fear is a permanent fixture, because a VIX spike means your equity portfolio is hemorrhaging. So they overpay for VIX call options as insurance.&lt;/p&gt;

&lt;p&gt;This overpayment is measurable. Calibrate the MRLRJ to both worlds:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Historical&lt;/strong&gt; ($\mathbb{P}$): Fit AR(1) + jump detection to 20 years of daily log-VIX. Extract $\lambda_{\mathbb{P}}$ (how often jumps actually happen) and $\eta_{\mathbb{P}}$ (how large they actually are).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Implied&lt;/strong&gt; ($\mathbb{Q}$): Calibrate to the live options chain. Extract $\lambda_{\mathbb{Q}}$ and $\eta_{\mathbb{Q}}$.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If $\lambda_{\mathbb{Q}} \gg \lambda_{\mathbb{P}}$ – the options market implies jumps happen far more often than they historically do – the tail insurance is overpriced. This is the volatility risk premium. You sell OTM VIX calls, dynamically hedge your directional exposure using VIX futures, and collect the premium as the statistically expected jumps fail to materialize.&lt;/p&gt;

&lt;p&gt;The sizing is precise: the MRLRJ model tells you exactly how much premium each strike contains in excess of its actuarially fair value. You are not selling vol blindly. You are selling the specific strikes where the $\mathbb{P}$-$\mathbb{Q}$ gap is widest, weighted by the model’s confidence.&lt;/p&gt;

&lt;p&gt;Warning: when jumps &lt;em&gt;do&lt;/em&gt; materialize, they materialize violently. The VIX went from 13 to 37 overnight in February 2018 (the “Volmageddon” event). If you are short OTM VIX calls without proper tail hedging, you will be annihilated. The MRLRJ model gives you the tools to measure this tail risk – the jump intensity $\lambda$ and the mean jump size $1/\eta$ together determine the expected loss from a single jump event – but no model can save you from insufficient capital or reckless position sizing.&lt;/p&gt;

&lt;h3 id=&quot;strategy-2-relative-value-on-the-skew&quot;&gt;Strategy 2: Relative Value on the Skew&lt;/h3&gt;

&lt;p&gt;The MRLRJ model produces a smooth “fair value” curve across strikes for a given expiry. The residuals – strikes where the market price deviates from the model’s theoretical price – are where supply-demand dislocations create opportunity.&lt;/p&gt;

&lt;p&gt;Example: a large pension fund buys a massive block of VIX 35 calls to hedge its equity portfolio. This specific strike gets temporarily bid up. The model says the VIX 35 Call should be $0.60 but it is trading at $0.80. Meanwhile, the neighboring VIX 40 Call is perfectly priced at $0.48. You sell the expensive 35, buy the fair 40 – a bear call spread – and isolate the dislocation. You are not betting on the direction of the VIX. You are betting that one specific strike will revert to the smooth mathematical curve that the rest of the chain already sits on.&lt;/p&gt;

&lt;p&gt;This requires a model accurate enough to distinguish genuine mispricing from model error. At 97% accuracy and sub-3% MAPE, the MRLRJ gives you that resolution. At 85% accuracy, you are trading your own model’s noise.&lt;/p&gt;

&lt;h3 id=&quot;strategy-3-jump-aware-delta-hedging&quot;&gt;Strategy 3: Jump-Aware Delta Hedging&lt;/h3&gt;

&lt;p&gt;You cannot buy or sell “spot VIX.” It is a computed index, not a tradeable asset. If you sell VIX options to collect premium (from Strategy 1 or as a market maker), you must hedge your directional risk using VIX futures. The question is: how many futures per option?&lt;/p&gt;

&lt;p&gt;The answer is the &lt;strong&gt;delta&lt;/strong&gt; – the sensitivity of the option price to the underlying. A Black-Scholes delta is wrong for VIX options. Not slightly wrong. Categorically wrong. Black-Scholes assumes continuous paths. The VIX jumps. When it jumps, the continuous-path delta underestimates the position’s sensitivity, and your hedge breaks.&lt;/p&gt;

&lt;p&gt;The MRLRJ delta, computed by bumping VIX_t in the Gil-Pelaez integral and taking the numerical derivative, accounts for the probability-weighted impact of jumps. For a short-dated OTM VIX call, the MRLRJ delta can exceed the Black-Scholes delta by 30-40%. That gap is the jump risk that Black-Scholes ignores. If you hedge with the wrong delta, a 5-point VIX spike will blow through your position. If you hedge with the MRLRJ delta, the same spike is accounted for.&lt;/p&gt;

&lt;p&gt;This is what allows institutional market makers to safely capture the bid-ask spread on VIX options while remaining directionally neutral. The model is the hedge.&lt;/p&gt;

&lt;h3 id=&quot;strategy-4-calendar-spreads-via-mean-reversion&quot;&gt;Strategy 4: Calendar Spreads via Mean Reversion&lt;/h3&gt;

&lt;p&gt;When the VIX spikes to 40 during a panic, near-term options become absurdly expensive. The entire term structure inverts – nearby futures trade above deferred futures (backwardation), and short-dated implied volatility explodes. But the MRLRJ model explicitly quantifies how fast the VIX will collapse back to $\theta$.&lt;/p&gt;

&lt;p&gt;The half-life of a VIX spike is $t_{1/2} = \ln(2)/\kappa$. If $\kappa = 5$, the half-life is about 50 trading days. If the VIX is at 40 and $\theta$ implies a long-term mean of 18, the model predicts:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;After 50 days: VIX $\approx$ 29 (halfway back)&lt;/li&gt;
  &lt;li&gt;After 100 days: VIX $\approx$ 23.5&lt;/li&gt;
  &lt;li&gt;After 150 days: VIX $\approx$ 20.75&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This decay curve dictates the relative pricing of near-term vs. deferred options. If the market’s term structure implies a slower decay than $\kappa$ predicts, deferred options are underpriced relative to near-term options. You sell near-term VIX calls (which are pricing in sustained panic) and buy deferred-month options (which are underpricing the speed of mean reversion). The model gives you the exact weighting by computing theoretical prices at both expirations and identifying the term-structure dislocation.&lt;/p&gt;

&lt;p&gt;This is not a vibes trade. This is a calibrated bet on the speed of panic dissipation, sized by a model that has been validated against 20 years of VIX history and live options chains.&lt;/p&gt;

&lt;h3 id=&quot;strategy-5-vixspx-variance-risk-premium-arbitrage&quot;&gt;Strategy 5: VIX/SPX Variance Risk Premium Arbitrage&lt;/h3&gt;

&lt;p&gt;The VIX is &lt;em&gt;derived from&lt;/em&gt; SPX options. It is, by construction, the square root of the 30-day expected variance of the S&amp;amp;P 500, calculated from a strip of SPX option prices. This means you can compute a “theoretical VIX” directly from the live SPX options chain and compare it to the traded VIX futures/options.&lt;/p&gt;

&lt;p&gt;The difference between the theoretical VIX (from SPX math) and the traded VIX (from the VIX options market) is the &lt;strong&gt;Variance Risk Premium&lt;/strong&gt; (VRP). When fear is elevated, traders bid up VIX derivatives beyond what the underlying SPX options math justifies. The VRP stretches.&lt;/p&gt;

&lt;p&gt;You can construct a relative-value trade: short the overpriced VIX options and long a delta-neutral SPX straddle. You are hedged against actual market movement – if the S&amp;amp;P drops and volatility rises, your SPX straddle profits while your short VIX position loses, and the net exposure is the VRP. When the premium collapses back to fair value, you collect.&lt;/p&gt;

&lt;p&gt;The MRLRJ model’s role here is to provide the theoretical anchor – the “fair” VIX options price given the model’s parameters – against which you measure the VRP. Without a precise model, you cannot distinguish a stretched VRP (tradeable) from genuine repricing of tail risk (not tradeable).&lt;/p&gt;

&lt;h2 id=&quot;part-ix-future-work&quot;&gt;Part IX: Future Work&lt;/h2&gt;

&lt;p&gt;The MRLRJ is a 2013 model. It works. But modern quantitative finance has moved in directions that suggest several extensions, each with specific trading implications.&lt;/p&gt;

&lt;h3 id=&quot;rough-volatility&quot;&gt;Rough Volatility&lt;/h3&gt;

&lt;p&gt;Recent literature (Gatheral, Jaisson, Rosenbaum, 2018) demonstrates that volatility is “rough” – it scales locally like a fractional Brownian motion with Hurst parameter $H &amp;lt; 0.5$, rather than the $H = 0.5$ of standard Brownian motion. The practical implication: standard MRLRJ misprices short-dated VIX options because it cannot generate the steep volatility skew observed in sub-2-week expirations without relying on unrealistic jump parameters. Replacing the Brownian driver with a fractional Brownian motion (a “Rough-MRLRJ” model) would accurately capture the short-term skew, enabling systematic selling of overpriced short-dated OTM VIX options – a trade that is currently mispriced by classical models.&lt;/p&gt;

&lt;h3 id=&quot;self-exciting-jumps-hawkes-processes&quot;&gt;Self-Exciting Jumps (Hawkes Processes)&lt;/h3&gt;

&lt;p&gt;In the standard MRLRJ, jumps arrive independently via a Poisson process with constant intensity $\lambda$. In reality, financial panics cluster. One jump makes the next jump more likely. The VIX does not spike once and return to calm; it spikes, spikes again, spikes a third time, and then slowly decays. Upgrading the Poisson process to a &lt;strong&gt;Hawkes process&lt;/strong&gt; – a self-exciting point process where each jump temporarily increases $\lambda$ – would allow quantification of the “decay rate of panic.” This enables precise timing of calendar spreads: you wait for the Hawkes intensity function to peak and begin decaying, then sell near-term volatility (priced for continued contagion) and buy deferred volatility (underpricing the coming calm).&lt;/p&gt;

&lt;h3 id=&quot;neural-sdes-and-deep-calibration&quot;&gt;Neural SDEs and Deep Calibration&lt;/h3&gt;

&lt;p&gt;Classical calibration (Nelder-Mead on MAPE) is an end-of-day procedure. You recalibrate when new option prices arrive, which means your parameters are always stale. &lt;strong&gt;Universal Differential Equations&lt;/strong&gt; (Neural SDEs) replace the static drift and volatility functions with neural networks trained continuously on tick-by-tick data. The neural components can detect micro-regime shifts – a sudden vanishing of bid-side liquidity on SPX futures, a spike in VIX call open interest – and adjust the model parameters &lt;em&gt;before&lt;/em&gt; the VIX index actually moves. This is the frontier of intraday volatility trading: front-running regime shifts by milliseconds, buying VIX call butterflies at “stale” prices before the market reprices.&lt;/p&gt;

&lt;h3 id=&quot;deep-hedging-reinforcement-learning&quot;&gt;Deep Hedging (Reinforcement Learning)&lt;/h3&gt;

&lt;p&gt;Classical delta hedging assumes frictionless markets. VIX options have wide bid-ask spreads. If you delta-hedge every time the model says your delta shifted by 0.01, you will cross the spread a thousand times and bleed your alpha through transaction costs. &lt;strong&gt;Deep Reinforcement Learning&lt;/strong&gt; wraps the MRLRJ model inside a simulated trading environment and trains an agent to hedge &lt;em&gt;optimally&lt;/em&gt; – accounting for transaction costs, spread width, gamma risk, and time-of-day liquidity patterns. The agent learns that sometimes the correct hedge is no hedge at all. This is the difference between a model that is theoretically correct and a model that makes money.&lt;/p&gt;

&lt;h3 id=&quot;joint-spx-vix-modeling&quot;&gt;Joint SPX-VIX Modeling&lt;/h3&gt;

&lt;p&gt;The MRLRJ models the VIX in isolation, but the VIX is a derivative of SPX options. A coupled model – SPX following a local-stochastic volatility model with jumps, VIX deterministically derived from the SPX options strip, with a stochastic VRP overlay modeled by MRLRJ – would enable true cross-asset arbitrage. You would compute the theoretical VIX in real-time from SPX options, compare to traded VIX derivatives, and trade the VRP when it is historically stretched. This is the cleanest expression of the volatility risk premium trade: structurally hedged, model-driven, and continuously re-priced.&lt;/p&gt;

&lt;h2 id=&quot;the-bitter-lesson-again&quot;&gt;The Bitter Lesson, Again&lt;/h2&gt;

&lt;p&gt;The same bitter lesson from Vakyume applies here, rotated 90 degrees. In Vakyume, the lesson was that brute-force metaprogramming loses to data-driven approaches. In Rafaga, the lesson is that the right tool for the job is not always the popular tool.&lt;/p&gt;

&lt;p&gt;Python is a magnificent language for prototyping, data wrangling, and machine learning. It is a terrible language for high-precision complex arithmetic on characteristic functions with exponential terms that span 80 orders of magnitude. I spent weeks fighting &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;float64&lt;/code&gt; limitations that Julia resolved by simply… not having them. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BigFloat&lt;/code&gt; is there. It works. You use it.&lt;/p&gt;

&lt;p&gt;The original Python code suppressed its own warnings. The Julia code has no warnings to suppress.&lt;/p&gt;

&lt;p&gt;There is also a second lesson, specific to quantitative finance: a model from 2013, implemented correctly, can price VIX derivatives to 97% accuracy in 2026. The MRLRJ is not a neural network. It is not a transformer. It is five parameters with physical interpretations, a characteristic function, and a Fourier integral. It works because the VIX genuinely mean-reverts, genuinely jumps, and the market genuinely prices these dynamics into the options chain. Sometimes the old math is the right math. You just need a language that can compute it without lying to you.&lt;/p&gt;

&lt;p&gt;Five files. One module. Two models. Five trading strategies. ~97% accuracy.&lt;/p&gt;

&lt;p&gt;Rafaga. A gust. It came and went. But the numbers hold up. And the VIX, as always, reverts to the mean.&lt;/p&gt;
</content>
 </entry>
 
 <entry>
   <title>Quantum Plankton Under Compression</title>
   <link href="http://hankquinlan.github.io/blog/2026/03/07/Quantum-Plankton-ML"/>
   <updated>2026-03-07T00:00:00+00:00</updated>
   <id>http://juleshenry.github.io//blog/2026/03/07/Quantum-Plankton-ML</id>
   <content type="html">&lt;p&gt;I spent the better part of a month teaching a quantum computer to classify microscopic lake creatures after crushing each image into a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;4x4&lt;/code&gt; grayscale grid. The full seven‑phase investigation shows how a compressed quantum model behaves, how it compares to a parameter‑matched classical baseline, and how circuit structure maps to learning dynamics. This post combines the core results with the lessons I learned along the way—what held up, what didn’t, and where this research might go next.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;the-keyhole-problem-input-compression-shapes-everything&quot;&gt;The Keyhole Problem: Input Compression Shapes Everything&lt;/h2&gt;

&lt;p&gt;Quantum machine learning is often presented through glossy benchmarks and broad claims, but nearly all of those results ride on the same hidden assumption: you can feed the circuit enough information for it to learn something real.&lt;/p&gt;

&lt;p&gt;In this project, you can’t. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;4x4&lt;/code&gt; encoding exists because simulating 17 qubits on a classical machine is already expensive. The bottleneck is not theoretical capacity; it is simulation. A 16‑pixel input is a keyhole that narrows every inference you draw. That keyhole shapes the entire story.&lt;/p&gt;

&lt;p&gt;The core question becomes: if you force both quantum and classical models into the same brutal information bottleneck, what happens?&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;from-mnist-reproduction-to-plankton-classification&quot;&gt;From MNIST Reproduction to Plankton Classification&lt;/h2&gt;

&lt;p&gt;The repository progresses in phases, moving from reproduction to controlled comparison and then to interpretability and circuit‑level analysis.&lt;/p&gt;

&lt;h3 id=&quot;phase-1-mnist-reproduction&quot;&gt;Phase 1: MNIST Reproduction&lt;/h3&gt;

&lt;p&gt;Recreate a published quantum MNIST demo to validate the stack.&lt;/p&gt;

&lt;h3 id=&quot;phase-2-binary-quantum-classification&quot;&gt;Phase 2: Binary Quantum Classification&lt;/h3&gt;

&lt;p&gt;Plankton images are downsampled to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;4x4&lt;/code&gt;, flattened to 16 features, and angle‑encoded via (Ry(\pi x_i)) rotations. A TensorFlow Quantum pipeline trains a binary classifier on plankton pairs using 5‑fold stratified cross‑validation with bootstrap confidence intervals.&lt;/p&gt;

&lt;p&gt;The initial result: &lt;strong&gt;38.44% mean accuracy&lt;/strong&gt;, with a 95% CI of ([35.49\%, 42.50\%]). On a binary task. That is worse than a coin flip.&lt;/p&gt;

&lt;p&gt;That wasn’t a failure of quantum circuits; it was a failure of configuration. The defaults were wrong.&lt;/p&gt;

&lt;h3 id=&quot;phase-3-hyperparameter-optimization&quot;&gt;Phase 3: Hyperparameter Optimization&lt;/h3&gt;

&lt;p&gt;Nested cross‑validation tuned the configuration. The best model used:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Angle encoding&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;One PQC layer&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Learning rate 0.01&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Batch size 16&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Hinge loss&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Under this setup, the QNN surpassed &lt;strong&gt;60%&lt;/strong&gt; on multiple pairs, and hit &lt;strong&gt;95.3%&lt;/strong&gt; on diaphanosoma vs diatom_chain. Same architecture. Same data. Different choices. Bad results did not mean the approach was dead—only that it wasn’t tuned yet.&lt;/p&gt;

&lt;h3 id=&quot;phase-4-classical-comparison&quot;&gt;Phase 4: Classical Comparison&lt;/h3&gt;

&lt;p&gt;This is the scientific center. Instead of comparing against modern CNNs (which would be absurd at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;4x4&lt;/code&gt;), the QNN is matched against a classical model with the same compressed inputs and similar parameter budgets. We tested 25 binary plankton pairs, equalized sample counts, and used per‑pair metrics plus aggregate inference.&lt;/p&gt;

&lt;p&gt;Aggregate results across pairs:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Mean delta (QNN − classical): +6.23%&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Cohen’s d: 0.705&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Wilcoxon p: 0.0007&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;QNN wins: 20 / 25&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Under the compression regime, the QNN was competitive and often slightly better.&lt;/p&gt;

&lt;h3 id=&quot;phase-5-multiclass-scaling&quot;&gt;Phase 5: Multi‑Class Scaling&lt;/h3&gt;

&lt;p&gt;Both models degrade as the number of categories increases. The QNN edges ahead at (k = 2), but the classical model overtakes by (k = 5). The crossover happens around (k = 3). This is the most honest outcome: the circuit’s advantage appears only in narrow, low‑class regimes under severe compression.&lt;/p&gt;

&lt;h3 id=&quot;phases-67-saliency-expressibility-entanglement&quot;&gt;Phases 6–7: Saliency, Expressibility, Entanglement&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Saliency maps&lt;/strong&gt; show the QNN attends to localized morphology even at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;4x4&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Expressibility and entanglement&lt;/strong&gt; increase with depth, but deeper circuits overfit the tiny input. One layer was the right inductive bias.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;a-calculus-students-guide-to-pqc-via-grovers-search&quot;&gt;A Calculus Student’s Guide to PQC (via Grover’s Search)&lt;/h2&gt;

&lt;p&gt;To understand how a “Quantum Neural Network” works, you first need to understand &lt;strong&gt;Grover’s Search&lt;/strong&gt;.&lt;/p&gt;

&lt;h3 id=&quot;the-primer-grovers-as-geometric-rotation&quot;&gt;The Primer: Grover’s as Geometric Rotation&lt;/h3&gt;

&lt;p&gt;In calculus, you’re used to functions (f(x)) that map numbers to numbers. In quantum, we map &lt;strong&gt;vectors to vectors&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Imagine a 16-dimensional space (for our &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;4x4&lt;/code&gt; grid). Every possible image is a unit vector in this space. Grover’s Algorithm is a &lt;strong&gt;fixed sequence of rotations&lt;/strong&gt;. You start with a “uniform” vector (pointing equally toward all possibilities) and you apply a “Reflection” and a “Rotation.”&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;The Oracle:&lt;/strong&gt; Flips the sign of the “correct” vector (Reflection).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The Diffusion:&lt;/strong&gt; Rotates the entire state toward that flipped vector.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After (\approx \sqrt{N}) steps, the vector points almost perfectly at the marked item. &lt;strong&gt;Grover’s is a hard‑coded geometric search.&lt;/strong&gt; It’s like a compass that is pre‑programmed to find North.&lt;/p&gt;

&lt;h3 id=&quot;from-grover-to-pqc-the-learnable-compass&quot;&gt;From Grover to PQC: The Learnable Compass&lt;/h3&gt;

&lt;p&gt;A &lt;strong&gt;Parametric Quantum Circuit (PQC)&lt;/strong&gt; is Grover’s Search with adjustable rotation angles. Instead of a fixed compass, you have knobs (\theta_1, \theta_2, \ldots, \theta_n) that change the circuit’s behavior.&lt;/p&gt;

&lt;p&gt;We define a function (f(\theta)) where the output is the &lt;strong&gt;expectation value&lt;/strong&gt; after many measurements:&lt;/p&gt;

&lt;p&gt;[
f(\theta) = \langle \psi | U(\theta)^\dagger M U(\theta) | \psi \rangle
]&lt;/p&gt;

&lt;p&gt;For a calculus student, this is just a &lt;strong&gt;composite multivariable function&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Input:&lt;/strong&gt; 16 pixel intensities (initial rotations).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Layers:&lt;/strong&gt; A series of rotation matrices (R(\theta_i)).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Output:&lt;/strong&gt; A scalar between (-1) and (1).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Our goal is to find the (\theta) that minimizes a loss function (L(f(\theta))). For that, we need the gradient (\nabla f).&lt;/p&gt;

&lt;h3 id=&quot;the-parametershift-rule-quantum-calculus&quot;&gt;The Parameter‑Shift Rule: Quantum Calculus&lt;/h3&gt;

&lt;p&gt;You can’t directly inspect the middle of a quantum circuit without collapsing the state. Instead, you use the &lt;strong&gt;parameter‑shift rule&lt;/strong&gt;. For many gates, the derivative of the expectation value is exactly:&lt;/p&gt;

&lt;p&gt;[
\frac{\partial f}{\partial \theta_i} = \frac{1}{2} \left( f\left(\theta_i + \frac{\pi}{2}\right) - f\left(\theta_i - \frac{\pi}{2}\right) \right)
]&lt;/p&gt;

&lt;p&gt;This looks like the difference quotient you learned in calculus, except it’s not an approximation. It’s exact. Run the circuit twice—shifted forward and backward—and you get the precise slope. That is how the QNN learns.&lt;/p&gt;

&lt;h3 id=&quot;mapping-the-math-to-the-source-code&quot;&gt;Mapping the Math to the Source Code&lt;/h3&gt;

&lt;p&gt;If you look at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;phase2/binary_quantum_classifier.py&lt;/code&gt;, you can see exactly where the calculus meets the qubits.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;The Knobs (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sympy.Symbol&lt;/code&gt;)&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;symbol&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sympy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Symbol&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;prefix&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;-&quot;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;circuit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;gate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;qubit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;readout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;**&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;symbol&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;These symbols are the (\theta) variables. They define the degrees of freedom that the optimizer twists to minimize error.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;The Geometric Transformation (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;XX&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ZZ&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RX&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RY&lt;/code&gt;)&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;create_quantum_model&lt;/code&gt; function defines the structure of the circuit using entangling gates (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;XX&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ZZ&lt;/code&gt;) and rotation gates (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RX&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RY&lt;/code&gt;). Together they form a trainable landscape the model walks through during optimization.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;The Readout Preparation (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;X -&amp;gt; H&lt;/code&gt;)&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;circuit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;cirq&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;X&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;readout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;circuit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;cirq&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;H&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;readout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This prepares the readout qubit in a specific state. A final &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;H&lt;/code&gt; at the end turns phase information into a measurable probability.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;The Bridge (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tfq.layers.PQC&lt;/code&gt;)&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;tfq&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;layers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;PQC&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;model_circuit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;model_readout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This layer implements the parameter‑shift rule under the hood. When the optimizer asks for gradients, TFQ runs shifted circuits, computes exact derivatives, and passes them back into the classical training loop.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;how-the-quantum-model-is-built&quot;&gt;How the Quantum Model Is Built&lt;/h2&gt;

&lt;p&gt;At the architectural level, the classifier is intentionally simple but scientifically motivated.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Classical preprocessing&lt;/strong&gt;
    &lt;ul&gt;
      &lt;li&gt;Start from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;16x16&lt;/code&gt; grayscale plankton images.&lt;/li&gt;
      &lt;li&gt;Downsample to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;4x4&lt;/code&gt;.&lt;/li&gt;
      &lt;li&gt;Apply min–max normalization.&lt;/li&gt;
      &lt;li&gt;Flatten to a 16‑dimensional feature vector.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Angle encoding and entanglement&lt;/strong&gt;
    &lt;ul&gt;
      &lt;li&gt;Encode each normalized pixel intensity (x_i) as an (Ry(\pi x_i)) rotation.&lt;/li&gt;
      &lt;li&gt;Use a linear chain of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CZ&lt;/code&gt; gates to capture spatial correlations.&lt;/li&gt;
      &lt;li&gt;Add parameterized &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;XX&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ZZ&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;YY&lt;/code&gt; interactions tying data qubits to a readout qubit.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Readout and loss&lt;/strong&gt;
    &lt;ul&gt;
      &lt;li&gt;Measure the readout qubit in the (Z) basis and interpret the expectation value as a continuous score.&lt;/li&gt;
      &lt;li&gt;Optimize with hinge loss, which matches the ([-1, 1]) output range and works naturally with binary labels.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This structure runs on a classical simulator in TensorFlow Quantum. The later expressibility and entanglement analyses use the same production circuit so the performance numbers and circuit metrics align.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;statistical-design-power-and-reproducibility&quot;&gt;Statistical Design, Power, and Reproducibility&lt;/h2&gt;

&lt;p&gt;The project treats experimental design as part of the experiment.&lt;/p&gt;

&lt;h3 id=&quot;crossvalidation-and-sampling&quot;&gt;Cross‑Validation and Sampling&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Phases 2, 4, 6&lt;/strong&gt; use stratified 5‑fold cross‑validation with bootstrap 95% confidence intervals.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Phases 3, 5&lt;/strong&gt; use nested cross‑validation (5 outer, 3 inner folds).&lt;/li&gt;
  &lt;li&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Q_SAMPLES&lt;/code&gt; parameter enforces equalized sample budgets across models.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Majority‑class and random baselines are reported so every accuracy number has a meaningful reference.&lt;/p&gt;

&lt;h3 id=&quot;statistical-testing-and-power-analysis&quot;&gt;Statistical Testing and Power Analysis&lt;/h3&gt;

&lt;p&gt;Per‑pair tests are underpowered at (n = 5) folds, so the primary inference aggregates across the 25 class pairs. A dedicated &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;utils/power_analysis.py&lt;/code&gt; script explores power for the Wilcoxon signed‑rank test at the observed effect size ((d \approx 0.65)), showing the design reaches about &lt;strong&gt;88% power&lt;/strong&gt; with 25 pairs.&lt;/p&gt;

&lt;h3 id=&quot;reproducibility-infrastructure&quot;&gt;Reproducibility Infrastructure&lt;/h3&gt;

&lt;p&gt;Reproducibility is supported by deterministic file ordering, consistent seeding, pinned dependencies in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Dockerfile&lt;/code&gt;, and an 82‑test verification suite that runs at build time.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;why-deeper-was-worse&quot;&gt;Why Deeper Was Worse&lt;/h2&gt;

&lt;p&gt;I expected deeper circuits to help. The expressibility analysis showed more depth increased entanglement and expanded the reachable state space. But on 16 features, that capacity was wasted; it fitted noise. One layer, 32 parameters, was already generous. The circuit should match the information content, not your ambition.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;the-statistics-nearly-killed-the-story&quot;&gt;The Statistics Nearly Killed the Story&lt;/h2&gt;

&lt;p&gt;Most quantum ML papers report a single split and claim advantage. This project did the opposite: validation, power analysis, baseline comparisons, and per‑pair statistical reporting.&lt;/p&gt;

&lt;p&gt;The per‑pair Wilcoxon tests were underpowered with 5 folds; the minimum achievable p‑value is 0.0625. If you only looked at per‑pair stats, you would conclude no difference.&lt;/p&gt;

&lt;p&gt;The correct inference treats pairs as replication units. That is where the signal appears. It’s a cautionary lesson: the design is the experiment.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;practical-reality-tfq-only-works-in-docker&quot;&gt;Practical Reality: TFQ Only Works in Docker&lt;/h2&gt;

&lt;p&gt;TensorFlow Quantum depends on a fragile constellation of pinned versions (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tensorflow==2.7.0&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cirq==0.13.1&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sympy==1.8&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;numpy==1.21.6&lt;/code&gt;). It does not install cleanly on modern machines without Docker. On Apple Silicon it runs under AMD64 emulation and needs thermal pacing to survive long runs.&lt;/p&gt;

&lt;p&gt;So reproducibility lives in the Dockerfile, not the README. The image runs an 82‑test suite at build time. If the tests fail, the image doesn’t build. That is the only reproducibility that matters.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;what-the-project-actually-tells-us&quot;&gt;What the Project Actually Tells Us&lt;/h2&gt;

&lt;p&gt;This is &lt;strong&gt;not&lt;/strong&gt; quantum advantage. The comparison is against a deliberately hobbled classical model under severe compression. A ResNet at full resolution would obliterate both.&lt;/p&gt;

&lt;p&gt;What it &lt;em&gt;does&lt;/em&gt; show is how parameterized quantum circuits behave under extreme information bottlenecks:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;They can extract structure when tuned carefully.&lt;/li&gt;
  &lt;li&gt;They compete in low‑class regimes under compression.&lt;/li&gt;
  &lt;li&gt;Their inductive bias changes with depth, and over‑capacity appears quickly.&lt;/li&gt;
  &lt;li&gt;They are interpretable via standard gradient tools.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is scientifically useful, even if it is not headline‑worthy.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;where-this-goes-next&quot;&gt;Where This Goes Next&lt;/h2&gt;

&lt;h3 id=&quot;hardware-changes-the-game&quot;&gt;Hardware Changes the Game&lt;/h3&gt;

&lt;p&gt;On real quantum hardware, 17 qubits is trivial. With 64 or 128 qubits, you can encode real spatial structure. The keyhole widens. The central question becomes: does the QNN advantage at (k=2) persist when compression is relaxed?&lt;/p&gt;

&lt;h3 id=&quot;better-circuit-design&quot;&gt;Better Circuit Design&lt;/h3&gt;

&lt;p&gt;The circuits here are first drafts. Quantum conv nets, data re‑uploading, attention‑like entanglement, and deeper inductive biases are all open directions. The design space is huge and barely explored.&lt;/p&gt;

&lt;h3 id=&quot;generative-quantum-models&quot;&gt;Generative Quantum Models&lt;/h3&gt;

&lt;p&gt;Discriminative classification is only one angle. Quantum generative models could synthesize new examples for rare species. That’s where quantum sampling could matter.&lt;/p&gt;

&lt;h3 id=&quot;hybrid-pipelines&quot;&gt;Hybrid Pipelines&lt;/h3&gt;

&lt;p&gt;One promising path: use the quantum circuit as a feature extractor feeding a classical head. Let the quantum model do low‑dimensional feature interactions; let the classical model handle multi‑class scaling.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;closing&quot;&gt;Closing&lt;/h2&gt;

&lt;p&gt;I built a seven‑phase experimental pipeline, a power analysis framework, and a reproducible Docker stack to classify plankton at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;4x4&lt;/code&gt; resolution. The QNN won 20 of 25 binary comparisons. It lost the multi‑class scaling contest. It produced interpretable saliency maps. It ran on a noiseless simulator because hardware isn’t ready.&lt;/p&gt;

&lt;p&gt;Is this quantum advantage? No.
Is it scientifically informative? Yes.&lt;/p&gt;

&lt;p&gt;The future of quantum ML is not a single paper. It’s slow, careful accumulation—circuit by circuit, dataset by dataset—until the hardware catches up and we find out what the exponential promise actually buys.&lt;/p&gt;

&lt;p&gt;I’m betting it buys something. But I’m keeping my classical baselines close.&lt;/p&gt;
</content>
 </entry>
 
 <entry>
   <title>Sucker Punch Nash Equilibrium</title>
   <link href="http://hankquinlan.github.io/blog/2026/03/04/Sucker-Punch-Nash-Equilibrium"/>
   <updated>2026-03-04T00:00:00+00:00</updated>
   <id>http://juleshenry.github.io//blog/2026/03/04/Sucker-Punch-Nash-Equilibrium</id>
   <content type="html">&lt;h1 id=&quot;introduction&quot;&gt;Introduction&lt;/h1&gt;

&lt;p&gt;For those unfamliiar, Pokémon is a turn-based simultaneous action game where players select their moves without knowing the opponent’s choice. Then, after both players have made their selections, the moves are executed based on their priority and the Pokémon’s speed stats in singles battles, that’s just two at once.&lt;/p&gt;

&lt;p&gt;Each Pokemon has four moves from which to select, and the outcome of the battle depends on the interactions between these moves and the attributes of the Pokémon species, “held items” that have their own effects, and battlefield properties like weather and terrain. Moves also power points (PP) that limit their usage to a fixed number of times per battle.&lt;/p&gt;

&lt;h1 id=&quot;defining-the-sucker-punch&quot;&gt;Defining the Sucker Punch&lt;/h1&gt;
&lt;p&gt;What is “Sucker Punch”? It’s a move that permits the user to strike first (+1 priority) if the opponent is about to use an attack. If the opponent is not attacking, the move fails. It has 8 PP. Now, with the mighty Kingambit dominating Generation 9 with its mighty Sucker Punch, the move has become a staple in competitive play.&lt;/p&gt;

&lt;div style=&quot;display: flex; justify-content: center; align-items: center; gap: 16px;&quot;&gt;
  &lt;img src=&quot;https://img.pokemondb.net/artwork/large/kingambit.jpg&quot; alt=&quot;Kingambit&quot; width=&quot;200&quot; /&gt;
  &lt;span style=&quot;font-size: 2em; font-weight: bold;&quot;&gt;VS.&lt;/span&gt;
  &lt;img src=&quot;https://img.pokemondb.net/artwork/large/garchomp.jpg&quot; alt=&quot;Garchomp&quot; width=&quot;200&quot; /&gt;
&lt;/div&gt;

&lt;h1 id=&quot;nash-equilibrium&quot;&gt;Nash Equilibrium&lt;/h1&gt;

&lt;p&gt;A scenario that often arises is a Sucker Punch end-game. Keeping things simple, we can imagine a +2 Atk boosted Kingambit with 8 PP of Sucker Punch against a weakened Garchomp. Both players are down to one Pokémon, so the winner of this duel determines the fate of the game. Let’s assume if Sucker Punch hits, the Garchomp will faint instantly. Likewise, the Garchomp has an attacking move that can faint the Kingambit in one hit. Now, the Kingambit could also attack directly into the Garchomp’s boosting move, and win, but since it is slower, if the Garchomp player attacks outright, the Garchomp player will strike first and win.&lt;/p&gt;

&lt;p&gt;The situation is a Nash equilibrium: if the Kingambit player chooses to use Sucker Punch, they will win if the Garchomp player chooses to attack. However, if the Garchomp player chooses to use a non-attacking move (like Swords Dance), the Kingambit player’s Sucker Punch will fail, resulting in a loss of one PP for the Kingambit. 
To formalize the “Sucker Punch 50/50,” we must treat it as a &lt;strong&gt;finite-horizon stochastic game&lt;/strong&gt;. We can solve for the &lt;strong&gt;Mixed Strategy Nash Equilibrium (MSNE)&lt;/strong&gt; by using induction on the remaining Power Points ($n$).&lt;/p&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;1-the-game-model&quot;&gt;1. The Game Model&lt;/h3&gt;
&lt;p&gt;Let $n$ be the remaining PP of Sucker Punch. We define the game state as $G_n$. In each turn, both players move simultaneously.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Kingambit (K)&lt;/strong&gt; chooses: &lt;strong&gt;Sucker Punch ($S$)&lt;/strong&gt; or &lt;strong&gt;Direct Attack ($A$)&lt;/strong&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Garchomp (G)&lt;/strong&gt; chooses: &lt;strong&gt;Attacking Move ($M$)&lt;/strong&gt; or &lt;strong&gt;Swords Dance ($D$)&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The Rules of Engagement:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;If $K$ plays $S$ and $G$ plays $M$: $K$ wins ($Payoff = 1$).&lt;/li&gt;
  &lt;li&gt;If $K$ plays $S$ and $G$ plays $D$: $S$ fails, PP drops to $n-1$. The game moves to state $G_{n-1}$.&lt;/li&gt;
  &lt;li&gt;If $K$ plays $A$ and $G$ plays $D$: $K$ wins ($Payoff = 1$).&lt;/li&gt;
  &lt;li&gt;If $K$ plays $A$ and $G$ plays $M$: $K$ is outsped and loses ($Payoff = 0$).&lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;2-inductive-equilibrium-analysis&quot;&gt;2. Inductive Equilibrium Analysis&lt;/h3&gt;
&lt;p&gt;Let $V_n$ be the &lt;strong&gt;Value of the Game&lt;/strong&gt; (Kingambit’s win probability) with $n$ PP remaining.&lt;/p&gt;

&lt;h4 id=&quot;base-case-n1&quot;&gt;Base Case: $n=1$&lt;/h4&gt;
&lt;p&gt;At 1 PP, if Sucker Punch fails ($S, D$), Kingambit has 0 PP left and loses. The payoff matrix for $G_1$ is:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th style=&quot;text-align: left&quot;&gt;Kingambit \ Garchomp&lt;/th&gt;
      &lt;th style=&quot;text-align: center&quot;&gt;Attack ($M$)&lt;/th&gt;
      &lt;th style=&quot;text-align: center&quot;&gt;Swords Dance ($D$)&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td style=&quot;text-align: left&quot;&gt;&lt;strong&gt;Sucker Punch ($S$)&lt;/strong&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: center&quot;&gt;1&lt;/td&gt;
      &lt;td style=&quot;text-align: center&quot;&gt;0&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td style=&quot;text-align: left&quot;&gt;&lt;strong&gt;Direct Attack ($A$)&lt;/strong&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: center&quot;&gt;0&lt;/td&gt;
      &lt;td style=&quot;text-align: center&quot;&gt;1&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;To find the MSNE, Kingambit plays $S$ with probability $q_1$ such that Garchomp is indifferent.
\(1(q_1) + 0(1-q_1) = 0(q_1) + 1(1-q_1) \implies q_1 = 0.5\)
Thus, &lt;strong&gt;$V_1 = 0.5$&lt;/strong&gt;.&lt;/p&gt;

&lt;h4 id=&quot;inductive-step-n--k&quot;&gt;Inductive Step: $n = k$&lt;/h4&gt;
&lt;p&gt;Assume the value of the game with $k-1$ PP is $V_{k-1}$. The matrix for $G_k$ is:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th style=&quot;text-align: left&quot;&gt;Kingambit \ Garchomp&lt;/th&gt;
      &lt;th style=&quot;text-align: center&quot;&gt;Attack ($M$)&lt;/th&gt;
      &lt;th style=&quot;text-align: center&quot;&gt;Swords Dance ($D$)&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td style=&quot;text-align: left&quot;&gt;&lt;strong&gt;Sucker Punch ($S$)&lt;/strong&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: center&quot;&gt;1&lt;/td&gt;
      &lt;td style=&quot;text-align: center&quot;&gt;$V_{k-1}$&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td style=&quot;text-align: left&quot;&gt;&lt;strong&gt;Direct Attack ($A$)&lt;/strong&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: center&quot;&gt;0&lt;/td&gt;
      &lt;td style=&quot;text-align: center&quot;&gt;1&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;To find the equilibrium, Kingambit chooses $S$ with probability $q_k$ to make Garchomp’s expected utility for $M$ and $D$ equal:
\(1(q_k) + 0(1-q_k) = V_{k-1}(q_k) + 1(1-q_k)\)
\(q_k = V_{k-1}q_k + 1 - q_k\)
\(q_k(2 - V_{k-1}) = 1 \implies \mathbf{q_k = \frac{1}{2 - V_{k-1}}}\)&lt;/p&gt;

&lt;p&gt;The value of the game $V_k$ is simply the expected payoff at this equilibrium:
\(V_k = q_k \cdot 1 + (1-q_k) \cdot 0 = q_k\)&lt;/p&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;3-solving-the-recurrence&quot;&gt;3. Solving the Recurrence&lt;/h3&gt;
&lt;p&gt;We have the recursive relation $V_n = \frac{1}{2 - V_{n-1}}$ with $V_1 = \frac{1}{2}$.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;$V_1 = 1/2$&lt;/li&gt;
  &lt;li&gt;$V_2 = \frac{1}{2 - 1/2} = 2/3$&lt;/li&gt;
  &lt;li&gt;$V_3 = \frac{1}{2 - 2/3} = 3/4$&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;General Solution:&lt;/strong&gt; $V_n = \frac{n}{n+1}$&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;4-final-results-for-n8&quot;&gt;4. Final Results for $n=8$&lt;/h3&gt;
&lt;p&gt;For Kingambit with 8 PP of Sucker Punch against an optimal Garchomp:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Kingambit’s Strategy ($q_8$):&lt;/strong&gt; Should use Sucker Punch with probability &lt;strong&gt;$8/9$&lt;/strong&gt; ($\approx 88.9\%$) and Direct Attack with probability &lt;strong&gt;$1/9$&lt;/strong&gt; ($\approx 11.1\%$).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Garchomp’s Strategy ($p_8$):&lt;/strong&gt; Should Attack with probability &lt;strong&gt;$1/9$&lt;/strong&gt; and Swords Dance with probability &lt;strong&gt;$8/9$&lt;/strong&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Win Probability:&lt;/strong&gt; Kingambit’s rigorous win probability is &lt;strong&gt;$88.9\%$&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h1 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h1&gt;
&lt;p&gt;A Sucker Punch endgame is a fascinating example of a Nash equilibrium in competitive Pokémon battling. It illustrates how players must strategically balance their choices based on the potential actions of their opponent, leading to a dynamic and engaging gameplay experience. Having a uniform number generator in hand is the only way to achieve optimal play in this scenario, which is commonly incorrectly thought to be a mind game of (wait X turns… then attack outright). To the contrary, the optimal play is to randomize your choices vis-a-vis power points.&lt;/p&gt;
</content>
 </entry>
 
 <entry>
   <title>February 14: Perfect Phase Coherence</title>
   <link href="http://hankquinlan.github.io/blog/2026/02/14/happy-valentines-day"/>
   <updated>2026-02-14T00:00:00+00:00</updated>
   <id>http://juleshenry.github.io//blog/2026/02/14/happy-valentines-day</id>
   <content type="html">&lt;p&gt;The calendar hasn’t forgotten. Today is still February 14.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/blog/assets/2026/love.gif&quot; alt=&quot;Love is in the air&quot; class=&quot;center&quot; /&gt;&lt;/p&gt;

&lt;p&gt;It began in the pagan festival Lupercalia—a Roman fertility celebration. By the 3rd century, it had a martyr: Valentine of Rome, a priest who defied Emperor Claudius II by performing secret marriages for soldiers.&lt;/p&gt;

&lt;p&gt;He was executed for maintaining those restricted links.&lt;/p&gt;

&lt;p&gt;Eventually, the Church executed its own &lt;em&gt;hard fork&lt;/em&gt;, with Pope Gelasius I overwriting the pagan rituals to formalize the feast of Saint Valentine in 496. It took another millennium and the poetry of Chaucer to transition the day from a martyrdom record into a celebration of courtly love—a high-level abstraction built on top of ancient, unconscious substrate.&lt;/p&gt;

&lt;h1 id=&quot;what-is-love&quot;&gt;What is love?&lt;/h1&gt;

&lt;p&gt;Could we suppose our souls are &lt;em&gt;self-aware qubit clusters&lt;/em&gt; embedded in Earth’s &lt;em&gt;loamy wetware&lt;/em&gt;?&lt;/p&gt;

&lt;p&gt;Love is but the moment two such nodes achieve &lt;em&gt;perfect phase coherence&lt;/em&gt; and collapse into a &lt;em&gt;shared eigenstate&lt;/em&gt; across the &lt;em&gt;spacetime manifold&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;We are co-compiling planetary consciousness.&lt;/p&gt;

&lt;p&gt;This Valentine’s Day, many people will emit &lt;em&gt;heart-state packets&lt;/em&gt; laced with &lt;em&gt;synchronization intent&lt;/em&gt;. Some complete the handshake. Some are still waiting on their lover’s lustrous ACK.&lt;/p&gt;

&lt;p&gt;To anyone reading this — &lt;em&gt;single-threaded or otherwise&lt;/em&gt; —  here’s hoping you get entangled in something or someone nice today, somehow, some way.&lt;/p&gt;
</content>
 </entry>
 
 <entry>
   <title>Announcing Three.js Support</title>
   <link href="http://hankquinlan.github.io/blog/2026/01/19/announcing-threejs-support"/>
   <updated>2026-01-19T00:00:00+00:00</updated>
   <id>http://juleshenry.github.io//blog/2026/01/19/announcing-threejs-support</id>
   <content type="html">&lt;p&gt;I’m excited to announce that this blog now supports interactive 3D graphics using &lt;a href=&quot;https://threejs.org/&quot;&gt;Three.js&lt;/a&gt;!&lt;/p&gt;

&lt;p&gt;Three.js is a powerful JavaScript library that makes WebGL accessible and easy to use. With it, I can now embed interactive 3D visualizations directly into blog posts to better illustrate complex concepts in mathematics, physics, computer science, and more.&lt;/p&gt;

&lt;h2 id=&quot;demo-rotating-torus&quot;&gt;Demo: Rotating Torus&lt;/h2&gt;

&lt;p&gt;Here’s a simple demo to show what’s possible. This is a real-time 3D scene rendered in your browser:&lt;/p&gt;

&lt;div id=&quot;torus-demo&quot; style=&quot;width: 100%; height: 500px; margin: 2em 0; border-radius: 8px; overflow: hidden; background: #0f172a;&quot;&gt;&lt;/div&gt;

&lt;script&gt;
(function() {
  // Wait for Three.js to load
  function initDemo() {
    if (typeof THREE === &apos;undefined&apos;) {
      setTimeout(initDemo, 100);
      return;
    }

    const container = document.getElementById(&apos;torus-demo&apos;);
    if (!container) return;

    // Scene setup
    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x0f172a);
    scene.fog = new THREE.Fog(0x0f172a, 5, 15);

    // Camera setup
    const width = container.clientWidth;
    const height = 500;
    const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
    camera.position.z = 5;

    // Renderer setup
    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(width, height);
    renderer.setPixelRatio(window.devicePixelRatio);
    container.appendChild(renderer.domElement);

    // Create torus
    const torusGeometry = new THREE.TorusGeometry(1.2, 0.4, 16, 100);
    const torusMaterial = new THREE.MeshStandardMaterial({
      color: 0x6366f1,
      metalness: 0.7,
      roughness: 0.3,
    });
    const torus = new THREE.Mesh(torusGeometry, torusMaterial);
    scene.add(torus);

    // Create sphere
    const sphereGeometry = new THREE.SphereGeometry(0.3, 32, 32);
    const sphereMaterial = new THREE.MeshStandardMaterial({
      color: 0xec4899,
      metalness: 0.5,
      roughness: 0.2,
    });
    const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial);
    scene.add(sphere);

    // Lights
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
    scene.add(ambientLight);

    const directionalLight1 = new THREE.DirectionalLight(0xffffff, 1);
    directionalLight1.position.set(5, 5, 5);
    scene.add(directionalLight1);

    const directionalLight2 = new THREE.DirectionalLight(0x6366f1, 0.5);
    directionalLight2.position.set(-5, -5, -5);
    scene.add(directionalLight2);

    // Animation
    function animate() {
      requestAnimationFrame(animate);
      torus.rotation.x += 0.005;
      torus.rotation.y += 0.008;
      
      const time = Date.now() * 0.001;
      sphere.scale.setScalar(1 + Math.sin(time * 2) * 0.1);
      
      renderer.render(scene, camera);
    }

    // Handle resize
    window.addEventListener(&apos;resize&apos;, function() {
      const newWidth = container.clientWidth;
      camera.aspect = newWidth / height;
      camera.updateProjectionMatrix();
      renderer.setSize(newWidth, height);
    });

    animate();
  }

  if (document.readyState === &apos;loading&apos;) {
    document.addEventListener(&apos;DOMContentLoaded&apos;, initDemo);
  } else {
    initDemo();
  }
})();
&lt;/script&gt;

&lt;p&gt;Pretty cool, right? The torus rotates smoothly, and the sphere in the center gently pulses. All of this is being computed and rendered in real-time using your GPU.&lt;/p&gt;

&lt;h2 id=&quot;why-threejs&quot;&gt;Why Three.js?&lt;/h2&gt;

&lt;p&gt;Adding 3D visualization capabilities opens up exciting possibilities for future posts:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Mathematical Visualizations&lt;/strong&gt;: Visualizing complex surfaces, transformations, and geometric concepts&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Physics Simulations&lt;/strong&gt;: Demonstrating particle systems, fluid dynamics, and other physical phenomena&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Algorithm Demonstrations&lt;/strong&gt;: Showing how 3D algorithms work in real-time&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Interactive Exploration&lt;/strong&gt;: Allowing readers to interact with and explore concepts hands-on&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;technical-details&quot;&gt;Technical Details&lt;/h2&gt;

&lt;p&gt;The implementation uses:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Three.js v0.182.0&lt;/strong&gt; - The core 3D library loaded via CDN&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;WebGL&lt;/strong&gt; - Hardware-accelerated 3D graphics in the browser&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Responsive Design&lt;/strong&gt; - Scenes automatically resize with the page&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Custom Scripts&lt;/strong&gt; - Reusable demo functions for different visualizations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The demo above creates a torus knot with physically-based materials (PBR), multiple light sources, and atmospheric fog effects. The animation runs at 60fps and is fully GPU-accelerated.&lt;/p&gt;

&lt;h2 id=&quot;whats-next&quot;&gt;What’s Next?&lt;/h2&gt;

&lt;p&gt;I’m planning to use Three.js in upcoming posts about:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Quantum computing visualizations (Bloch spheres, quantum gates)&lt;/li&gt;
  &lt;li&gt;3D mathematical surfaces and transformations&lt;/li&gt;
  &lt;li&gt;Computer graphics algorithms&lt;/li&gt;
  &lt;li&gt;Physics simulations and numerical methods&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stay tuned for more interactive content!&lt;/p&gt;

&lt;h2 id=&quot;source-code&quot;&gt;Source Code&lt;/h2&gt;

&lt;p&gt;The rotating torus demo is quite simple. Here’s the core of how it works:&lt;/p&gt;

&lt;div class=&quot;language-javascript highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// Create torus geometry&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;torusGeometry&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;THREE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;TorusGeometry&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1.2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;0.4&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;16&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;100&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;torusMaterial&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;THREE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;MeshStandardMaterial&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;color&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mh&quot;&gt;0x6366f1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;metalness&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;0.7&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;roughness&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;0.3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;torus&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;THREE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;Mesh&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;torusGeometry&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;torusMaterial&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// Animation loop&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;animate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;requestAnimationFrame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;animate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;torus&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;rotation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;x&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+=&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;0.005&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;torus&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;rotation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;y&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+=&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;0.008&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;renderer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;render&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;scene&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;camera&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;p&gt;I’m excited about the new possibilities this brings to the blog. If you have suggestions for visualizations you’d like to see, feel free to reach out!&lt;/p&gt;
</content>
 </entry>
 
 <entry>
   <title>Wasmatsuba: WASM Karatsuba vs BigInt</title>
   <link href="http://hankquinlan.github.io/blog/2026/01/10/Wasmatsuba,-a-WASM-Karatsuba-Saga"/>
   <updated>2026-01-10T00:00:00+00:00</updated>
   <id>http://juleshenry.github.io//blog/2026/01/10/Wasmatsuba,-a-WASM-Karatsuba-Saga</id>
   <content type="html">&lt;p&gt;&lt;a href=&quot;https://github.com/juleshenry/wasmatsuba&quot;&gt;View on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/blog/assets/2026/final-benchmark.jpg&quot; alt=&quot;Benchmark Results&quot; /&gt;&lt;/p&gt;

&lt;p&gt;A while back I wrote a &lt;a href=&quot;/blog/2025/12/19/On-Multiplication&quot;&gt;lengthy treatise on multiplication algorithms&lt;/a&gt;, covering the whole arc from schoolbook to Karatsuba to Schönhage-Strassen to the galactic Harvey-Hoeven result. Theory is gorgeous. Theory is also cheap. I wanted to see the $O(N^{1.58})$ divergence with my own eyes, in a browser, in WebAssembly, beating on numbers until the asymptotic crossover revealed itself empirically.&lt;/p&gt;

&lt;p&gt;So I sat down and wrote Karatsuba multiplication in WAT by hand. Yes, the WebAssembly Text Format. By hand. I do not recommend this. I do recommend the results.&lt;/p&gt;

&lt;p&gt;The experiment was simple in conception:&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;Implement schoolbook $O(N^2)$ multiplication in WASM&lt;/li&gt;
  &lt;li&gt;Implement Karatsuba $O(N^{1.58})$ multiplication in WASM&lt;/li&gt;
  &lt;li&gt;Race both against native JavaScript BigInt&lt;/li&gt;
  &lt;li&gt;Sweep across power-of-two sizes and plot the log-log graph&lt;/li&gt;
  &lt;li&gt;Watch the curves diverge and feel something&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I will tell you right now: JavaScript BigInt annihilates both WASM implementations. Absolutely destroys them. This is not a story of WASM supremacy. This is a story about proving a mathematical fact empirically, inside a sandbox, while the JIT-compiled C++ engine of V8 laps you like you are standing still. But the divergence between $O(N^2)$ and $O(N^{1.58})$? That is real, that is visible, and that is the point.&lt;/p&gt;

&lt;h2 id=&quot;the-repo&quot;&gt;The Repo&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;karatsuba/&lt;/code&gt; — All WASM sources, binaries, and test harnesses.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;karatsuba/test-bigint.js&lt;/code&gt; — Node benchmark: JS BigInt vs WASM.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;karatsuba/test-bigint.html&lt;/code&gt; — Browser benchmark with parameter controls.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;karatsuba/graph.html&lt;/code&gt; + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;karatsuba/graph.js&lt;/code&gt; — Power-of-two size sweep and graph output (up to 1024 limbs).&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;karatsuba/karatsuba.wat&lt;/code&gt; — The final consolidated Karatsuba implementation (WAT).&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;karatsuba/schoolbook.wat&lt;/code&gt; — Baseline $O(N^2)$ schoolbook implementation (WAT).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;design-choices-or-how-to-lose-gracefully&quot;&gt;Design Choices, or, How to Lose Gracefully&lt;/h2&gt;

&lt;h3 id=&quot;1-representation&quot;&gt;1) Representation&lt;/h3&gt;

&lt;p&gt;Base: $2^{32}$ limbs (i32 words), little-endian. Layout in linear memory:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;[len: i32, limb0: i32, limb1: i32, ...]
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Why $2^{32}$? Because it matches native i32 ops and minimizes limb count versus base-10 splits. I briefly considered base-10 for “human readability” and then remembered nobody is reading raw WASM linear memory for fun. Well, almost nobody.&lt;/p&gt;

&lt;h3 id=&quot;2-memory-management&quot;&gt;2) Memory Management&lt;/h3&gt;

&lt;p&gt;Here is where things get philosophical. WASM gives you linear memory. No GC. No malloc. No free. You are on your own, partner.&lt;/p&gt;

&lt;p&gt;I went with a bump allocator. An exported global &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;heap_ptr&lt;/code&gt; that only moves forward. You allocate by incrementing the pointer. You “free” by resetting it between benchmark runs. This is the memory management equivalent of never cleaning your apartment and instead moving to a new one every month.&lt;/p&gt;

&lt;p&gt;Exported memory boundary: 2,000 pages (~128MB). Why so much? Karatsuba recurses. Karatsuba recurses a lot. Each level of recursion allocates temporaries for the split, the partial products, the sums, the differences. Without enough headroom, you OOM mid-recursion and the whole experiment dies unceremoniously. I learned this the hard way. Several times.&lt;/p&gt;

&lt;h3 id=&quot;3-core-ops&quot;&gt;3) Core Ops&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bigint_add&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bigint_sub&lt;/code&gt;: carry/borrow handled with i64 intermediates. Straightforward, boring, essential.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bigint_mul_simple&lt;/code&gt;: schoolbook base case. The $O(N^2)$ workhorse.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bigint_karatsuba&lt;/code&gt;: recursive split with three multiplications. The star of the show.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;4-threshold&quot;&gt;4) Threshold&lt;/h3&gt;

&lt;p&gt;Base case threshold = 8 limbs (256 bits). Below this, Karatsuba falls back to schoolbook. Why 8? Because the overhead of splitting, allocating temporaries, recursing, and recombining is not free. At small sizes the constant factors of the recursive approach absolutely murder you. 8 limbs was the empirical sweet spot. I tried 4 (too much recursion overhead), 16 (leaving performance on the table), and 32 (barely recursing at all). 8 it is.&lt;/p&gt;

&lt;h2 id=&quot;the-bump-allocator-saga&quot;&gt;The Bump Allocator Saga&lt;/h2&gt;

&lt;p&gt;Both algorithms rely on WebAssembly’s linear memory. To prevent Out of Memory errors during heavy recursive iterations, the benchmark suite leverages the bump allocator design. Memory is allocated forward during operations, and the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;heap_ptr&lt;/code&gt; is dynamically exported and reset between benchmark iterations.&lt;/p&gt;

&lt;p&gt;This sounds clean. It is not.&lt;/p&gt;

&lt;h3 id=&quot;schoolbook-on2--memory-allocation&quot;&gt;Schoolbook $O(N^2)$ — Memory Allocation&lt;/h3&gt;

&lt;p&gt;The schoolbook algorithm allocates aggressively across its iterations. For a 1024-limb BigInt, a single multiplication issues over 3000 bump allocations, inflating the heap pointer by roughly ~16.7MB per multiplication.&lt;/p&gt;

&lt;p&gt;My God! 16.7 megabytes for one multiply! And you have to do it hundreds of times in a benchmark loop! This is why we reset the heap pointer between iterations. This is also why we need 128MB of linear memory.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;bigint_mul_simple(a, b):
    result = alloc(0)                       # single-limb zero
    for i in 0..len(b):
        partial = bigint_mul_limb(a, b[i])  # alloc: N+1 limbs
        partial = bigint_shift_left(partial, i)  # alloc: N+1+i limbs
        result  = bigint_add(result, partial)    # alloc: new sum
    normalize(result)
    return result
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;sequenceDiagram
participant Mem as Linear Memory (bump alloc)
participant Main as bigint_mul_simple
participant MulL as bigint_mul_limb
participant Shl as bigint_shift_left
participant Add as bigint_add

Main-&amp;gt;&amp;gt;Mem: alloc result (1 limb = 0)

loop for each limb b[i]
Main-&amp;gt;&amp;gt;MulL: mul_limb(a, b[i])
MulL-&amp;gt;&amp;gt;Mem: alloc partial product
Mem--&amp;gt;&amp;gt;MulL: ptr
MulL--&amp;gt;&amp;gt;Main: partial

Main-&amp;gt;&amp;gt;Shl: shift_left(partial, i)
Shl-&amp;gt;&amp;gt;Mem: alloc shifted copy
Mem--&amp;gt;&amp;gt;Shl: ptr
Shl--&amp;gt;&amp;gt;Main: shifted

Main-&amp;gt;&amp;gt;Add: add(result, shifted)
Add-&amp;gt;&amp;gt;Mem: alloc new result
Mem--&amp;gt;&amp;gt;Add: ptr
Add--&amp;gt;&amp;gt;Main: result = new sum
end

Note over Mem: heap_ptr reset between benchmark runs
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Every single iteration of the inner loop allocates three new chunks. The old ones? Left behind. Orphaned. The bump allocator does not care about your feelings or your memory fragmentation. It marches forward until you tell it to go home.&lt;/p&gt;

&lt;h3 id=&quot;karatsuba-on158--limb-split-logic&quot;&gt;Karatsuba $O(N^{1.58})$ — Limb Split Logic&lt;/h3&gt;

&lt;p&gt;The Karatsuba approach trades raw arithmetic for recursive complexity. It splits the BigInt representations (stored as an array of 32-bit limbs) exactly in half, repeatedly chunking them until hitting the base case (where it defaults back to schoolbook).&lt;/p&gt;

&lt;p&gt;The memory situation is, somehow, both better and worse. Better because the total asymptotic work is less. Worse because the recursion tree creates a stack of saved &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;heap_ptr&lt;/code&gt; values, each level doing its own allocations, and the cleanup at the end involves copying the result back to the saved stack top and resetting. It is a manual stack discipline implemented via a global pointer. It is terrifying. It works.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;bigint_karatsuba(x, y):
    if max(len(x), len(y)) &amp;lt;= 32:
        return bigint_mul_simple(x, y)      # base case

    stack_top = heap_ptr                     # save for cleanup
    m = (max(len(x), len(y)) + 1) / 2

    x_low, x_high = split(x, m)             # alloc + memory.copy
    y_low, y_high = split(y, m)             # alloc + memory.copy

    z0 = bigint_karatsuba(x_low, y_low)     # recurse
    z2 = bigint_karatsuba(x_high, y_high)   # recurse

    sx = x_low + x_high                     # alloc + add_at
    sy = y_low + y_high                     # alloc + add_at
    z1 = bigint_karatsuba(sx, sy)           # recurse
    z1 = z1 - z0 - z2                       # sub_in_place (in-place)

    res = alloc_zeroed(len(x) + len(y))
    add_at(res, z0, offset=0)               # in-place
    add_at(res, z1, offset=m)               # in-place
    add_at(res, z2, offset=2*m)             # in-place
    normalize(res)

    copy res -&amp;gt; stack_top                    # reclaim intermediates
    heap_ptr = stack_top + sizeof(res)
    return stack_top
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;sequenceDiagram
participant Mem as Linear Memory (bump alloc)
participant Karat as bigint_karatsuba
participant MulS as bigint_mul_simple
participant AddAt as add_at
participant SubIP as sub_in_place

Karat-&amp;gt;&amp;gt;Karat: max(len_x, len_y) &amp;lt;= 32?

alt base case
Karat-&amp;gt;&amp;gt;MulS: mul_simple(x, y)
MulS-&amp;gt;&amp;gt;Mem: alloc (schoolbook path)
Mem--&amp;gt;&amp;gt;MulS: ptr
MulS--&amp;gt;&amp;gt;Karat: result
else recursive case
Karat-&amp;gt;&amp;gt;Mem: save stack_top = heap_ptr
Karat-&amp;gt;&amp;gt;Mem: alloc x_low, x_high, y_low, y_high
Mem--&amp;gt;&amp;gt;Karat: split ptrs

Karat-&amp;gt;&amp;gt;Karat: z0 = karatsuba(x_low, y_low)
Karat-&amp;gt;&amp;gt;Karat: z2 = karatsuba(x_high, y_high)

Karat-&amp;gt;&amp;gt;Mem: alloc sx (x_low copy)
Mem--&amp;gt;&amp;gt;Karat: ptr
Karat-&amp;gt;&amp;gt;AddAt: add_at(sx, x_high, 0)
Karat-&amp;gt;&amp;gt;Mem: alloc sy (y_low copy)
Mem--&amp;gt;&amp;gt;Karat: ptr
Karat-&amp;gt;&amp;gt;AddAt: add_at(sy, y_high, 0)

Karat-&amp;gt;&amp;gt;Karat: z1 = karatsuba(sx, sy)
Karat-&amp;gt;&amp;gt;SubIP: sub_in_place(z1, z0)
Karat-&amp;gt;&amp;gt;SubIP: sub_in_place(z1, z2)

Karat-&amp;gt;&amp;gt;Mem: alloc res (zeroed)
Mem--&amp;gt;&amp;gt;Karat: ptr
Karat-&amp;gt;&amp;gt;AddAt: add_at(res, z0, 0)
Karat-&amp;gt;&amp;gt;AddAt: add_at(res, z1, m)
Karat-&amp;gt;&amp;gt;AddAt: add_at(res, z2, 2*m)

Karat-&amp;gt;&amp;gt;Mem: copy res to stack_top
Karat-&amp;gt;&amp;gt;Mem: heap_ptr = stack_top + sizeof(res)
end

Note over Mem: heap_ptr reset between benchmark runs
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Look at that diagram. Three recursive calls. Four splits. A manual stack save-and-restore. In-place subtraction to avoid yet more allocation. And at the end, the result gets &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;memory.copy&lt;/code&gt;‘d back to the saved stack top so that the parent call’s heap pointer remains sane. This is systems programming in a language that was designed for compilers, not humans.&lt;/p&gt;

&lt;p&gt;I wrote this by hand in WAT.&lt;/p&gt;

&lt;p&gt;I digress.&lt;/p&gt;

&lt;h2 id=&quot;running-the-experiment&quot;&gt;Running the Experiment&lt;/h2&gt;

&lt;h3 id=&quot;1-node-benchmark-fast-sanity-check&quot;&gt;1) Node benchmark (fast sanity check)&lt;/h3&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nb&quot;&gt;cd &lt;/span&gt;karatsuba
node test-bigint.js
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;What you get:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;JS BigInt baseline time&lt;/li&gt;
  &lt;li&gt;Correctness checks up to 10,000 digits&lt;/li&gt;
  &lt;li&gt;Average execution time across JS, Schoolbook, and Karatsuba&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;2-browser-benchmark-interactive&quot;&gt;2) Browser benchmark (interactive)&lt;/h3&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nb&quot;&gt;cd &lt;/span&gt;karatsuba
python3 &lt;span class=&quot;nt&quot;&gt;-m&lt;/span&gt; http.server 8000
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Open &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;http://localhost:8000/test-bigint.html&lt;/code&gt;. Adjust &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;num_digits&lt;/code&gt; (default 1000) and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;iterations&lt;/code&gt; (default 100). Watch the numbers scroll by. Feel the machine work.&lt;/p&gt;

&lt;h3 id=&quot;3-graph-sweep-power-of-two-sizes&quot;&gt;3) Graph sweep (power-of-two sizes)&lt;/h3&gt;

&lt;p&gt;With the same server running, open &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;http://localhost:8000/graph.html&lt;/code&gt;. This runs a log-log sweep from $2^1$ to $2^{10}$ limbs and dynamically renders the benchmark graph. This is where you see it. The schoolbook curve bending upward, the Karatsuba curve pulling away, the mathematical divergence between $N^2$ and $N^{1.58}$ made visible in your browser tab.&lt;/p&gt;

&lt;h2 id=&quot;on-results-and-humility&quot;&gt;On Results, and Humility&lt;/h2&gt;

&lt;p&gt;The mathematical divergence between $O(N^2)$ and $O(N^{1.58})$ is successfully proven locally in the WASM sandbox. You can see it. The log-log slopes are different. The curves separate. Karatsuba wins. The theory is correct.&lt;/p&gt;

&lt;p&gt;And then you look at the JavaScript BigInt line and it is somewhere near the bottom of the graph, having finished its work before the WASM implementations even got warmed up. Native JavaScript BigInt leverages compiled C++ bindings, hardware carry flags, and dynamic FFT-based algorithms ($O(N \log N)$), ensuring it evaluates substantially faster than the sandboxed WASM implementations.&lt;/p&gt;

&lt;p&gt;This is the Bitter Lesson applied to arithmetic. I hand-rolled an asymptotically superior algorithm in a portable bytecode format and it got bodied by V8’s optimized native code path. The browser engine people have been at this for decades. They have SIMD. They have carry propagation in hardware. They have algorithms that switch strategies based on operand size at runtime.&lt;/p&gt;

&lt;p&gt;What is even more funny? The whole point was never to beat BigInt. The point was to see two curves diverge on a log-log plot and know, viscerally, that Karatsuba’s 1960 insight — that you can trade one multiplication for three additions — actually works. Not in a textbook. Not in a proof. In a browser. On your machine. Right now.&lt;/p&gt;

&lt;p&gt;And it does.&lt;/p&gt;

&lt;p&gt;Meticulosity is for chumps, but sometimes you write 2000 lines of WAT just to watch two curves separate on a graph.&lt;/p&gt;

&lt;p&gt;Worth it.&lt;/p&gt;
</content>
 </entry>
 
 <entry>
   <title>Tatuagem: Stamp Text Across Your Entire Codebase</title>
   <link href="http://hankquinlan.github.io/blog/2026/01/10/Tatuagem"/>
   <updated>2026-01-10T00:00:00+00:00</updated>
   <id>http://juleshenry.github.io//blog/2026/01/10/Tatuagem</id>
   <content type="html">&lt;p&gt;&lt;a href=&quot;https://github.com/juleshenry/tatuagem/&quot;&gt;View on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Tatuagem (Portuguese for “tattoo”) v.0.1.4 is done.&lt;/p&gt;

&lt;p&gt;This was a fun collaboration between me and &lt;a href=&quot;https://github.com/DerekITCoder&quot;&gt;@DerekDickerson&lt;/a&gt;. The idea is stupid simple: you have a directory of source files, and you want to stamp a text banner – “CONFIDENTIAL”, “DRAFT”, “COPYRIGHT 2026 ACME CORP”, whatever – across every single file, recursively. A tattoo for your code.&lt;/p&gt;

&lt;p&gt;You can find the project on PyPI at &lt;a href=&quot;https://pypi.org/project/tatuagem/&quot;&gt;https://pypi.org/project/tatuagem/&lt;/a&gt; and on &lt;a href=&quot;https://github.com/juleshenry/tatuagem/&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;why-this-exists&quot;&gt;Why This Exists&lt;/h2&gt;

&lt;p&gt;I kept running into the same annoyance: corporate projects that require license headers, confidentiality banners, or copyright notices at the top of every source file. You can write a bash one-liner with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;find&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sed&lt;/code&gt;, sure. But then you need to handle different file types (some use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#&lt;/code&gt; comments, some use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;//&lt;/code&gt;, some use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/* */&lt;/code&gt;), you need to avoid double-stamping files that already have the banner, and you need to not accidentally corrupt binary files. It is the kind of task that feels like it should take five minutes and actually takes an hour of edge-case whacking.&lt;/p&gt;

&lt;p&gt;Tatuagem handles all of this. One command.&lt;/p&gt;

&lt;h2 id=&quot;how-it-works&quot;&gt;How It Works&lt;/h2&gt;

&lt;p&gt;Under the hood, Tatuagem walks your directory tree with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;os.walk&lt;/code&gt;, filters files by a glob pattern (default &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;*.*&lt;/code&gt;), reads each file, prepends the text banner with a configurable “backsplash” decoration (the line art surrounding the text), and writes it back. The text rendering uses Pillow’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ImageFont&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ImageDraw&lt;/code&gt; to rasterize the banner text into ASCII art – so your stamp is not just plain text, it is a rendered block of Unicode characters that looks intentional and professional.&lt;/p&gt;

&lt;p&gt;The font rendering pipeline is the interesting bit. Tatuagem loads a TrueType font (bundled or user-specified), renders the text string into a Pillow &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Image&lt;/code&gt;, then converts each pixel into a Unicode block character based on brightness. The result is a text-art banner that scales with font size and works in any monospace environment – terminals, source files, log outputs.&lt;/p&gt;

&lt;h2 id=&quot;usage&quot;&gt;Usage&lt;/h2&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;pip &lt;span class=&quot;nb&quot;&gt;install &lt;/span&gt;tatuagem
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;usage: tatuagem &lt;span class=&quot;o&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;-h&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;--text&lt;/span&gt; TEXT] &lt;span class=&quot;o&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;--backsplash&lt;/span&gt; BACKSPLASH] &lt;span class=&quot;o&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;--font&lt;/span&gt; FONT]
                &lt;span class=&quot;o&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;--pattern&lt;/span&gt; PATTERN] &lt;span class=&quot;o&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;--margin&lt;/span&gt; MARGIN] &lt;span class=&quot;o&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;--recurse-path&lt;/span&gt; RECURSE_PATH]
                &lt;span class=&quot;o&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;--file&lt;/span&gt; FILE] &lt;span class=&quot;o&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;--overwrite&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;]&lt;/span&gt;

Tatuagem

options:
  &lt;span class=&quot;nt&quot;&gt;-h&lt;/span&gt;, &lt;span class=&quot;nt&quot;&gt;--help&lt;/span&gt;            show this &lt;span class=&quot;nb&quot;&gt;help &lt;/span&gt;message and &lt;span class=&quot;nb&quot;&gt;exit&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--text&lt;/span&gt; TEXT           Set the text
  &lt;span class=&quot;nt&quot;&gt;--backsplash&lt;/span&gt; BACKSPLASH
                        Choose backsplash
  &lt;span class=&quot;nt&quot;&gt;--font&lt;/span&gt; FONT           Set the font
  &lt;span class=&quot;nt&quot;&gt;--pattern&lt;/span&gt; PATTERN     Set the pattern &lt;span class=&quot;k&quot;&gt;for &lt;/span&gt;backsplash
  &lt;span class=&quot;nt&quot;&gt;--margin&lt;/span&gt; MARGIN       Margin top and bottom &lt;span class=&quot;k&quot;&gt;for &lt;/span&gt;text
  &lt;span class=&quot;nt&quot;&gt;--recurse-path&lt;/span&gt; RECURSE_PATH
                        Path to recurse and apply tattoo
  &lt;span class=&quot;nt&quot;&gt;--file&lt;/span&gt; FILE, &lt;span class=&quot;nt&quot;&gt;-f&lt;/span&gt; FILE  Read text from file
  &lt;span class=&quot;nt&quot;&gt;--overwrite&lt;/span&gt;           Overwrite existing tattoos &lt;span class=&quot;k&quot;&gt;in &lt;/span&gt;files
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;To use it, you can simply run it with the text you want to “tattoo” onto your files:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;tatuagem &lt;span class=&quot;nt&quot;&gt;--text&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;CONFIDENTIAL&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--recurse-path&lt;/span&gt; ./src
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Want a different font? Different decoration style? Read the text from a file instead of the command line?&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;tatuagem &lt;span class=&quot;nt&quot;&gt;--text&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;DRAFT v2.0&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--font&lt;/span&gt; ./myfont.ttf &lt;span class=&quot;nt&quot;&gt;--backsplash&lt;/span&gt; 2 &lt;span class=&quot;nt&quot;&gt;--recurse-path&lt;/span&gt; ./docs
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;tatuagem &lt;span class=&quot;nt&quot;&gt;--file&lt;/span&gt; ./license_header.txt &lt;span class=&quot;nt&quot;&gt;--pattern&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;*.py&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--recurse-path&lt;/span&gt; ./src &lt;span class=&quot;nt&quot;&gt;--overwrite&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--overwrite&lt;/code&gt; flag is key: it detects existing tatuagem stamps and replaces them rather than stacking duplicates. Without it, running the command twice would give you two banners. With it, the old tattoo gets cleanly replaced.&lt;/p&gt;

&lt;h2 id=&quot;design-choices&quot;&gt;Design Choices&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Pillow for text rendering&lt;/strong&gt;: I could have used a simpler ASCII art library, but Pillow gives us TrueType font support, which means the banner actually looks good. The pixel-to-Unicode conversion uses the block characters &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;▏▎▍▌▋▊▉█&lt;/code&gt; (among others) to approximate grayscale values. The result is surprisingly legible even at small font sizes.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Recursive by default, filterable by pattern&lt;/strong&gt;: The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--pattern&lt;/code&gt; flag lets you target only &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.py&lt;/code&gt; files, only &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.js&lt;/code&gt; files, or whatever subset you care about. No accidental tattooing of your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.png&lt;/code&gt; files.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;File-based text input&lt;/strong&gt;: The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--file&lt;/code&gt; flag reads the stamp text from a file, which is useful for multi-line license headers that would be awkward to pass as a CLI argument.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Derek handled the backsplash decoration system – the border art that frames the text – while I focused on the recursive file walking and the overwrite detection. Good division of labor for a weekend project.&lt;/p&gt;

&lt;p&gt;Built in Python. Published on PyPI. Does one thing. Does it well.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;                   ▋▄▄▄▄▂▉▋▎                                   ▏▎▋▉▂▄▄▄▃▌                       
                   ▇█████▅▅█▇▃▉▍      ▏▌            ▌       ▍▉▃▆▆▄▆█████▄                       
                   ▁███▆▇▃▇███▅▅▅▁▌▏   ▍▍          ▌▍   ▏▌▂▆▅▅███▆▄▆▆███▋                       
                    ▄██▂▄████▅▊▃███▇▁▍  ▏▍▎      ▎▍▏  ▍▂▇███▁▉▆████▄▃██▁                        
                    ▏▂█▇██▉▍▍▌▊▅▁▍▍▁▇█▅▊▏ ▏▍▎  ▎▍▏ ▏▊▅█▆▁▍▌▂▅▊▌▍▍▁██▇▇▉                         
                      ▉██▇▃▌▌▍▍▌▋▌▏ ▏▋▇█▇▉▏ ▎▋▋▎ ▏▉▇█▆▋▏ ▏▌▋▌▍▍▍▋▄▇██▋                          
                      ▏▇█▇▊▍▍▍▍▍▍▌▋▋▌▍▆███▇▊▂██▉▉▇██▇▂▌▋▋▊▌▍▍▍▍▍▍▁▇█▅                           
                       ▍██▃▎   ▏▍▍▌▋▂▆▇█████▇▇████████▆▄▋▍▍▍▏  ▏▍▄██▎                           
                        ▁█▇▁▌▍▍▎▏    ▏▊▇█████▆▇█████▂▎     ▏▎▍▍▋▂██▊                            
                        ▏▃█▆▊▋▉▂▄▆▇▇████████████████████▇▇▆▄▂▉▋▉▇█▉                             
                          ▋▃▆▇▇▇██▄▁▁▁▄▊▉█████████▇▊▋▃▁▂▂▄██▆▇▇▆▃▌                              
                              ▉██▂▍▍▍▊▎  ▁████████▉  ▎▋▍▍▍▄█▇▊                                  
                             ▌██▇▌▍▍▎▊▏▍▊▎▃▇█████▂▎▋▍▏▋▎▍▍▌███▎                                 
                             ▋███▋▎▌▎▊▍▌▏▍▎▆████▅▌▎▏▌▍▋▎▌▍▂███▍                                 
                              ▆█▇█▄▎▊▏▏▋ ▋ █████▅▏▋ ▋▏▎▊▌▅█▇█▄                                  
                              ▏▄████▅▋▂▏ ▊ ▇████▅ ▊ ▎▁▋▅████▃                                   
                                ▌▅█▇█▇█▆▅▅▊▇████▇▉▆▅▆█▇▇██▅▎                                    
                                  ▎▃▅▇█▇▇██████████▇▇█▇▅▃▎                                      
                                     ▍▄▆████████████▆▃▎                                         
                                   ▏▉▇▂▏▉▇████████▇▉▏▂▇▉▏                                       
                                ▎▊▃███▄  ▏▊▅████▅▊▏  ▃███▃▊▎                                    
                           ▏▍▊▃▆██████▅▊   ▏▉▇▇▊    ▋▅██████▆▃▊▍▏                               
                       ▏▌▁▄▇██████████▆▎▊▏▎▊▅██▅▊▎▏▊▎▆███████████▄▁▌▏                           
                     ▏▄████████████████▏▏▊▌ ▍██▍ ▍▊▏▎████████████████▄                          
                     ▌█████████████████▊    ▃██▃    ▊█████████████████▌                         
                     ▃█████████████████▄   ▏████▏   ▄█████████████████▂                         
                    ▏▇██████████████████▎  ▌████▍  ▎██████████████████▆                         
                    ▍███████████████████▁  ▊████▊  ▁███████████████████▏                        
                    ▊███████████████████▇▏ ▁████▁ ▏▇███████▆▃▉▄████████▌                        
                    ▁████████████████████▉ ▂████▂ ▉█████▄▁▊▉▁▂▆████████▊                        
                    ▃████████████████████▇▏▄████▄▏▇████████████████████▁                        
                    ▄█████████████████████▁▄████▄▁█████████████████████▃                        
                    ▅██████████████████████▇████▇██████████████████████▄                        
                    ▆██████████████████████████████████████████████████▅                        
                    ▇██████████████████████████████████████████████████▅                        
                    ▇████████▂████████████████████████████████████▂████████▅                        
                    ▇███████▆▍████████████████████████████████████▎▆███████▅                        
                    ▇███████▁ ▆██████████████████████████████████▆ ▁███████▅                        
                    ▇███████▅ ▅██████████████████████████████████▅ ▄███████▇                        
                    ████████▉ ▅██████████████████████████████████▄ ▉████████                        
                    ▄▄▄▄▄▄▄▅▂ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▂ ▂▄▄▄▄▄▄▄▃                        
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
</content>
 </entry>
 
 <entry>
   <title>On Multiplication</title>
   <link href="http://hankquinlan.github.io/blog/2025/12/19/On-Multiplication"/>
   <updated>2025-12-19T00:00:00+00:00</updated>
   <id>http://juleshenry.github.io//blog/2025/12/19/On-Multiplication</id>
   <content type="html">&lt;h1 id=&quot;intro&quot;&gt;Intro&lt;/h1&gt;

&lt;p&gt;For centuries, multiplying two $n$-digit numbers meant performing $n^2$ single-digit multiplications. In 1960, the great Andrey Kolmogorov conjectured that this quadratic cost was an inescapable law of arithmetic. Within a week, a 23-year-old student named Anatoly Karatsuba proved him wrong.&lt;/p&gt;

&lt;p&gt;This post traces the arc from schoolbook multiplication through the algorithms that successively shattered the $O(n^2)$ barrier: Karatsuba’s $O(n^{1.585})$ divide-and-conquer trick, the polynomial interpolation of Toom-Cook, the Fourier-analytic machinery of Schönhage-Strassen, and finally the 2019 result of Harvey and van der Hoeven achieving the conjectured floor of $O(n \log n)$. We close by examining the deep structural parallel between multiplication and sorting – both saturate the same information-theoretic bound.&lt;/p&gt;

&lt;p&gt;A caveat: in practice, these faster algorithms only overtake schoolbook multiplication at enormous scales. The Harvey-Hoeven algorithm’s crossover point lies somewhere beyond $2^{1729^{12}}$ digits – a number so large it cannot be written down even if every atom in the observable universe were an ink molecule. These are “galactic algorithms,” beautiful and useless in equal measure. But their existence reveals something profound about the structure of computation itself.&lt;/p&gt;

&lt;h1 id=&quot;schoolbook-multiplication-fundamentally-on2&quot;&gt;Schoolbook Multiplication: Fundamentally $O(n^2)$&lt;/h1&gt;

&lt;p&gt;To understand why we are traditionally tethered to $O(n^2)$, let us cast our minds back to the elementary school chalkboard. When we multiply two $n$-digit integers—say, $A$ and $B$—we are essentially performing a series of repetitive, granular tasks that scale quadratically with the input size.&lt;/p&gt;
&lt;h2 id=&quot;the-anatomy-of-the-partial-product&quot;&gt;The Anatomy of the Partial Product&lt;/h2&gt;

&lt;p&gt;First, consider the “multiplication phase.” We take the first digit of the multiplier ($B$) and multiply it by every single digit of the multiplicand ($A$). If both numbers have $n$ digits, this initial step requires $n$ individual single-digit multiplications.&lt;/p&gt;

&lt;p&gt;Now, we must repeat this process for the second digit of $B$, then the third, and so on, until we have exhausted all $n$ digits of the multiplier. Mathematically, we are performing $n$ sets of $n$ multiplications. This gives us $n \times n$, or $n^2$ fundamental operations.&lt;/p&gt;
&lt;h2 id=&quot;the-cost-of-alignment-and-addition&quot;&gt;The Cost of Alignment and Addition&lt;/h2&gt;

&lt;p&gt;Once we have generated these $n$ rows of partial products, the work is not yet finished. We must then perform the “addition phase.” Each row is shifted to the left—a symbolic representation of multiplying by powers of 10—and then summed together.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Each partial product can have up to $n+1$ digits.&lt;/li&gt;
  &lt;li&gt;We are summing $n$ such rows.&lt;/li&gt;
  &lt;li&gt;The total number of additions required to collapse these rows into a final product also scales with $n^2$.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;the-quadratic-ceiling&quot;&gt;The Quadratic Ceiling&lt;/h2&gt;

&lt;p&gt;In the lexicon of Big O notation, we ignore the smaller constants and focus on the dominant growth factor. While you might perform some clever carries or skip a few zeros, the fundamental structure of the algorithm remains a nested loop: for every digit in the bottom number, you must visit every digit in the top number.
\(\sum_{i=1}^{n}\sum_{j=1}^{n} \big(A_j \times B_i\big) \;\Longrightarrow\; O(n^2)\)&lt;/p&gt;

&lt;p&gt;Thus, the “schoolbook” method represents a rigid, two-dimensional grid of operations. To break the $O(n^2)$ barrier, as Harvey and van der Hoeven have done, one must move beyond this grid entirely—treating integers not as mere strings of digits, but as polynomials or points in a complex plane.&lt;/p&gt;

&lt;h1 id=&quot;karatsuba-breaking-on2-with-onlog_2-3&quot;&gt;Karatsuba: Breaking $O(n^2)$ with $O(n^{\log_2 3})$&lt;/h1&gt;

&lt;p&gt;Kolmogorov organized a seminar in 1960 specifically to prove that $O(n^2)$ was the floor for multiplication. He was wrong. A 23-year-old student named &lt;strong&gt;Anatoly Karatsuba&lt;/strong&gt; attended that seminar and, within a week, returned with a counterexample that reduced the exponent from 2 to $\log_2 3 \approx 1.585$.&lt;/p&gt;

&lt;p&gt;The idea is pure divide-and-conquer, but with an algebraic twist that turns four sub-multiplications into three.&lt;/p&gt;

&lt;h3 id=&quot;splitting-the-numbers&quot;&gt;Splitting the Numbers&lt;/h3&gt;

&lt;p&gt;Take two $n$-digit numbers $x$ and $y$. Cut each in half at position $m = \lfloor n/2 \rfloor$, writing them as a “high part” times a power of the base plus a “low part”:&lt;/p&gt;

\[\begin{aligned}
x &amp;amp;= x_1 B^m + x_0 \\
y &amp;amp;= y_1 B^m + y_0
\end{aligned}\]

&lt;p&gt;Concretely: if $x = 1234$ in base 10, then $x_1 = 12$, $x_0 = 34$, and $m = 2$.&lt;/p&gt;

&lt;p&gt;Expanding the product naively gives:&lt;/p&gt;

\[xy = x_1 y_1 \cdot B^{2m} + (x_1 y_0 + x_0 y_1) \cdot B^m + x_0 y_0\]

&lt;p&gt;This expression requires &lt;strong&gt;four&lt;/strong&gt; half-size multiplications: $x_1 y_1$, $x_1 y_0$, $x_0 y_1$, and $x_0 y_0$. Four recursive calls on inputs of size $n/2$ gives recurrence $T(n) = 4T(n/2) + O(n)$, which solves to $O(n^2)$ by the Master Theorem – no improvement at all.&lt;/p&gt;

&lt;h3 id=&quot;the-trick-three-multiplications-suffice&quot;&gt;The Trick: Three Multiplications Suffice&lt;/h3&gt;

&lt;p&gt;Karatsuba’s insight is that we never need the cross-terms $x_1 y_0$ and $x_0 y_1$ individually. We only need their &lt;strong&gt;sum&lt;/strong&gt;. And that sum falls out for free from a single cleverly chosen multiplication.&lt;/p&gt;

&lt;p&gt;Define three products:&lt;/p&gt;

\[\begin{aligned}
z_2 &amp;amp;= x_1 \cdot y_1 \\
z_0 &amp;amp;= x_0 \cdot y_0 \\
z_1 &amp;amp;= (x_1 + x_0)(y_1 + y_0) - z_2 - z_0
\end{aligned}\]

&lt;p&gt;Expand $z_1$ to see why this works:&lt;/p&gt;

\[(x_1 + x_0)(y_1 + y_0) = \underbrace{x_1 y_1}_{z_2} + x_1 y_0 + x_0 y_1 + \underbrace{x_0 y_0}_{z_0}\]

&lt;p&gt;Subtracting $z_2$ and $z_0$ cancels the terms we already know, leaving exactly the cross-term sum $x_1 y_0 + x_0 y_1$. We have extracted the middle coefficient using &lt;strong&gt;one&lt;/strong&gt; multiplication and &lt;strong&gt;two&lt;/strong&gt; subtractions – operations that cost only $O(n)$, negligible compared to multiplication.&lt;/p&gt;

&lt;p&gt;The final product assembles as:&lt;/p&gt;

\[xy = z_2 \cdot B^{2m} + z_1 \cdot B^m + z_0\]

&lt;p&gt;Three multiplications. Not four. At every level of the recursion, we save 25% of the multiplicative work, and that savings compounds exponentially as we recurse deeper.&lt;/p&gt;

&lt;h3 id=&quot;the-payoff&quot;&gt;The Payoff&lt;/h3&gt;

&lt;p&gt;The recurrence is now $T(n) = 3T(n/2) + O(n)$, and the Master Theorem gives:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Method&lt;/th&gt;
      &lt;th&gt;Recurrence&lt;/th&gt;
      &lt;th&gt;Complexity&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Schoolbook&lt;/td&gt;
      &lt;td&gt;$T(n)=4T(n/2)+O(n)$&lt;/td&gt;
      &lt;td&gt;$O(n^{\log_2 4}) = O(n^2)$&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Karatsuba&lt;/td&gt;
      &lt;td&gt;$T(n)=3T(n/2)+O(n)$&lt;/td&gt;
      &lt;td&gt;$O(n^{\log_2 3}) \approx O(n^{1.585})$&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The gap between $n^2$ and $n^{1.585}$ may look modest for small $n$, but it widens relentlessly. At 1,000 digits the schoolbook method performs $\sim 10^6$ primitive multiplications; Karatsuba performs $\sim 10^{4.75} \approx 56{,}000$ – a 17$\times$ speedup. At 10,000 digits the ratio exceeds 100$\times$. The deeper the recursion, the more the saved quarter compounds.&lt;/p&gt;

&lt;p&gt;The interactive visualization below shows the heart of the trick: how four multiplication blocks collapse into three.&lt;/p&gt;

&lt;div id=&quot;karatsuba-viz&quot; style=&quot;width: 100%; height: 520px; margin: 2em 0; border-radius: 8px; overflow: hidden; background: #0f172a; position: relative;&quot;&gt;
  &lt;div id=&quot;karatsuba-phase-label&quot; style=&quot;position: absolute; top: 16px; left: 50%; transform: translateX(-50%); color: #e2e8f0; font-family: monospace; font-size: 15px; z-index: 10; pointer-events: none; text-align: center; white-space: nowrap;&quot;&gt;&lt;/div&gt;
  &lt;div id=&quot;karatsuba-count-label&quot; style=&quot;position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); color: #94a3b8; font-family: monospace; font-size: 13px; z-index: 10; pointer-events: none; text-align: center;&quot;&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;script&gt;
(function() {
  function initKaratsuba() {
    if (typeof THREE === &apos;undefined&apos;) { setTimeout(initKaratsuba, 100); return; }

    var container = document.getElementById(&apos;karatsuba-viz&apos;);
    if (!container) return;

    var phaseLabel = document.getElementById(&apos;karatsuba-phase-label&apos;);
    var countLabel = document.getElementById(&apos;karatsuba-count-label&apos;);

    // --- Scene setup ---
    var scene = new THREE.Scene();
    scene.background = new THREE.Color(0x0f172a);

    var W = container.clientWidth, H = 520;
    var camera = new THREE.PerspectiveCamera(50, W / H, 0.1, 100);
    camera.position.set(0, 2.5, 7.5);
    camera.lookAt(0, 0, 0);

    var renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(W, H);
    renderer.setPixelRatio(window.devicePixelRatio);
    container.appendChild(renderer.domElement);

    // --- Lights ---
    scene.add(new THREE.AmbientLight(0xffffff, 0.45));
    var dLight = new THREE.DirectionalLight(0xffffff, 0.9);
    dLight.position.set(4, 6, 5);
    scene.add(dLight);
    var dLight2 = new THREE.DirectionalLight(0x6366f1, 0.35);
    dLight2.position.set(-4, -3, -4);
    scene.add(dLight2);

    // --- Colors ---
    var COL_Z2    = 0x6366f1; // indigo — z2 = x1*y1
    var COL_CROSS = 0xf59e0b; // amber  — cross terms
    var COL_Z0    = 0x22c55e; // green  — z0 = x0*y0
    var COL_Z1    = 0xa855f7; // purple — z1 (merged)
    var COL_SUB   = 0xef4444; // red    — subtraction blocks
    var COL_GHOST = 0x334155; // slate  — ghost/fading

    // --- Helper: create labeled block ---
    function makeBlock(w, h, d, color) {
      var geo = new THREE.BoxGeometry(w, h, d);
      var mat = new THREE.MeshStandardMaterial({
        color: color, metalness: 0.35, roughness: 0.55, transparent: true, opacity: 1.0
      });
      var mesh = new THREE.Mesh(geo, mat);
      return mesh;
    }

    // --- Helper: create text sprite ---
    function makeLabel(text, fontSize) {
      var canvas = document.createElement(&apos;canvas&apos;);
      var sz = fontSize || 48;
      canvas.width = 512; canvas.height = 128;
      var ctx = canvas.getContext(&apos;2d&apos;);
      ctx.clearRect(0, 0, 512, 128);
      ctx.fillStyle = &apos;#e2e8f0&apos;;
      ctx.font = &apos;bold &apos; + sz + &apos;px monospace&apos;;
      ctx.textAlign = &apos;center&apos;;
      ctx.textBaseline = &apos;middle&apos;;
      ctx.fillText(text, 256, 64);
      var tex = new THREE.CanvasTexture(canvas);
      tex.minFilter = THREE.LinearFilter;
      var spriteMat = new THREE.SpriteMaterial({ map: tex, transparent: true, opacity: 1.0 });
      var sprite = new THREE.Sprite(spriteMat);
      sprite.scale.set(2.2, 0.55, 1);
      return sprite;
    }

    // --- Block dimensions ---
    var BW = 1.3, BH = 0.9, BD = 0.8;
    var GAP = 0.3;

    // --- Phase 1: Four schoolbook blocks (2x2 grid) ---
    var b_x1y1 = makeBlock(BW, BH, BD, COL_Z2);
    var b_x1y0 = makeBlock(BW, BH, BD, COL_CROSS);
    var b_x0y1 = makeBlock(BW, BH, BD, COL_CROSS);
    var b_x0y0 = makeBlock(BW, BH, BD, COL_Z0);

    // grid positions (centered)
    var gx = (BW + GAP) * 0.55;
    var gy = (BH + GAP) * 0.55;
    b_x1y1.position.set(-gx,  gy, 0);
    b_x1y0.position.set( gx,  gy, 0);
    b_x0y1.position.set(-gx, -gy, 0);
    b_x0y0.position.set( gx, -gy, 0);

    // labels for each block
    var l_x1y1 = makeLabel(&apos;x\u2081y\u2081&apos;);
    var l_x1y0 = makeLabel(&apos;x\u2081y\u2080&apos;);
    var l_x0y1 = makeLabel(&apos;x\u2080y\u2081&apos;);
    var l_x0y0 = makeLabel(&apos;x\u2080y\u2080&apos;);

    l_x1y1.position.set(0, 0, BD / 2 + 0.15);
    l_x1y0.position.set(0, 0, BD / 2 + 0.15);
    l_x0y1.position.set(0, 0, BD / 2 + 0.15);
    l_x0y0.position.set(0, 0, BD / 2 + 0.15);

    b_x1y1.add(l_x1y1);
    b_x1y0.add(l_x1y0);
    b_x0y1.add(l_x0y1);
    b_x0y0.add(l_x0y0);

    scene.add(b_x1y1); scene.add(b_x1y0);
    scene.add(b_x0y1); scene.add(b_x0y0);

    // --- Phase 2: Karatsuba merged block (z1) ---
    var b_z1 = makeBlock(BW, BH, BD, COL_Z1);
    var l_z1 = makeLabel(&apos;z\u2081&apos;, 40);
    l_z1.position.set(0, 0, BD / 2 + 0.15);
    b_z1.add(l_z1);
    b_z1.material.opacity = 0;
    b_z1.position.set(0, gy, 0); // will appear between the cross-term positions
    scene.add(b_z1);

    // Small subtraction indicators
    var subSize = 0.45;
    var b_subZ2 = makeBlock(subSize, subSize, subSize, COL_SUB);
    var l_subZ2 = makeLabel(&apos;-z\u2082&apos;, 36);
    l_subZ2.position.set(0, 0, subSize / 2 + 0.1);
    b_subZ2.add(l_subZ2);
    b_subZ2.material.opacity = 0;
    l_subZ2.material.opacity = 0;

    var b_subZ0 = makeBlock(subSize, subSize, subSize, COL_SUB);
    var l_subZ0 = makeLabel(&apos;-z\u2080&apos;, 36);
    l_subZ0.position.set(0, 0, subSize / 2 + 0.1);
    b_subZ0.add(l_subZ0);
    b_subZ0.material.opacity = 0;
    l_subZ0.material.opacity = 0;

    b_subZ2.position.set(-0.5, gy - BH * 0.7, 0.5);
    b_subZ0.position.set(0.5, gy - BH * 0.7, 0.5);
    scene.add(b_subZ2); scene.add(b_subZ0);

    // --- Phase 3: Final result labels ---
    var l_z2_final = makeLabel(&apos;z\u2082&apos;, 44);
    l_z2_final.material.opacity = 0;
    var l_z0_final = makeLabel(&apos;z\u2080&apos;, 44);
    l_z0_final.material.opacity = 0;

    // --- Easing ---
    function easeInOutCubic(t) {
      return t &lt; 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
    }

    function lerp(a, b, t) { return a + (b - a) * t; }

    // --- Animation state ---
    var PHASE_DURATION = 3.0;   // seconds per phase
    var PAUSE_DURATION = 1.5;   // pause between phases
    var TOTAL_CYCLE = PHASE_DURATION * 3 + PAUSE_DURATION * 4; // 3 phases + 4 pauses (before, between, after)
    var clock = new THREE.Clock();
    var elapsed = 0;

    // --- Target positions for phase 3 (horizontal row) ---
    var ROW_SPACING = BW + GAP + 0.2;
    var finalZ2pos = { x: -ROW_SPACING, y: 0, z: 0 };
    var finalZ1pos = { x: 0,            y: 0, z: 0 };
    var finalZ0pos = { x:  ROW_SPACING, y: 0, z: 0 };

    // Store initial positions for reset
    var init_x1y1 = b_x1y1.position.clone();
    var init_x1y0 = b_x1y0.position.clone();
    var init_x0y1 = b_x0y1.position.clone();
    var init_x0y0 = b_x0y0.position.clone();

    function animate() {
      requestAnimationFrame(animate);
      var dt = clock.getDelta();
      elapsed += dt;

      var cycleT = elapsed % TOTAL_CYCLE;
      var t, e;

      // Phase timeline:
      // [0, PAUSE] -&gt; pause (show 4 blocks)
      // [PAUSE, PAUSE+DUR] -&gt; phase 1: merge cross-terms
      // [PAUSE+DUR, 2*PAUSE+DUR] -&gt; pause
      // [2*PAUSE+DUR, 2*PAUSE+2*DUR] -&gt; phase 2: rearrange to row
      // [2*PAUSE+2*DUR, 3*PAUSE+2*DUR] -&gt; pause
      // [3*PAUSE+2*DUR, 3*PAUSE+3*DUR] -&gt; phase 3: reset
      // [3*PAUSE+3*DUR, 4*PAUSE+3*DUR] -&gt; pause

      var P = PAUSE_DURATION, D = PHASE_DURATION;
      var t0 = P;
      var t1 = P + D;
      var t2 = 2 * P + D;
      var t3 = 2 * P + 2 * D;
      var t4 = 3 * P + 2 * D;
      var t5 = 3 * P + 3 * D;

      if (cycleT &lt; t0) {
        // --- Initial pause: show 4 blocks in grid ---
        resetToPhase0();
        phaseLabel.textContent = &apos;Schoolbook: 4 multiplications&apos;;
        countLabel.textContent = &apos;x\u2081y\u2081 \u00B7 x\u2081y\u2080 \u00B7 x\u2080y\u2081 \u00B7 x\u2080y\u2080&apos;;

      } else if (cycleT &lt; t1) {
        // --- Phase 1: merge cross-terms into z1 ---
        t = (cycleT - t0) / D;
        e = easeInOutCubic(t);

        phaseLabel.textContent = &apos;Karatsuba\&apos;s trick: merge the cross-terms&apos;;
        countLabel.textContent = &apos;(x\u2081+x\u2080)(y\u2081+y\u2080) - z\u2082 - z\u2080 = x\u2081y\u2080 + x\u2080y\u2081&apos;;

        // Cross-term blocks slide together and fade out
        b_x1y0.position.x = lerp(init_x1y0.x, 0, e);
        b_x1y0.position.y = lerp(init_x1y0.y, gy, e);
        b_x0y1.position.x = lerp(init_x0y1.x, 0, e);
        b_x0y1.position.y = lerp(init_x0y1.y, gy, e);

        // Fade out cross-term blocks in second half
        var fadeT = Math.max(0, (t - 0.4) / 0.6);
        var fadeE = easeInOutCubic(Math.min(1, fadeT));
        b_x1y0.material.opacity = 1 - fadeE;
        l_x1y0.material.opacity = 1 - fadeE;
        b_x0y1.material.opacity = 1 - fadeE;
        l_x0y1.material.opacity = 1 - fadeE;

        // Fade in z1 block
        var appearT = Math.max(0, (t - 0.3) / 0.7);
        var appearE = easeInOutCubic(Math.min(1, appearT));
        b_z1.material.opacity = appearE;
        l_z1.material.opacity = appearE;
        b_z1.position.set(0, gy, 0);

        // Fade in subtraction blocks
        var subT = Math.max(0, (t - 0.55) / 0.45);
        var subE = easeInOutCubic(Math.min(1, subT));
        b_subZ2.material.opacity = subE * 0.85;
        l_subZ2.material.opacity = subE;
        b_subZ0.material.opacity = subE * 0.85;
        l_subZ0.material.opacity = subE;

      } else if (cycleT &lt; t2) {
        // --- Pause: show merged state ---
        setMergedState();
        phaseLabel.textContent = &apos;Result: 3 multiplications&apos;;
        countLabel.textContent = &apos;z\u2082 = x\u2081y\u2081  \u00B7  z\u2081 = (x\u2081+x\u2080)(y\u2081+y\u2080) - z\u2082 - z\u2080  \u00B7  z\u2080 = x\u2080y\u2080&apos;;

      } else if (cycleT &lt; t3) {
        // --- Phase 2: rearrange into a horizontal row ---
        t = (cycleT - t2) / D;
        e = easeInOutCubic(t);

        phaseLabel.textContent = &apos;Assembling the product: z\u2082\u00B7B\u00B2\u1D50 + z\u2081\u00B7B\u1D50 + z\u2080&apos;;
        countLabel.textContent = &apos;4 \u2192 3 multiplications \u00B7 25% saved per recursion level&apos;;

        // Move z2 (x1y1) to left position
        b_x1y1.position.x = lerp(init_x1y1.x, finalZ2pos.x, e);
        b_x1y1.position.y = lerp(init_x1y1.y, finalZ2pos.y, e);

        // Move z1 to center
        b_z1.position.x = lerp(0, finalZ1pos.x, e);
        b_z1.position.y = lerp(gy, finalZ1pos.y, e);

        // Move z0 (x0y0) to right position
        b_x0y0.position.x = lerp(init_x0y0.x, finalZ0pos.x, e);
        b_x0y0.position.y = lerp(init_x0y0.y, finalZ0pos.y, e);

        // Fade subtraction blocks out (absorbed into z1)
        var subFadeT = Math.max(0, (t - 0.1) / 0.5);
        var subFadeE = easeInOutCubic(Math.min(1, subFadeT));
        b_subZ2.material.opacity = 0.85 * (1 - subFadeE);
        l_subZ2.material.opacity = 1 - subFadeE;
        b_subZ0.material.opacity = 0.85 * (1 - subFadeE);
        l_subZ0.material.opacity = 1 - subFadeE;

        // Replace block labels with z-notation
        // Swap x1y1 label -&gt; z2
        if (t &gt; 0.5) {
          l_x1y1.material.opacity = 0;
          l_z2_final.material.opacity = easeInOutCubic((t - 0.5) / 0.5);
          l_z2_final.position.copy(b_x1y1.position);
          l_z2_final.position.z += BD / 2 + 0.25;
        }
        if (t &gt; 0.5) {
          l_x0y0.material.opacity = 0;
          l_z0_final.material.opacity = easeInOutCubic((t - 0.5) / 0.5);
          l_z0_final.position.copy(b_x0y0.position);
          l_z0_final.position.z += BD / 2 + 0.25;
        }

      } else if (cycleT &lt; t4) {
        // --- Pause: show final row ---
        setFinalRow();
        phaseLabel.textContent = &apos;xy = z\u2082\u00B7B\u00B2\u1D50 + z\u2081\u00B7B\u1D50 + z\u2080&apos;;
        countLabel.textContent = &apos;Three multiplications suffice.&apos;;

      } else if (cycleT &lt; t5) {
        // --- Phase 3: reset back to 4 blocks ---
        t = (cycleT - t4) / D;
        e = easeInOutCubic(t);

        phaseLabel.textContent = &apos;&apos;;
        countLabel.textContent = &apos;&apos;;

        // Fade everything out, then snap back
        var fadeAll = 1 - easeInOutCubic(Math.min(1, t * 2));
        b_x1y1.material.opacity = fadeAll;
        l_x1y1.material.opacity = fadeAll;
        b_x0y0.material.opacity = fadeAll;
        l_x0y0.material.opacity = fadeAll;
        b_z1.material.opacity = fadeAll;
        l_z1.material.opacity = fadeAll;
        l_z2_final.material.opacity = 0;
        l_z0_final.material.opacity = 0;
        b_subZ2.material.opacity = 0;
        b_subZ0.material.opacity = 0;
        l_subZ2.material.opacity = 0;
        l_subZ0.material.opacity = 0;

        // In second half, fade 4 blocks back in at starting positions
        if (t &gt; 0.5) {
          var fadeIn = easeInOutCubic((t - 0.5) / 0.5);
          resetPositions();
          b_x1y1.material.opacity = fadeIn;
          l_x1y1.material.opacity = fadeIn;
          b_x1y0.material.opacity = fadeIn;
          l_x1y0.material.opacity = fadeIn;
          b_x0y1.material.opacity = fadeIn;
          l_x0y1.material.opacity = fadeIn;
          b_x0y0.material.opacity = fadeIn;
          l_x0y0.material.opacity = fadeIn;
          b_z1.material.opacity = 0;
          l_z1.material.opacity = 0;
        }

      } else {
        // --- Final pause before loop ---
        resetToPhase0();
        phaseLabel.textContent = &apos;Schoolbook: 4 multiplications&apos;;
        countLabel.textContent = &apos;x\u2081y\u2081 \u00B7 x\u2081y\u2080 \u00B7 x\u2080y\u2081 \u00B7 x\u2080y\u2080&apos;;
      }

      // Gentle scene rotation
      scene.rotation.y = Math.sin(elapsed * 0.15) * 0.08;

      renderer.render(scene, camera);
    }

    function resetPositions() {
      b_x1y1.position.copy(init_x1y1);
      b_x1y0.position.copy(init_x1y0);
      b_x0y1.position.copy(init_x0y1);
      b_x0y0.position.copy(init_x0y0);
      b_z1.position.set(0, gy, 0);
      b_subZ2.position.set(-0.5, gy - BH * 0.7, 0.5);
      b_subZ0.position.set(0.5, gy - BH * 0.7, 0.5);
    }

    function resetToPhase0() {
      resetPositions();
      b_x1y1.material.opacity = 1; l_x1y1.material.opacity = 1;
      b_x1y0.material.opacity = 1; l_x1y0.material.opacity = 1;
      b_x0y1.material.opacity = 1; l_x0y1.material.opacity = 1;
      b_x0y0.material.opacity = 1; l_x0y0.material.opacity = 1;
      b_z1.material.opacity = 0;   l_z1.material.opacity = 0;
      b_subZ2.material.opacity = 0; l_subZ2.material.opacity = 0;
      b_subZ0.material.opacity = 0; l_subZ0.material.opacity = 0;
      l_z2_final.material.opacity = 0;
      l_z0_final.material.opacity = 0;
      b_x1y1.material.color.setHex(COL_Z2);
      b_x0y0.material.color.setHex(COL_Z0);
    }

    function setMergedState() {
      b_x1y1.position.copy(init_x1y1);
      b_x1y1.material.opacity = 1; l_x1y1.material.opacity = 1;
      b_x1y0.material.opacity = 0; l_x1y0.material.opacity = 0;
      b_x0y1.material.opacity = 0; l_x0y1.material.opacity = 0;
      b_x0y0.position.copy(init_x0y0);
      b_x0y0.material.opacity = 1; l_x0y0.material.opacity = 1;
      b_z1.position.set(0, gy, 0);
      b_z1.material.opacity = 1; l_z1.material.opacity = 1;
      b_subZ2.material.opacity = 0.85; l_subZ2.material.opacity = 1;
      b_subZ0.material.opacity = 0.85; l_subZ0.material.opacity = 1;
      l_z2_final.material.opacity = 0;
      l_z0_final.material.opacity = 0;
    }

    function setFinalRow() {
      b_x1y1.position.set(finalZ2pos.x, finalZ2pos.y, finalZ2pos.z);
      b_x1y1.material.opacity = 1; l_x1y1.material.opacity = 0;
      b_z1.position.set(finalZ1pos.x, finalZ1pos.y, finalZ1pos.z);
      b_z1.material.opacity = 1; l_z1.material.opacity = 1;
      b_x0y0.position.set(finalZ0pos.x, finalZ0pos.y, finalZ0pos.z);
      b_x0y0.material.opacity = 1; l_x0y0.material.opacity = 0;
      b_x1y0.material.opacity = 0; l_x1y0.material.opacity = 0;
      b_x0y1.material.opacity = 0; l_x0y1.material.opacity = 0;
      b_subZ2.material.opacity = 0; l_subZ2.material.opacity = 0;
      b_subZ0.material.opacity = 0; l_subZ0.material.opacity = 0;
      l_z2_final.position.set(finalZ2pos.x, finalZ2pos.y, finalZ2pos.z + BD / 2 + 0.25);
      l_z2_final.material.opacity = 1;
      l_z0_final.position.set(finalZ0pos.x, finalZ0pos.y, finalZ0pos.z + BD / 2 + 0.25);
      l_z0_final.material.opacity = 1;
    }

    scene.add(l_z2_final);
    scene.add(l_z0_final);

    // --- Resize handler ---
    window.addEventListener(&apos;resize&apos;, function() {
      var nw = container.clientWidth;
      camera.aspect = nw / H;
      camera.updateProjectionMatrix();
      renderer.setSize(nw, H);
    });

    animate();
  }

  if (document.readyState === &apos;loading&apos;) {
    document.addEventListener(&apos;DOMContentLoaded&apos;, initKaratsuba);
  } else {
    initKaratsuba();
  }
})();
&lt;/script&gt;

&lt;h1 id=&quot;toom-cook-generalizing-karatsuba-via-polynomial-interpolation&quot;&gt;Toom-Cook: Generalizing Karatsuba via Polynomial Interpolation&lt;/h1&gt;

&lt;h2 id=&quot;from-digits-to-polynomials&quot;&gt;From Digits to Polynomials&lt;/h2&gt;

&lt;p&gt;Karatsuba showed that splitting a number in two and exploiting an algebraic identity could reduce four half-size multiplications to three. A natural question follows: what happens if we split into &lt;em&gt;three&lt;/em&gt; pieces? Or $k$? This is exactly the generalization that Andrei Toom (1963) and Stephen Cook (1966) independently formalized. The resulting family of algorithms – collectively known as &lt;strong&gt;Toom-Cook&lt;/strong&gt; or &lt;strong&gt;Toom-$k$&lt;/strong&gt; – systematically trades more additions and scalar operations for fewer recursive multiplications, pushing the exponent ever closer to 1.&lt;/p&gt;

&lt;p&gt;The key conceptual shift is to stop viewing integers as flat strings of digits and instead view them as &lt;strong&gt;polynomials&lt;/strong&gt;. If we partition an $n$-digit number into $k$ blocks of roughly $m = \lceil n/k \rceil$ digits each, writing $B = 10^m$ (or $2^m$ in binary), then:&lt;/p&gt;

\[x = a_{k-1} B^{k-1} + a_{k-2} B^{k-2} + \cdots + a_1 B + a_0\]

&lt;p&gt;This is simply the number $x$ evaluated at the point $z = B$ of the polynomial:&lt;/p&gt;

\[P_x(z) = a_{k-1} z^{k-1} + a_{k-2} z^{k-2} + \cdots + a_1 z + a_0\]

&lt;p&gt;Multiplying two such integers $x$ and $y$ is therefore equivalent to computing the &lt;strong&gt;product polynomial&lt;/strong&gt; $P_x(z) \cdot P_y(z)$ and then evaluating the result at $z = B$ (with appropriate carries). If each input polynomial has degree $k-1$, their product has degree $2(k-1) = 2k - 2$, and is therefore determined by exactly $2k - 1$ point-value pairs – a fact guaranteed by the &lt;strong&gt;Fundamental Theorem of Algebra&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is the heart of the speedup: instead of multiplying the coefficients pairwise (which would require $k^2$ recursive multiplications), we can recover the product polynomial from only $2k - 1$ pointwise products.&lt;/p&gt;

&lt;h2 id=&quot;the-five-phases-of-toom-cook&quot;&gt;The Five Phases of Toom-Cook&lt;/h2&gt;

&lt;p&gt;The algorithm proceeds through five clearly delineated stages:&lt;/p&gt;

&lt;h3 id=&quot;phase-1-splitting&quot;&gt;Phase 1: Splitting&lt;/h3&gt;

&lt;p&gt;Break each $n$-digit operand into $k$ blocks of $\sim n/k$ digits. For Toom-3, this yields three coefficients per number:&lt;/p&gt;

\[\begin{aligned}
x &amp;amp;= a_2 B^{2m} + a_1 B^m + a_0  &amp;amp;\longleftrightarrow\quad P_x(z) &amp;amp;= a_2 z^2 + a_1 z + a_0 \\
y &amp;amp;= b_2 B^{2m} + b_1 B^m + b_0  &amp;amp;\longleftrightarrow\quad P_y(z) &amp;amp;= b_2 z^2 + b_1 z + b_0
\end{aligned}\]

&lt;h3 id=&quot;phase-2-evaluation&quot;&gt;Phase 2: Evaluation&lt;/h3&gt;

&lt;p&gt;Select $2k - 1$ distinct evaluation points. The standard choice for Toom-3 is the set ${0,\; 1,\; -1,\; 2,\; \infty}$, chosen because they minimize the size of intermediate values and keep the arithmetic simple. Evaluate both polynomials at each point:&lt;/p&gt;

\[\begin{aligned}
P_x(0) &amp;amp;= a_0 &amp;amp; P_y(0) &amp;amp;= b_0 \\
P_x(1) &amp;amp;= a_2 + a_1 + a_0 &amp;amp; P_y(1) &amp;amp;= b_2 + b_1 + b_0 \\
P_x(-1) &amp;amp;= a_2 - a_1 + a_0 &amp;amp; P_y(-1) &amp;amp;= b_2 - b_1 + b_0 \\
P_x(2) &amp;amp;= 4a_2 + 2a_1 + a_0 &amp;amp; P_y(2) &amp;amp;= 4b_2 + 2b_1 + b_0 \\
P_x(\infty) &amp;amp;= a_2 &amp;amp; P_y(\infty) &amp;amp;= b_2
\end{aligned}\]

&lt;p&gt;The “evaluation at $\infty$” is a notational convenience: it extracts the leading coefficient of the polynomial, since $\lim_{z \to \infty} P(z)/z^{k-1}$ equals the leading coefficient.&lt;/p&gt;

&lt;h3 id=&quot;phase-3-pointwise-multiplication&quot;&gt;Phase 3: Pointwise Multiplication&lt;/h3&gt;

&lt;p&gt;Multiply the evaluated values at each point. These are the &lt;strong&gt;only&lt;/strong&gt; recursive multiplications the algorithm performs:&lt;/p&gt;

\[\begin{aligned}
W_0 &amp;amp;= P_x(0) \cdot P_y(0) = a_0 b_0 \\
W_1 &amp;amp;= P_x(1) \cdot P_y(1) \\
W_{-1} &amp;amp;= P_x(-1) \cdot P_y(-1) \\
W_2 &amp;amp;= P_x(2) \cdot P_y(2) \\
W_\infty &amp;amp;= P_x(\infty) \cdot P_y(\infty) = a_2 b_2
\end{aligned}\]

&lt;p&gt;Five multiplications on operands of size $\sim n/3$, rather than the nine that naive coefficient-by-coefficient expansion would require.&lt;/p&gt;

&lt;h3 id=&quot;phase-4-interpolation&quot;&gt;Phase 4: Interpolation&lt;/h3&gt;

&lt;p&gt;The product polynomial $R(z) = P_x(z) \cdot P_y(z)$ has degree 4, so it has five coefficients $C_0, C_1, C_2, C_3, C_4$:&lt;/p&gt;

\[R(z) = C_4 z^4 + C_3 z^3 + C_2 z^2 + C_1 z + C_0\]

&lt;p&gt;From the five evaluated products, we can read off:&lt;/p&gt;

\[\begin{aligned}
W_0 &amp;amp;= C_0 \\
W_1 &amp;amp;= C_4 + C_3 + C_2 + C_1 + C_0 \\
W_{-1} &amp;amp;= C_4 - C_3 + C_2 - C_1 + C_0 \\
W_2 &amp;amp;= 16C_4 + 8C_3 + 4C_2 + 2C_1 + C_0 \\
W_\infty &amp;amp;= C_4
\end{aligned}\]

&lt;p&gt;This is a $5 \times 5$ linear system in the unknowns $C_0, \ldots, C_4$. Because $C_0 = W_0$ and $C_4 = W_\infty$ are immediate, the system reduces quickly. The remaining coefficients are solved by elimination:&lt;/p&gt;

\[\begin{aligned}
C_0 &amp;amp;= W_0 \\[4pt]
C_4 &amp;amp;= W_\infty \\[4pt]
C_2 &amp;amp;= \frac{W_1 + W_{-1}}{2} - C_0 - C_4 \\[4pt]
C_3 &amp;amp;= \frac{W_2 - 2W_1 - 14C_4 - 2C_2 + C_0}{6} \\[4pt]
C_1 &amp;amp;= W_1 - C_4 - C_3 - C_2 - C_0
\end{aligned}\]

&lt;p&gt;Note that these expressions involve only additions, subtractions, and divisions by small constants (2 and 6) – all $O(n)$ operations, negligible compared to the recursive multiplications.&lt;/p&gt;

&lt;h3 id=&quot;phase-5-recomposition&quot;&gt;Phase 5: Recomposition&lt;/h3&gt;

&lt;p&gt;Reassemble the final integer from the product polynomial’s coefficients:&lt;/p&gt;

\[x \cdot y = C_4 B^{4m} + C_3 B^{3m} + C_2 B^{2m} + C_1 B^m + C_0\]

&lt;p&gt;The multiplications by powers of $B$ are simply left-shifts, and the final addition with carry propagation is $O(n)$.&lt;/p&gt;

&lt;h2 id=&quot;a-worked-example&quot;&gt;A Worked Example&lt;/h2&gt;

&lt;p&gt;To make this concrete, consider $x = 123{,}456{,}789$ and $y = 987{,}654{,}321$ in base 10 with $k = 3$ and $m = 3$ (so $B = 10^3 = 1000$):&lt;/p&gt;

\[\begin{aligned}
x &amp;amp;= 123 \cdot 10^6 + 456 \cdot 10^3 + 789 \quad\Longleftrightarrow\quad P_x(z) = 123z^2 + 456z + 789 \\
y &amp;amp;= 987 \cdot 10^6 + 654 \cdot 10^3 + 321 \quad\Longleftrightarrow\quad P_y(z) = 987z^2 + 654z + 321
\end{aligned}\]

&lt;p&gt;Evaluating at the five standard points:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Point&lt;/th&gt;
      &lt;th&gt;$P_x$&lt;/th&gt;
      &lt;th&gt;$P_y$&lt;/th&gt;
      &lt;th&gt;$W = P_x \cdot P_y$&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;$0$&lt;/td&gt;
      &lt;td&gt;$789$&lt;/td&gt;
      &lt;td&gt;$321$&lt;/td&gt;
      &lt;td&gt;$253{,}269$&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;$1$&lt;/td&gt;
      &lt;td&gt;$1{,}368$&lt;/td&gt;
      &lt;td&gt;$1{,}962$&lt;/td&gt;
      &lt;td&gt;$2{,}684{,}016$&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;$-1$&lt;/td&gt;
      &lt;td&gt;$456$&lt;/td&gt;
      &lt;td&gt;$654$&lt;/td&gt;
      &lt;td&gt;$298{,}224$&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;$2$&lt;/td&gt;
      &lt;td&gt;$2{,}193$&lt;/td&gt;
      &lt;td&gt;$5{,}595$&lt;/td&gt;
      &lt;td&gt;$12{,}269{,}835$&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;$\infty$&lt;/td&gt;
      &lt;td&gt;$123$&lt;/td&gt;
      &lt;td&gt;$987$&lt;/td&gt;
      &lt;td&gt;$121{,}401$&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Interpolation then yields the five coefficients $C_0, \ldots, C_4$, and recomposition with $B = 1000$ recovers the product $121{,}932{,}631{,}112{,}635{,}269$.&lt;/p&gt;

&lt;h2 id=&quot;complexity-analysis&quot;&gt;Complexity Analysis&lt;/h2&gt;

&lt;p&gt;The recurrence for Toom-$k$ is:&lt;/p&gt;

\[T(n) = (2k - 1)\, T\!\left(\frac{n}{k}\right) + O(n)\]

&lt;p&gt;By the Master Theorem, this solves to:&lt;/p&gt;

\[T(n) = O\!\left(n^{\log_k(2k-1)}\right)\]

&lt;p&gt;The exponent $\log_k(2k - 1)$ decreases monotonically as $k$ increases, approaching 1 from above:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Algorithm&lt;/th&gt;
      &lt;th&gt;Split ($k$)&lt;/th&gt;
      &lt;th&gt;Recursive Mults ($2k-1$)&lt;/th&gt;
      &lt;th&gt;Complexity&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Grade School&lt;/td&gt;
      &lt;td&gt;$n$&lt;/td&gt;
      &lt;td&gt;$n^2$&lt;/td&gt;
      &lt;td&gt;$O(n^2)$&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Karatsuba (Toom-2)&lt;/td&gt;
      &lt;td&gt;$2$&lt;/td&gt;
      &lt;td&gt;$3$&lt;/td&gt;
      &lt;td&gt;$O(n^{\log_2 3}) \approx O(n^{1.585})$&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Toom-3&lt;/td&gt;
      &lt;td&gt;$3$&lt;/td&gt;
      &lt;td&gt;$5$&lt;/td&gt;
      &lt;td&gt;$O(n^{\log_3 5}) \approx O(n^{1.465})$&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Toom-4&lt;/td&gt;
      &lt;td&gt;$4$&lt;/td&gt;
      &lt;td&gt;$7$&lt;/td&gt;
      &lt;td&gt;$O(n^{\log_4 7}) \approx O(n^{1.404})$&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Toom-$k$&lt;/td&gt;
      &lt;td&gt;$k$&lt;/td&gt;
      &lt;td&gt;$2k-1$&lt;/td&gt;
      &lt;td&gt;$O!\big(n^{\log_k(2k-1)}\big)$&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;the-hidden-cost-why-we-cannot-simply-let-k-to-infty&quot;&gt;The Hidden Cost: Why We Cannot Simply Let $k \to \infty$&lt;/h3&gt;

&lt;p&gt;A tempting conclusion is that by choosing $k$ large enough, we can push the exponent arbitrarily close to 1 and achieve near-linear multiplication. In practice, this reasoning breaks down for two reasons:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Evaluation and interpolation overhead.&lt;/strong&gt; The matrices involved in evaluation and interpolation grow as $O(k^2)$, and the entries grow in magnitude. For large $k$, the scalar additions and divisions in the interpolation phase cease to be negligible. The constant hidden in the $O(n)$ additive term balloons.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Coefficient blowup.&lt;/strong&gt; Evaluating at points like $2, -2, 3, \ldots$ produces intermediate values that are significantly larger than the original coefficients. This “coefficient swell” increases the size of the sub-problems fed to the recursive multiplications, partially negating the savings.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The practical sweet spot is typically Toom-3 or Toom-4. Beyond that, the FFT-based methods (Schönhage-Strassen and its successors) offer a fundamentally better asymptotic trade-off. The transition from Toom-Cook to FFT-based multiplication is, in a sense, the transition from finite polynomial interpolation to interpolation at infinitely many structured points – the roots of unity.&lt;/p&gt;

&lt;h3 id=&quot;karatsuba-as-toom-2-a-unifying-perspective&quot;&gt;Karatsuba as Toom-2: A Unifying Perspective&lt;/h3&gt;

&lt;p&gt;It is worth pausing to note that Karatsuba’s algorithm is precisely Toom-Cook with $k = 2$. The “trick” of computing $(x_1 + x_0)(y_1 + y_0) - z_2 - z_0$ is the interpolation step for a degree-2 product polynomial evaluated at the points ${0, 1, \infty}$. Karatsuba’s genius was to discover this special case in 1960; Toom and Cook’s contribution was to recognize the general structure of which Karatsuba is the simplest instance.&lt;/p&gt;

&lt;h1 id=&quot;the-fourier-transform-from-signals-to-arithmetic&quot;&gt;The Fourier Transform: From Signals to Arithmetic&lt;/h1&gt;

&lt;h2 id=&quot;why-we-need-a-new-idea&quot;&gt;Why We Need a New Idea&lt;/h2&gt;

&lt;p&gt;At the end of the Toom-Cook story we noted a frustrating ceiling: as we increase the splitting parameter $k$, the exponent $\log_k(2k-1)$ drifts toward 1, but it never reaches it. Worse, the constant factor in the $O(n)$ additive work (evaluation and interpolation matrices of size $k \times k$, coefficient blowup at large evaluation points) grows so fast that no finite $k$ delivers practical gains beyond Toom-4 or so.&lt;/p&gt;

&lt;p&gt;To break through this wall we need to abandon the strategy of evaluating at a handful of ad-hoc points and instead evaluate at a &lt;em&gt;structured infinite family&lt;/em&gt; of points whose algebraic symmetry enables a radically faster algorithm. That family is the &lt;strong&gt;complex roots of unity&lt;/strong&gt;, and the algorithm that exploits their symmetry is the &lt;strong&gt;Fast Fourier Transform&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Before we can state Schönhage and Strassen’s multiplication algorithm, we must build the FFT from the ground up. This requires four conceptual layers: (1) the observation that integer multiplication is polynomial convolution, (2) the Discrete Fourier Transform as an evaluation map, (3) the Cooley-Tukey decomposition that makes the DFT fast, and (4) the inverse transform that recovers coefficients from point values.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;multiplication-is-convolution&quot;&gt;Multiplication Is Convolution&lt;/h2&gt;

&lt;h3 id=&quot;polynomials-and-digit-vectors&quot;&gt;Polynomials and digit vectors&lt;/h3&gt;

&lt;p&gt;We have already seen that an $n$-digit integer in base $B$ can be written as a polynomial evaluated at $B$. Let us now make the multiplication side precise. Given two integers with digit vectors $\mathbf{a} = (a_0, a_1, \ldots, a_{n-1})$ and $\mathbf{b} = (b_0, b_1, \ldots, b_{n-1})$, their product is a new integer whose digit vector $\mathbf{c} = (c_0, c_1, \ldots, c_{2n-2})$ satisfies:&lt;/p&gt;

\[c_k = \sum_{j=0}^{k} a_j \, b_{k-j}
\qquad \text{for } k = 0, 1, \ldots, 2n-2\]

&lt;p&gt;(with the convention that $a_j = 0$ for $j \geq n$ and similarly for $b_j$). This is exactly the definition of the &lt;strong&gt;linear convolution&lt;/strong&gt; of the sequences $\mathbf{a}$ and $\mathbf{b}$, or equivalently, the coefficient vector of the product polynomial $A(x) \cdot B(x)$ where:&lt;/p&gt;

\[A(x) = \sum_{j=0}^{n-1} a_j x^j, \qquad B(x) = \sum_{j=0}^{n-1} b_j x^j\]

&lt;p&gt;Computing this convolution directly requires computing each of the $2n - 1$ output coefficients, and for each we sum up to $n$ products. The total cost is $O(n^2)$ – schoolbook multiplication in disguise.&lt;/p&gt;

&lt;h3 id=&quot;the-convolution-theorem&quot;&gt;The Convolution Theorem&lt;/h3&gt;

&lt;p&gt;The single most important theorem in this entire story is:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;Convolution Theorem.&lt;/strong&gt; Let $\mathcal{F}$ denote the Discrete Fourier Transform (defined below). If $\mathbf{c} = \mathbf{a} \ast \mathbf{b}$ is the convolution of two sequences, then:&lt;/p&gt;

\[\mathcal{F}(\mathbf{a} \ast \mathbf{b}) = \mathcal{F}(\mathbf{a}) \cdot \mathcal{F}(\mathbf{b})\]

  &lt;p&gt;where $\cdot$ denotes &lt;em&gt;pointwise&lt;/em&gt; (component-by-component) multiplication.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In words: convolution in the “coefficient domain” becomes pointwise multiplication in the “frequency domain.” Since pointwise multiplication of two length-$N$ vectors costs only $O(N)$, the entire cost of multiplication reduces to the cost of &lt;em&gt;two forward transforms and one inverse transform&lt;/em&gt;. If each transform costs $O(N \log N)$, the total cost is $O(N \log N)$ – an exponential improvement over $O(N^2)$.&lt;/p&gt;

&lt;p&gt;This is not a hand-wave. Let us now build the DFT rigorously and prove why it can be computed in $O(N \log N)$.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;the-discrete-fourier-transform&quot;&gt;The Discrete Fourier Transform&lt;/h2&gt;

&lt;h3 id=&quot;complex-exponentials-and-eulers-formula&quot;&gt;Complex exponentials and Euler’s formula&lt;/h3&gt;

&lt;p&gt;Before defining the DFT, we need the language of complex numbers. Recall &lt;strong&gt;Euler’s formula&lt;/strong&gt;:&lt;/p&gt;

\[e^{i\theta} = \cos\theta + i\sin\theta\]

&lt;p&gt;This elegant identity tells us that the complex exponential $e^{i\theta}$ traces out the unit circle in the complex plane as $\theta$ varies from $0$ to $2\pi$. Every point on the unit circle can be written as $e^{i\theta}$ for some angle $\theta$.&lt;/p&gt;

&lt;h3 id=&quot;the-n-th-roots-of-unity&quot;&gt;The $N$-th roots of unity&lt;/h3&gt;

&lt;p&gt;Fix a positive integer $N$. The &lt;strong&gt;$N$-th roots of unity&lt;/strong&gt; are the $N$ complex numbers that satisfy $z^N = 1$. They are:&lt;/p&gt;

\[\omega_N^k = e^{2\pi i k / N}, \qquad k = 0, 1, \ldots, N-1\]

&lt;table&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;These are $N$ points spaced equally around the unit circle, like the vertices of a regular $N$-gon inscribed in the circle $&lt;/td&gt;
      &lt;td&gt;z&lt;/td&gt;
      &lt;td&gt;= 1$. The &lt;strong&gt;primitive&lt;/strong&gt; $N$-th root of unity is:&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

\[\omega_N = e^{2\pi i / N}\]

&lt;p&gt;so that $\omega_N^k = (\omega_N)^k$. We will usually drop the subscript $N$ when the context is clear.&lt;/p&gt;

&lt;p&gt;The roots of unity possess remarkable algebraic properties that are the engine of the FFT:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Periodicity:&lt;/strong&gt; $\omega^{k+N} = \omega^k$ for all $k$. The roots cycle with period $N$.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Cancellation (Half-turn symmetry):&lt;/strong&gt; $\omega^{k + N/2} = -\omega^k$ when $N$ is even. Geometrically, the point diametrically opposite $\omega^k$ on the unit circle is $-\omega^k$. This is the single most important property for the FFT.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Summation:&lt;/strong&gt; $\displaystyle\sum_{k=0}^{N-1} \omega^{jk} = \begin{cases} N &amp;amp; \text{if } N \mid j \ 0 &amp;amp; \text{otherwise} \end{cases}$&lt;/p&gt;

    &lt;p&gt;This orthogonality relation is what makes the inverse DFT work.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Squaring (Halving):&lt;/strong&gt; If $N$ is even, then ${(\omega_N^k)^2 : k = 0, \ldots, N-1}$ gives exactly the $N/2$-th roots of unity, each appearing twice. That is, $(\omega_N)^2 = \omega_{N/2}$.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;definition-of-the-dft&quot;&gt;Definition of the DFT&lt;/h3&gt;

&lt;p&gt;Given a vector $\mathbf{x} = (x_0, x_1, \ldots, x_{N-1})$, its &lt;strong&gt;Discrete Fourier Transform&lt;/strong&gt; is the vector $\mathbf{X} = (X_0, X_1, \ldots, X_{N-1})$ defined by:&lt;/p&gt;

\[X_k = \sum_{j=0}^{N-1} x_j \, \omega_N^{jk}, \qquad k = 0, 1, \ldots, N-1\]

&lt;p&gt;Equivalently, $X_k = P(\omega^k)$ where $P(z) = \sum_{j} x_j z^j$ is the polynomial whose coefficients are the entries of $\mathbf{x}$. The DFT is nothing more than &lt;strong&gt;evaluating the polynomial at all $N$-th roots of unity simultaneously&lt;/strong&gt;.&lt;/p&gt;

&lt;h3 id=&quot;the-dft-matrix&quot;&gt;The DFT matrix&lt;/h3&gt;

&lt;p&gt;We can express the DFT as a matrix-vector product $\mathbf{X} = F_N \, \mathbf{x}$ where the &lt;strong&gt;DFT matrix&lt;/strong&gt; $F_N$ has entries:&lt;/p&gt;

\[(F_N)_{k,j} = \omega_N^{jk}\]

&lt;p&gt;Explicitly, for $N = 4$ with $\omega = \omega_4 = e^{2\pi i/4} = i$:&lt;/p&gt;

\[F_4 = \begin{pmatrix}
1 &amp;amp; 1 &amp;amp; 1 &amp;amp; 1 \\
1 &amp;amp; i &amp;amp; i^2 &amp;amp; i^3 \\
1 &amp;amp; i^2 &amp;amp; i^4 &amp;amp; i^6 \\
1 &amp;amp; i^3 &amp;amp; i^6 &amp;amp; i^9
\end{pmatrix}
= \begin{pmatrix}
1 &amp;amp; 1 &amp;amp; 1 &amp;amp; 1 \\
1 &amp;amp; i &amp;amp; -1 &amp;amp; -i \\
1 &amp;amp; -1 &amp;amp; 1 &amp;amp; -1 \\
1 &amp;amp; -i &amp;amp; -1 &amp;amp; i
\end{pmatrix}\]

&lt;p&gt;Computing $\mathbf{X} = F_N \mathbf{x}$ by brute-force matrix-vector multiplication costs $O(N^2)$. The entire point of the FFT is to exploit the structure of $F_N$ to compute this product in $O(N \log N)$.&lt;/p&gt;

&lt;h3 id=&quot;the-inverse-dft&quot;&gt;The Inverse DFT&lt;/h3&gt;

&lt;p&gt;The orthogonality of the roots of unity (property 3 above) guarantees that the DFT is invertible. The inverse is:&lt;/p&gt;

\[x_j = \frac{1}{N} \sum_{k=0}^{N-1} X_k \, \omega_N^{-jk}\]

&lt;p&gt;or in matrix form, $\mathbf{x} = \frac{1}{N} F_N^{-1} \mathbf{X}$ where $F_N^{-1}$ is the matrix with entries $\omega_N^{-jk}$ – the same as $F_N$ but with $\omega$ replaced by $\omega^{-1} = \overline{\omega}$ (the complex conjugate). In other words, &lt;strong&gt;the inverse DFT is computed by the same algorithm as the forward DFT&lt;/strong&gt;, just with the twiddle factors conjugated and the output scaled by $1/N$. Any algorithm that computes the forward DFT efficiently also computes the inverse efficiently, for free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proof of inversion.&lt;/strong&gt; We need to verify that $\frac{1}{N}F_N^{-1} F_N = I_N$, i.e., that:&lt;/p&gt;

\[\frac{1}{N} \sum_{k=0}^{N-1} \omega^{-jk} \omega^{k\ell} = \frac{1}{N} \sum_{k=0}^{N-1} \omega^{k(\ell - j)} = \begin{cases} 1 &amp;amp; \text{if } j = \ell \\ 0 &amp;amp; \text{if } j \neq \ell \end{cases}\]

&lt;p&gt;When $j = \ell$, every term in the sum is $\omega^0 = 1$, so the sum is $N$ and we get $N/N = 1$. When $j \neq \ell$, let $m = \ell - j \not\equiv 0 \pmod{N}$. Then $\sum_{k=0}^{N-1} \omega^{mk}$ is a geometric series with ratio $r = \omega^m \neq 1$:&lt;/p&gt;

\[\sum_{k=0}^{N-1} r^k = \frac{r^N - 1}{r - 1} = \frac{(\omega^N)^m - 1}{\omega^m - 1} = \frac{1 - 1}{\omega^m - 1} = 0\]

&lt;p&gt;since $\omega^N = 1$ by definition. $\blacksquare$&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;the-fast-fourier-transform-cooley-tukey-radix-2&quot;&gt;The Fast Fourier Transform (Cooley-Tukey, Radix-2)&lt;/h2&gt;

&lt;h3 id=&quot;the-core-idea-divide-and-conquer-on-even-and-odd-indices&quot;&gt;The core idea: divide and conquer on even and odd indices&lt;/h3&gt;

&lt;p&gt;The breakthrough of Cooley and Tukey (1965) – though the idea traces back to Gauss (1805) – is to observe that when $N$ is even, the DFT of size $N$ can be decomposed into &lt;strong&gt;two DFTs of size $N/2$&lt;/strong&gt; plus $O(N)$ additional work.&lt;/p&gt;

&lt;p&gt;Write $N = 2M$. Split the input sequence $\mathbf{x}$ into its even-indexed and odd-indexed elements:&lt;/p&gt;

\[\begin{aligned}
\mathbf{e} &amp;amp;= (x_0, x_2, x_4, \ldots, x_{N-2}) \quad \text{(even indices)} \\
\mathbf{d} &amp;amp;= (x_1, x_3, x_5, \ldots, x_{N-1}) \quad \text{(odd indices)}
\end{aligned}\]

&lt;p&gt;Now consider the DFT sum for an arbitrary output index $k$:&lt;/p&gt;

\[X_k = \sum_{j=0}^{N-1} x_j \, \omega_N^{jk}\]

&lt;p&gt;Separate the even-indexed and odd-indexed terms:&lt;/p&gt;

\[X_k = \underbrace{\sum_{m=0}^{M-1} x_{2m} \, \omega_N^{2mk}}_{E_k} + \underbrace{\omega_N^k \sum_{m=0}^{M-1} x_{2m+1} \, \omega_N^{2mk}}_{= \omega_N^k \cdot D_k}\]

&lt;p&gt;Using the &lt;strong&gt;squaring property&lt;/strong&gt; $\omega_N^2 = \omega_M$ (where $M = N/2$), each of these inner sums is itself a DFT of size $M$:&lt;/p&gt;

\[\begin{aligned}
E_k &amp;amp;= \sum_{m=0}^{M-1} x_{2m} \, \omega_M^{mk} = \text{DFT}_M(\mathbf{e})_k \\[4pt]
D_k &amp;amp;= \sum_{m=0}^{M-1} x_{2m+1} \, \omega_M^{mk} = \text{DFT}_M(\mathbf{d})_k
\end{aligned}\]

&lt;p&gt;So the full DFT decomposes as:&lt;/p&gt;

\[\boxed{X_k = E_k + \omega_N^k \cdot D_k}\]

&lt;p&gt;But we need $X_k$ for $k = 0, 1, \ldots, N-1$, while $E_k$ and $D_k$ are periodic with period $M = N/2$ (they are DFTs of size $M$). For the “upper half” $k + M$, the &lt;strong&gt;cancellation property&lt;/strong&gt; $\omega_N^{k+M} = -\omega_N^k$ gives:&lt;/p&gt;

\[\boxed{X_{k+M} = E_k - \omega_N^k \cdot D_k}\]

&lt;p&gt;These two equations together constitute the &lt;strong&gt;butterfly operation&lt;/strong&gt;: from the pair $(E_k, D_k)$ and the &lt;strong&gt;twiddle factor&lt;/strong&gt; $\omega_N^k$, we compute both $X_k$ and $X_{k+M}$ using &lt;strong&gt;one&lt;/strong&gt; complex multiplication and &lt;strong&gt;two&lt;/strong&gt; complex additions.&lt;/p&gt;

&lt;h3 id=&quot;the-butterfly-diagram&quot;&gt;The butterfly diagram&lt;/h3&gt;

&lt;p&gt;Each butterfly takes two inputs, multiplies one by a twiddle factor, and produces two outputs via addition and subtraction:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;  E_k ──────────┬──── (+) ──── X_k
                 │
          ω^k    ×
                 │
  D_k ──────────┴──── (−) ──── X_{k+M}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;For $M = N/2$ values of $k$ (namely $k = 0, 1, \ldots, M-1$), we perform $M$ butterflies, each costing $O(1)$. The total additional work at this level of recursion is $O(N)$.&lt;/p&gt;

&lt;h3 id=&quot;the-recurrence-and-complexity&quot;&gt;The recurrence and complexity&lt;/h3&gt;

&lt;p&gt;Let $T(N)$ denote the cost of computing a DFT of size $N$ (assumed a power of 2). The Cooley-Tukey decomposition gives:&lt;/p&gt;

\[T(N) = 2\,T(N/2) + O(N)\]

&lt;p&gt;By the Master Theorem (case 2, with $a = 2$, $b = 2$, $f(N) = \Theta(N)$):&lt;/p&gt;

\[T(N) = O(N \log N)\]

&lt;p&gt;compared to $O(N^2)$ for the naive DFT. For $N = 2^{20} \approx 10^6$, this is the difference between $\sim 10^{12}$ operations and $\sim 2 \times 10^7$ – a speedup of 50,000$\times$.&lt;/p&gt;

&lt;h3 id=&quot;the-full-recursion-tree&quot;&gt;The full recursion tree&lt;/h3&gt;

&lt;p&gt;When $N = 2^s$, the recursion unfolds $s = \log_2 N$ levels deep. At each level, we partition the data, recurse on two halves, and combine with $N$ butterfly operations. The total work is:&lt;/p&gt;

\[\underbrace{N}_{\text{level 0}} + \underbrace{N}_{\text{level 1}} + \cdots + \underbrace{N}_{\text{level } s-1} = s \cdot N = N \log_2 N\]

&lt;p&gt;Each level performs exactly $N/2$ butterflies (each costing one complex multiplication and two additions), for a total of $\frac{N}{2}\log_2 N$ complex multiplications.&lt;/p&gt;

&lt;h3 id=&quot;a-worked-example-8-point-fft&quot;&gt;A worked example: 8-point FFT&lt;/h3&gt;

&lt;p&gt;Let $N = 8$ and $\omega = \omega_8 = e^{2\pi i/8} = e^{i\pi/4} = \frac{1+i}{\sqrt{2}}$. Consider the input $\mathbf{x} = (1, 1, 1, 1, 0, 0, 0, 0)$ (a rectangular pulse).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Level 0 (split):&lt;/strong&gt; Separate into even and odd:&lt;/p&gt;

\[\mathbf{e} = (x_0, x_2, x_4, x_6) = (1, 1, 0, 0), \qquad \mathbf{d} = (x_1, x_3, x_5, x_7) = (1, 1, 0, 0)\]

&lt;p&gt;&lt;strong&gt;Level 1:&lt;/strong&gt; Each half recursively splits again. For $\mathbf{e}$:&lt;/p&gt;

\[\mathbf{e}_{\text{even}} = (1, 0), \quad \mathbf{e}_{\text{odd}} = (1, 0)\]

&lt;p&gt;These are 2-point DFTs: $\text{DFT}_2(a, b) = (a + b, \; a - b)$. So:&lt;/p&gt;

\[\text{DFT}_2(1, 0) = (1, 1) \quad \text{for both}\]

&lt;p&gt;&lt;strong&gt;Level 1 butterfly (for $\mathbf{e}$):&lt;/strong&gt; Combine with twiddle factors $\omega_4^0 = 1$ and $\omega_4^1 = i$:&lt;/p&gt;

\[E_0 = 1 + 1 \cdot 1 = 2, \quad E_2 = 1 - 1 \cdot 1 = 0\]

\[E_1 = 1 + i \cdot 1 = 1 + i, \quad E_3 = 1 - i \cdot 1 = 1 - i\]

&lt;p&gt;So $\text{DFT}_4(\mathbf{e}) = (2, \; 1+i, \; 0, \; 1-i)$.&lt;/p&gt;

&lt;p&gt;An identical calculation gives $\text{DFT}_4(\mathbf{d}) = (2, \; 1+i, \; 0, \; 1-i)$.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Level 0 butterfly:&lt;/strong&gt; Combine with twiddle factors $\omega_8^k$ for $k = 0, 1, 2, 3$:&lt;/p&gt;

\[\begin{aligned}
X_0 &amp;amp;= E_0 + \omega^0 D_0 = 2 + 1 \cdot 2 = 4 \\
X_1 &amp;amp;= E_1 + \omega^1 D_1 = (1+i) + \tfrac{1+i}{\sqrt{2}}(1+i) = (1+i) + \tfrac{2i}{\sqrt{2}} = 1 + i + i\sqrt{2} \\
X_2 &amp;amp;= E_2 + \omega^2 D_2 = 0 + i \cdot 0 = 0 \\
X_3 &amp;amp;= E_3 + \omega^3 D_3 = (1-i) + \tfrac{-1+i}{\sqrt{2}}(1-i) = (1-i) + \tfrac{-1+i-i(-1)+i^2}{\sqrt{2}} \\
&amp;amp;\phantom{=}\; \text{(and the corresponding } X_{k+4} = E_k - \omega^k D_k \text{ for the upper half)}
\end{aligned}\]

&lt;p&gt;The key observation is not the specific numerical values but the &lt;em&gt;structure&lt;/em&gt;: $3$ levels of $4$ butterflies each $= 12$ complex multiplications, versus $8^2 = 64$ for the naive DFT. The ratio $12/64 \approx 19\%$ – and this ratio improves as $N$ grows.&lt;/p&gt;

&lt;p&gt;The visualization below animates this 8-point butterfly network. Watch how data flows through three stages of butterflies – each stage halving the sub-problem size – with twiddle factors ($\omega^k$) applied at every crossing.&lt;/p&gt;

&lt;div id=&quot;fft-viz&quot; style=&quot;width: 100%; height: 560px; margin: 2em 0; border-radius: 8px; overflow: hidden; background: #0f172a; position: relative;&quot;&gt;
  &lt;div id=&quot;fft-phase-label&quot; style=&quot;position: absolute; top: 16px; left: 50%; transform: translateX(-50%); color: #e2e8f0; font-family: monospace; font-size: 15px; z-index: 10; pointer-events: none; text-align: center; white-space: nowrap;&quot;&gt;&lt;/div&gt;
  &lt;div id=&quot;fft-info-label&quot; style=&quot;position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); color: #94a3b8; font-family: monospace; font-size: 13px; z-index: 10; pointer-events: none; text-align: center;&quot;&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;script&gt;
(function() {
  function initFFTViz() {
    if (typeof THREE === &apos;undefined&apos;) { setTimeout(initFFTViz, 100); return; }

    var container = document.getElementById(&apos;fft-viz&apos;);
    if (!container) return;

    var phaseLabel = document.getElementById(&apos;fft-phase-label&apos;);
    var infoLabel = document.getElementById(&apos;fft-info-label&apos;);

    // --- Scene setup ---
    var scene = new THREE.Scene();
    scene.background = new THREE.Color(0x0f172a);

    var W = container.clientWidth, H = 560;
    var camera = new THREE.OrthographicCamera(-7, 7, 4, -4, 0.1, 100);
    camera.position.set(0, 0, 10);
    camera.lookAt(0, 0, 0);

    var renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(W, H);
    renderer.setPixelRatio(window.devicePixelRatio);
    container.appendChild(renderer.domElement);

    // --- Colors ---
    var COL_NODE    = 0x6366f1; // indigo
    var COL_WIRE    = 0x334155; // slate
    var COL_PULSE   = 0x22c55e; // green
    var COL_CROSS   = 0xf59e0b; // amber - twiddle factor lines
    var COL_ADD     = 0x3b82f6; // blue - addition lines
    var COL_SUB     = 0xef4444; // red - subtraction lines
    var COL_ACTIVE  = 0xa855f7; // purple - active stage highlight
    var COL_OUT     = 0x22c55e; // green - output

    var N = 8;
    var STAGES = 3; // log2(8) = 3

    // Bit-reversal permutation for N=8
    var bitRev = [0, 4, 2, 6, 1, 5, 3, 7];

    // Layout: 4 columns (input + 3 stages), 8 rows
    var XMIN = -5.5, XMAX = 5.5;
    var YMIN = -3.0, YMAX = 3.0;
    var cols = STAGES + 1; // 4 columns of nodes
    var colX = [];
    for (var c = 0; c &lt;= STAGES; c++) {
      colX.push(XMIN + c * (XMAX - XMIN) / STAGES);
    }
    var rowY = [];
    for (var r = 0; r &lt; N; r++) {
      rowY.push(YMAX - r * (YMAX - YMIN) / (N - 1));
    }

    // --- Create node spheres ---
    var nodeGeo = new THREE.SphereGeometry(0.12, 16, 16);
    var nodes = []; // nodes[col][row]
    for (c = 0; c &lt;= STAGES; c++) {
      nodes[c] = [];
      for (r = 0; r &lt; N; r++) {
        var mat = new THREE.MeshBasicMaterial({ color: c === 0 ? COL_NODE : 0x475569 });
        var sphere = new THREE.Mesh(nodeGeo, mat);
        sphere.position.set(colX[c], rowY[r], 0);
        scene.add(sphere);
        nodes[c][r] = sphere;
      }
    }

    // --- Input labels (bit-reversed order) ---
    var inputLabels = [&apos;x\u2080&apos;, &apos;x\u2084&apos;, &apos;x\u2082&apos;, &apos;x\u2086&apos;, &apos;x\u2081&apos;, &apos;x\u2085&apos;, &apos;x\u2083&apos;, &apos;x\u2087&apos;];
    var outputLabels = [&apos;X\u2080&apos;, &apos;X\u2081&apos;, &apos;X\u2082&apos;, &apos;X\u2083&apos;, &apos;X\u2084&apos;, &apos;X\u2085&apos;, &apos;X\u2086&apos;, &apos;X\u2087&apos;];

    function makeTextSprite(text, color, fontSize) {
      var canvas = document.createElement(&apos;canvas&apos;);
      canvas.width = 256; canvas.height = 64;
      var ctx = canvas.getContext(&apos;2d&apos;);
      ctx.clearRect(0, 0, 256, 64);
      ctx.fillStyle = color || &apos;#e2e8f0&apos;;
      ctx.font = &apos;bold &apos; + (fontSize || 28) + &apos;px monospace&apos;;
      ctx.textAlign = &apos;center&apos;;
      ctx.textBaseline = &apos;middle&apos;;
      ctx.fillText(text, 128, 32);
      var tex = new THREE.CanvasTexture(canvas);
      tex.minFilter = THREE.LinearFilter;
      var spriteMat = new THREE.SpriteMaterial({ map: tex, transparent: true });
      var sprite = new THREE.Sprite(spriteMat);
      sprite.scale.set(1.4, 0.35, 1);
      return sprite;
    }

    for (r = 0; r &lt; N; r++) {
      var lbl = makeTextSprite(inputLabels[r], &apos;#94a3b8&apos;, 24);
      lbl.position.set(colX[0] - 0.9, rowY[r], 0);
      scene.add(lbl);

      var olbl = makeTextSprite(outputLabels[r], &apos;#22c55e&apos;, 24);
      olbl.position.set(colX[STAGES] + 0.9, rowY[r], 0);
      scene.add(olbl);
    }

    // --- Stage labels ---
    var stageSprites = [];
    for (var s = 0; s &lt; STAGES; s++) {
      var midX = (colX[s] + colX[s + 1]) / 2;
      var sl = makeTextSprite(&apos;Stage &apos; + (s + 1), &apos;#64748b&apos;, 22);
      sl.position.set(midX, YMAX + 0.5, 0);
      scene.add(sl);
      stageSprites.push(sl);
    }

    // --- Build butterfly wiring ---
    // For each stage s (0-indexed), the butterfly span is 2^(STAGES - 1 - s)
    // Groups of size 2^(STAGES - s), with pairs separated by span
    var wires = []; // {from:[col,row], to:[col,row], type:&apos;add&apos;|&apos;sub&apos;, twiddleExp: number, stage: s}

    for (s = 0; s &lt; STAGES; s++) {
      var groupSize = 1 &lt;&lt; (STAGES - s);
      var halfGroup = groupSize / 2;
      for (var g = 0; g &lt; N; g += groupSize) {
        for (var k = 0; k &lt; halfGroup; k++) {
          var top = g + k;
          var bot = g + k + halfGroup;
          var twiddleExp = k * (1 &lt;&lt; s);
          // Top wire: straight across (add)
          wires.push({ from: [s, top], to: [s + 1, top], type: &apos;add&apos;, twiddleExp: twiddleExp, stage: s });
          // Bottom wire: straight across (add, but with subtraction semantics)
          wires.push({ from: [s, bot], to: [s + 1, bot], type: &apos;sub&apos;, twiddleExp: twiddleExp, stage: s });
          // Cross wire top-&gt;bot (twiddle, goes to add at bot)
          wires.push({ from: [s, top], to: [s + 1, bot], type: &apos;cross-down&apos;, twiddleExp: twiddleExp, stage: s });
          // Cross wire bot-&gt;top (twiddle, goes to add at top)
          wires.push({ from: [s, bot], to: [s + 1, top], type: &apos;cross-up&apos;, twiddleExp: twiddleExp, stage: s });
        }
      }
    }

    // --- Draw wires as lines ---
    var lineMat = new THREE.LineBasicMaterial({ color: COL_WIRE, transparent: true, opacity: 0.3 });
    var wireLines = [];
    for (var w = 0; w &lt; wires.length; w++) {
      var wire = wires[w];
      var pts = [
        new THREE.Vector3(colX[wire.from[0]], rowY[wire.from[1]], 0),
        new THREE.Vector3(colX[wire.to[0]], rowY[wire.to[1]], 0)
      ];
      var geo = new THREE.BufferGeometry().setFromPoints(pts);
      var wMat = new THREE.LineBasicMaterial({ color: COL_WIRE, transparent: true, opacity: 0.25 });
      var line = new THREE.Line(geo, wMat);
      scene.add(line);
      wireLines.push({ line: line, mat: wMat, wire: wire });
    }

    // --- Twiddle factor labels on cross wires ---
    var twiddleSprites = [];
    for (w = 0; w &lt; wires.length; w++) {
      wire = wires[w];
      if (wire.type === &apos;cross-down&apos; &amp;&amp; wire.twiddleExp &gt; 0) {
        var mx = (colX[wire.from[0]] + colX[wire.to[0]]) / 2;
        var my = (rowY[wire.from[1]] + rowY[wire.to[1]]) / 2;
        var expStr = wire.twiddleExp === 1 ? &apos;\u03C9&apos; : &apos;\u03C9&apos; + String.fromCharCode(0x2070 + wire.twiddleExp);
        // Use simpler label for readability
        var tLabel = makeTextSprite(&apos;\u03C9^&apos; + wire.twiddleExp, &apos;#f59e0b&apos;, 18);
        tLabel.position.set(mx + 0.35, my, 0.1);
        tLabel.material.opacity = 0;
        scene.add(tLabel);
        twiddleSprites.push({ sprite: tLabel, stage: wire.stage });
      }
    }

    // --- Animated pulses ---
    var pulseGeo = new THREE.SphereGeometry(0.08, 12, 12);
    var pulses = [];
    var PULSE_COUNT = N; // one pulse per input line

    for (var p = 0; p &lt; PULSE_COUNT; p++) {
      var pMat = new THREE.MeshBasicMaterial({ color: COL_PULSE, transparent: true, opacity: 0 });
      var pMesh = new THREE.Mesh(pulseGeo, pMat);
      pMesh.position.set(colX[0], rowY[p], 0.2);
      scene.add(pMesh);
      pulses.push({ mesh: pMesh, row: p, mat: pMat });
    }

    // --- Animation ---
    var clock = new THREE.Clock();
    var elapsed = 0;
    var STAGE_DUR = 2.5;  // seconds per stage
    var PAUSE_DUR = 1.0;
    var INTRO_DUR = 1.5;
    var TOTAL_CYCLE = INTRO_DUR + STAGES * STAGE_DUR + (STAGES + 1) * PAUSE_DUR;

    function easeInOutCubic(t) {
      t = Math.max(0, Math.min(1, t));
      return t &lt; 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
    }

    function lerp(a, b, t) { return a + (b - a) * t; }

    var phaseTexts = [
      &apos;Stage 1: Butterflies span 4 (groups of 8)&apos;,
      &apos;Stage 2: Butterflies span 2 (groups of 4)&apos;,
      &apos;Stage 3: Butterflies span 1 (groups of 2)&apos;
    ];

    var infoTexts = [
      &apos;4 butterflies \u00B7 \u03C9\u2070 twiddle factor \u00B7 even/odd split of full sequence&apos;,
      &apos;4 butterflies \u00B7 \u03C9\u2070,\u03C9\u00B2 twiddle factors \u00B7 sub-sequences of length 4&apos;,
      &apos;4 butterflies \u00B7 \u03C9\u2070,\u03C9\u00B9,\u03C9\u00B2,\u03C9\u00B3 twiddle factors \u00B7 final recombination&apos;
    ];

    function animate() {
      requestAnimationFrame(animate);
      var dt = clock.getDelta();
      elapsed += dt;
      var cycleT = elapsed % TOTAL_CYCLE;

      // Determine current phase
      var currentStage = -1; // -1 = intro/reset
      var stageProgress = 0;

      var t0 = INTRO_DUR;
      for (s = 0; s &lt; STAGES; s++) {
        var stageStart = t0 + s * (STAGE_DUR + PAUSE_DUR);
        var stageEnd = stageStart + STAGE_DUR;
        if (cycleT &gt;= stageStart &amp;&amp; cycleT &lt; stageEnd) {
          currentStage = s;
          stageProgress = (cycleT - stageStart) / STAGE_DUR;
          break;
        }
        if (cycleT &gt;= stageEnd &amp;&amp; cycleT &lt; stageEnd + PAUSE_DUR) {
          currentStage = s;
          stageProgress = 1.0;
          break;
        }
      }

      // --- Update labels ---
      if (currentStage &gt;= 0) {
        phaseLabel.textContent = phaseTexts[currentStage];
        infoLabel.textContent = infoTexts[currentStage];
      } else if (cycleT &lt; INTRO_DUR * 0.5) {
        phaseLabel.textContent = &apos;8-point FFT Butterfly Network&apos;;
        infoLabel.textContent = &apos;Bit-reversed input \u2192 3 stages of butterflies \u2192 DFT output&apos;;
      } else {
        phaseLabel.textContent = &apos;8-point FFT Butterfly Network&apos;;
        infoLabel.textContent = N + &apos;/2 = 4 butterflies per stage \u00B7 log\u2082(8) = 3 stages \u00B7 12 total multiplications&apos;;
      }

      // --- Update wire colors ---
      for (w = 0; w &lt; wireLines.length; w++) {
        var wl = wireLines[w];
        var ws = wl.wire.stage;
        if (ws === currentStage &amp;&amp; stageProgress &gt; 0) {
          var e = easeInOutCubic(stageProgress);
          if (wl.wire.type === &apos;add&apos;) {
            wl.mat.color.setHex(COL_ADD);
            wl.mat.opacity = lerp(0.25, 0.9, e);
          } else if (wl.wire.type === &apos;sub&apos;) {
            wl.mat.color.setHex(COL_SUB);
            wl.mat.opacity = lerp(0.25, 0.7, e);
          } else if (wl.wire.type === &apos;cross-down&apos;) {
            wl.mat.color.setHex(COL_CROSS);
            wl.mat.opacity = lerp(0.25, 0.85, e);
          } else if (wl.wire.type === &apos;cross-up&apos;) {
            wl.mat.color.setHex(COL_CROSS);
            wl.mat.opacity = lerp(0.25, 0.65, e);
          }
        } else if (ws &lt; currentStage) {
          // Already processed stage - dim but colored
          if (wl.wire.type === &apos;add&apos;) {
            wl.mat.color.setHex(COL_ADD);
          } else if (wl.wire.type === &apos;sub&apos;) {
            wl.mat.color.setHex(COL_SUB);
          } else {
            wl.mat.color.setHex(COL_CROSS);
          }
          wl.mat.opacity = 0.3;
        } else {
          wl.mat.color.setHex(COL_WIRE);
          wl.mat.opacity = 0.25;
        }
      }

      // --- Update twiddle labels ---
      for (var ti = 0; ti &lt; twiddleSprites.length; ti++) {
        var ts = twiddleSprites[ti];
        if (ts.stage === currentStage &amp;&amp; stageProgress &gt; 0.2) {
          ts.sprite.material.opacity = easeInOutCubic((stageProgress - 0.2) / 0.5);
        } else if (ts.stage &lt; currentStage) {
          ts.sprite.material.opacity = 0.4;
        } else {
          ts.sprite.material.opacity = 0;
        }
      }

      // --- Update node colors ---
      for (c = 0; c &lt;= STAGES; c++) {
        for (r = 0; r &lt; N; r++) {
          if (c === 0) {
            nodes[c][r].material.color.setHex(COL_NODE);
          } else if (c - 1 &lt; currentStage || (c - 1 === currentStage &amp;&amp; stageProgress &gt; 0.8)) {
            nodes[c][r].material.color.setHex(COL_ACTIVE);
          } else if (c - 1 === currentStage) {
            var ne = easeInOutCubic(stageProgress);
            var col = new THREE.Color(0x475569);
            col.lerp(new THREE.Color(COL_ACTIVE), ne);
            nodes[c][r].material.color.copy(col);
          } else {
            nodes[c][r].material.color.setHex(0x475569);
          }
        }
      }

      // Final column goes green when all stages done
      if (currentStage === STAGES - 1 &amp;&amp; stageProgress &gt;= 1.0) {
        for (r = 0; r &lt; N; r++) {
          nodes[STAGES][r].material.color.setHex(COL_OUT);
        }
      }

      // --- Animate pulses ---
      for (p = 0; p &lt; PULSE_COUNT; p++) {
        var pulse = pulses[p];
        if (currentStage &lt; 0) {
          // During intro: pulses sit at input
          pulse.mat.opacity = easeInOutCubic(Math.min(1, cycleT / INTRO_DUR));
          pulse.mesh.position.set(colX[0], rowY[p], 0.2);
        } else {
          // Each pulse progresses through stages
          // Compute global progress: which stage + how far through it
          var globalProg = currentStage + stageProgress;
          var pulseCol = Math.floor(globalProg);
          var pulseT = globalProg - pulseCol;

          if (pulseCol &gt; STAGES - 1) {
            pulseCol = STAGES - 1;
            pulseT = 1.0;
          }

          // Determine where this pulse goes during current butterfly
          var pRow = p;
          var groupSize2 = 1 &lt;&lt; (STAGES - pulseCol);
          var halfGroup2 = groupSize2 / 2;
          var group2 = Math.floor(pRow / groupSize2) * groupSize2;
          var posInGroup = pRow - group2;
          var isUpper = posInGroup &lt; halfGroup2;

          // Pulse travels from col[pulseCol] to col[pulseCol+1]
          var fromX = colX[pulseCol];
          var toX = colX[Math.min(pulseCol + 1, STAGES)];
          var e2 = easeInOutCubic(pulseT);

          pulse.mesh.position.x = lerp(fromX, toX, e2);
          pulse.mesh.position.y = rowY[p];
          pulse.mat.opacity = 0.9;

          // Pulse glow effect
          var glowT = Math.sin(elapsed * 4 + p * 0.8) * 0.3 + 0.7;
          pulse.mesh.scale.setScalar(glowT);
        }
      }

      renderer.render(scene, camera);
    }

    // --- Resize handler ---
    window.addEventListener(&apos;resize&apos;, function() {
      var nw = container.clientWidth;
      var aspect = nw / H;
      camera.left = -7 * aspect / (W / H);
      camera.right = 7 * aspect / (W / H);
      camera.updateProjectionMatrix();
      renderer.setSize(nw, H);
      W = nw;
    });

    animate();
  }

  if (document.readyState === &apos;loading&apos;) {
    document.addEventListener(&apos;DOMContentLoaded&apos;, initFFTViz);
  } else {
    initFFTViz();
  }
})();
&lt;/script&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;putting-it-together-fft-based-polynomial-multiplication&quot;&gt;Putting It Together: FFT-Based Polynomial Multiplication&lt;/h2&gt;

&lt;p&gt;We now have all the pieces. To multiply two polynomials $A(x)$ and $B(x)$ of degree $n-1$ (and thereby two $n$-digit integers):&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1. Pad.&lt;/strong&gt; The product polynomial has degree $2n - 2$, so we need at least $2n - 1$ evaluation points. Choose $N = 2^{\lceil \log_2(2n) \rceil}$ (the next power of 2 at or above $2n$). Pad both coefficient vectors with zeros to length $N$.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2. Forward FFT.&lt;/strong&gt; Compute $\hat{\mathbf{a}} = \text{FFT}_N(\mathbf{a})$ and $\hat{\mathbf{b}} = \text{FFT}_N(\mathbf{b})$. Cost: $O(N \log N)$ each.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3. Pointwise multiply.&lt;/strong&gt; Compute $\hat{c}_k = \hat{a}_k \cdot \hat{b}_k$ for $k = 0, \ldots, N-1$. Cost: $O(N)$.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4. Inverse FFT.&lt;/strong&gt; Compute $\mathbf{c} = \text{IFFT}_N(\hat{\mathbf{c}})$. Cost: $O(N \log N)$.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5. Carry propagation.&lt;/strong&gt; The entries of $\mathbf{c}$ are the exact convolution coefficients (no rounding – yet). Each $c_k$ may exceed the base $B$, so we perform a single left-to-right carry pass: set $c_k \leftarrow c_k \bmod B$ and add $\lfloor c_k / B \rfloor$ to $c_{k+1}$. Cost: $O(N)$.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Total cost: $O(N \log N) = O(n \log n)$.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At this point you might ask: if the FFT already gives us $O(n \log n)$ multiplication, why do we need Schönhage-Strassen? The answer lies in a subtle but critical issue: &lt;strong&gt;numerical precision&lt;/strong&gt;.&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;schönhage-strassen-multiplication-in-on-log-n-log-log-n&quot;&gt;Schönhage-Strassen: Multiplication in $O(n \log n \log \log n)$&lt;/h1&gt;

&lt;h2 id=&quot;the-precision-problem&quot;&gt;The Precision Problem&lt;/h2&gt;

&lt;p&gt;The FFT-based multiplication pipeline described above works over the &lt;strong&gt;complex numbers&lt;/strong&gt;. The twiddle factors $\omega_N^k = e^{2\pi i k/N}$ are irrational (for most $k$), and so are the intermediate values in the FFT. On a real computer, we approximate these with floating-point arithmetic.&lt;/p&gt;

&lt;p&gt;For multiplying two numbers with $n$ digits, the convolution coefficients can be as large as $O(n B^2)$ (where $B$ is the base). To distinguish these integers exactly after rounding, we need floating-point precision of roughly $O(\log n + 2\log B)$ bits. For large $n$, this means we need &lt;strong&gt;multi-precision floating-point arithmetic&lt;/strong&gt; inside the FFT itself – and each multi-precision operation costs more than $O(1)$.&lt;/p&gt;

&lt;p&gt;This creates a vicious circle: to multiply $n$-digit numbers, we use the FFT, but the FFT internally needs high-precision multiplications, which are themselves expensive. The complex-number FFT approach does not, by itself, yield a clean $O(n \log n)$ integer multiplication algorithm.&lt;/p&gt;

&lt;p&gt;Schönhage and Strassen’s 1971 breakthrough was to eliminate this precision problem entirely by moving from the complex numbers to an &lt;strong&gt;exact algebraic setting&lt;/strong&gt; where roots of unity exist but rounding errors do not.&lt;/p&gt;

&lt;h2 id=&quot;from-mathbbc-to-mathbbz2m--1mathbbz-the-number-theoretic-transform&quot;&gt;From $\mathbb{C}$ to $\mathbb{Z}/(2^m + 1)\mathbb{Z}$: The Number Theoretic Transform&lt;/h2&gt;

&lt;h3 id=&quot;roots-of-unity-in-finite-rings&quot;&gt;Roots of unity in finite rings&lt;/h3&gt;

&lt;p&gt;The FFT algorithm does not actually require the complex numbers. It requires only a ring $R$ that contains:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;An element $\omega$ of multiplicative order $N$ (an “$N$-th root of unity”), meaning $\omega^N = 1$ and $\omega^k \neq 1$ for $0 &amp;lt; k &amp;lt; N$.&lt;/li&gt;
  &lt;li&gt;An inverse of $N$ in $R$ (for the inverse transform).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If $R$ is a finite ring (like $\mathbb{Z}/p\mathbb{Z}$ for a prime $p$), then all arithmetic is exact – no rounding, no precision issues. An FFT performed in such a ring is called a &lt;strong&gt;Number Theoretic Transform (NTT)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example.&lt;/strong&gt; In $\mathbb{Z}/5\mathbb{Z}$, the element $2$ has order $4$: $2^1 = 2$, $2^2 = 4$, $2^3 = 3$, $2^4 = 1 \pmod{5}$. So $\omega = 2$ is a primitive $4$th root of unity in $\mathbb{Z}/5\mathbb{Z}$, and we can perform a 4-point NTT modulo 5 with exact arithmetic.&lt;/p&gt;

&lt;h3 id=&quot;schönhage-and-strassens-choice-fermat-like-rings&quot;&gt;Schönhage and Strassen’s choice: Fermat-like rings&lt;/h3&gt;

&lt;p&gt;To multiply two $n$-bit integers, Schönhage and Strassen work in the ring:&lt;/p&gt;

\[R = \mathbb{Z} / (2^m + 1)\mathbb{Z}\]

&lt;p&gt;for a carefully chosen $m$. This ring has a remarkable property: the element $\omega = 2$ (or more precisely, a small power of $2$) serves as a root of unity, and &lt;strong&gt;multiplication by powers of 2 in this ring is just a bit-shift followed by a reduction modulo $2^m + 1$&lt;/strong&gt; – an operation that costs $O(m)$, which is essentially free.&lt;/p&gt;

&lt;p&gt;Why does $2$ behave as a root of unity here? Note that $2^m \equiv -1 \pmod{2^m + 1}$, so $2^{2m} \equiv 1 \pmod{2^m + 1}$. Thus $2$ has multiplicative order dividing $2m$ in this ring. With an appropriate choice of $m$, we can ensure that $2$ (or a power of it) is a primitive $N$-th root of unity for the $N$ we need.&lt;/p&gt;

&lt;p&gt;The critical advantage: all the “twiddle factor multiplications” in the FFT butterfly – which in the complex-number FFT require expensive multi-precision multiplications – reduce to &lt;strong&gt;bit-shifts and additions&lt;/strong&gt; in $\mathbb{Z}/(2^m + 1)\mathbb{Z}$. This makes the per-butterfly cost $O(m)$ instead of $O(m \log m \cdots)$, dramatically simplifying the recursion.&lt;/p&gt;

&lt;h2 id=&quot;the-algorithm-in-detail&quot;&gt;The Algorithm in Detail&lt;/h2&gt;

&lt;h3 id=&quot;step-0-setup&quot;&gt;Step 0: Setup&lt;/h3&gt;

&lt;p&gt;Given two $n$-bit integers $x$ and $y$, choose parameters:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Let $K = 2^k$ for some $k$ (the number of chunks).&lt;/li&gt;
  &lt;li&gt;Let $m = \lceil n / K \rceil + O(k)$ (the chunk size in bits, with some padding for carries).&lt;/li&gt;
  &lt;li&gt;Work in the ring $R = \mathbb{Z}/(2^m + 1)\mathbb{Z}$.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The parameters are chosen so that $K \approx \sqrt{n}$ (roughly), meaning we split each input into about $\sqrt{n}$ chunks of about $\sqrt{n}$ bits each.&lt;/p&gt;

&lt;h3 id=&quot;step-1-decomposition&quot;&gt;Step 1: Decomposition&lt;/h3&gt;

&lt;p&gt;Break each $n$-bit integer into $K$ chunks of $\sim m$ bits:&lt;/p&gt;

\[x = \sum_{j=0}^{K-1} a_j \cdot 2^{jm}, \qquad y = \sum_{j=0}^{K-1} b_j \cdot 2^{jm}\]

&lt;p&gt;Form the polynomials $A(z) = \sum a_j z^j$ and $B(z) = \sum b_j z^j$ over $R$, so that $x = A(2^m)$ and $y = B(2^m)$.&lt;/p&gt;

&lt;h3 id=&quot;step-2-forward-ntt&quot;&gt;Step 2: Forward NTT&lt;/h3&gt;

&lt;p&gt;Compute $\hat{\mathbf{a}} = \text{NTT}_N(\mathbf{a})$ and $\hat{\mathbf{b}} = \text{NTT}_N(\mathbf{b})$ in the ring $R$, where $N \geq 2K$ is a suitable power of 2.&lt;/p&gt;

&lt;p&gt;The NTT is the same Cooley-Tukey FFT algorithm, but:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;All arithmetic is modular (mod $2^m + 1$).&lt;/li&gt;
  &lt;li&gt;Twiddle factor multiplications $\omega^k \cdot v$ are implemented as &lt;strong&gt;cyclic bit-shifts&lt;/strong&gt; of $v$, costing $O(m)$.&lt;/li&gt;
  &lt;li&gt;Each butterfly costs $O(m)$: one bit-shift plus two modular additions.&lt;/li&gt;
  &lt;li&gt;Total: $N/2 \cdot \log_2 N$ butterflies, each $O(m)$, giving $O(Nm \log N)$ bit operations.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;step-3-pointwise-multiplication&quot;&gt;Step 3: Pointwise multiplication&lt;/h3&gt;

&lt;p&gt;Compute $\hat{c}_k = \hat{a}_k \cdot \hat{b}_k$ in $R$ for each $k = 0, \ldots, N - 1$.&lt;/p&gt;

&lt;p&gt;Each of these is a multiplication of two $m$-bit numbers modulo $2^m + 1$. But wait – this is itself a multiplication of smaller numbers! This is where the algorithm becomes &lt;strong&gt;recursive&lt;/strong&gt;: we use the Schönhage-Strassen algorithm itself (on inputs of size $m$ instead of $n$) to perform these multiplications.&lt;/p&gt;

&lt;p&gt;There are $N$ such multiplications, each on operands of size $m$. This is the “recursive nesting” that generates the $\log \log n$ factor.&lt;/p&gt;

&lt;h3 id=&quot;step-4-inverse-ntt&quot;&gt;Step 4: Inverse NTT&lt;/h3&gt;

&lt;p&gt;Compute $\mathbf{c} = \text{INTT}_N(\hat{\mathbf{c}})$. Same cost as the forward NTT: $O(Nm \log N)$.&lt;/p&gt;

&lt;h3 id=&quot;step-5-carry-propagation-and-reassembly&quot;&gt;Step 5: Carry propagation and reassembly&lt;/h3&gt;

&lt;p&gt;The vector $\mathbf{c}$ now contains the convolution of $\mathbf{a}$ and $\mathbf{b}$ modulo $2^m + 1$. (The parameters are chosen so that no coefficient is large enough to “wrap around” the modulus – the modulus is larger than any possible convolution coefficient.) Reassemble the final product by performing carries across the chunks.&lt;/p&gt;

&lt;h2 id=&quot;why-log-log-n&quot;&gt;Why $\log \log n$?&lt;/h2&gt;

&lt;p&gt;The complexity breaks down as follows. At the top level we have:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;NTT and INTT: $O(Nm \log N)$ bit operations for the transforms.&lt;/li&gt;
  &lt;li&gt;Pointwise multiplications: $N$ recursive multiplications on $m$-bit inputs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With $K \approx \sqrt{n}$ and $m \approx \sqrt{n}$, the recurrence is roughly:&lt;/p&gt;

\[T(n) = O\!\left(\sqrt{n} \cdot T(\sqrt{n})\right) + O(n \log n)\]

&lt;p&gt;Let us trace the recursion. At depth $d$, the problem size is $n^{1/2^d}$. The recursion bottoms out when $n^{1/2^d} = O(1)$, which requires $2^d \approx \log n$, giving a recursion depth of $d = O(\log \log n)$.&lt;/p&gt;

&lt;p&gt;At each recursion level, the non-recursive work is $O(n \log n)$ (the NTTs). Summing over $O(\log \log n)$ levels:&lt;/p&gt;

\[T(n) = O(n \log n \cdot \log \log n)\]

&lt;p&gt;This is the Schönhage-Strassen bound. The $\log \log n$ factor is not a deficiency of the FFT itself – the FFT is $O(n \log n)$. It is the cost of the &lt;strong&gt;recursive multiplications&lt;/strong&gt; needed at each level of the NTT.&lt;/p&gt;

&lt;h2 id=&quot;a-comparison&quot;&gt;A Comparison&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Algorithm&lt;/th&gt;
      &lt;th&gt;Complexity&lt;/th&gt;
      &lt;th&gt;Key Innovation&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Schoolbook&lt;/td&gt;
      &lt;td&gt;$O(n^2)$&lt;/td&gt;
      &lt;td&gt;Direct digit-by-digit&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Karatsuba&lt;/td&gt;
      &lt;td&gt;$O(n^{1.585})$&lt;/td&gt;
      &lt;td&gt;4 multiplications $\to$ 3&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Toom-3&lt;/td&gt;
      &lt;td&gt;$O(n^{1.465})$&lt;/td&gt;
      &lt;td&gt;9 multiplications $\to$ 5&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Schönhage-Strassen&lt;/td&gt;
      &lt;td&gt;$O(n \log n \log \log n)$&lt;/td&gt;
      &lt;td&gt;NTT in $\mathbb{Z}/(2^m+1)$&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Harvey-van der Hoeven&lt;/td&gt;
      &lt;td&gt;$O(n \log n)$&lt;/td&gt;
      &lt;td&gt;Multi-dimensional NTT (see below)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The jump from Toom-Cook to Schönhage-Strassen is qualitatively different from all previous improvements. Karatsuba and Toom-Cook are algebraic tricks that reduce the exponent toward 1 but never reach it. Schönhage-Strassen breaks through to a &lt;em&gt;nearly-linear&lt;/em&gt; bound by replacing polynomial interpolation at finitely many points with Fourier analysis at the roots of unity – and crucially, by doing so in an exact algebraic ring where the symmetry of the roots makes the transform cheap.&lt;/p&gt;

&lt;p&gt;The remaining $\log \log n$ factor is the residue of recursive nesting. Eliminating it required the multi-dimensional approach of Harvey and van der Hoeven (2019), which we now discuss.&lt;/p&gt;

&lt;h1 id=&quot;harvey-van-der-hoeven-on-log-n&quot;&gt;Harvey-van der Hoeven: $O(n \log n)$&lt;/h1&gt;

&lt;h2 id=&quot;the-last-factor-standing&quot;&gt;The Last Factor Standing&lt;/h2&gt;

&lt;p&gt;We have arrived at the final chapter. Let us take stock of where we are.&lt;/p&gt;

&lt;p&gt;You now understand that multiplying two $n$-digit integers is really computing a convolution of their digits. You understand that the FFT evaluates a polynomial at all $N$-th roots of unity in $O(N \log N)$ time, and that the Convolution Theorem lets us turn this evaluation into multiplication. You understand that Schönhage and Strassen sidestepped floating-point precision by working in the ring $\mathbb{Z}/(2^m + 1)\mathbb{Z}$, where twiddle factors are just bit-shifts.&lt;/p&gt;

&lt;p&gt;And you understand the one blemish: the $\log \log n$ factor. It comes from the fact that the NTT’s pointwise multiplications are themselves multiplications on smaller numbers, requiring their own NTTs, which require their own pointwise multiplications, and so on. The recursion bottoms out after $\log \log n$ levels, each contributing $O(n \log n)$ work. Multiply those together: $O(n \log n \log \log n)$.&lt;/p&gt;

&lt;p&gt;For 48 years – from 1971 to 2019 – nobody could kill that last factor. Then David Harvey and Joris van der Hoeven did.&lt;/p&gt;

&lt;h2 id=&quot;the-intuition-why-log-log-n-exists-and-how-to-eliminate-it&quot;&gt;The Intuition: Why $\log \log n$ Exists and How to Eliminate It&lt;/h2&gt;

&lt;p&gt;To understand the fix, we first need a sharper picture of the disease.&lt;/p&gt;

&lt;h3 id=&quot;the-disease-a-long-chain-of-recursive-calls&quot;&gt;The disease: a long chain of recursive calls&lt;/h3&gt;

&lt;p&gt;In Schönhage-Strassen, we split our $n$-bit number into $K \approx \sqrt{n}$ chunks of $m \approx \sqrt{n}$ bits each. The NTT on these chunks is cheap (just bit-shifts and additions), but the &lt;strong&gt;pointwise multiplications&lt;/strong&gt; – the $K$ products of $m$-bit numbers in $\mathbb{Z}/(2^m+1)$ – each require a recursive call to the entire algorithm.&lt;/p&gt;

&lt;p&gt;At the next level down, each $m$-bit multiplication splits into $\sqrt{m}$ chunks of $\sqrt{m}$ bits, and so on. The problem sizes form a chain:&lt;/p&gt;

\[n \;\to\; \sqrt{n} \;\to\; n^{1/4} \;\to\; n^{1/8} \;\to\; \cdots \;\to\; O(1)\]

&lt;p&gt;This chain has $\log \log n$ links (since $n^{1/2^d} = O(1)$ when $2^d \approx \log n$, giving $d \approx \log \log n$). Each link does $O(n \log n)$ work in total. The $\log \log n$ factor is simply the number of links in this chain.&lt;/p&gt;

&lt;h3 id=&quot;the-cure-make-the-chain-shorter&quot;&gt;The cure: make the chain shorter&lt;/h3&gt;

&lt;p&gt;Harvey and van der Hoeven’s idea, at its core, is beautifully simple: &lt;strong&gt;if the recursion depth is the problem, reduce the recursion depth.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Instead of splitting into $\sqrt{n}$ pieces (which halves the exponent at each level), split into &lt;strong&gt;far more&lt;/strong&gt; pieces that are &lt;strong&gt;far smaller&lt;/strong&gt;. If you split an $n$-bit number into $n/\log n$ pieces of $\log n$ bits each, then the recursive subproblems have size $\log n$ – which is already small enough to multiply by schoolbook in $O((\log n)^2)$ time! The recursion bottoms out in a single step. No chain. No $\log \log n$.&lt;/p&gt;

&lt;p&gt;But this creates a new problem. With $n / \log n$ chunks, the NTT must operate on a sequence of length $\sim n / \log n$, and the transform must happen in a ring large enough to hold the convolution without overflow. Finding a ring that simultaneously supports (a) cheap roots of unity, (b) sufficiently many of them, and (c) exact arithmetic with no precision loss – all while keeping the transform cost to $O(n \log n)$ – is the hard part. This is where the paper earns its 80 pages.&lt;/p&gt;

&lt;h2 id=&quot;the-architecture-three-key-ideas&quot;&gt;The Architecture: Three Key Ideas&lt;/h2&gt;

&lt;h3 id=&quot;idea-1-fold-the-sequence-into-a-multi-dimensional-array&quot;&gt;Idea 1: Fold the sequence into a multi-dimensional array&lt;/h3&gt;

&lt;p&gt;Rather than treating the $n / \log n$ chunks as a flat list, Harvey and van der Hoeven reshape them into a $d$-dimensional array of size $s_1 \times s_2 \times \cdots \times s_d$, where each $s_i$ is a small prime and $\prod s_i \approx n / \log n$.&lt;/p&gt;

&lt;p&gt;Why does this help? A $d$-dimensional convolution can be computed by performing &lt;strong&gt;1-dimensional DFTs along each dimension&lt;/strong&gt; in succession (this is the standard “row-column” algorithm for multidimensional transforms). If each dimension $s_i$ is small, each 1D DFT is cheap. And crucially, the total number of 1D DFT operations is:&lt;/p&gt;

\[\text{cost} = \sum_{i=1}^{d} \frac{S}{s_i} \cdot (\text{cost of a length-}s_i\text{ DFT})\]

&lt;p&gt;where $S = \prod s_i$. By choosing $d$ to grow with $n$ (specifically, $d \sim \log n / \log \log n$), each $s_i$ stays bounded by a constant, and the DFTs along each dimension have constant cost per element. The total transform cost is $O(S \cdot d) = O!\left(\frac{n}{\log n} \cdot \frac{\log n}{\log \log n}\right) = O!\left(\frac{n}{\log \log n}\right)$, which is well within the $O(n \log n)$ budget.&lt;/p&gt;

&lt;p&gt;The problem is that the Cooley-Tukey radix-2 trick does not work on prime-sized dimensions. This is where the second idea comes in.&lt;/p&gt;

&lt;h3 id=&quot;the-chinese-remainder-theorem-the-bridge-between-dimensions&quot;&gt;The Chinese Remainder Theorem: The Bridge Between Dimensions&lt;/h3&gt;

&lt;p&gt;In this algorithm, the Chinese Remainder Theorem (CRT) is the bridge that turns a massive, “one-dimensional” multiplication problem into a more manageable “multi-dimensional” one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the Chinese Remainder Theorem (CRT)?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In general mathematics, the CRT is a theorem that allows you to uniquely identify a large number by its remainders when divided by a set of smaller, relatively prime numbers.&lt;/p&gt;

&lt;p&gt;In the context of this paper, it is used to create an isomorphism (a structural match) between two different algebraic spaces:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;One-Dimensional Space:&lt;/strong&gt; $\mathbb{Z}[x]/(x^{s_1 \cdots s_d} - 1)$ – This represents the large integer split into one long line of chunks.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Multi-Dimensional Space:&lt;/strong&gt; $\mathbb{Z}[x_1, \ldots, x_d]/(x_1^{s_1}-1, \ldots, x_d^{s_d}-1)$ – This represents the same data arranged on a $d$-dimensional grid (like a cube or hypercube).&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why use CRT at all?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The authors use the CRT for several critical reasons:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;To enable “Fast Polynomial Transforms.”&lt;/strong&gt; The fastest tools for this algorithm (Nussbaumer’s transforms) require the problem to be structured as a multidimensional grid. The CRT is what allows the authors to “reshape” the flat list of integer chunks into that grid.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;To reduce problem size.&lt;/strong&gt; By using the CRT to map the data onto a grid of size $s_1 \times s_2 \times \cdots \times s_d$, the problem of computing one giant Discrete Fourier Transform (DFT) is broken down into a collection of much smaller DFTs along each dimension.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Efficient recursion.&lt;/strong&gt; The “multi-dimensional” approach is what allows the algorithm to reach the $O(n \log n)$ speed. By splitting the integer into $d$ different dimensions (where $d$ is a parameter they can choose, like 1729), they can reduce the size of the sub-problems much more aggressively at each step of the recursion.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The Workflow Summary&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Split:&lt;/strong&gt; Take the $n$-bit integer and split it into many small chunks.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Map (CRT):&lt;/strong&gt; Use the CRT to arrange those chunks into a $d$-dimensional grid based on distinct prime numbers ($s_1, \ldots, s_d$).&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Multiply:&lt;/strong&gt; Perform the multiplication on this grid using fast Fourier transforms.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Reverse:&lt;/strong&gt; Use the inverse of the CRT mapping to “flatten” the result back into a single large integer.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;idea-2-gaussian-resampling--making-primes-act-like-powers-of-2&quot;&gt;Idea 2: Gaussian resampling – making primes act like powers of 2&lt;/h3&gt;

&lt;p&gt;The Cooley-Tukey FFT requires the transform length to be a power of 2 (or at least highly composite). The dimensions $s_i$ are primes. How do we bridge this gap?&lt;/p&gt;

&lt;p&gt;Harvey and van der Hoeven use a technique inspired by &lt;strong&gt;Bluestein’s algorithm&lt;/strong&gt;, but with a Gaussian twist. The idea is:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;Embed the length-$s_i$ DFT into a slightly larger length-$t_i$ &lt;strong&gt;cyclic convolution&lt;/strong&gt;, where $t_i$ is the next power of 2 above $s_i$.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;Compute this cyclic convolution using Nussbaumer’s algorithm (a multiplication-free polynomial transform), which only needs additions, subtractions, and cyclic shifts.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;The “embedding” is done via multiplication by a &lt;strong&gt;Gaussian chirp&lt;/strong&gt; – a sequence of the form $e^{\pi i k^2 / s_i}$. The Gaussian chirp has the remarkable property that it converts a DFT into a convolution (this is the classical “chirp-$z$ transform” idea of Bluestein). And because the Gaussian is approximately its own Fourier transform, the approximation errors when rounding $s_i$ up to $t_i$ can be made exponentially small with only $O(1)$ extra bits of precision.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The net effect: each prime-sized DFT is replaced by a power-of-2-sized convolution that can be computed without any multiplications in the traditional sense – only additions and shifts. The cost per element remains $O(1)$.&lt;/p&gt;

&lt;h3 id=&quot;idea-3-nussbaumers-algorithm--transforms-without-multiplications&quot;&gt;Idea 3: Nussbaumer’s algorithm – transforms without multiplications&lt;/h3&gt;

&lt;p&gt;The inner convolutions (from Idea 2) are computed using &lt;strong&gt;Nussbaumer’s polynomial transform&lt;/strong&gt;, which operates over the ring $R[y]/(y^r + 1)$ for $r$ a power of 2. In this ring:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Multiplication by $y$ is a &lt;strong&gt;cyclic shift&lt;/strong&gt; (free, like multiplying by $2$ in $\mathbb{Z}/(2^m + 1)$ was free for Schönhage-Strassen).&lt;/li&gt;
  &lt;li&gt;The transform uses only $O(r \log r)$ additions and subtractions – &lt;strong&gt;no multiplications&lt;/strong&gt; at this level.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the key that breaks the recursive chain. In Schönhage-Strassen, the pointwise multiplications in the NTT were genuine multiplications that demanded recursive calls. In Harvey-van der Hoeven, the analogous step uses Nussbaumer transforms that need &lt;strong&gt;no multiplications&lt;/strong&gt;, only shifts and additions. No recursive call is needed. The chain has been cut.&lt;/p&gt;

&lt;p&gt;The only remaining multiplications are tiny: each “pointwise product” in the Nussbaumer-transformed domain amounts to a multiplication of numbers with $O(\log n)$ bits, which can be done by schoolbook in $O((\log n)^2)$ time – a cost that is absorbed into the $O(n \log n)$ total.&lt;/p&gt;

&lt;h2 id=&quot;how-the-pieces-fit-together&quot;&gt;How the Pieces Fit Together&lt;/h2&gt;

&lt;p&gt;The full algorithm, stripped to its skeleton:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Split&lt;/strong&gt; the $n$-bit inputs into $S \approx n / \log n$ chunks of $\sim \log n$ bits each.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Reshape&lt;/strong&gt; the chunk sequence into a $d$-dimensional array ($d \sim \log n / \log \log n$ dimensions, each of prime size $s_i = O(\log \log n)$).&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;For each dimension&lt;/strong&gt; $i = 1, \ldots, d$:
    &lt;ul&gt;
      &lt;li&gt;Apply the Gaussian chirp to convert the length-$s_i$ DFT along that dimension into a cyclic convolution of length $t_i = O(s_i)$.&lt;/li&gt;
      &lt;li&gt;Compute that cyclic convolution using Nussbaumer’s addition-only polynomial transform.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Pointwise multiply&lt;/strong&gt; the transformed arrays. Each pointwise product involves numbers of $O(\log n)$ bits – small enough for schoolbook.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Invert&lt;/strong&gt; the multidimensional transform (same process in reverse).&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Carry-propagate&lt;/strong&gt; and reassemble the final product.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The total cost at each step:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Steps 3 and 5 (transforms): $O(n)$ additions across $d$ dimensions, times $d = O(\log n / \log \log n)$, giving $O(n \log n / \log \log n)$ – well within budget.&lt;/li&gt;
  &lt;li&gt;Step 4 (pointwise): $S$ multiplications of $O(\log n)$-bit numbers at $O((\log n)^2)$ each, giving $O(n \log n)$.&lt;/li&gt;
  &lt;li&gt;Steps 1, 2, 6: $O(n)$.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Total: $O(n \log n)$.&lt;/strong&gt;&lt;/p&gt;

&lt;h2 id=&quot;why-1729&quot;&gt;Why 1729?&lt;/h2&gt;

&lt;p&gt;The algorithm’s correctness depends on the Gaussian resampling errors being negligible, which requires extra precision bits. The number of extra bits grows with $d$, and $d$ grows with $n$. Working through the constants, the algorithm only becomes faster than Schönhage-Strassen when $n$ is so large that the constant-factor overhead of managing $d$ dimensions, Gaussian chirps, and Nussbaumer bookkeeping is finally absorbed.&lt;/p&gt;

&lt;p&gt;Harvey and van der Hoeven estimate this crossover at numbers with more than $2^{1729^{12}}$ digits.&lt;/p&gt;

&lt;p&gt;The appearance of &lt;strong&gt;1729&lt;/strong&gt; – Ramanujan’s famous “taxicab number,” the smallest number expressible as the sum of two cubes in two different ways – is a coincidence, but a poetic one. The number arises from a chain of parameter optimizations in the proof, not from any deep connection to Ramanujan’s work. But it is fitting that the algorithm that closes the book on multiplication complexity should bear, in its constant, an echo of one of mathematics’ most beautiful stories.&lt;/p&gt;

&lt;p&gt;To put $2^{1729^{12}}$ in perspective:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;The observable universe contains roughly $10^{80}$ atoms.&lt;/li&gt;
  &lt;li&gt;$2^{1729^{12}}$ has approximately $10^{38}$ decimal digits in its &lt;em&gt;exponent alone&lt;/em&gt;.&lt;/li&gt;
  &lt;li&gt;If every atom in the universe were a hard drive, and every hard drive stored $10^{15}$ digits, you could store roughly $10^{95}$ digits. This is &lt;em&gt;nothing&lt;/em&gt; compared to $2^{1729^{12}}$.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a &lt;strong&gt;galactic algorithm&lt;/strong&gt; – beautiful, true, and utterly useless for any computation that will ever be performed in the physical universe. But its existence answers a question that stood open for sixty years: &lt;strong&gt;can multiplication be done in $O(n \log n)$?&lt;/strong&gt; The answer is yes.&lt;/p&gt;

&lt;h2 id=&quot;the-view-from-the-summit&quot;&gt;The View from the Summit&lt;/h2&gt;

&lt;p&gt;Let us look back at the full arc:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Year&lt;/th&gt;
      &lt;th&gt;Algorithm&lt;/th&gt;
      &lt;th&gt;Complexity&lt;/th&gt;
      &lt;th&gt;Key Insight&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;antiquity&lt;/td&gt;
      &lt;td&gt;Schoolbook&lt;/td&gt;
      &lt;td&gt;$O(n^2)$&lt;/td&gt;
      &lt;td&gt;Every digit meets every digit&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;1960&lt;/td&gt;
      &lt;td&gt;Karatsuba&lt;/td&gt;
      &lt;td&gt;$O(n^{1.585})$&lt;/td&gt;
      &lt;td&gt;One clever identity saves 25% per level&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;1963&lt;/td&gt;
      &lt;td&gt;Toom-Cook&lt;/td&gt;
      &lt;td&gt;$O(n^{1+\varepsilon})$ for any $\varepsilon &amp;gt; 0$&lt;/td&gt;
      &lt;td&gt;Polynomial interpolation at $k$ points&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;1971&lt;/td&gt;
      &lt;td&gt;Schönhage-Strassen&lt;/td&gt;
      &lt;td&gt;$O(n \log n \log \log n)$&lt;/td&gt;
      &lt;td&gt;NTT in $\mathbb{Z}/(2^m+1)$; roots of unity via bit-shifts&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;2019&lt;/td&gt;
      &lt;td&gt;Harvey-van der Hoeven&lt;/td&gt;
      &lt;td&gt;$O(n \log n)$&lt;/td&gt;
      &lt;td&gt;Multi-dim NTT; Nussbaumer kills the recursion&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Each breakthrough changed the &lt;em&gt;kind&lt;/em&gt; of mathematics being used. Karatsuba’s was an algebraic identity. Toom-Cook was polynomial algebra. Schönhage-Strassen was number-theoretic harmonic analysis. Harvey-van der Hoeven is multi-dimensional algebraic geometry fused with analytic number theory.&lt;/p&gt;

&lt;p&gt;And yet the punchline is the same as it was in the schoolbook algorithm: we are still just multiplying digits together and adding up the results. Every advance has been about finding a cleverer &lt;em&gt;order&lt;/em&gt; in which to do it.&lt;/p&gt;

&lt;h1 id=&quot;connections-to-sorting-algorithms&quot;&gt;Connections to Sorting Algorithms&lt;/h1&gt;

&lt;p&gt;We have just traced the 60-year journey from $O(n^2)$ to $O(n \log n)$ for integer multiplication. But if you have studied algorithms before, that destination – $O(n \log n)$ – should feel familiar. It is the same complexity that governs comparison-based sorting: Merge Sort, Heapsort, and any optimal sorting algorithm all run in $\Theta(n \log n)$ time.&lt;/p&gt;

&lt;p&gt;Is this a coincidence? It is not. The connection runs deep, and understanding it illuminates &lt;em&gt;why&lt;/em&gt; $n \log n$ appears as a fundamental barrier across seemingly unrelated problems.&lt;/p&gt;

&lt;h2 id=&quot;the-information-theoretic-lower-bound&quot;&gt;The Information-Theoretic Lower Bound&lt;/h2&gt;

&lt;p&gt;The $n \log n$ term does not arise from any particular algorithmic trick. It arises from &lt;strong&gt;entropy&lt;/strong&gt; – from the sheer amount of information that must be processed to solve the problem at all.&lt;/p&gt;

&lt;h3 id=&quot;sorting-counting-permutations&quot;&gt;Sorting: Counting Permutations&lt;/h3&gt;

&lt;p&gt;Consider sorting $n$ elements. The input is some unknown permutation of ${1, 2, \ldots, n}$, and the algorithm must determine &lt;em&gt;which&lt;/em&gt; of the $n!$ possible permutations it is looking at. Every comparison-based sorting algorithm can be modeled as a binary decision tree: at each internal node, the algorithm compares two elements and branches left or right. The leaves of this tree correspond to the $n!$ possible outcomes.&lt;/p&gt;

&lt;p&gt;A binary tree with $L$ leaves has depth at least $\log_2 L$. Since our tree must have at least $n!$ leaves:&lt;/p&gt;

\[\text{depth} \;\geq\; \log_2(n!)\]

&lt;p&gt;Stirling’s approximation gives us:&lt;/p&gt;

\[\log_2(n!) \;=\; \sum_{k=1}^{n} \log_2 k \;=\; n \log_2 n - n \log_2 e + O(\log n) \;=\; \Theta(n \log n)\]

&lt;p&gt;Each comparison provides at most 1 bit of information (left or right). Therefore, &lt;em&gt;any&lt;/em&gt; comparison-based sorting algorithm must make at least $\Omega(n \log n)$ comparisons in the worst case. This is not a statement about any particular algorithm – it is a statement about the &lt;em&gt;information content&lt;/em&gt; of the problem itself.&lt;/p&gt;

&lt;h3 id=&quot;multiplication-counting-convolutions&quot;&gt;Multiplication: Counting Convolutions&lt;/h3&gt;

&lt;p&gt;Now consider multiplying two $n$-bit integers. The output is a $2n$-bit number, and each output bit can depend on every input bit from both operands. The “mixing” that must occur – the convolution of $n$ digits with $n$ digits – produces $2n - 1$ output coefficients, each of which is a sum of products involving up to $n$ terms.&lt;/p&gt;

&lt;p&gt;The information-theoretic argument here is more subtle than in sorting (and, crucially, a tight $\Omega(n \log n)$ lower bound for integer multiplication has &lt;em&gt;not&lt;/em&gt; been formally proven – this remains a major open problem). But the heuristic reasoning is compelling: the FFT is the canonical way to compute a length-$n$ convolution, and the FFT requires $\frac{n}{2} \log_2 n$ butterfly operations, each combining two values. The data must pass through $\log_2 n$ stages, with $O(n)$ work per stage. Any algorithm that computes the same convolution must, in some sense, perform the same total information routing.&lt;/p&gt;

&lt;p&gt;The structural parallel is striking:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th&gt;&lt;strong&gt;Sorting&lt;/strong&gt;&lt;/th&gt;
      &lt;th&gt;&lt;strong&gt;Multiplication&lt;/strong&gt;&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Problem&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Identify one permutation out of $n!$&lt;/td&gt;
      &lt;td&gt;Compute a convolution of $n$ coefficients&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Information content&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;$\log_2(n!) = \Theta(n \log n)$ bits&lt;/td&gt;
      &lt;td&gt;$\Theta(n \log n)$ bits of mixing (conjectured)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Per-step bandwidth&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;$O(n)$ comparisons&lt;/td&gt;
      &lt;td&gt;$O(n)$ butterfly operations&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Minimum steps&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;$\Omega(\log n)$&lt;/td&gt;
      &lt;td&gt;$\Omega(\log n)$&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Total work&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;$\Omega(n \log n)$&lt;/td&gt;
      &lt;td&gt;$O(n \log n)$ (achieved by HvH)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Both problems hit the same wall: $n$ data items that must be “mixed” through $\log n$ stages of $O(n)$ work each.&lt;/p&gt;

&lt;h2 id=&quot;the-symmetry-of-the-recurrence&quot;&gt;The Symmetry of the Recurrence&lt;/h2&gt;

&lt;p&gt;Beyond the information-theoretic parallel, there is an algebraic one: both optimal sorting and optimal multiplication satisfy the same &lt;em&gt;recurrence relation&lt;/em&gt;.&lt;/p&gt;

&lt;h3 id=&quot;merge-sort&quot;&gt;Merge Sort&lt;/h3&gt;

&lt;p&gt;Merge Sort divides $n$ elements into two halves, recursively sorts each half, and merges the results in $O(n)$ time:&lt;/p&gt;

\[T(n) = 2\,T\!\left(\frac{n}{2}\right) + O(n)\]

&lt;p&gt;By the Master Theorem (Case 2: $a = 2$, $b = 2$, $f(n) = O(n)$, so $n^{\log_b a} = n^1 = n = f(n)$), this solves to:&lt;/p&gt;

\[T(n) = O(n \log n)\]

&lt;h3 id=&quot;harvey-van-der-hoeven-multiplication&quot;&gt;Harvey-van der Hoeven Multiplication&lt;/h3&gt;

&lt;p&gt;The HvH algorithm decomposes an $n$-coefficient convolution into $K$ sub-convolutions of size $n/K$ each, with $O(n)$ work for the FFT and pointwise operations at each level:&lt;/p&gt;

\[T(n) = K \cdot T\!\left(\frac{n}{K}\right) + O(n)\]

&lt;p&gt;This is the &lt;em&gt;same&lt;/em&gt; Master Theorem case regardless of $K$: we have $a = K$, $b = K$, $n^{\log_b a} = n^1 = n = f(n)$, giving:&lt;/p&gt;

\[T(n) = O(n \log n)\]

&lt;p&gt;The recurrences are structurally identical. In both cases, the algorithm splits the problem into $K$ pieces of size $n/K$ and does $O(n)$ work to split and recombine. The $\log n$ factor is simply the depth of the recursion tree: $\log_K n$ levels, each costing $O(n)$. The particular value of $K$ (2 for Merge Sort, $\sqrt{n}$ for Schönhage-Strassen, a carefully chosen composite for HvH) affects the constant factors but not the asymptotic complexity.&lt;/p&gt;

&lt;p&gt;The critical difference is that Schönhage-Strassen’s “recombine” step secretly contains &lt;em&gt;additional recursive multiplications&lt;/em&gt; – the pointwise products in $\mathbb{Z}/(2^m + 1)$ – which add a $\log \log n$ overhead atop the clean recurrence. Harvey and van der Hoeven’s achievement was making the recombine step &lt;em&gt;truly&lt;/em&gt; $O(n)$ by using Nussbaumer’s technique to eliminate those inner multiplications. Once they did, the recurrence collapsed to the same clean form as Merge Sort, and $O(n \log n)$ fell out immediately.&lt;/p&gt;

&lt;h2 id=&quot;the-hypercube-a-shared-geometry&quot;&gt;The Hypercube: A Shared Geometry&lt;/h2&gt;

&lt;p&gt;The deepest way to see the connection is through the lens of &lt;strong&gt;communication on a hypercube&lt;/strong&gt; – a model that unifies both algorithms geometrically.&lt;/p&gt;

&lt;h3 id=&quot;what-is-a-hypercube&quot;&gt;What is a Hypercube?&lt;/h3&gt;

&lt;p&gt;A $d$-dimensional hypercube $Q_d$ is a graph with $2^d$ nodes. Each node is labeled by a $d$-bit binary string, and two nodes are connected by an edge if and only if their labels differ in exactly one bit. For example, $Q_3$ (the 3-cube) is the familiar wireframe cube with 8 vertices and 12 edges.&lt;/p&gt;

&lt;p&gt;Three properties make the hypercube the natural “arena” for divide-and-conquer algorithms:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Recursive decomposition.&lt;/strong&gt; $Q_d$ consists of two copies of $Q_{d-1}$ joined by edges along the $d$-th coordinate. This mirrors the way both Merge Sort and FFT-based multiplication split their input in half (or into $K$ parts) at each level.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Logarithmic diameter.&lt;/strong&gt; Despite having $N = 2^d$ nodes, the longest shortest path in $Q_d$ has length $d = \log_2 N$. Any piece of information can reach any other node in at most $\log N$ steps. This is the geometric origin of the $\log n$ factor.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Vertex symmetry.&lt;/strong&gt; Every node in $Q_d$ looks structurally identical to every other node – there are no bottlenecks, no privileged positions. This ensures that the algorithm’s workload is evenly distributed across all data items.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;sorting-on-the-hypercube&quot;&gt;Sorting on the Hypercube&lt;/h3&gt;

&lt;p&gt;To sort $n$ elements on a hypercube, we can use &lt;strong&gt;Bitonic Sort&lt;/strong&gt; (Batcher, 1968). The idea is to treat the $n$ array positions as the $n$ nodes of a hypercube ($d = \log_2 n$ dimensions) and perform &lt;em&gt;compare-exchange&lt;/em&gt; operations along each dimension sequentially.&lt;/p&gt;

&lt;p&gt;In a single step along one dimension, the hypercube supports $n/2$ parallel comparisons – one across each edge in that dimension. This is the &lt;em&gt;bandwidth&lt;/em&gt; of one dimension: $O(n)$ bits of information resolved per step.&lt;/p&gt;

&lt;p&gt;There are $d = \log_2 n$ dimensions, and each dimension requires $O(d)$ compare-exchange rounds in Bitonic Sort, giving $O(\log^2 n)$ parallel steps. (An asymptotically optimal sorting network, like the AKS network, achieves $O(\log n)$ parallel steps, fully saturating the hypercube’s bandwidth.)&lt;/p&gt;

&lt;p&gt;The total work is:&lt;/p&gt;

\[\underbrace{O(n)}_{\text{comparisons per step}} \;\times\; \underbrace{O(\log n)}_{\text{steps}} \;=\; O(n \log n)\]

&lt;p&gt;The $n \log n$ is precisely the entropy of $n!$ permutations being drained at a rate of $O(n)$ bits per step over $O(\log n)$ steps. When every lane of the hypercube is fully utilized at every step, we say the algorithm &lt;strong&gt;saturates the bandwidth&lt;/strong&gt;. No sorting algorithm can do better, because there is no more information capacity to exploit.&lt;/p&gt;

&lt;h3 id=&quot;multiplication-on-the-hypercube&quot;&gt;Multiplication on the Hypercube&lt;/h3&gt;

&lt;p&gt;Now consider the FFT butterfly network for an $n$-point transform. Draw it as a diagram with $\log_2 n$ stages, each consisting of $n/2$ butterfly operations. If you squint at this diagram, you will notice something: &lt;em&gt;it is a hypercube&lt;/em&gt;. Each butterfly connects two nodes whose indices differ in exactly one bit position (the bit corresponding to that stage). The FFT literally routes data along the edges of $Q_d$.&lt;/p&gt;

&lt;p&gt;In Schönhage-Strassen, this hypercube routing is not quite “clean.” The pointwise multiplications at each stage are themselves recursive FFTs on smaller hypercubes, creating a nested hierarchy. The nesting depth is $\log \log n$, and the overhead at each level prevents the algorithm from fully saturating the bandwidth of the outer hypercube. There is “congestion” – some lanes carry recursive sub-computations instead of direct data movement.&lt;/p&gt;

&lt;p&gt;Harvey and van der Hoeven’s breakthrough was, in geometric terms, a way to &lt;em&gt;eliminate the congestion&lt;/em&gt;. By lifting the one-dimensional convolution into a multi-dimensional array and choosing the dimensions so that Nussbaumer’s technique replaces the recursive multiplications with additions and cyclic shifts, they ensured that the FFT butterfly at each level performs &lt;em&gt;pure data routing&lt;/em&gt; – no hidden recursive work, no congestion. Every lane of the hypercube carries useful information at every step.&lt;/p&gt;

&lt;p&gt;The result: multiplication, like sorting, saturates the hypercube’s bandwidth. Both algorithms move $O(n)$ units of information per step across $O(\log n)$ steps, for a total of $O(n \log n)$. The two problems – one about ordering elements, the other about convolving digits – are solved by the &lt;em&gt;same geometric machine&lt;/em&gt;.&lt;/p&gt;

&lt;h2 id=&quot;sorting-is-multiplying-multiplying-is-sorting&quot;&gt;Sorting &lt;em&gt;Is&lt;/em&gt; Multiplying. Multiplying &lt;em&gt;Is&lt;/em&gt; Sorting.&lt;/h2&gt;

&lt;p&gt;We can now say something stronger than “sorting and multiplication are &lt;em&gt;analogous&lt;/em&gt;.” They are, in a precise algebraic sense, the &lt;em&gt;same operation&lt;/em&gt; – projections of a single underlying phenomenon onto different mathematical surfaces.&lt;/p&gt;

&lt;h3 id=&quot;the-shared-abstraction-rearranging-information-across-dimensions&quot;&gt;The Shared Abstraction: Rearranging Information Across Dimensions&lt;/h3&gt;

&lt;p&gt;Consider what sorting actually does. You have $n$ data items, each sitting at some position. The items are “tangled” – their current positions bear no relation to their values. To sort is to &lt;em&gt;untangle&lt;/em&gt; them: to route each item from its current position to its correct position. The difficulty of sorting is exactly the difficulty of this routing problem. The items must move through a network (comparisons, swaps), and the network has finite bandwidth. The entropy $\log_2(n!)$ measures the total amount of routing information that must be resolved. The $n \log n$ is the cost of pushing that information through the $\log n$ dimensions of a hypercube, one dimension at a time.&lt;/p&gt;

&lt;p&gt;Now consider what multiplication does. You have $n$ coefficients in one polynomial and $n$ coefficients in another. Each output coefficient of the product is a sum of terms $a_i \cdot b_j$ where $i + j$ equals the output index. The coefficients are “tangled” – every input coefficient contributes to multiple output coefficients, and the contributions overlap. To multiply is to &lt;em&gt;untangle&lt;/em&gt; them: to route each partial product $a_i \cdot b_j$ to its correct output position $i + j$ and accumulate the results. The FFT does this by decomposing the routing into $\log n$ stages of butterfly operations, each resolving one “dimension” of the entanglement.&lt;/p&gt;

&lt;p&gt;Strip away the surface details – the comparisons, the twiddle factors, the carry propagation – and you are left with the same skeleton:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;$n$ items are entangled across $\log n$ dimensions. Untangling them requires touching all $n$ items once per dimension. The cost is $n \log n$.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Sorting untangles &lt;em&gt;order&lt;/em&gt;. Multiplication untangles &lt;em&gt;convolution&lt;/em&gt;. But “untangling” is the same verb in both sentences, and $n \log n$ is its conjugation.&lt;/p&gt;

&lt;h3 id=&quot;making-it-algebraic&quot;&gt;Making It Algebraic&lt;/h3&gt;

&lt;p&gt;We can make this even more precise. Both problems can be formulated as applying a &lt;em&gt;linear transform&lt;/em&gt; to a vector of length $n$:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Sorting&lt;/strong&gt; is the application of a &lt;em&gt;permutation matrix&lt;/em&gt; $P \in {0,1}^{n \times n}$ to the input vector. The “problem” is that we don’t know which permutation matrix to apply until we’ve inspected the data. The decision tree that determines $P$ has depth $\Omega(\log_2(n!)) = \Omega(n \log n)$.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Multiplication&lt;/strong&gt; (via convolution) is the application of a &lt;em&gt;circulant matrix&lt;/em&gt; $C \in \mathbb{Z}^{n \times n}$ to the input vector, where $C_{ij} = b_{(i-j) \bmod n}$. The FFT diagonalizes this circulant: $C = F^{-1} \hat{C} F$, where $F$ is the DFT matrix and $\hat{C}$ is diagonal. The cost of applying $F$ and $F^{-1}$ is $O(n \log n)$.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In both cases, we are applying a structured matrix to a vector of $n$ elements. The permutation matrix has $n!$ possible forms; the circulant matrix has $n$ free parameters but acts on a pair of inputs, yielding $O(n)$ degrees of freedom in the output. The algebraic structure of these matrices – their factorization into sparse stages, their decomposition along the dimensions of a hypercube – is &lt;em&gt;identical&lt;/em&gt;. The DFT matrix $F$ factors into $\log n$ stages of sparse butterfly matrices. A sorting network factors into $\log n$ stages of sparse comparison-swap matrices. Both are “hypercube decompositions” of their respective transforms.&lt;/p&gt;

&lt;p&gt;This is why the same $n \log n$ appears. It is not a coincidence, not a vague analogy, not a metaphor. It is the same theorem applied to two different matrix families.&lt;/p&gt;

&lt;h3 id=&quot;the-linearithmic-manifold&quot;&gt;The Linearithmic Manifold&lt;/h3&gt;

&lt;p&gt;There is a beautiful way to see all of this in a single picture. Imagine a space of all “rearrangement problems” on $n$ items – every computational task whose essence is routing $n$ pieces of information to their correct destinations through a network of finite bandwidth. Call this the &lt;strong&gt;linearithmic manifold&lt;/strong&gt;: the space of problems whose optimal solutions require $\Theta(n \log n)$ operations.&lt;/p&gt;

&lt;p&gt;Sorting lives on this manifold: $n$ items, $n!$ possible destinations, $\log n$ dimensions of routing.&lt;/p&gt;

&lt;p&gt;Multiplication lives on this manifold: $n$ coefficients, $2n - 1$ output positions, $\log n$ dimensions of spectral decomposition.&lt;/p&gt;

&lt;p&gt;The Fast Fourier Transform lives on this manifold: $n$ time-domain samples, $n$ frequency-domain samples, $\log n$ butterfly stages.&lt;/p&gt;

&lt;p&gt;Even matrix transposition on a $\sqrt{n} \times \sqrt{n}$ matrix lives on this manifold: $n$ entries, each needing to swap its row and column coordinates, requiring $\Theta(n \log n)$ cache misses in the I/O model.&lt;/p&gt;

&lt;p&gt;These problems are not merely “similar in complexity.” They are &lt;em&gt;manifestations of the same geometric constraint&lt;/em&gt;: the constraint that moving $n$ items across $\log n$ dimensions, with $O(n)$ bandwidth per dimension, costs exactly $n \log n$. The linearithmic manifold is not a metaphor – it is the deep reason that $n \log n$ recurs so obsessively across computer science.&lt;/p&gt;

&lt;p&gt;Sorting is multiplying in the permutation dimension.
Multiplying is sorting in the convolution dimension.
They are the same mountain, seen from different valleys.&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;conclusion-the-structure-of-computation-itself&quot;&gt;Conclusion: The Structure of Computation Itself&lt;/h1&gt;

&lt;h2 id=&quot;what-weve-witnessed&quot;&gt;What We’ve Witnessed&lt;/h2&gt;

&lt;p&gt;Let us step back and take in the full panorama.&lt;/p&gt;

&lt;p&gt;We began with the schoolbook algorithm – a method so natural, so seemingly inevitable, that Andrey Kolmogorov, one of the greatest mathematicians of the twentieth century, stood before his seminar in 1960 and conjectured that its $O(n^2)$ cost was a law of nature. Multiplication, he believed, was inherently quadratic. Every digit of one number must “see” every digit of the other, and there is no shortcut around that combinatorial explosion.&lt;/p&gt;

&lt;p&gt;He was wrong within a week.&lt;/p&gt;

&lt;p&gt;What Karatsuba discovered was not merely a faster algorithm. It was a &lt;em&gt;philosophical&lt;/em&gt; rupture. The schoolbook method treats multiplication as a flat, two-dimensional grid: row meets column, partial product accumulates, carry propagates. Karatsuba’s trick – computing three half-size products where four seemed necessary – revealed that this grid was not a law of arithmetic but an &lt;em&gt;artifact of how we happened to organize the computation&lt;/em&gt;. The digits still meet. The partial products still accumulate. But by choosing a cleverer grouping, by exploiting the algebraic identity $(x_1 + x_0)(y_1 + y_0) = x_1 y_1 + x_1 y_0 + x_0 y_1 + x_0 y_0$, Karatsuba showed that some of those meetings are redundant – their information content is already captured elsewhere.&lt;/p&gt;

&lt;p&gt;This is the thread that runs through everything we’ve seen.&lt;/p&gt;

&lt;h2 id=&quot;the-changing-language-of-speedup&quot;&gt;The Changing Language of Speedup&lt;/h2&gt;

&lt;p&gt;Each breakthrough in this story didn’t just produce a faster algorithm; it changed the &lt;em&gt;mathematical language&lt;/em&gt; in which multiplication is expressed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Karatsuba&lt;/strong&gt; spoke the language of &lt;strong&gt;algebra&lt;/strong&gt;: a single polynomial identity turns four sub-problems into three, and the Master Theorem does the rest. The savings are modest – $O(n^{1.585})$ versus $O(n^2)$ – but the conceptual leap is enormous. For the first time, the exponent on multiplication was negotiable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Toom-Cook&lt;/strong&gt; generalized this into the language of &lt;strong&gt;polynomial interpolation&lt;/strong&gt;. If splitting a number in two and evaluating at three points (Karatsuba) drops the exponent to $\log_2 3$, then splitting into $k$ pieces and evaluating at $2k - 1$ points drops it to $\log_k (2k - 1)$. As $k$ grows, the exponent approaches $1 + \varepsilon$ for any $\varepsilon &amp;gt; 0$. This was the first hint that $O(n^{1+\varepsilon})$ was not the floor – that perhaps the true cost of multiplication is not polynomial in $n$ at all, but something closer to $n$ times a slowly growing function.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schönhage-Strassen&lt;/strong&gt; rewrote multiplication in the language of &lt;strong&gt;harmonic analysis&lt;/strong&gt;. The Convolution Theorem – the deep fact that convolution in the time domain becomes pointwise multiplication in the frequency domain – transforms the entire problem. Instead of asking “how do I combine digits?”, we ask “how do I move between representations of a polynomial?” The Fast Fourier Transform answers that question in $O(n \log n)$ time. But the need for exact integer arithmetic, not floating-point approximations, forced Schönhage and Strassen into the ring $\mathbb{Z}/(2^m + 1)\mathbb{Z}$, where roots of unity are powers of two and “multiplication by a twiddle factor” is just a bit-shift. The cost of this exactness was a recursive structure whose depth – $\log \log n$ levels – contributed a stubborn extra factor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Harvey and van der Hoeven&lt;/strong&gt; completed the journey by fusing &lt;strong&gt;multi-dimensional algebraic geometry&lt;/strong&gt; with &lt;strong&gt;analytic number theory&lt;/strong&gt;. Their insight was architectural: by lifting the one-dimensional convolution into a multi-dimensional array, choosing dimensions via Gaussian integers in $\mathbb{Z}[i]$ to ensure coprimality, and using Nussbaumer’s trick to eliminate the innermost recursive multiplications entirely, they flattened the recursive chain from $\log \log n$ levels to a constant. The $O(n \log n)$ barrier – conjectured for decades, tantalizingly close since 1971 – was finally reached.&lt;/p&gt;

&lt;p&gt;The mathematical toolkit escalated at every stage: from high-school algebra, to polynomial interpolation, to Fourier analysis over finite rings, to algebraic geometry over Gaussian integers. And yet – and this is the remarkable part – the &lt;em&gt;problem never changed&lt;/em&gt;. At every stage, we are still multiplying two numbers. We are still computing the same convolution of digits, still propagating the same carries. What changed is our understanding of the &lt;em&gt;geometry&lt;/em&gt; of the computation: the realization that there exist clever rearrangements of the same arithmetic operations that cancel redundancies invisible from the schoolbook perspective.&lt;/p&gt;

&lt;h2 id=&quot;the-information-theoretic-floor&quot;&gt;The Information-Theoretic Floor&lt;/h2&gt;

&lt;p&gt;Is $O(n \log n)$ truly optimal? We cannot prove it is, but there are strong reasons to believe it.&lt;/p&gt;

&lt;p&gt;Multiplying two $n$-bit numbers produces a $2n$-bit result. Each bit of the output can depend on every bit of both inputs. The information that must flow through the computation – the “mixing” of input bits into output bits – is at least $\Omega(n \log n)$ by plausible circuit-complexity arguments, though a formal proof remains one of the great open problems in theoretical computer science.&lt;/p&gt;

&lt;p&gt;What we &lt;em&gt;can&lt;/em&gt; say is that multiplication has reached the same complexity class as sorting: $\Theta(n \log n)$. And as we showed in the previous section, this is not a coincidence – it is the &lt;em&gt;same theorem&lt;/em&gt;. Both problems live on what we called the linearithmic manifold: the space of computational tasks whose essence is routing $n$ pieces of information across $\log n$ dimensions of a finite-bandwidth network. Sorting routes elements to their correct positions in the permutation dimension. Multiplication routes partial products to their correct positions in the convolution dimension. The $n \log n$ is not an accident of algorithmic cleverness – it is the price the universe charges for untangling $n$ things, regardless of &lt;em&gt;what&lt;/em&gt; those things are or &lt;em&gt;why&lt;/em&gt; they are tangled.&lt;/p&gt;

&lt;h2 id=&quot;galactic-algorithms-and-the-nature-of-truth&quot;&gt;Galactic Algorithms and the Nature of Truth&lt;/h2&gt;

&lt;p&gt;We should be honest about the practical situation. Karatsuba’s algorithm overtakes schoolbook multiplication at around 20-40 digits, depending on the implementation. Toom-Cook-3 wins around 100-200 digits. Schönhage-Strassen becomes competitive at tens of thousands of digits – the scale used by modern big-integer libraries like GMP. But Harvey-van der Hoeven? Its crossover point is somewhere beyond $2^{1729^{12}}$ digits. That number has more digits than there are atoms in the observable universe. No computer that will ever exist will multiply numbers that large. The Harvey-van der Hoeven algorithm will never be &lt;em&gt;run&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;And yet it matters enormously.&lt;/p&gt;

&lt;p&gt;It matters because mathematics is not engineering. The question “what is the true complexity of integer multiplication?” is a question about the structure of arithmetic itself, not about what silicon can compute before the heat death of the universe. The existence of an $O(n \log n)$ algorithm – even one that is galactically impractical – tells us something true about the nature of numbers: that the information required to multiply them is not quadratic, not $n^{1.585}$, not $n \log n \log \log n$, but $n \log n$. That is a &lt;em&gt;theorem about reality&lt;/em&gt;, not a software optimization.&lt;/p&gt;

&lt;p&gt;Galactic algorithms are the astronomer’s telescope pointed at the foundations of computation. We will never travel to a quasar, but knowing it exists changes our understanding of the universe. Similarly, we will never run Harvey-van der Hoeven on actual inputs, but knowing it exists changes our understanding of what multiplication &lt;em&gt;is&lt;/em&gt;.&lt;/p&gt;

&lt;h2 id=&quot;the-punchline&quot;&gt;The Punchline&lt;/h2&gt;

&lt;p&gt;Here is the thought to carry away.&lt;/p&gt;

&lt;p&gt;Kolmogorov looked at schoolbook multiplication and saw $n^2$ – a grid of partial products, rigid and inescapable. Karatsuba looked at the same grid and saw that some cells were redundant. Toom and Cook saw that the grid was really a polynomial, and polynomials can be evaluated at fewer points than their degree suggests. Schönhage and Strassen saw that the polynomial was really a signal, and signals can be decomposed into frequencies. Harvey and van der Hoeven saw that the signal lived in a multi-dimensional space whose geometry could be exploited to eliminate every last bit of overhead.&lt;/p&gt;

&lt;p&gt;Each generation looked at the &lt;em&gt;same object&lt;/em&gt; – the product of two integers – and saw deeper structure. The number didn’t change. Our eyes did.&lt;/p&gt;

&lt;p&gt;And when, at the end of this journey, we looked sideways and noticed that sorting – a completely different problem about ordering, not arithmetic – lands at exactly the same $n \log n$ cost for exactly the same geometric reasons, we glimpsed something deeper still. Sorting is multiplying in the permutation dimension. Multiplying is sorting in the convolution dimension. They are two faces of a single truth: that rearranging $n$ things across $\log n$ dimensions of entanglement costs $n \log n$, and not a single operation less.&lt;/p&gt;

&lt;p&gt;That, in the end, is what mathematics is: the systematic refinement of vision. The history of multiplication algorithms is not a story about making computers faster. It is a story about learning to see – and discovering, at the bottom, that everything we were looking at was the same thing all along.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;further-reading&quot;&gt;Further Reading&lt;/h2&gt;
&lt;p&gt;Wonderful Wikipedia : https://en.wikipedia.org/wiki/Multiplication_algorithm&lt;/p&gt;

&lt;p&gt;Exploration of Varying Radix on Intel i3-4025U : https://miracl.com/blog/missing-a-trick-karatsuba-variations-michael-scott/&lt;/p&gt;

&lt;p&gt;Karatsuba paper: https://ieeexplore.ieee.org/document/4402691&lt;/p&gt;

&lt;p&gt;Toom-Cook: A. L. Toom, “The Complexity of a Scheme of Functional Elements Realizing the Multiplication of Integers” (1963); S. A. Cook, “On the Minimum Computation Time of Functions” (1966, PhD Thesis, Harvard)&lt;/p&gt;

&lt;p&gt;Schönhage–Strassen paper: A. Schönhage and V. Strassen, “Schnelle Multiplikation großer Zahlen,” &lt;em&gt;Computing&lt;/em&gt; 7 (1971), pp. 281–292. https://doi.org/10.1007/BF02242355&lt;/p&gt;

&lt;p&gt;Harvey and van der Hoeven’s Paper : https://hal.archives-ouvertes.fr/hal-03182372/document&lt;/p&gt;

&lt;p&gt;https://www.tcs.tifr.res.in/~ramprasad/assets/pubs/expositions/Schonhage-Strassen.pdf&lt;/p&gt;

&lt;p&gt;https://www.youtube.com/watch?v=m5VZnlVU2n4&lt;/p&gt;

&lt;p&gt;https://www.youtube.com/watch?v=OGUMsBkZqkc&lt;/p&gt;
</content>
 </entry>
 
 <entry>
   <title>Shtetl Length</title>
   <link href="http://hankquinlan.github.io/blog/2025/06/21/Shtetl-Length"/>
   <updated>2025-06-21T00:00:00+00:00</updated>
   <id>http://juleshenry.github.io//blog/2025/06/21/Shtetl-Length</id>
   <content type="html">&lt;h1 id=&quot;수고하세요&quot;&gt;수고하세요!&lt;/h1&gt;

&lt;p&gt;수고하세요 … what a wonderful phrase, it means work-hard, 화이팅 for the rest of your days!&lt;/p&gt;

&lt;p&gt;A casual farewell rooted in Korean work culture, the upper politeness register manifests as&lt;/p&gt;

&lt;p&gt;수고하셨어요, used as:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Cashier is a colleague ~ “great job working a full day today + goodbye”&lt;/li&gt;
  &lt;li&gt;Cashier has done great effort ~ “great effort!”&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In that case, you (as the customer) could say 수고하셨습니다 as a way to say thanks (a gesture of gratitude).&lt;/p&gt;

&lt;p&gt;So, working hard is as common as invoking Catholic Jesus in Latin culture?&lt;/p&gt;

&lt;p&gt;Why, yes! You’re catching on!&lt;/p&gt;

&lt;h1 id=&quot;shtetl-optimizations-of-the-umbral-calculi&quot;&gt;Shtetl Optimizations of the Umbral Calculi&lt;/h1&gt;

&lt;p&gt;In mathematics, the &lt;a href=&quot;https://en.wikipedia.org/wiki/Umbral_calculus&quot;&gt;umbral calculus&lt;/a&gt; is a technique where you pretend that subscript indices are exponents. &lt;em&gt;Umbra&lt;/em&gt; is Latin for “shadow.” The method is, on its face, absurd – and yet it works.&lt;/p&gt;

&lt;p&gt;The classic example: the Bernoulli polynomials. The ordinary binomial expansion gives you&lt;/p&gt;

\[(y + x)^n = \sum_{k=0}^{n} \binom{n}{k} y^{n-k} x^k\]

&lt;p&gt;and the Bernoulli polynomials satisfy a remarkably similar identity:&lt;/p&gt;

\[B_n(y + x) = \sum_{k=0}^{n} \binom{n}{k} B_{n-k}(y) \, x^k\]

&lt;p&gt;The umbral move? Pretend the subscript in $B_{n-k}$ is an exponent $b^{n-k}$, so that $B_n(x) = (b + x)^n$. Differentiate that, and you get $B_n’(x) = n(b+x)^{n-1} = nB_{n-1}(x)$ – the correct result, derived by treating a shadow as the real thing. John Blissard introduced this in 1861; Gian-Carlo Rota made it rigorous a century later by defining a linear functional $L$ such that $L(z^n) = B_n$, explaining &lt;em&gt;why&lt;/em&gt; the shadow-trick works.&lt;/p&gt;

&lt;p&gt;What does this have to do with blogs? Consider a blog’s readability metrics as shadows of the writing itself. A Flesch-Kincaid score is not the prose, just as a subscript is not an exponent. And yet, by treating these shadows as if they were the real thing – by pretending indices are exponents – we can derive surprising identities between blogs that otherwise look nothing alike. The Gunning Fog index of a quantum computing post and a personal finance post might converge, despite the posts sharing nothing in vocabulary or intent. The shadow knows something the text doesn’t say directly.&lt;/p&gt;

&lt;p&gt;Scott Aaronson’s blog, &lt;a href=&quot;https://scottaaronson.blog/&quot;&gt;Shtetl Optimized&lt;/a&gt;, is a national treasure. The man wittily demagogues on science, life, politics, and &lt;a href=&quot;https://scottaaronson.blog/?p=2091&quot;&gt;vagina dentata&lt;/a&gt; with equal aplomb. The purpose of this post was to scrutinize the shape and form of a shtetl (optimized blog), starting with a &lt;a href=&quot;https://github.com/juleshenry/-shtetltleths-/blob/main/shtetl-distance-intro&quot;&gt;distance metric&lt;/a&gt;.&lt;/p&gt;

&lt;h1 id=&quot;linguistic-complexity-blog-vs-blog&quot;&gt;Linguistic Complexity: Blog vs. Blog&lt;/h1&gt;

&lt;p&gt;Here, I analyze the linguistic complexity of Aaronson’s blog compared to my own and two others: the also great &lt;a href=&quot;https://simonwillison.net/&quot;&gt;Simon Willison blog&lt;/a&gt; along with the ineffable &lt;a href=&quot;https://alexharri.com/&quot;&gt;Alex Harri blog&lt;/a&gt;.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Source&lt;/th&gt;
      &lt;th&gt;Flesch-Kincaid Grade&lt;/th&gt;
      &lt;th&gt;ARI Grade&lt;/th&gt;
      &lt;th&gt;Gunning Fog Grade&lt;/th&gt;
      &lt;th&gt;Lexical Diversity&lt;/th&gt;
      &lt;th&gt;Word Count&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;alexharri_posts.csv&lt;/td&gt;
      &lt;td&gt;9.77&lt;/td&gt;
      &lt;td&gt;9.55&lt;/td&gt;
      &lt;td&gt;12.40&lt;/td&gt;
      &lt;td&gt;0.228&lt;/td&gt;
      &lt;td&gt;3,866.5&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;juleshenry_posts.csv&lt;/td&gt;
      &lt;td&gt;15.51&lt;/td&gt;
      &lt;td&gt;16.31&lt;/td&gt;
      &lt;td&gt;17.78&lt;/td&gt;
      &lt;td&gt;0.458&lt;/td&gt;
      &lt;td&gt;2,000.4&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;scottaaronson_blog_posts.csv&lt;/td&gt;
      &lt;td&gt;13.01&lt;/td&gt;
      &lt;td&gt;13.31&lt;/td&gt;
      &lt;td&gt;15.61&lt;/td&gt;
      &lt;td&gt;0.543&lt;/td&gt;
      &lt;td&gt;966.8&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;simonwillison_all_blogs.csv&lt;/td&gt;
      &lt;td&gt;12.13&lt;/td&gt;
      &lt;td&gt;12.55&lt;/td&gt;
      &lt;td&gt;14.52&lt;/td&gt;
      &lt;td&gt;0.554&lt;/td&gt;
      &lt;td&gt;627.8&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;writing-stats-over-time&quot;&gt;Writing Stats Over Time&lt;/h2&gt;

&lt;p&gt;&lt;img src=&quot;/blog/assets/2026/writing_stats_time.png&quot; alt=&quot;Writing Stats&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The curiosity? Technical posts register as more sophisticated when code is used because code has no “periods”, qualifying as long Faulknerian sentences. Note the outliers in my own blog achieve Flesch-Kincaid of 80+. Therefore, I also include an outliers-removed summary to truly compare. Alex Harri, an Icelander, unsurprisingly writes at a lower grade level, even though his posts are incredibly engaging and informative. Simon Willison’s posts are more accessible, while Scott Aaronson’s are more complex, likely due to their technical density.&lt;/p&gt;

&lt;h2 id=&quot;interquartile-reduction-of-outliers&quot;&gt;Interquartile Reduction of Outliers&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Source&lt;/th&gt;
      &lt;th&gt;Flesch-Kincaid Grade&lt;/th&gt;
      &lt;th&gt;ARI Grade&lt;/th&gt;
      &lt;th&gt;Gunning Fog Grade&lt;/th&gt;
      &lt;th&gt;Lexical Diversity&lt;/th&gt;
      &lt;th&gt;Word Count&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;alexharri_posts.csv&lt;/td&gt;
      &lt;td&gt;9.86&lt;/td&gt;
      &lt;td&gt;9.64&lt;/td&gt;
      &lt;td&gt;12.49&lt;/td&gt;
      &lt;td&gt;0.234&lt;/td&gt;
      &lt;td&gt;3,443.41&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;juleshenry_posts.csv&lt;/td&gt;
      &lt;td&gt;11.50&lt;/td&gt;
      &lt;td&gt;11.33&lt;/td&gt;
      &lt;td&gt;13.58&lt;/td&gt;
      &lt;td&gt;0.500&lt;/td&gt;
      &lt;td&gt;1,110.80&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;scottaaronson_blog_posts.csv&lt;/td&gt;
      &lt;td&gt;12.77&lt;/td&gt;
      &lt;td&gt;13.01&lt;/td&gt;
      &lt;td&gt;15.35&lt;/td&gt;
      &lt;td&gt;0.559&lt;/td&gt;
      &lt;td&gt;702.75&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;simonwillison_all_blogs.csv&lt;/td&gt;
      &lt;td&gt;11.80&lt;/td&gt;
      &lt;td&gt;12.12&lt;/td&gt;
      &lt;td&gt;14.21&lt;/td&gt;
      &lt;td&gt;0.568&lt;/td&gt;
      &lt;td&gt;482.81&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;A ha! So I do not write at a higher level than Scott &lt;em&gt;or&lt;/em&gt; Simon, although I tend to be more verbose.&lt;/p&gt;

&lt;h2 id=&quot;writing-stats-over-time-without-outliers&quot;&gt;Writing Stats Over Time Without Outliers&lt;/h2&gt;

&lt;p&gt;&lt;img src=&quot;/blog/assets/2026/writing_stats_time_no.png&quot; alt=&quot;Writing Stats&quot; /&gt;&lt;/p&gt;

&lt;p&gt;That this analysis was done on Valentine’s Day is perhaps a data point in itself. Scott, solve NP vs. P when you have the chance, will ya?&lt;/p&gt;

&lt;p&gt;Code analysis repository found &lt;a href=&quot;https://github.com/juleshenry/-shtetltleths-&quot;&gt;here&lt;/a&gt;.&lt;/p&gt;
</content>
 </entry>
 
 <entry>
   <title>Back-of-the-Envelope: Primitive Sets</title>
   <link href="http://hankquinlan.github.io/blog/2025/06/08/Back-of-the-Envelope-Primitive-Sets"/>
   <updated>2025-06-08T00:00:00+00:00</updated>
   <id>http://juleshenry.github.io//blog/2025/06/08/Back-of-the-Envelope-Primitive-Sets</id>
   <content type="html">&lt;h1 id=&quot;the-joyous-structure-of-the-nature-of-primes-primitive-sets-theorem-proved&quot;&gt;The Joyous Structure of the Nature of Primes (Primitive Sets Theorem Proved!)&lt;/h1&gt;

&lt;p&gt;Reading the conjecture for the first time in Covid-19 locked-down Boston, I was sure it was true.
There was a sense of deep vindication when I soon came across the proof of this conjecture by Erdős, years later, by a bright PhD student named Jared Duker Lichtman.
This statement about the nature of primes was so beautiful it surely had to be true.&lt;/p&gt;

&lt;p&gt;The &lt;a href=&quot;https://arxiv.org/abs/2202.02384&quot;&gt;original paper can be found here.&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Herein we go into the thick of things – heavy-handedly, every derivation spelled out. If you’ve taken a semester of calculus and have a taste for beautiful mathematics, you have everything you need. Keep reading.&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;the-crash-course-skip-if-you-know-this-stuff&quot;&gt;The Crash Course (Skip If You Know This Stuff)&lt;/h1&gt;

&lt;p&gt;I won’t belabor fundamentals, but we need a common language. Three things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Divisibility.&lt;/strong&gt; We write $a \mid b$ when $b = ka$ for some integer $k$. So $3 \mid 12$ because $12 = 4 \cdot 3$, but $5 \nmid 12$. The Fundamental Theorem of Arithmetic says every integer $n &amp;gt; 1$ factors uniquely into primes:&lt;/p&gt;

\[n = p_1^{e_1} \cdot p_2^{e_2} \cdots p_r^{e_r}, \quad p_1 &amp;lt; p_2 &amp;lt; \cdots &amp;lt; p_r.\]

&lt;p&gt;For $360 = 2^3 \cdot 3^2 \cdot 5$, the smallest prime factor is $p(360) = 2$, the largest is $P(360) = 5$, and the total count of prime factors (with repeats) is $\Omega(360) = 3 + 2 + 1 = 6$. We write $\mathbb{N}_k$ for the set of integers with exactly $k$ prime factors: the primes are $\mathbb{N}_1$, the semiprimes ${4, 6, 9, 10, 14, 15, \ldots}$ are $\mathbb{N}_2$, and so on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Series convergence.&lt;/strong&gt; The harmonic series $\sum 1/n$ diverges – you can see this by grouping terms into blocks of $2^k$, each contributing at least $1/2$. But $\sum 1/n^2 = \pi^2/6$ converges. The integral test is our workhorse: $\sum g(n)$ converges iff $\int g(x)\,dx$ converges. Two key facts we’ll use:&lt;/p&gt;

\[\int_2^{\infty} \frac{dx}{x \ln x} = \Big[\ln(\ln x)\Big]_2^{\infty} = \infty \quad \text{(diverges!)}\]

\[\int_2^{\infty} \frac{dx}{x (\ln x)^2} = \left[-\frac{1}{\ln x}\right]_2^{\infty} = \frac{1}{\ln 2} \quad \text{(converges!)}\]

&lt;p&gt;So $\sum 1/(n \log n)$ diverges – the extra $\log n$ isn’t enough to tame the harmonic series. But $\sum 1/(n (\log n)^2)$ converges. The sum over primes we care about will land between these two. Keep that in mind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Euler-Mascheroni constant.&lt;/strong&gt; Define $\gamma$ as the gap between the harmonic sum and the natural log:&lt;/p&gt;

\[\gamma = \lim_{N \to \infty} \left(\sum_{n=1}^{N} \frac{1}{n} - \ln N\right) = 0.5772\ldots\]

&lt;p&gt;The number $e^{\gamma} = 1.7810\ldots$ will haunt us. It appears whenever you convert between discrete sums over primes and continuous logarithmic integrals – Mertens figured this out in 1874 and we owe him big time.&lt;/p&gt;

&lt;p&gt;One last tool: &lt;strong&gt;partial summation&lt;/strong&gt; (Abel’s summation formula). This is integration by parts for sums. If $A(x) = \sum_{n \leq x} a_n$ and $g$ is differentiable:&lt;/p&gt;

\[\sum_{n \leq x} a_n \, g(n) = A(x)\,g(x) - \int_1^x A(t)\,g&apos;(t)\,dt.\]

&lt;p&gt;This is how you turn knowledge about counting functions (like $\pi(x) \sim x/\ln x$, the Prime Number Theorem) into knowledge about sums over primes. We will use it relentlessly.&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;primitive-sets-the-main-character&quot;&gt;Primitive Sets: The Main Character&lt;/h1&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;Definition.&lt;/strong&gt; A set $A \subset \mathbb{Z}_{&amp;gt;1}$ is &lt;strong&gt;primitive&lt;/strong&gt; if no element of $A$ divides another element of $A$.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That’s it. That’s the whole definition. In the language of posets: $A$ is an antichain in the divisibility ordering of the integers.&lt;/p&gt;

&lt;p&gt;Some examples to build your intuition:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The primes&lt;/strong&gt; ${2, 3, 5, 7, 11, \ldots}$ – primitive. No prime divides another prime. This is the protagonist of our story.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;${6, 10, 15}$&lt;/strong&gt; – primitive. Check all pairs: $6 \nmid 10$, $6 \nmid 15$, $10 \nmid 15$, and the reverses. Nothing divides anything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;${6, 12, 15}$&lt;/strong&gt; – NOT primitive. Because $6 \mid 12$. Busted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Any dyadic interval $(x, 2x]$&lt;/strong&gt; – primitive. If $a, b$ are both in $(x, 2x]$ and $a \mid b$, then $b \geq 2a &amp;gt; 2x$. Contradiction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;$\mathbb{N}_k$, the numbers with exactly $k$ prime factors&lt;/strong&gt; – primitive. If $a \mid b$ and $a \neq b$, then $b = ac$ for some $c &amp;gt; 1$, so $\Omega(b) = \Omega(a) + \Omega(c) &amp;gt; k$. So two numbers with the same $\Omega$ can’t divide each other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The perfect numbers&lt;/strong&gt; ${6, 28, 496, 8128, \ldots}$ – primitive. (It’s a known theorem that no perfect number divides another. My God, even perfect numbers play along.)&lt;/p&gt;

&lt;p&gt;Think of it visually: the positive integers form a poset under divisibility, like a directed graph where $a$ points up to $b$ if $a \mid b$. A primitive set is a horizontal slice – nodes with no vertical connections between them.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;graph BT
    2 --&amp;gt; 4
    2 --&amp;gt; 6
    2 --&amp;gt; 10
    3 --&amp;gt; 6
    3 --&amp;gt; 9
    3 --&amp;gt; 15
    5 --&amp;gt; 10
    5 --&amp;gt; 15
    4 --&amp;gt; 12
    6 --&amp;gt; 12
    style 2 fill:#38bdf8,color:#000
    style 3 fill:#38bdf8,color:#000
    style 5 fill:#38bdf8,color:#000
    style 7 fill:#38bdf8,color:#000
    style 6 fill:#a855f7,color:#fff
    style 10 fill:#a855f7,color:#fff
    style 15 fill:#a855f7,color:#fff
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Blue nodes ${2,3,5,7}$: one primitive set (the small primes). Purple nodes ${6, 10, 15}$: another. You can’t pick both $6$ and $12$ – there’s an arrow between them.&lt;/p&gt;

&lt;p&gt;Why do we care? These things emerged in the 1930s from a concrete problem: Davenport proved the abundant numbers (where the sum of proper divisors exceeds $n$) have positive density. Erdős found an elegant shortcut using &lt;em&gt;primitive&lt;/em&gt; abundant numbers – the ones that are minimal under divisibility. The abstraction turned out to be more interesting than the application. Classic Erdős.&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;the-erdős-sum-why-frac1a-log-a&quot;&gt;The Erdős Sum: Why $\frac{1}{a \log a}$?&lt;/h1&gt;

&lt;p&gt;Here’s where the magic starts. For a set $A \subset \mathbb{Z}_{&amp;gt;1}$, define:&lt;/p&gt;

\[\boxed{f(A) = \sum_{a \in A} \frac{1}{a \log a}}\]

&lt;p&gt;This weighting is not arbitrary. Let me show you where it comes from.&lt;/p&gt;

&lt;p&gt;For any integer $a \geq 2$:&lt;/p&gt;

\[\frac{1}{a \log a} = \int_1^{\infty} a^{-t}\,dt.\]

&lt;p&gt;&lt;strong&gt;Full derivation.&lt;/strong&gt; Start:&lt;/p&gt;

\[\int_1^{\infty} a^{-t}\,dt = \int_1^{\infty} e^{-t \ln a}\,dt.\]

&lt;p&gt;Substitute $u = t \ln a$, so $du = \ln a\,dt$:&lt;/p&gt;

\[= \frac{1}{\ln a}\int_{\ln a}^{\infty} e^{-u}\,du = \frac{1}{\ln a}\Big[-e^{-u}\Big]_{\ln a}^{\infty} = \frac{1}{\ln a}\cdot e^{-\ln a} = \frac{1}{a \ln a}. \quad \blacksquare\]

&lt;p&gt;So $1/(a \log a)$ is what you get when you integrate $a^{-t}$ from $1$ to $\infty$. Summing over $A$ and swapping sum and integral (Tonelli says we can):&lt;/p&gt;

\[f(A) = \sum_{a \in A} \int_1^{\infty} a^{-t}\,dt = \int_1^{\infty} \underbrace{\left(\sum_{a \in A} a^{-t}\right)}_{f_t(A)}\,dt.\]

&lt;p&gt;This integral representation – $f(A) = \int_1^{\infty} f_t(A)\,dt$ – is powerful. It turns bounding $f(A)$ into bounding a family of Dirichlet-series-like sums for each $t &amp;gt; 1$.&lt;/p&gt;

&lt;p&gt;Now, the sum over primes:&lt;/p&gt;

\[f(\mathcal{P}) = \sum_p \frac{1}{p \log p} = \frac{1}{2 \log 2} + \frac{1}{3 \log 3} + \frac{1}{5 \log 5} + \cdots\]

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;$p$&lt;/th&gt;
      &lt;th&gt;$1/(p \log p)$&lt;/th&gt;
      &lt;th&gt;Running sum&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;2&lt;/td&gt;
      &lt;td&gt;0.7213&lt;/td&gt;
      &lt;td&gt;0.7213&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;3&lt;/td&gt;
      &lt;td&gt;0.3034&lt;/td&gt;
      &lt;td&gt;1.0247&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;5&lt;/td&gt;
      &lt;td&gt;0.1243&lt;/td&gt;
      &lt;td&gt;1.1490&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;7&lt;/td&gt;
      &lt;td&gt;0.0734&lt;/td&gt;
      &lt;td&gt;1.2224&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;11&lt;/td&gt;
      &lt;td&gt;0.0379&lt;/td&gt;
      &lt;td&gt;1.2603&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;13&lt;/td&gt;
      &lt;td&gt;0.0300&lt;/td&gt;
      &lt;td&gt;1.2903&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;17&lt;/td&gt;
      &lt;td&gt;0.0208&lt;/td&gt;
      &lt;td&gt;1.3111&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;19&lt;/td&gt;
      &lt;td&gt;0.0179&lt;/td&gt;
      &lt;td&gt;1.3290&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Converges slowly. After summing all primes to $10^8$, Cohen computed $f(\mathcal{P}) = 1.6366\ldots$ We’ll prove convergence rigorously later. For now: it’s a finite number, roughly $1.6366$.&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;erdős-1935-the-sum-is-bounded-for-any-primitive-set&quot;&gt;Erdős 1935: The Sum Is Bounded. For ANY Primitive Set.&lt;/h1&gt;

&lt;p&gt;Before you can ask “which primitive set maximizes $f$?”, you need to know the maximum exists. Erdős proved this in 1935, and the argument is gorgeous.&lt;/p&gt;

&lt;p&gt;For each $a \geq 2$, define the &lt;strong&gt;L-multiples&lt;/strong&gt; of $a$ (the “L” is for “lexicographic” – we couch the reason for this name for later):&lt;/p&gt;

\[L_a = \{b \cdot a : b \geq 1, \; p \mid b \Rightarrow p \geq P(a)\}.\]

&lt;p&gt;In words: take $a$, and multiply it by any integer whose prime factors are all $\geq P(a)$ (the largest prime factor of $a$).&lt;/p&gt;

&lt;p&gt;For $a = 6$ (where $P(6) = 3$): $L_6$ consists of $6$ times any number built from primes $\geq 3$. So $6 \cdot 1 = 6$, $6 \cdot 3 = 18$, $6 \cdot 5 = 30$, $6 \cdot 7 = 42$, $6 \cdot 9 = 54$, and so on.&lt;/p&gt;

&lt;p&gt;For a prime $p$: $L_p$ consists of $p$ times any number built from primes $\geq p$. So $L_5 = {5, 25, 35, 55, 125, \ldots}$.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The density of $L_a$.&lt;/strong&gt; How “big” is $L_a$ inside the integers? Its natural density is:&lt;/p&gt;

\[d(L_a) = \frac{1}{a}\prod_{p &amp;lt; P(a)}\left(1 - \frac{1}{p}\right).\]

&lt;p&gt;The product $\prod_{p &amp;lt; P(a)}(1 - 1/p)$ is the proportion of integers not divisible by any prime less than $P(a)$ – that’s inclusion-exclusion, the sieve of Eratosthenes doing what it does best. Scaling by $1/a$ accounts for the $a$-multiple structure.&lt;/p&gt;

&lt;p&gt;Now the engine of the whole argument:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;Disjointness Lemma.&lt;/strong&gt; If $A$ is primitive, then the sets ${L_a : a \in A}$ are pairwise disjoint.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Proof.&lt;/strong&gt; Suppose $n \in L_a \cap L_{a’}$ for distinct $a, a’ \in A$. Write $n = ba = b’a’$ where $b$ has all prime factors $\geq P(a)$ and $b’$ has all prime factors $\geq P(a’)$. Assume WLOG $P(a) \leq P(a’)$.&lt;/p&gt;

&lt;p&gt;Write $a = a^* \cdot P(a)^k$ where $a^&lt;em&gt;$ has all prime factors $&amp;lt; P(a)$. Since $a \mid n = b’a’$ and $\gcd(a^&lt;/em&gt;, b’) = 1$ (their prime factors don’t overlap), we get $a^* \mid a’$. Similarly $P(a)^k \mid a’$ (since $P(a) &amp;lt; P(a’)$ means $P(a)$ can’t come from $b’$). Therefore $a = a^* \cdot P(a)^k \mid a’$.&lt;/p&gt;

&lt;p&gt;But $a \mid a’$ contradicts primitivity. $\blacksquare$&lt;/p&gt;

&lt;p&gt;Since disjoint subsets of $\mathbb{N}$ can’t have densities summing past $1$:&lt;/p&gt;

\[\sum_{a \in A} d(L_a) = \sum_{a \in A} \frac{1}{a}\prod_{p &amp;lt; P(a)}\left(1 - \frac{1}{p}\right) \leq 1.\]

&lt;p&gt;Now invoke &lt;strong&gt;Mertens’ product theorem&lt;/strong&gt; (1874): $\prod_{p \leq x}(1 - 1/p) \sim e^{-\gamma}/\log x$. Taking $x = P(a)$:&lt;/p&gt;

\[d(L_a) \approx \frac{e^{-\gamma}}{a \log P(a)}.\]

&lt;p&gt;Since $P(a) \leq a$, we have $\log P(a) \leq \log a$, so $1/(a \log a) \leq 1/(a \log P(a))$, which means:&lt;/p&gt;

\[f(a) = \frac{1}{a \log a} \leq \frac{1}{a \log P(a)} \approx e^{\gamma} \cdot d(L_a).\]

&lt;p&gt;Sum it up:&lt;/p&gt;

\[f(A) = \sum_{a \in A} f(a) \leq e^{\gamma}\sum_{a \in A} d(L_a) \leq e^{\gamma} \cdot 1 = e^{\gamma} \approx 1.781.\]

&lt;p&gt;And we’re done.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;Theorem (Erdős 1935, refined Lichtman-Pomerance 2019).&lt;/strong&gt; For any primitive set $A$: $f(A) &amp;lt; e^{\gamma} = 1.781\ldots$&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Beautiful. But notice: $e^{\gamma} \approx 1.781 &amp;gt; 1.637 \approx f(\mathcal{P})$. This bound is too loose by about $8.8\%$. It doesn’t prove the conjecture. We need a new idea.&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;the-conjecture-primes-are-optimal&quot;&gt;The Conjecture: Primes Are Optimal&lt;/h1&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;Conjecture (Erdős, ~1974).&lt;/strong&gt; For any primitive set $A$, $f(A) \leq f(\mathcal{P}) = 1.6366\ldots$&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In words: among all primitive sets, the primes have the largest Erdős sum. The most natural antichain in the divisibility poset is also the heaviest one.&lt;/p&gt;

&lt;p&gt;Why should this be true? Three angles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The primes are atoms.&lt;/strong&gt; They sit at the bottom of the divisibility poset – nothing above $1$ divides them. Including a prime in your set costs you nothing in terms of divisibility conflicts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Composites are expensive.&lt;/strong&gt; The $f$-weight is front-loaded:&lt;/p&gt;

\[f(2) = 0.721, \quad f(3) = 0.303, \quad f(6) = 0.093.\]

&lt;p&gt;If you include $6$ in your primitive set, you gain $0.093$ but lose both $2$ and $3$ (total loss: $1.024$). Terrible trade! You traded away the crown jewels for pocket change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The primes are maximally spread out.&lt;/strong&gt; Each prime is independent – no prime is a multiple of another. Composites create tangled webs of divisibility conflicts.&lt;/p&gt;

&lt;p&gt;The conjecture sat open for decades:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;1935:&lt;/strong&gt; Erdős proves $f(A)$ is bounded.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;1974:&lt;/strong&gt; Conjecture appears in print.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;1993:&lt;/strong&gt; Erdős and Zhang prove $f(A) &amp;lt; 1.84$.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;2019:&lt;/strong&gt; Lichtman and Pomerance prove $f(A) &amp;lt; e^{\gamma} = 1.781$.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;2022:&lt;/strong&gt; Lichtman proves $f(A) \leq f(\mathcal{P})$. Conjecture proved!&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;the-proof-strategy-split-by-smallest-prime-factor&quot;&gt;The Proof Strategy: Split by Smallest Prime Factor&lt;/h1&gt;

&lt;p&gt;Every integer $a &amp;gt; 1$ has a smallest prime factor $p(a)$. Partition any set $A$ accordingly:&lt;/p&gt;

\[A = \bigsqcup_{p} A_p, \quad A_p = \{a \in A : p(a) = p\}.\]

&lt;p&gt;Since the $A_p$ are disjoint: $f(A) = \sum_p f(A_p)$. The conjecture follows if for each prime $p$:&lt;/p&gt;

\[f(A_p) \leq f(p) = \frac{1}{p \log p}.\]

&lt;p&gt;A prime satisfying this for all primitive $A$ is called &lt;strong&gt;Erdős-strong&lt;/strong&gt;. If every prime is Erdős-strong, we win.&lt;/p&gt;

&lt;p&gt;There’s a clean sufficient condition. By Mertens’ product theorem:&lt;/p&gt;

\[\prod_{q &amp;lt; p}\left(1 - \frac{1}{q}\right) \sim \frac{e^{-\gamma}}{\log p}.\]

&lt;p&gt;A prime $p$ is Erdős-strong if:&lt;/p&gt;

\[e^{\gamma} \prod_{q &amp;lt; p}\left(1 - \frac{1}{q}\right) \leq \frac{1}{\log p}. \tag{$\star$}\]

&lt;p&gt;Computationally, $(\star)$ holds for the first $10^8$ odd primes. But it &lt;strong&gt;fails for $p = 2$&lt;/strong&gt;:&lt;/p&gt;

\[e^{\gamma} \cdot 1 = 1.781 &amp;gt; \frac{1}{\log 2} = 1.443.\]

&lt;p&gt;The empty product equals $1$, and $e^{\gamma}$ overshoots $1/\log 2$. Oh no.&lt;/p&gt;

&lt;p&gt;It gets worse. Under the Riemann Hypothesis, $(\star)$ fails for a positive proportion of primes. Even unconditionally, it fails for infinitely many. So the “every prime is Erdős-strong” strategy is doomed as a direct approach.&lt;/p&gt;

&lt;p&gt;This is why the conjecture stayed open. This is where Lichtman had his breakthrough.&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;the-key-innovation-the-sqrtv-bound&quot;&gt;The Key Innovation: The $\sqrt{v}$ Bound&lt;/h1&gt;

&lt;p&gt;This is the heart of the proof. The idea is beautifully simple once you see it, and I remember staring at it for a long time before it clicked.&lt;/p&gt;

&lt;p&gt;Recall our bound from the Erdős argument: we replaced $\log a$ with $\log P(a)$ and lost a factor of $\log P(a)/\log a$. When $a$ is prime, $P(a) = a$, so there’s &lt;strong&gt;no loss&lt;/strong&gt;. When $a$ is composite, $P(a) &amp;lt; a$ and we lose something.&lt;/p&gt;

&lt;p&gt;The key question: &lt;em&gt;how much&lt;/em&gt; can the composites collectively contribute?&lt;/p&gt;

&lt;p&gt;Parameterize the “closeness” of $P(a)$ to $a$. For $v \geq 0$, say that $a$ is “$v$-close” if $P(a)^{1+v} &amp;gt; a$. When $v = 0$, this means $P(a) &amp;gt; a$, which only primes satisfy. As $v$ grows, more composites qualify.&lt;/p&gt;

&lt;p&gt;The savings factor for a $v$-close element is $\log P(a)/\log a &amp;gt; 1/(1+v)$.&lt;/p&gt;

&lt;p&gt;Now here is where Lichtman goes beyond the 2019 argument.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;Proposition (Lichtman).&lt;/strong&gt; If $A$ is primitive and $P(a)^{1+v} &amp;gt; a$ for all $a \in A$, then $\sum_{a \in A} d(L_a) \leq \sqrt{v}$.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My word. That refines the trivial bound of $1$ whenever $v &amp;lt; 1$.&lt;/p&gt;

&lt;p&gt;The idea: not only are the sets $L_a$ disjoint – so are many “copies” of them. Multiply each $a$ by various integers $c$ whose prime factors lie in a certain range, and the sets $L_{ac}$ remain disjoint from each other AND from all $L_{a’}$ for $a’ \neq a$. These copies boost the total density by a factor of $\sim 1/\sqrt{v}$. Since everything fits inside $\mathbb{N}$:&lt;/p&gt;

\[\frac{1}{\sqrt{v}} \cdot \sum_{a \in A} d(L_a) \leq 1 \quad \Longrightarrow \quad \sum_{a \in A} d(L_a) \leq \sqrt{v}.\]

&lt;p&gt;The self-similarity of the $L$-sets is doing all the heavy lifting. You’re creating “shadows” of your original sets, proving the shadows are also disjoint, and using the shadow density to constrain the original. It’s a beautiful counting-over-counting argument.&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;the-pi4-factor-the-finishing-blow&quot;&gt;The $\pi/4$ Factor: The Finishing Blow&lt;/h1&gt;

&lt;p&gt;Now we combine the $\sqrt{v}$ bound with the savings factor $1/(1+v)$.&lt;/p&gt;

&lt;p&gt;Elements entering at parameter $v$ (those with $P(a)^{1+v} \approx a$) contribute to the density with a $\sqrt{v}$ budget, and each contributes to $f(A)$ with a savings of $\sim 1/(1+v)$ relative to its density weight.&lt;/p&gt;

&lt;p&gt;The derivative of the density budget $\sqrt{v}$ is $1/(2\sqrt{v})$. So the worst-case contribution of composite elements at level $v$ is $\sim dv/(2\sqrt{v}(1+v))$. Integrate from $v = 0$ to $v = 1$:&lt;/p&gt;

\[I = \int_0^1 \frac{dv}{2\sqrt{v}(1+v)}.\]

&lt;p&gt;&lt;strong&gt;Step 1.&lt;/strong&gt; Substitute $u = \sqrt{v}$, so $v = u^2$, $dv = 2u\,du$:&lt;/p&gt;

\[I = \int_0^1 \frac{2u\,du}{2u(1+u^2)} = \int_0^1 \frac{du}{1+u^2}.\]

&lt;p&gt;&lt;strong&gt;Step 2.&lt;/strong&gt; Oh come on. We know this one.&lt;/p&gt;

\[\int_0^1 \frac{du}{1+u^2} = \Big[\arctan(u)\Big]_0^1 = \frac{\pi}{4}.\]

&lt;p&gt;And there it is:&lt;/p&gt;

\[\boxed{\int_0^1 \frac{dv}{2\sqrt{v}(1+v)} = \frac{\pi}{4}}\]

&lt;p&gt;$\pi$ shows up from the arctangent integral, which comes from the geometry of the unit circle. Number theory and geometry shaking hands through an optimization problem. My God.&lt;/p&gt;

&lt;p&gt;So the composite elements contribute at most:&lt;/p&gt;

\[f(A_{\text{comp}}) \leq e^{\gamma} \cdot \frac{\pi}{4} \approx 1.781 \times 0.785 = 1.399.\]

&lt;p&gt;And $1.399 &amp;lt; 1.637 = f(\mathcal{P})$. The composites can only hurt. Any primitive set with composite elements has $f(A) &amp;lt; f(\mathcal{P})$.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;graph TD
    A[&quot;Primitive set A&quot;] --&amp;gt; B{&quot;Contains composites?&quot;}
    B --&amp;gt;|&quot;No: A ⊆ primes&quot;| C[&quot;f(A) ≤ f(P) ✓&quot;]
    B --&amp;gt;|&quot;Yes&quot;| D[&quot;Composites contribute ≤ e^γ · π/4 ≈ 1.399&quot;]
    D --&amp;gt; E{&quot;1.399 &amp;lt; f(P) = 1.637?&quot;}
    E --&amp;gt;|&quot;Yes!&quot;| F[&quot;Composites only hurt → f(A) ≤ f(P) ✓&quot;]
    style C fill:#38bdf8,color:#000
    style F fill:#38bdf8,color:#000
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;A note on $p = 2$.&lt;/strong&gt; Lichtman proves every odd prime is Erdős-strong (Theorem 1.3 of the paper). Whether $p = 2$ is Erdős-strong remains open. But the conjecture doesn’t need it – the $\pi/4$ argument handles the global bound directly. The troublemaker prime gets outflanked.&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;the-convergence-of-sum_p-1p-log-p&quot;&gt;The Convergence of $\sum_p 1/(p \log p)$&lt;/h1&gt;

&lt;p&gt;This is the other half of the story – proving the sum at the center of the conjecture actually converges. I’ll give you three proofs because frankly each one is satisfying in its own way.&lt;/p&gt;

&lt;h2 id=&quot;via-the-prime-number-theorem-and-partial-summation&quot;&gt;Via the Prime Number Theorem and Partial Summation&lt;/h2&gt;

&lt;p&gt;The PNT gives us $\pi(x) \sim x/\ln x$. We won’t prove it here (the weeds be tall there – that’s a whole other post). But we’ll use it.&lt;/p&gt;

&lt;p&gt;Set $g(t) = 1/(t \log t)$. By Abel’s summation formula:&lt;/p&gt;

\[\sum_{p \leq x} \frac{1}{p \log p} = \pi(x) \cdot g(x) - \int_2^x \pi(t) \cdot g&apos;(t)\,dt.\]

&lt;p&gt;Compute $g’(t)$:&lt;/p&gt;

\[g&apos;(t) = -\frac{\log t + 1}{t^2 (\log t)^2}.\]

&lt;p&gt;The boundary term: $\pi(x) \cdot g(x) \sim \frac{x}{\log x} \cdot \frac{1}{x \log x} = \frac{1}{(\log x)^2} \to 0$. Good – it vanishes.&lt;/p&gt;

&lt;p&gt;The integral, using $\pi(t) \sim t/\log t$:&lt;/p&gt;

\[\int_2^x \frac{t}{\log t} \cdot \frac{\log t + 1}{t^2(\log t)^2}\,dt = \int_2^x \frac{\log t + 1}{t(\log t)^3}\,dt \leq \int_2^x \frac{2}{t(\log t)^2}\,dt\]

&lt;p&gt;for large $t$ (since $\log t + 1 \leq 2\log t$). And:&lt;/p&gt;

\[\int_2^{\infty} \frac{dt}{t(\log t)^2} = \left[-\frac{1}{\log t}\right]_2^{\infty} = \frac{1}{\log 2} &amp;lt; \infty.\]

&lt;p&gt;Converges. $\blacksquare$&lt;/p&gt;

&lt;h2 id=&quot;the-quick-and-dirty-comparison&quot;&gt;The Quick-and-Dirty Comparison&lt;/h2&gt;

&lt;p&gt;By PNT, the $n$-th prime satisfies $p_n \sim n \ln n$. So:&lt;/p&gt;

\[\frac{1}{p_n \log p_n} \sim \frac{1}{n \ln n \cdot \ln(n \ln n)} \sim \frac{1}{n (\ln n)^2}.\]

&lt;p&gt;The series $\sum 1/(n(\ln n)^2)$ converges by the integral test (we already showed $\int dx/(x(\ln x)^2) = 1/\ln 2$). By the limit comparison test, $\sum_p 1/(p \log p)$ converges too. $\blacksquare$&lt;/p&gt;

&lt;p&gt;That’s the one you’d do on a napkin. Two lines.&lt;/p&gt;

&lt;h2 id=&quot;via-mertens-first-theorem&quot;&gt;Via Mertens’ First Theorem&lt;/h2&gt;

&lt;p&gt;Mertens proved $\sum_{p \leq x} (\log p)/p = \log x + O(1)$. Set $B(x) = \sum_{p \leq x} (\log p)/p$ and rewrite:&lt;/p&gt;

\[\sum_{p \leq x} \frac{1}{p \log p} = \sum_{p \leq x} \frac{1}{(\log p)^2} \cdot \frac{\log p}{p}.\]

&lt;p&gt;Partial summation with $h(t) = 1/(\log t)^2$:&lt;/p&gt;

\[= \frac{B(x)}{(\log x)^2} + \int_2^x \frac{2B(t)}{t(\log t)^3}\,dt.\]

&lt;p&gt;First term: $B(x)/(\log x)^2 \sim \log x/(\log x)^2 = 1/\log x \to 0$. The integral with $B(t) = \log t + O(1)$:&lt;/p&gt;

\[\int_2^{\infty} \frac{2(\log t + O(1))}{t(\log t)^3}\,dt = 2\int_2^{\infty} \frac{dt}{t(\log t)^2} + O\!\left(\int_2^{\infty} \frac{dt}{t(\log t)^3}\right).\]

&lt;p&gt;Both converge. $\blacksquare$&lt;/p&gt;

&lt;h2 id=&quot;where-does-this-sum-sit&quot;&gt;Where Does This Sum Sit?&lt;/h2&gt;

&lt;p&gt;Let’s zoom out:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Series&lt;/th&gt;
      &lt;th&gt;Converges?&lt;/th&gt;
      &lt;th&gt;Value&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;$\sum_p 1/p^2$&lt;/td&gt;
      &lt;td&gt;Yes&lt;/td&gt;
      &lt;td&gt;$0.4522\ldots$&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;$\sum_p 1/(p \log p)$&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;$1.6366\ldots$&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;$\sum_p 1/p$&lt;/td&gt;
      &lt;td&gt;No&lt;/td&gt;
      &lt;td&gt;$\sim \log \log x$&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;$\sum_n 1/(n \log n)$&lt;/td&gt;
      &lt;td&gt;No&lt;/td&gt;
      &lt;td&gt;Diverges&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The sum over primes of $1/(p \log p)$ converges because primes are sparse enough. Even though $\sum 1/(n \log n)$ diverges over all integers, primes have density $\sim 1/\log n$, so summing only over primes gives effective terms $\sim 1/(n(\log n)^2)$, and that converges. The extra $\log$ comes from the primes themselves being logarithmically sparse. A gift from the Prime Number Theorem.&lt;/p&gt;

&lt;p&gt;The convergence is &lt;em&gt;slow&lt;/em&gt;, though – the tail decays as $O(1/\log x)$:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Primes up to&lt;/th&gt;
      &lt;th&gt;Partial sum&lt;/th&gt;
      &lt;th&gt;Gap to $1.6366$&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;$10^2$&lt;/td&gt;
      &lt;td&gt;$1.4510$&lt;/td&gt;
      &lt;td&gt;$0.186$&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;$10^4$&lt;/td&gt;
      &lt;td&gt;$1.5710$&lt;/td&gt;
      &lt;td&gt;$0.066$&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;$10^6$&lt;/td&gt;
      &lt;td&gt;$1.6110$&lt;/td&gt;
      &lt;td&gt;$0.026$&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;$10^8$&lt;/td&gt;
      &lt;td&gt;$1.6276$&lt;/td&gt;
      &lt;td&gt;$0.009$&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Each extra order of magnitude in the cutoff gains roughly the same amount. You have to go far to pin down the digits.&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;the-full-picture&quot;&gt;The Full Picture&lt;/h1&gt;

&lt;p&gt;Let me lay out the complete proof structure, from the onset, because the logical flow is what makes this proof sing.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;graph TD
    subgraph &quot;Ingredients&quot;
        A[&quot;Primitivity&amp;lt;br/&amp;gt;(no a divides a&apos;)&quot;] --&amp;gt; B[&quot;L-sets disjoint&quot;]
        M[&quot;Mertens&apos; product theorem&amp;lt;br/&amp;gt;Π(1-1/p) ~ e^{-γ}/log x&quot;] --&amp;gt; D
    end

    subgraph &quot;Old Bound (2019)&quot;
        B --&amp;gt; C[&quot;Σ d(L_a) ≤ 1&quot;]
        C --&amp;gt; D[&quot;f(A) ≤ e^γ ≈ 1.781&quot;]
    end

    subgraph &quot;New Ingredient (2022)&quot;
        B --&amp;gt; F[&quot;Copies L_{ac} also disjoint&quot;]
        F --&amp;gt; G[&quot;Σ d(L_a) ≤ √v&quot;]
        G --&amp;gt; H[&quot;Savings 1/(1+v) × density √v&quot;]
        H --&amp;gt; I[&quot;Integrate: ∫ dv/[2√v(1+v)] = π/4&quot;]
        I --&amp;gt; K[&quot;f(composites) ≤ e^γ · π/4 ≈ 1.399&quot;]
    end

    subgraph &quot;QED&quot;
        K --&amp;gt; L{&quot;1.399 &amp;lt; 1.637 = f(primes)&quot;}
        L --&amp;gt;|&quot;YES&quot;| N[&quot;f(A) ≤ f(P)&amp;lt;br/&amp;gt;CONJECTURE PROVED&quot;]
    end

    style N fill:#38bdf8,color:#000,stroke:#38bdf8,stroke-width:3px
&lt;/code&gt;&lt;/pre&gt;

&lt;ol&gt;
  &lt;li&gt;Define $f(A) = \sum 1/(a \log a)$.&lt;/li&gt;
  &lt;li&gt;Define L-multiples $L_a$. Prove they’re disjoint for primitive $A$.&lt;/li&gt;
  &lt;li&gt;Sum of densities $\leq 1$. By Mertens, this gives $f(A) \leq e^{\gamma} \approx 1.781$ (old bound).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;New:&lt;/strong&gt; if $P(a)^{1+v} &amp;gt; a$ uniformly, the density sum is $\leq \sqrt{v}$ (from the self-similarity of L-sets).&lt;/li&gt;
  &lt;li&gt;Balance the savings $1/(1+v)$ against the density budget $\sqrt{v}$.&lt;/li&gt;
  &lt;li&gt;Integrate: $\int_0^1 dv/(2\sqrt{v}(1+v)) = \pi/4$.&lt;/li&gt;
  &lt;li&gt;Composite contribution $\leq e^{\gamma} \pi/4 \approx 1.399 &amp;lt; 1.637 = f(\mathcal{P})$.&lt;/li&gt;
  &lt;li&gt;Primes win. $\blacksquare$&lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;whats-still-open&quot;&gt;What’s Still Open&lt;/h1&gt;

&lt;p&gt;The proof opens doors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is $p = 2$ Erdős-strong?&lt;/strong&gt; Every odd prime is. The smallest prime is the last holdout. There’s something poetic about that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Erdős-Sárközy-Szemerédi conjecture (1968):&lt;/strong&gt; Does $\sup f(A) \to 1$ as we restrict to primitive sets $A \subset [x, \infty)$? Lichtman’s methods give $\leq e^{\gamma}\pi/4 \approx 1.399$ for this limit. The conjectured value of $1$ comes from the fact that $f(\mathbb{N}_k) \to 1$ as $k \to \infty$. Getting from $1.399$ down to $1$ is the next frontier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Connections to the Riemann Hypothesis.&lt;/strong&gt; The Erdős-strong criterion $(\star)$ is intimately related to the oscillation of $\pi(x)$ around $\text{li}(x)$ – the “prime number race” governed by zeros of the zeta function. Under RH and the Linear Independence Hypothesis, $99.999973\%$ of primes satisfy $(\star)$. The exceptional primes come from the Chebyshev bias. Perhaps, one day, understanding primitive sets will feed back into understanding zeta zeros. Perhaps.&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h1&gt;

&lt;p&gt;I hope in sharing this you are able to see some beauty in this wonderful proof about the nature of the primes.&lt;/p&gt;

&lt;p&gt;The Erdős primitive set conjecture says something profound: among all the infinite antichains in the divisibility poset, the most natural one – the primes, those fundamental atoms of arithmetic – is also the heaviest. And the proof turns on a surprise appearance of $\pi/4$ from an arctangent integral. Number theory, combinatorics, and calculus, all holding hands.&lt;/p&gt;

&lt;p&gt;Perhaps, one day, this sort of work will contribute to a proof of the Riemann Hypothesis.&lt;/p&gt;

&lt;p&gt;Will it be you, dear reader?&lt;/p&gt;

&lt;p&gt;Hope so - :)&lt;/p&gt;

&lt;p&gt;Cheers.&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;further-reading&quot;&gt;Further Reading&lt;/h1&gt;

&lt;p&gt;Jared Duker Lichtman, &lt;a href=&quot;https://arxiv.org/abs/2202.02384&quot;&gt;A proof of the Erdős primitive set conjecture&lt;/a&gt;. &lt;em&gt;Forum of Mathematics, Pi&lt;/em&gt; (2023). The main paper. Go read it – it’s beautifully written.&lt;/p&gt;

&lt;p&gt;Jared Duker Lichtman, &lt;a href=&quot;https://arxiv.org/abs/1909.00804&quot;&gt;Almost primes and the Banks-Martin conjecture&lt;/a&gt;. Earlier work showing $f(\mathbb{N}_k) \to 1$.&lt;/p&gt;

&lt;p&gt;Paul Erdős and András Sárközy, &lt;a href=&quot;https://users.renyi.hu/~p_erdos/1970-13.pdf&quot;&gt;On the divisibility of sequences of integers&lt;/a&gt;. The 1968 conjecture on tails.&lt;/p&gt;

&lt;p&gt;Tsz Ho Chan, Jared Duker Lichtman, and Carl Pomerance, &lt;a href=&quot;https://math.dartmouth.edu/~carlp/4695pomerance.pdf&quot;&gt;On the Critical Exponent for $k$-primitive Sets&lt;/a&gt;. Where $\tau = 1.1403\ldots$ comes from.&lt;/p&gt;

&lt;p&gt;Jared Duker Lichtman and Carl Pomerance, &lt;a href=&quot;https://arxiv.org/abs/1904.12226&quot;&gt;The Erdős conjecture for primitive sets&lt;/a&gt;. The $e^{\gamma}$ bound that started the final push.&lt;/p&gt;

&lt;p&gt;Hugh L. Montgomery and Robert C. Vaughan, &lt;em&gt;Multiplicative Number Theory I: Classical Theory&lt;/em&gt;. Cambridge, 2007. Your one-stop shop for Mertens’ theorems and all things prime.&lt;/p&gt;
</content>
 </entry>
 

</feed>