<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="/feed.xml" rel="self" type="application/atom+xml" /><link href="/" rel="alternate" type="text/html" /><updated>2026-08-10T18:04:52+00:00</updated><id>/feed.xml</id><title type="html">Ideia.me</title><author><name>Jônatas Davi Paganini</name><email>jonatasdp@gmail.com</email></author><entry><title type="html">Vibe Coding Postgres Extensions with pgrx</title><link href="/vibe-coding-postgres-extensions-pgrx" rel="alternate" type="text/html" title="Vibe Coding Postgres Extensions with pgrx" /><published>2026-05-30T00:00:00+00:00</published><updated>2026-05-30T00:00:00+00:00</updated><id>/vibe-coding-postgres-extensions-pgrx</id><content type="html" xml:base="/vibe-coding-postgres-extensions-pgrx"><![CDATA[<style>
code {
  background-color: rgba(14, 165, 233, 0.15) !important;
  color: #7dd3fc !important;
  padding: 0.11rem 0.35rem !important;
  border-radius: 4px !important;
  font-weight: 500 !important;
  border: 1px solid rgba(14, 165, 233, 0.2) !important;
}
.math-formula {
  background: rgba(15, 23, 42, 0.5);
  padding: 1.5rem;
  border-radius: 8px;
  border: 1px solid #1e293b;
  margin: 1.5rem 0;
  overflow-x: auto;
  text-align: center;
}
</style>

<h1 id="the-vibe-coding-philosophy">The Vibe Coding Philosophy</h1>

<p>Writing a database extension used to feel like performing open-heart surgery with a butter knife. One slip in C memory management and the entire cluster crashes. <strong>Vibe Coding</strong> is a mindset shift enabled by Rust and <code class="language-plaintext highlighter-rouge">pgrx</code>: moving from “don’t break it” to “build it as fast as you can think it.”</p>

<ul>
  <li><strong>Fearless Exploration</strong>: Rust’s compiler handles the safety, so you can focus on the algorithms.</li>
  <li><strong>Instant Gratification</strong>: <code class="language-plaintext highlighter-rouge">cargo pgrx run</code> compiles and launches a live Postgres instance in seconds.</li>
  <li><strong>Deep Integration</strong>: Access low-level Postgres hooks without the boilerplate of C macros.</li>
</ul>

<h1 id="postgresql-storage-internals">PostgreSQL Storage Internals</h1>

<h2 id="the-8kb-page">The 8KB Page</h2>
<p>To understand why we need a new storage model, we must first look at how the standard <strong>Heap Access Method</strong> works. Postgres organizes data into 8KB pages.</p>

<div class="mermaid">
graph TD
    subgraph "8KB Page Structure"
    H[Page Header] --&gt; L[Line Pointers]
    L --&gt; T1[Tuple 1]
    L --&gt; T2[Tuple 2]
    L --&gt; FS[Free Space]
    T1 --&gt; ST[Special Space]
    end
</div>

<h2 id="the-buffer-manager-overhead">The Buffer Manager Overhead</h2>
<p>Every time you read a row, Postgres loads an entire 8KB page into the <strong>Buffer Manager</strong>. This is efficient for OLTP, but creates massive IO overhead for analytical time-series scans that only need a few columns from billions of rows.</p>

<h1 id="the-1d-storage-trap">The 1D Storage Trap</h1>

<h2 id="standard-b-trees">Standard B-Trees</h2>
<p>Standard Postgres uses the Heap to store rows mostly unordered as they arrive. To find data, we use B-Trees. But a B-Tree is fundamentally one-dimensional.</p>

<h2 id="the-composite-index-trap">The Composite Index Trap</h2>
<p>When you create a composite index on <code class="language-plaintext highlighter-rouge">(tenant_id, time)</code>, you are sorting your data by tenant first, then by time.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">WHERE tenant_id = 1 AND t &gt; now() - interval '1h'</code> — <strong>FAST</strong>.</li>
  <li><code class="language-plaintext highlighter-rouge">WHERE t &gt; now() - interval '1h'</code> — <strong>SLOW</strong> (Fragmented across all tenants).</li>
</ul>

<h1 id="the-scan-gap-animation">The Scan Gap Animation</h1>

<p>In traditional storage, data for a specific timeframe across multiple tenants is scattered. The database must scan many pages and filter out irrelevant rows. Spiral changes this geometry.</p>

<div id="scan-comparison-root" class="interactive-widget" style="margin: 2rem 0; background: #0f172a; padding: 2rem; border-radius: 12px; border: 1px solid #1e293b;">
  <div style="display: flex; justify-content: space-around; margin-bottom: 1rem;">
    <button id="btn-run-traditional" class="talk-btn">Run Traditional Scan</button>
    <button id="btn-run-spiral" class="talk-btn" style="color: #0ea5e9; border-color: #0ea5e9;">Run Spiral Scan</button>
  </div>
  <svg id="scan-svg" width="100%" height="200" viewBox="0 0 600 200"></svg>
  <div id="scan-status" style="text-align: center; margin-top: 1rem; font-family: monospace; color: #94a3b8;">Click a button to simulate a query...</div>
</div>

<script>
(function() {
  document.addEventListener('DOMContentLoaded', function() {
    const svg = document.getElementById('scan-svg'); const status = document.getElementById('scan-status');
    const btnTrad = document.getElementById('btn-run-traditional'); const btnSpiral = document.getElementById('btn-run-spiral');
    if (!svg) return;
    const cols = 30; const rows = 4; const cellSize = 18; const gap = 2;
    function initGrid(isSpiral) {
      svg.innerHTML = '';
      for (let r = 0; r < rows; r++) {
        for (let c = 0; c < cols; c++) {
          const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
          rect.setAttribute('x', c * (cellSize + gap)); rect.setAttribute('y', r * (cellSize + gap));
          rect.setAttribute('width', cellSize); rect.setAttribute('height', cellSize);
          rect.setAttribute('fill', '#1e293b'); rect.setAttribute('rx', '2');
          rect.id = `cell-${isSpiral ? 'a' : 't'}-${r}-${c}`;
          let isMatch = false; if (isSpiral) { isMatch = (c >= 10 && c <= 12); } else { isMatch = (c + r) % 7 === 0; }
          if (isMatch) rect.dataset.match = 'true';
          svg.appendChild(rect);
        }
      }
    }
    async function runScan(isSpiral) {
      btnTrad.disabled = true; btnSpiral.disabled = true; initGrid(isSpiral);
      const prefix = isSpiral ? 'a' : 't'; let scanned = 0; let hits = 0; const scanColor = isSpiral ? '#0ea5e9' : '#ef4444';
      for (let r = 0; r < rows; r++) {
        for (let c = 0; c < cols; c++) {
          const cell = document.getElementById(`cell-${prefix}-${r}-${c}`); cell.setAttribute('fill', '#334155'); scanned++;
          status.textContent = `Scanning... Pages Read: ${scanned} | Hits: ${hits}`;
          if (cell.dataset.match === 'true') { hits++; cell.setAttribute('fill', scanColor); cell.setAttribute('stroke', '#fff'); cell.setAttribute('stroke-width', '2'); }
          if (isSpiral && scanned > (12 * rows + rows) && hits >= 3 * rows) {
             status.textContent = `SUCCESS: Clustered Read Complete! Pages Read: ${scanned} | IO Savings: 60%`;
             btnTrad.disabled = false; btnSpiral.disabled = false; return;
          }
          await new Promise(res => setTimeout(res, isSpiral ? 10 : 30));
        }
      }
      status.textContent = isSpiral ? "Spiral: 100% Locality Hit!" : "Traditional: High IO Overhead (Scattered Data)";
      btnTrad.disabled = false; btnSpiral.disabled = false;
    }
    btnTrad.addEventListener('click', () => runScan(false)); btnSpiral.addEventListener('click', () => runScan(true));
    initGrid(false);
  });
})();
</script>

<h1 id="the-spiral-proposal">The Spiral Proposal</h1>

<h2 id="the-gravity-of-large-datasets">The Gravity of Large Datasets</h2>
<p>In a growing system, data has <strong>Entropy</strong>. As you ingest billions of rows, the traditional Heap becomes a massive “Gravity Well.” Standard 8KB pages are too small to handle the weight of analytical scans.</p>

<div class="mermaid">
graph LR
    A[Raw Ingestion] --&gt; B{Data Entropy}
    B --&gt; C[Page Fragmentation]
    C --&gt; D[IO Latency Increase]
    D --&gt; E[System Exhaustion]
</div>

<h2 id="the-spiral-metaphor-cosmic-storage">The Spiral Metaphor: Cosmic Storage</h2>
<p>Spiral reimagines storage as a <strong>Spiral</strong>. Fresh data arrives at high velocity in the center, and as it ages, it migrates to outer “Lanes” (rollups).</p>

<div id="cosmic-spiral-root" class="interactive-widget" style="margin: 2rem 0; background: #020617; padding: 2rem; border-radius: 12px; border: 1px solid #1e293b; display: flex; flex-direction: column; align-items: center; overflow: hidden; height: 400px; position: relative;">
  <svg id="spiral-svg" width="350" height="350" viewBox="0 0 400 400">
    <circle cx="200" cy="200" r="180" fill="none" stroke="#1e293b" stroke-width="1" stroke-dasharray="5,5" />
    <circle cx="200" cy="200" r="130" fill="none" stroke="#1e293b" stroke-width="1" stroke-dasharray="5,5" />
    <circle cx="200" cy="200" r="80" fill="none" stroke="#1e293b" stroke-width="1" stroke-dasharray="5,5" />
    <circle cx="200" cy="200" r="10" fill="#0ea5e9" />
  </svg>
  <div style="position: absolute; bottom: 20px; color: #64748b; font-family: monospace; font-size: 0.7rem; text-align: center;">
    Lanes: 1m (Inner) | 1h (Middle) | 1d (Outer)<br />
    <span style="color: #0ea5e9;">System Health: Optimal</span>
  </div>
</div>

<script>
(function() {
  document.addEventListener('DOMContentLoaded', function() {
    const svg = document.getElementById('spiral-svg'); if (!svg) return;
    const center = 200; const lanes = [80, 130, 180]; const colors = ['#38bdf8', '#818cf8', '#c084fc'];
    function createStar() {
      const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
      const laneIdx = Math.floor(Math.random() * 3); const radius = lanes[laneIdx];
      let angle = Math.random() * Math.PI * 2; circle.setAttribute('r', '3'); circle.setAttribute('fill', colors[laneIdx]); svg.appendChild(circle);
      function animate() { angle += (0.01 / (laneIdx + 1)); const x = center + radius * Math.cos(angle); const y = center + radius * Math.sin(angle); circle.setAttribute('cx', x); circle.setAttribute('cy', y); requestAnimationFrame(animate); }
      animate();
    }
    for(let i=0; i<15; i++) createStar();
  });
})();
</script>

<p><img src="/images/postgresql-performance-workshop.webp" alt="Postgres Workshop" /></p>

<h1 id="spirals-user-interface-magic-comments">Spiral’s User Interface: Magic Comments</h1>

<p>Instead of complex DDL, Spiral parses standard SQL comments to define your analytics pipeline.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">asset_ticks</span> <span class="p">(</span>
    <span class="n">t</span> <span class="n">timestamptz</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">symbol_id</span> <span class="nb">int</span> <span class="k">REFERENCES</span> <span class="n">symbols</span><span class="p">(</span><span class="n">id</span><span class="p">),</span> <span class="c1">-- Auto-detected Tenant</span>
    <span class="n">price</span> <span class="nb">double</span> <span class="nb">precision</span><span class="p">,</span> <span class="c1">-- Spiral: ohlc, stats, sketch</span>
    <span class="n">vol</span> <span class="nb">int</span>                 <span class="c1">-- Spiral: sum</span>
<span class="p">);</span> 
</code></pre></div></div>

<h1 id="mathematics-of-locality-z-ordering">Mathematics of Locality: Z-Ordering</h1>

<p>To solve the Composite Index Trap, we use <strong>Z-Ordering</strong> (The Morton Curve). It interleaves the bits of multiple coordinates to create a single value that preserves multidimensional locality.</p>

<div class="math-formula">\[\text{Z}(x, y) = \sum_{i=0}^{n-1} (x_i \cdot 2^{2i+1} + y_i \cdot 2^{2i})\]
</div>

<h2 id="decoding-the-z-value-formula">Decoding the Z-Value Formula</h2>
<ul>
  <li><strong>(x_i, y_i)</strong>: Individual bits of your coordinates.</li>
  <li><strong>(2^{2i+1}) and (2^{2i})</strong>: Masks that “zip” bits together.</li>
  <li><strong>The Result</strong>: A single scalar that keeps conceptual neighbors physically close.</li>
</ul>

<h1 id="z-ordering-interactive-visualization">Z-Ordering Interactive Visualization</h1>

<p>Hover over the grid to see how the Morton Curve visits every point, linearizing space while keeping blocks physically close.</p>

<div id="zorder-root" class="interactive-widget" style="margin: 2rem 0; background: #0f172a; padding: 2rem; border-radius: 12px; border: 1px solid #1e293b; display: flex; flex-direction: column; align-items: center;">
  <svg id="zorder-svg" width="320" height="320" viewBox="0 0 320 320" style="cursor: crosshair;"></svg>
  <div id="zorder-info" style="margin-top: 1rem; font-family: 'JetBrains Mono', monospace; color: #0ea5e9;">Hover over a cell</div>
</div>

<script>
(function() {
  document.addEventListener('DOMContentLoaded', function() {
    const svg = document.getElementById('zorder-svg'); const info = document.getElementById('zorder-info');
    if (!svg) return;
    const size = 8; const cellSize = 40;
    function getZ(x, y) {
      let z = 0;
      for (let i = 0; i < 4; i++) { z |= ((x & (1 << i)) << i) | ((y & (1 << i)) << (i + 1)); }
      return z;
    }
    const points = [];
    for (let z = 0; z < size * size; z++) {
      for (let x = 0; x < size; x++) {
        for (let y = 0; y < size; y++) { if (getZ(x, y) === z) points.push({x: x * cellSize + cellSize/2, y: y * cellSize + cellSize/2}); }
      }
    }
    const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
    let d = `M ${points[0].x} ${points[0].y}`;
    for (let i = 1; i < points.length; i++) { d += ` L ${points[i].x} ${points[i].y}`; }
    path.setAttribute('d', d); path.setAttribute('fill', 'none'); path.setAttribute('stroke', '#1e293b'); path.setAttribute('stroke-width', '2');
    svg.appendChild(path);
    for (let x = 0; x < size; x++) {
      for (let y = 0; y < size; y++) {
        const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
        const zValue = getZ(x, y);
        rect.setAttribute('x', x * cellSize); rect.setAttribute('y', y * cellSize);
        rect.setAttribute('width', cellSize - 2); rect.setAttribute('height', cellSize - 2);
        rect.setAttribute('fill', 'rgba(14, 165, 233, 0.1)'); rect.setAttribute('stroke', '#1e293b');
        rect.addEventListener('mouseenter', () => {
          rect.setAttribute('fill', '#0ea5e9');
          info.innerHTML = `Time: ${x} | Tenant: ${y} | <span style="color: #fff">Z-Value: ${zValue}</span>`;
        });
        rect.addEventListener('mouseleave', () => { rect.setAttribute('fill', 'rgba(14, 165, 233, 0.1)'); });
        svg.appendChild(rect);
      }
    }
  });
})();
</script>

<h1 id="standard-vs-z-order-the-frontend-scan">Standard vs. Z-Order: The Frontend Scan</h1>

<p>Select a range! Row-Major (Standard) is perfect for horizontal scans, but fails for <strong>Vertical or Cluster</strong> slices. Z-Order provides “Fair Locality” for any shape by grouping data into power-of-two blocks.</p>

<div id="js-locality-root" class="interactive-widget" style="margin: 2rem 0; background: #0f172a; padding: 2rem; border-radius: 12px; border: 1px solid #1e293b; display: flex; flex-direction: column; align-items: center;">
  <div style="display: flex; gap: 2rem; width: 100%; justify-content: center;">
    <div style="text-align: center;">
      <div style="font-size: 0.7rem; color: #94a3b8; margin-bottom: 5px;">STANDARD (ROW-MAJOR)</div>
      <canvas id="canvas-std" width="200" height="200" style="background: #020617; border: 1px solid #334155; cursor: crosshair;"></canvas>
      <div id="jumps-std" style="font-family: monospace; font-size: 0.7rem; color: #ef4444; margin-top: 5px;">Page Loads: 0</div>
    </div>
    <div style="text-align: center;">
      <div style="font-size: 0.7rem; color: #0ea5e9; margin-bottom: 5px;">Z-ORDER (MORTON)</div>
      <canvas id="canvas-z" width="200" height="200" style="background: #020617; border: 1px solid #334155; cursor: crosshair;"></canvas>
      <div id="jumps-z" style="font-family: monospace; font-size: 0.7rem; color: #0ea5e9; margin-top: 5px;">Page Loads: 0</div>
    </div>
  </div>
</div>

<script>
(function() {
  document.addEventListener('DOMContentLoaded', function() {
    const cStd = document.getElementById('canvas-std'); const cZ = document.getElementById('canvas-z');
    const jStd = document.getElementById('jumps-std'); const jZ = document.getElementById('jumps-z');
    if (!cStd || !cZ) return;
    const ctxS = cStd.getContext('2d'); const ctxZ = cZ.getContext('2d');
    const size = 16; const cell = 200 / size; const PAGE_SIZE = 8;
    let isDrawing = false; let start = null;
    function getZ(x, y) { let z = 0; for (let i = 0; i < 4; i++) { z |= ((x & (1 << i)) << i) | ((y & (1 << i)) << (i + 1)); } return z; }
    function drawGrid(ctx) { ctx.clearRect(0,0,200,200); ctx.strokeStyle = '#1e293b'; for(let i=0; i<=size; i++) { ctx.beginPath(); ctx.moveTo(i*cell, 0); ctx.lineTo(i*cell, 200); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, i*cell); ctx.lineTo(200, i*cell); ctx.stroke(); } }
    async function visualize(x1, y1, x2, y2) {
      drawGrid(ctxS); drawGrid(ctxZ);
      const minX = Math.min(x1, x2); const maxX = Math.max(x1, x2);
      const minY = Math.min(y1, y2); const maxY = Math.max(y1, y2);
      let cells = []; for(let y=minY; y<=maxY; y++) { for(let x=minX; x<=maxX; x++) { cells.push({x, y, std: y * size + x, z: getZ(x, y)}); } }
      
      const stdOrder = [...cells].sort((a,b) => a.std - b.std); 
      let activePagesS = new Set();
      for(let i=0; i<stdOrder.length; i++) {
        const p = stdOrder[i]; ctxS.fillStyle = '#ef4444'; ctxS.fillRect(p.x*cell+1, p.y*cell+1, cell-2, cell-2);
        activePagesS.add(Math.floor(p.std / PAGE_SIZE));
        jStd.textContent = `Page Loads: ${activePagesS.size}`; await new Promise(r => setTimeout(r, 20));
      }
      
      const zOrder = [...cells].sort((a,b) => a.z - b.z); 
      let activePagesZ = new Set();
      for(let i=0; i<zOrder.length; i++) {
        const p = zOrder[i]; ctxZ.fillStyle = '#0ea5e9'; ctxZ.fillRect(p.x*cell+1, p.y*cell+1, cell-2, cell-2);
        activePagesZ.add(Math.floor(p.z / PAGE_SIZE));
        jZ.textContent = `Page Loads: ${activePagesZ.size}`; await new Promise(r => setTimeout(r, 20));
      }
    }
    const handleInput = (e) => { 
      const rect = cStd.getBoundingClientRect(); 
      const x = Math.max(0, Math.min(size-1, Math.floor((e.clientX - rect.left) / cell))); 
      const y = Math.max(0, Math.min(size-1, Math.floor((e.clientY - rect.top) / cell))); 
      if(e.type === 'mousedown') { isDrawing = true; start = {x, y}; } 
      if(e.type === 'mouseup' && isDrawing) { isDrawing = false; visualize(start.x, start.y, x, y); } 
    };
    cStd.addEventListener('mousedown', handleInput); window.addEventListener('mouseup', handleInput); drawGrid(ctxS); drawGrid(ctxZ);
  });
})();
</script>

<h1 id="what-is-a-table-access-method-tam">What is a Table Access Method (TAM)?</h1>

<p>Postgres 12 decoupled storage from the executor via the TAM API.</p>

<ul>
  <li>Implement callbacks for <code class="language-plaintext highlighter-rouge">insert</code>, <code class="language-plaintext highlighter-rouge">scan</code>, <code class="language-plaintext highlighter-rouge">delete</code>.</li>
  <li>Control physical byte layout on disk.</li>
</ul>

<p><img src="/images/postgresql-buffer-whirlpool.png" alt="Buffer Manager Whirlpool" /></p>

<h1 id="interactive-direct-seek-vs-page-traversal">Interactive: Direct Seek vs Page Traversal</h1>

<p>Observe how the Spiral TAM calculates the address and jumps directly to the data.</p>

<div id="tam-jump-root" class="interactive-widget" style="margin: 2rem 0; background: #0f172a; padding: 2rem; border-radius: 12px; border: 1px solid #1e293b;">
  <div style="display: flex; gap: 2rem; height: 300px;">
    <div style="flex: 1; border: 1px solid #334155; border-radius: 8px; padding: 1rem; position: relative; display: flex; flex-direction: column; align-items: center;">
      <div style="font-size: 0.7rem; color: #94a3b8; margin-bottom: 10px; text-align: center;">POSTGRES HEAP (PAGE-BASED)</div>
      <div style="position: relative; width: 100%; max-width: 200px;">
        <div id="heap-pages" style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 5px;">
          <div class="pg-page" style="height: 40px; border: 1px solid #1e293b; background: #020617; font-size: 0.6rem; display: flex; align-items: center; justify-content: center; color: #475569;">PAGE 0</div>
          <div class="pg-page" style="height: 40px; border: 1px solid #1e293b; background: #020617; font-size: 0.6rem; display: flex; align-items: center; justify-content: center; color: #475569;">PAGE 1</div>
          <div class="pg-page" style="height: 40px; border: 1px solid #1e293b; background: #020617; font-size: 0.6rem; display: flex; align-items: center; justify-content: center; color: #475569;">PAGE 2</div>
          <div class="pg-page" id="target-page" style="height: 40px; border: 1px solid #1e293b; background: #020617; font-size: 0.6rem; display: flex; align-items: center; justify-content: center; color: #475569;">PAGE 3</div>
        </div>
        <div id="heap-pointer" style="position: absolute; top: 10px; left: -25px; color: #ef4444; transition: all 0.4s; font-size: 1.2rem;"><i class="bi bi-caret-right-fill"></i></div>
      </div>
    </div>
    <div style="flex: 1; border: 1px solid #0ea5e9; border-radius: 8px; padding: 1rem; position: relative; background: rgba(14, 165, 233, 0.05);">
      <div style="font-size: 0.7rem; color: #0ea5e9; margin-bottom: 10px; text-align: center;">SPIRAL TAM (DIRECT SEEK)</div>
      <div style="height: 180px; border-left: 2px solid #334155; margin-left: 50%; position: relative;">
        <div style="position: absolute; top: 0; left: -50px; font-size: 0.6rem; color: #64748b;">0x0000</div>
        <div id="spiral-target" style="position: absolute; top: 120px; left: -10px; width: 20px; height: 4px; background: #0ea5e9; box-shadow: 0 0 10px #0ea5e9;"></div>
      </div>
      <div id="spiral-laser" style="position: absolute; top: 20px; left: 10px; color: #0ea5e9; font-weight: bold; font-family: monospace; font-size: 0.7rem; transition: all 0.6s;">CALC OFFSET...</div>
    </div>
  </div>
  <div style="display: flex; justify-content: center; margin-top: 1.5rem;">
    <button id="btn-compare-access" class="talk-btn" style="padding: 0.5rem 2rem;">Simulate Fetch(T=120, Tenant=5)</button>
  </div>
</div>

<script>
(function() {
  document.addEventListener('DOMContentLoaded', function() {
    const btn = document.getElementById('btn-compare-access');
    const heapPtr = document.getElementById('heap-pointer'); const targetPage = document.getElementById('target-page');
    const laser = document.getElementById('spiral-laser'); const target = document.getElementById('spiral-target');
    if (!btn) return;
    btn.addEventListener('click', async () => {
      btn.disabled = true; heapPtr.style.top = '10px'; await new Promise(r => setTimeout(r, 400));
      heapPtr.style.left = '75px'; await new Promise(r => setTimeout(r, 400));
      heapPtr.style.top = '55px'; heapPtr.style.left = '-25px'; await new Promise(r => setTimeout(r, 400));
      heapPtr.style.left = '75px'; await new Promise(r => setTimeout(r, 400));
      targetPage.style.background = '#334155'; targetPage.style.color = '#fff';
      laser.textContent = "OFFSET = 120 * 64k + 5 * 64"; laser.style.color = "#fff";
      await new Promise(r => setTimeout(r, 600));
      laser.style.transform = "translateY(110px) translateX(80px)"; laser.textContent = "SEEK(0x7840) -> FOUND!";
      target.style.background = "#fff"; target.style.boxShadow = "0 0 20px #fff";
      setTimeout(() => {
        btn.disabled = false; targetPage.style.background = '#020617'; target.style.background = '#0ea5e9';
        laser.style.transform = "none"; laser.textContent = "CALC OFFSET..."; heapPtr.style.top = '10px'; heapPtr.style.left = '-25px';
      }, 3000);
    });
  });
})();
</script>

<h1 id="rust-implementation-reprc-structs">Rust Implementation: <code class="language-plaintext highlighter-rouge">repr(C)</code> Structs</h1>

<p>Ensure binary storage is hardware-compatible using <code class="language-plaintext highlighter-rouge">#[repr(C)]</code>.</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">#[derive(Debug,</span> <span class="nd">Clone,</span> <span class="nd">Copy)]</span>
<span class="nd">#[repr(C)]</span>
<span class="k">pub</span> <span class="k">struct</span> <span class="n">SpiralingRow</span> <span class="p">{</span>
    <span class="k">pub</span> <span class="n">t</span><span class="p">:</span> <span class="nb">i64</span><span class="p">,</span>          <span class="c1">// 8 bytes</span>
    <span class="k">pub</span> <span class="n">tenant_id</span><span class="p">:</span> <span class="nb">i32</span><span class="p">,</span>  <span class="c1">// 4 bytes</span>
    <span class="k">pub</span> <span class="n">value</span><span class="p">:</span> <span class="nb">f64</span><span class="p">,</span>      <span class="c1">// 8 bytes</span>
    <span class="k">pub</span> <span class="n">padding</span><span class="p">:</span> <span class="p">[</span><span class="nb">u8</span><span class="p">;</span> <span class="mi">40</span><span class="p">],</span> <span class="c1">// Total 64 bytes</span>
<span class="p">}</span>
</code></pre></div></div>

<h1 id="the-binary-packing-logic">The Binary Packing Logic</h1>

<p><code class="language-plaintext highlighter-rouge">spiral_pack_delta</code> reads from an unlogged table and packs it into the binary file.</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="n">offset</span> <span class="o">=</span> <span class="p">(</span><span class="n">t</span> <span class="o">*</span> <span class="n">BUNDLE_SIZE</span> <span class="k">as</span> <span class="nb">i64</span><span class="p">)</span> <span class="o">+</span> <span class="p">(</span><span class="n">tenant_id</span> <span class="o">*</span> <span class="n">ROW_SIZE</span> <span class="k">as</span> <span class="nb">i64</span><span class="p">);</span>
<span class="k">let</span> <span class="n">bytes</span><span class="p">:</span> <span class="p">[</span><span class="nb">u8</span><span class="p">;</span> <span class="n">ROW_SIZE</span><span class="p">]</span> <span class="o">=</span> <span class="k">unsafe</span> <span class="p">{</span> <span class="nn">std</span><span class="p">::</span><span class="nn">mem</span><span class="p">::</span><span class="nf">transmute</span><span class="p">(</span><span class="n">data</span><span class="p">)</span> <span class="p">};</span>
<span class="n">file</span><span class="nf">.seek</span><span class="p">(</span><span class="nn">SeekFrom</span><span class="p">::</span><span class="nf">Start</span><span class="p">(</span><span class="n">offset</span> <span class="k">as</span> <span class="nb">u64</span><span class="p">))</span><span class="o">?</span><span class="p">;</span>
<span class="n">file</span><span class="nf">.write_all</span><span class="p">(</span><span class="o">&amp;</span><span class="n">bytes</span><span class="p">)</span><span class="o">?</span><span class="p">;</span>
</code></pre></div></div>

<h1 id="explain-analyze-before--after-tam">EXPLAIN ANALYZE: Before &amp; After TAM</h1>

<p>Notice how <code class="language-plaintext highlighter-rouge">Buffers: shared hit</code> disappears.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- BEFORE: Standard Heap Scan</span>
<span class="o">-&gt;</span> <span class="n">Parallel</span> <span class="n">Seq</span> <span class="n">Scan</span> <span class="k">on</span> <span class="n">ticks</span> <span class="p">(</span><span class="n">actual</span> <span class="nb">time</span><span class="o">=</span><span class="mi">428</span><span class="p">.</span><span class="mi">215</span><span class="p">..</span><span class="mi">428</span><span class="p">.</span><span class="mi">215</span> <span class="k">rows</span><span class="o">=</span><span class="mi">1000000</span><span class="p">)</span>
   <span class="n">Buffers</span><span class="p">:</span> <span class="n">shared</span> <span class="n">hit</span><span class="o">=</span><span class="mi">4218</span>

<span class="c1">-- AFTER: Spiral TAM Direct Seek</span>
<span class="o">-&gt;</span> <span class="n">Custom</span> <span class="n">Scan</span> <span class="p">(</span><span class="n">Spiral</span> <span class="nb">Binary</span> <span class="k">Map</span><span class="p">)</span> <span class="p">(</span><span class="n">actual</span> <span class="nb">time</span><span class="o">=</span><span class="mi">0</span><span class="p">.</span><span class="mi">076</span><span class="p">..</span><span class="mi">0</span><span class="p">.</span><span class="mi">076</span> <span class="k">rows</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span>
   <span class="n">Buffers</span><span class="p">:</span> <span class="n">shared</span> <span class="n">hit</span><span class="o">=</span><span class="mi">2</span> <span class="p">(</span><span class="k">catalog</span> <span class="k">only</span><span class="p">)</span>
   <span class="n">Direct</span> <span class="n">IO</span><span class="p">:</span> <span class="mi">64</span> <span class="n">bytes</span> <span class="k">read</span> <span class="n">via</span> <span class="n">seek</span><span class="p">()</span>
</code></pre></div></div>

<h1 id="incremental-view-maintenance-ivm">Incremental View Maintenance (IVM)</h1>

<p>In standard Postgres, <code class="language-plaintext highlighter-rouge">REFRESH MATERIALIZED VIEW</code> rebuilds everything. Spiral tracks “dirty buckets” in a transactional changelog.</p>

<p><img src="/images/postgresql-ocean-pages.png" alt="Ocean Pages" /></p>

<h1 id="the-streaming-hierarchy-in-motion">The Streaming Hierarchy in Motion</h1>

<p>Watch data points flow from raw insert to cascading rollups.</p>

<div id="streaming-root" class="interactive-widget" style="margin: 2rem 0; background: #0f172a; padding: 2rem; border-radius: 12px; border: 1px solid #1e293b; overflow: hidden;">
  <div style="display: flex; justify-content: center; margin-bottom: 2rem;">
    <button id="btn-stream-data" class="talk-btn" style="color: #0ea5e9; border-color: #0ea5e9; padding: 0.5rem 1.5rem;"><i class="bi bi-play-fill"></i> Stream 100 Ticks</button>
  </div>
  <div style="position: relative; height: 300px; width: 100%; display: flex; align-items: flex-end; justify-content: space-around; padding-bottom: 20px;">
    <div style="position: absolute; top: 0; left: 50%; transform: translateX(-50%); text-align: center;"><div id="ingest-box" style="width: 60px; height: 40px; border: 2px dashed #334155; border-radius: 4px; display: flex; align-items: center; justify-content: center;"><i class="bi bi-database-down" style="color: #64748b; font-size: 1.5rem;"></i></div></div>
    <div class="h-block" id="hb-1m" style="width: 80px; height: 40px; background: #1e293b; border-radius: 4px; display: flex; flex-direction: column; align-items: center; justify-content: center; transition: all 0.3s;"><div id="hb-1m-val" style="font-family: monospace; font-size: 0.8rem; color: #0ea5e9;">0</div></div>
    <div class="h-block" id="hb-1h" style="width: 80px; height: 60px; background: #1e293b; border-radius: 4px; display: flex; flex-direction: column; align-items: center; justify-content: center; transition: all 0.3s;"><div id="hb-1h-val" style="font-family: monospace; font-size: 0.8rem; color: #0ea5e9;">0</div></div>
    <div class="h-block" id="hb-1d" style="width: 80px; height: 80px; background: #1e293b; border-radius: 4px; display: flex; flex-direction: column; align-items: center; justify-content: center; transition: all 0.3s;"><div id="hb-1d-val" style="font-family: monospace; font-size: 0.8rem; color: #0ea5e9;">0</div></div>
    <div id="ticks-layer" style="position: absolute; inset: 0; pointer-events: none;"></div>
  </div>
  <div id="stream-status" style="text-align: center; margin-top: 1rem; font-family: monospace; color: #64748b; font-size: 0.8rem;">Waiting for stream...</div>
</div>

<script>
(function() {
  document.addEventListener('DOMContentLoaded', function() {
    const btn = document.getElementById('btn-stream-data'); const layer = document.getElementById('ticks-layer'); const status = document.getElementById('stream-status');
    if (!btn) return;
    let counts = { m1: 0, h1: 0, d1: 0 };
    const hb1m = document.getElementById('hb-1m'); const hb1h = document.getElementById('hb-1h'); const hb1d = document.getElementById('hb-1d');
    const v1m = document.getElementById('hb-1m-val'); const v1h = document.getElementById('hb-1h-val'); const v1d = document.getElementById('hb-1d-val');
    async function spawnTick() {
      const tick = document.createElement('div'); tick.style.position = 'absolute'; tick.style.top = '20px'; tick.style.left = '50%'; tick.style.width = '6px'; tick.style.height = '6px'; tick.style.background = '#0ea5e9'; tick.style.borderRadius = '50%'; tick.style.boxShadow = '0 0 8px #0ea5e9'; tick.style.transition = 'all 1s cubic-bezier(0.4, 0, 0.2, 1)'; layer.appendChild(tick);
      const xOffset = (Math.random() - 0.5) * 40; await new Promise(r => setTimeout(r, 50)); const target1m = hb1m.getBoundingClientRect(); const layerRect = layer.getBoundingClientRect(); tick.style.top = (target1m.top - layerRect.top + 10) + 'px'; tick.style.left = (target1m.left - layerRect.left + 30 + xOffset) + 'px'; await new Promise(r => setTimeout(r, 800));
      counts.m1++; v1m.textContent = counts.m1; hb1m.style.background = '#1e3a8a'; setTimeout(() => hb1m.style.background = '#1e293b', 100);
      if (counts.m1 % 5 === 0) {
        tick.style.top = (hb1h.getBoundingClientRect().top - layerRect.top + 10) + 'px'; tick.style.left = (hb1h.getBoundingClientRect().left - layerRect.left + 30 + xOffset) + 'px'; await new Promise(r => setTimeout(r, 800)); counts.h1++; v1h.textContent = counts.h1; hb1h.style.background = '#1e3a8a'; setTimeout(() => hb1h.style.background = '#1e293b', 100);
        if (counts.h1 % 3 === 0) {
          tick.style.top = (hb1d.getBoundingClientRect().top - layerRect.top + 10) + 'px'; tick.style.left = (hb1d.getBoundingClientRect().left - layerRect.left + 30 + xOffset) + 'px'; await new Promise(r => setTimeout(r, 800)); counts.d1++; v1d.textContent = counts.d1; hb1d.style.background = '#1e3a8a'; setTimeout(() => hb1d.style.background = '#1e293b', 100);
        }
      }
      tick.style.opacity = '0'; setTimeout(() => tick.remove(), 1000);
    }
    btn.addEventListener('click', async () => { btn.disabled = true; status.textContent = "INGESTING & AGGREGATING..."; for(let i=0; i<30; i++) { spawnTick(); await new Promise(r => setTimeout(r, 200)); } setTimeout(() => { btn.disabled = false; status.textContent = "REACTIVE REFRESH COMPLETE: Hierarchy In-Sync."; }, 5000); });
  });
})();
</script>

<h1 id="mathematical-engine-welfords-algorithm">Mathematical Engine: Welford’s Algorithm</h1>

<p>How do we “merge” a standard deviation? Spiral uses <strong>Welford’s Algorithm</strong> to store moments ((n, M_1, M_2, M_3, M_4)).</p>

<div class="math-formula">\[\bar{x}_n = \bar{x}_{n-1} + \frac{x_n - \bar{x}_{n-1}}{n}\]
</div>

<div class="math-formula">\[M_{2,n} = M_{2,n-1} + (x_n - \bar{x}_{n-1})(x_n - \bar{x}_n)\]
</div>

<h1 id="parallel-aggregation-lifecycle">Parallel Aggregation Lifecycle</h1>

<p>Follow a data point from SQL <code class="language-plaintext highlighter-rouge">INSERT</code> to final analytical projection.</p>

<div id="unified-agg-root" class="interactive-widget" style="margin: 2rem 0; background: #0f172a; padding: 2rem; border-radius: 12px; border: 1px solid #1e293b; min-height: 500px; position: relative; display: flex; flex-direction: column;">
  <div style="background: #020617; border-radius: 6px; padding: 1rem; margin-bottom: 2rem; border: 1px solid #334155; font-family: 'JetBrains Mono', monospace; font-size: 0.8rem;">
    <div id="sql-line" style="color: #fff;">INSERT INTO ticks (price) VALUES (<span id="sql-val" style="color: #f97316;">60000.0</span>);</div>
  </div>
  <div style="flex: 1; position: relative; display: flex; justify-content: space-around; align-items: center; min-height: 300px;">
    <div style="display: flex; flex-direction: column; gap: 30px;">
      <div id="u-w1" class="u-worker" style="width: 140px; height: 100px; background: #1e293b; border: 2px solid #334155; border-radius: 8px; padding: 10px; transition: all 0.5s;"><div style="font-family: monospace; font-size: 0.65rem; color: #0ea5e9;">n: <span id="u-w1-n">0</span><br />OHLC: <span id="u-w1-ohlc">0/0/0/0</span></div></div>
      <div id="u-w2" class="u-worker" style="width: 140px; height: 100px; background: #1e293b; border: 2px solid #334155; border-radius: 8px; padding: 10px; transition: all 0.5s;"><div style="font-family: monospace; font-size: 0.65rem; color: #0ea5e9;">n: <span id="u-w2-n">0</span><br />OHLC: <span id="u-w2-ohlc">0/0/0/0</span></div></div>
    </div>
    <div id="u-master" style="width: 120px; height: 120px; background: #854d0e; border: 2px solid #eab308; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: bold; box-shadow: 0 0 20px rgba(234, 179, 8, 0.2); transition: all 0.5s; opacity: 0.3; transform: scale(0.8);">MASTER</div>
    <div id="u-results" style="display: flex; flex-direction: column; gap: 10px;"><div id="u-res-mean" style="width: 100px; padding: 8px; background: #064e3b; border: 1px solid #16a34a; color: #4ade80; border-radius: 4px; font-size: 0.7rem; font-family: monospace; opacity: 0; transform: translateX(20px);">MEAN: 0</div></div>
    <div id="u-tick" style="position: absolute; width: 12px; height: 12px; background: #f97316; border-radius: 50%; box-shadow: 0 0 10px #f97316; opacity: 0; pointer-events: none; z-index: 100;"></div>
  </div>
  <div style="display: flex; justify-content: center; gap: 1rem; margin-top: 2rem;"><button id="btn-next-step" class="talk-btn" style="border-color: #0ea5e9; color: #0ea5e9; font-weight: bold;">NEXT STEP: Ingest Tick</button></div>
</div>

<script>
(function() {
  document.addEventListener('DOMContentLoaded', function() {
    const btnNext = document.getElementById('btn-next-step'); const tick = document.getElementById('u-tick'); const root = document.getElementById('unified-agg-root'); const sqlVal = document.getElementById('sql-val');
    if (!btnNext) return;
    let step = 0; let tickCount = 0; let workerStates = [{n: 0, o: 0, h: 0, l: 0, c: 0}, {n: 0, o: 0, h: 0, l: 0, c: 0}];
    async function ingestTick() {
      const workerId = tickCount % 2; const targetWorker = document.getElementById(`u-w${workerId+1}`); const val = 60000 + (Math.random() * 100); sqlVal.textContent = val.toFixed(1); const rootRect = root.getBoundingClientRect(); const sqlRect = document.getElementById('sql-line').getBoundingClientRect(); const workerRect = targetWorker.getBoundingClientRect();
      tick.style.left = (sqlRect.left - rootRect.left + 150) + 'px'; tick.style.top = (sqlRect.top - rootRect.top + 10) + 'px'; tick.style.opacity = '1'; tick.style.transform = 'scale(1)'; await new Promise(r => setTimeout(r, 100)); tick.style.transition = 'all 0.8s cubic-bezier(0.4, 0, 0.2, 1)'; tick.style.left = (workerRect.left - rootRect.left + 60) + 'px'; tick.style.top = (workerRect.top - rootRect.top + 40) + 'px'; await new Promise(r => setTimeout(r, 800));
      const s = workerStates[workerId]; if (s.n === 0) { s.o = val; s.l = val; } s.n++; s.h = Math.max(s.h, val); s.l = Math.min(s.l, val); s.c = val; document.getElementById(`u-w${workerId+1}-n`).textContent = s.n; document.getElementById(`u-w${workerId+1}-ohlc`).textContent = `${s.o.toFixed(0)}/${s.h.toFixed(0)}/${s.l.toFixed(0)}/${s.c.toFixed(0)}`; tick.style.transform = 'scale(2)'; tick.style.opacity = '0'; targetWorker.style.background = '#1e3a8a'; setTimeout(() => targetWorker.style.background = '#1e293b', 150); tickCount++; if (tickCount >= 4) { step = 1; btnNext.textContent = "NEXT STEP: Run Merge (Combine)"; }
    }
    async function mergeStates() {
      const master = document.getElementById('u-master'); const w1 = document.getElementById('u-w1'); const w2 = document.getElementById('u-w2'); w1.style.opacity = '0.5'; w2.style.opacity = '0.5'; const mRect = master.getBoundingClientRect(); const dist1 = mRect.left - w1.getBoundingClientRect().left; w1.style.transform = `translateX(${dist1}px) scale(0.5)`; w2.style.transform = `translateX(${mRect.left - w2.getBoundingClientRect().left}px) scale(0.5)`; await new Promise(r => setTimeout(r, 600)); master.style.opacity = '1'; master.style.transform = 'scale(1.2)'; master.style.background = '#eab308'; master.textContent = "MERGING..."; await new Promise(r => setTimeout(r, 1000)); master.style.transform = 'scale(1)'; master.style.background = '#854d0e'; master.innerHTML = 'CONSOLIDATED'; step = 2; btnNext.textContent = "NEXT STEP: Finalize Projection";
    }
    async function finalize() {
      const mean = document.getElementById('u-res-mean'); mean.style.opacity = '1'; mean.style.transform = 'none'; mean.textContent = `MEAN: 60050.45`; btnNext.disabled = true;
    }
    btnNext.addEventListener('click', () => { if (step === 0) ingestTick(); else if (step === 1) mergeStates(); else if (step === 2) finalize(); });
  });
})();
</script>

<h1 id="stress-test--the-1b-scalability">Stress Test — The 1B Scalability</h1>

<p>Achieved ingestion rates exceeding <strong>1.9 Million rows per second</strong>.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Phase</th>
      <th style="text-align: left">Ingest Rate</th>
      <th style="text-align: left">Duration</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>Bulk Load</strong></td>
      <td style="text-align: left">1,940,482 rows/s</td>
      <td style="text-align: left">0.51s (1M sample)</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Backfill</strong></td>
      <td style="text-align: left">302,413 rows/s</td>
      <td style="text-align: left">3.30s (1M sample)</td>
    </tr>
  </tbody>
</table>

<h1 id="conclusion-the-future-is-extensible">Conclusion: The Future is Extensible</h1>

<p>PostgreSQL is a <strong>platform for data structures</strong>.</p>

<ul>
  <li><strong>Rust is the Key</strong>: Memory safety makes internals accessible.</li>
  <li><strong>Math is the Leverage</strong>: Smart algorithms beat brute-force hardware.</li>
</ul>

<div class="admonition admonition-success" style="--admonition-color: #00c853;">
  <div class="admonition-title">
    <span class="admonition-icon">✅</span>
    Final Takeaway
  </div>
  <div class="admonition-content">
    
<p>Spiral achieves performance thought impossible in a general-purpose database by using smart math and custom storage.</p>

  </div>
</div>

<hr />
<p><em>Presented at PGDay Blumenau 2026.</em></p>]]></content><author><name>Jônatas Davi Paganini</name><email>jonatasdp@gmail.com</email></author><category term="postgresql" /><category term="rust" /><category term="technology" /><category term="pgrx" /><category term="rust" /><category term="internals" /><category term="database" /><category term="tam" /><category term="z-order" /><category term="time-series" /><summary type="html"><![CDATA[The complete guide to building Spiral: a high-performance PostgreSQL extension using Rust, Z-Ordering, and Custom Access Methods.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/images/postgresql-performance-workshop.webp" /><media:content medium="image" url="/images/postgresql-performance-workshop.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Spiral: An Experiment in Geometry-Aware Storage for PostgreSQL</title><link href="/spiral-intro" rel="alternate" type="text/html" title="Spiral: An Experiment in Geometry-Aware Storage for PostgreSQL" /><published>2026-05-24T00:00:00+00:00</published><updated>2026-05-24T00:00:00+00:00</updated><id>/spiral-intro</id><content type="html" xml:base="/spiral-intro"><![CDATA[<style>
.math-formula {
  background: rgba(15, 23, 42, 0.5);
  padding: 1.5rem;
  border-radius: 8px;
  border: 1px solid #1e293b;
  margin: 1.5rem 0;
  overflow-x: auto;
  text-align: center;
}
.interactive-widget {
  box-shadow: 0 20px 50px rgba(0,0,0,0.4);
}
</style>

<h1 id="a-personal-journey-into-database-internals">A Personal Journey into Database Internals</h1>

<p>I should start with a confession: I am not a math expert, nor am I a core PostgreSQL developer. I am a curious engineer who loves to experiment. <strong>Spiral</strong> is the result of letting my creative side take the wheel—a research project born from a simple desire to understand how databases work at the bit-level and to see if I could implement some of my own “crazy” ideas.</p>

<p>Everything you see here is a <strong>prototype</strong>. It is an exploration of what becomes possible when we use Rust to extend the heart of the database. I’m sharing this as a research outcome, hoping to find other curious minds who might want to contribute or challenge these concepts.</p>

<h1 id="try-it-in-five-minutes">Try It in Five Minutes</h1>

<p>Spiral targets PostgreSQL 16–18. Install via Homebrew on macOS:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew <span class="nb">install </span>postgresql@18
brew <span class="nb">install</span> <span class="nt">--build-from-source</span> ./Formula/spiral.rb
</code></pre></div></div>

<p>Or build from source with <code class="language-plaintext highlighter-rouge">cargo-pgrx</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cargo <span class="nb">install </span>cargo-pgrx
cargo pgrx init
cargo pgrx run pg18   <span class="c"># drops into a psql session with spiral loaded</span>
</code></pre></div></div>

<p>Add to <code class="language-plaintext highlighter-rouge">postgresql.conf</code> so the planner hook and background worker start at boot:</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="py">shared_preload_libraries</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">'spiral'</span>
</code></pre></div></div>

<p>Then try the short walkthrough:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cargo pgrx run pg18 &lt; examples/short_walkthrough.sql
</code></pre></div></div>

<p>Or paste this into any psql session:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="n">EXTENSION</span> <span class="n">spiral</span><span class="p">;</span>
<span class="k">SET</span> <span class="n">spiral</span><span class="p">.</span><span class="n">kickoff_date</span> <span class="o">=</span> <span class="s1">'2026-01-01'</span><span class="p">;</span>

<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">ticks</span> <span class="p">(</span>
    <span class="n">t</span>         <span class="n">timestamptz</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">symbol_id</span> <span class="nb">int</span>         <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">price</span>     <span class="nb">double</span> <span class="nb">precision</span><span class="p">,</span> <span class="c1">-- Spiral: ohlcv</span>
    <span class="n">vol</span>       <span class="nb">int</span>               <span class="c1">-- Spiral: sum</span>
<span class="p">)</span> <span class="k">WITH</span> <span class="p">(</span><span class="n">spiral</span><span class="p">.</span><span class="n">frames</span> <span class="o">=</span> <span class="s1">'1m,1h'</span><span class="p">,</span> <span class="n">spiral</span><span class="p">.</span><span class="n">tenant</span> <span class="o">=</span> <span class="s1">'symbol_id'</span><span class="p">);</span>

<span class="c1">-- Insert some data — background worker auto-refreshes rollups</span>
<span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">ticks</span> <span class="k">SELECT</span>
    <span class="n">now</span><span class="p">()</span> <span class="o">-</span> <span class="p">(</span><span class="n">random</span><span class="p">()</span> <span class="o">*</span> <span class="n">interval</span> <span class="s1">'2 hours'</span><span class="p">),</span>
    <span class="p">(</span><span class="n">random</span><span class="p">()</span> <span class="o">*</span> <span class="mi">10</span><span class="p">)::</span><span class="nb">int</span><span class="p">,</span>
    <span class="mi">100</span> <span class="o">+</span> <span class="n">random</span><span class="p">()</span> <span class="o">*</span> <span class="mi">50</span><span class="p">,</span>
    <span class="p">(</span><span class="n">random</span><span class="p">()</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">)::</span><span class="nb">int</span>
<span class="k">FROM</span> <span class="n">generate_series</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">100000</span><span class="p">);</span>

<span class="c1">-- Manually refresh (or just wait for the bgworker)</span>
<span class="k">SELECT</span> <span class="n">spiral_refresh</span><span class="p">(</span><span class="s1">'ticks'</span><span class="p">);</span>

<span class="c1">-- Query the 1-minute rollup</span>
<span class="k">SELECT</span> <span class="n">t</span><span class="p">,</span> <span class="n">symbol_id</span><span class="p">,</span> <span class="n">price_ohlcv_h</span><span class="p">,</span> <span class="n">price_ohlcv_l</span><span class="p">,</span> <span class="n">vol</span>
<span class="k">FROM</span> <span class="n">ticks_1m</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">t</span> <span class="k">DESC</span> <span class="k">LIMIT</span> <span class="mi">10</span><span class="p">;</span>
</code></pre></div></div>

<p>The source is at <a href="https://github.com/jonatas/spiral">github.com/jonatas/spiral</a>.</p>

<h1 id="the-spiral-concept-a-metaphor-for-data-flow">The “Spiral” Concept: A Metaphor for Data Flow</h1>

<p>The central idea behind this project is to stop thinking of data as a flat, linear history. As datasets grow to billions of rows, the traditional model faces what I call <strong>Data Gravity</strong>—where the weight of the data makes every operation exponentially harder.</p>

<p>In my experiments, I started imagining storage as a <strong>Spiral</strong>.</p>

<div id="cosmic-spiral-root" class="interactive-widget" style="margin: 2rem 0; background: #020617; padding: 3rem 2rem; border-radius: 12px; border: 1px solid #1e293b; display: flex; flex-direction: column; align-items: center; overflow: hidden; height: 450px; position: relative;">
  <svg id="spiral-svg" width="350" height="350" viewBox="0 0 400 400">
    <circle cx="200" cy="200" r="180" fill="none" stroke="#1e293b" stroke-width="1" stroke-dasharray="5,5" />
    <circle cx="200" cy="200" r="130" fill="none" stroke="#1e293b" stroke-width="1" stroke-dasharray="5,5" />
    <circle cx="200" cy="200" r="80" fill="none" stroke="#1e293b" stroke-width="1" stroke-dasharray="5,5" />
    <circle cx="200" cy="200" r="10" fill="#0ea5e9" />
  </svg>
  <div style="position: absolute; bottom: 30px; color: #64748b; font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; text-align: center; line-height: 1.5;">
    <strong style="color: #fff;">THE SPIRAL METAPHOR</strong><br />
    Inner Core: High-velocity raw ticks<br />
    Outer Lanes: Hierarchical rollups
  </div>
</div>

<script>
(function() {
  document.addEventListener('DOMContentLoaded', function() {
    const svg = document.getElementById('spiral-svg'); if (!svg) return;
    const center = 200; const lanes = [80, 130, 180]; const colors = ['#38bdf8', '#818cf8', '#c084fc'];
    function createStar() {
      const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
      const laneIdx = Math.floor(Math.random() * 3); const radius = lanes[laneIdx];
      let angle = Math.random() * Math.PI * 2; circle.setAttribute('r', '3'); circle.setAttribute('fill', colors[laneIdx]); svg.appendChild(circle);
      function animate() { angle += (0.01 / (laneIdx + 1)); const x = center + radius * Math.cos(angle); const y = center + radius * Math.sin(angle); circle.setAttribute('cx', x); circle.setAttribute('cy', y); requestAnimationFrame(animate); }
      animate();
    }
    for(let i=0; i<20; i++) createStar();
  });
})();
</script>

<p>In this model, fresh data arrives at high velocity in the center. As it “cools” and ages, it migrates to outer orbits where it’s aggregated into stable, dense structures.</p>

<h1 id="multi-tenancy-the-lane-system">Multi-Tenancy: The Lane System</h1>

<p>In a multi-tenant environment, the “Data Gravity” problem is multiplied. If we store everyone’s data in the same linear file, noisy neighbors can drown out everyone else.</p>

<p>Spiral solves this by assigning each tenant their own <strong>Lane</strong> within the spiral. Imagine a cosmic highway where each tenant has their own orbital path. This preserves locality not just in time, but in tenant identity. You can explore this concept in depth at the <a href="../spiral">Spiral Interactive Lab</a>.</p>

<div id="multi-tenant-spiral-root" class="interactive-widget" style="margin: 2rem 0; background: #020617; padding: 2rem; border-radius: 12px; border: 1px solid #1e293b; display: flex; flex-direction: column; align-items: center; overflow: hidden; height: 550px; position: relative;">
  <canvas id="multi-tenant-canvas" width="600" height="450" style="width: 100%; height: 100%; max-width: 600px;"></canvas>
  
  <div style="position: absolute; top: 20px; right: 20px; display: flex; flex-direction: column; gap: 10px;">
    <button id="add-tenant-btn" style="background: #0ea5e9; color: #fff; border: none; padding: 8px 16px; border-radius: 4px; font-size: 0.8rem; cursor: pointer; font-family: 'JetBrains Mono', monospace; font-weight: bold; box-shadow: 0 0 15px rgba(14, 165, 233, 0.4);">+ ADD TENANT</button>
  </div>

  <div id="spiral-slides" style="position: absolute; bottom: 20px; left: 20px; right: 20px; background: rgba(15, 23, 42, 0.85); backdrop-filter: blur(8px); padding: 1.2rem; border-radius: 10px; border: 1px solid #1e3a8a; color: #94a3b8; font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; line-height: 1.5; box-shadow: 0 10px 30px rgba(0,0,0,0.5);">
    <div class="spiral-slide" data-step="0">
      <strong style="color: #4ade80;">[01] THE SINGLE TENANT FLOW</strong><br />
      Data originates at the high-velocity center (Hot Storage). As it ages, the algorithm pushes it outward, creating a natural temporal gradient.
    </div>
    <div class="spiral-slide" data-step="1" style="display: none;">
      <strong style="color: #60a5fa;">[02] OPENING THE LANES</strong><br />
      The space is mathematically partitioned into lanes. Each lane represents a different aggregation level (Raw -&gt; 1m -&gt; 1h -&gt; 1d).
    </div>
    <div class="spiral-slide" data-step="2" style="display: none;">
      <strong style="color: #c084fc;">[03] SCALABLE MULTI-TENANCY</strong><br />
      By shifting the orbital phase, we stack multiple tenants into the same table. They share the same physical "lane" boundaries but never collide in bit-space.
    </div>
  </div>
  
  <div style="position: absolute; bottom: 25px; right: 30px; display: flex; gap: 8px;">
    <button id="prev-slide" style="background: #1e293b; color: #fff; border: 1px solid #334155; padding: 6px 12px; border-radius: 4px; cursor: pointer; transition: all 0.2s;">&lt;</button>
    <button id="next-slide" style="background: #1e293b; color: #fff; border: 1px solid #334155; padding: 6px 12px; border-radius: 4px; cursor: pointer; transition: all 0.2s;">&gt;</button>
  </div>
</div>

<script>
(function() {
  document.addEventListener('DOMContentLoaded', function() {
    const canvas = document.getElementById('multi-tenant-canvas');
    if (!canvas) return;
    const ctx = canvas.getContext('2d');
    const addBtn = document.getElementById('add-tenant-btn');
    const prevBtn = document.getElementById('prev-slide');
    const nextBtn = document.getElementById('next-slide');
    const slides = document.querySelectorAll('.spiral-slide');
    
    let currentStep = 0;
    let time = 0;
    let tenants = [
      { id: 0, color: '#0ea5e9', phase: 0, points: [] }
    ];

    function updateSlides() {
      slides.forEach((s, i) => {
        s.style.display = i === currentStep ? 'block' : 'none';
        s.style.animation = 'fadeIn 0.5s ease-out';
      });
    }

    nextBtn.onclick = () => { currentStep = (currentStep + 1) % slides.length; updateSlides(); };
    prevBtn.onclick = () => { currentStep = (currentStep - 1 + slides.length) % slides.length; updateSlides(); };

    addBtn.onclick = () => {
      const colors = ['#f43f5e', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#f97316'];
      const newPhase = (tenants.length * Math.PI * 2) / Math.max(5, tenants.length + 1);
      tenants.push({
        id: tenants.length,
        color: colors[tenants.length % colors.length],
        phase: newPhase,
        points: []
      });
      if (currentStep < 2) { currentStep = 2; updateSlides(); }
    };

    function draw() {
      const w = canvas.width;
      const h = canvas.height;
      ctx.clearRect(0, 0, w, h);
      
      const centerX = w / 2;
      const centerY = h / 2;
      
      time += 0.01;
      const globalRotation = time * 0.15;
      const zoom = 1.1 + Math.sin(time * 0.4) * 0.1;

      // Draw background grid/stars
      ctx.fillStyle = 'rgba(30, 41, 59, 0.2)';
      for(let i=0; i<50; i++) {
        const x = (Math.sin(i * 123.45) * 0.5 + 0.5) * w;
        const y = (Math.cos(i * 543.21) * 0.5 + 0.5) * h;
        ctx.fillRect(x, y, 1, 1);
      }

      // Draw background lanes
      if (currentStep >= 1) {
        ctx.strokeStyle = '#1e293b';
        ctx.lineWidth = 1;
        for (let r = 50; r < 250; r += 40) {
          const animatedR = r * zoom;
          ctx.beginPath();
          ctx.setLineDash([10, 15]);
          ctx.arc(centerX, centerY, animatedR, 0, Math.PI * 2);
          ctx.stroke();
          
          // Lane Labels
          if (time % 2 < 0.1) {
            ctx.fillStyle = '#1e3a8a';
            ctx.font = '8px monospace';
            ctx.fillText(r < 100 ? 'RAW' : (r < 180 ? 'HOURLY' : 'DAILY'), centerX + animatedR + 5, centerY);
          }
        }
        ctx.setLineDash([]);
      }

      tenants.forEach((t, tIdx) => {
        // Add points
        if (Math.random() > 0.7) {
          t.points.push({ age: 0, initialAngle: (Math.random() - 0.5) * 0.15, size: 2 + Math.random() * 2 });
        }

        ctx.fillStyle = t.color;
        ctx.strokeStyle = t.color;
        t.points = t.points.filter(p => p.age < 6);
        t.points.forEach(p => {
          p.age += 0.012;
          // Radius grows over time, creating the spiral effect
          const r = (15 + p.age * 45) * zoom;
          // Spiral angle: time-based growth + tenant phase + global rotation
          const angle = p.age * 2.5 + t.phase + globalRotation + p.initialAngle;
          
          const x = centerX + Math.cos(angle) * r;
          const y = centerY + Math.sin(angle) * r;
          
          const currentSize = Math.max(0.5, p.size - p.age * 0.4);
          ctx.beginPath();
          ctx.arc(x, y, currentSize, 0, Math.PI * 2);
          ctx.fill();
          
          // Connect to previous position for trailing effect
          if (p.age > 0.05) {
            ctx.globalAlpha = Math.max(0, 0.4 - p.age * 0.06);
            ctx.beginPath();
            ctx.lineWidth = currentSize;
            ctx.moveTo(x, y);
            const prevR = (15 + (p.age - 0.05) * 45) * zoom;
            const prevAngle = (p.age - 0.05) * 2.5 + t.phase + globalRotation + p.initialAngle;
            ctx.lineTo(centerX + Math.cos(prevAngle) * prevR, centerY + Math.sin(prevAngle) * prevR);
            ctx.stroke();
            ctx.globalAlpha = 1.0;
          }
        });
      });

      requestAnimationFrame(draw);
    }
    
    // Simple fadeIn animation for slides
    const style = document.createElement('style');
    style.textContent = '@keyframes fadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }';
    document.head.appendChild(style);
    
    draw();
  });
})();
</script>

<h1 id="the-geometry-of-packing-massively-multi-tenant-scale">The Geometry of Packing: Massively Multi-Tenant Scale</h1>

<p>To understand how Spiral achieves constant-time seeks across billions of rows, we have to look at the <strong>Packing Geometry</strong>. By using an Archimedean spiral, we can map a 1D sequence (time) into a 2D plane where distance from the center represents “age” and the rotation phase represents “tenant identity.”</p>

<p>In this advanced simulator, you can push the system to <strong>100 concurrent tenants</strong>. Watch how the <strong>Temporal Resolution (Step)</strong> and <strong>Packing Density</strong> configs mirror the internal <code class="language-plaintext highlighter-rouge">bundle_size</code> and <code class="language-plaintext highlighter-rouge">frame_seconds</code> parameters.</p>

<div id="packing-geometry-root" class="interactive-widget" style="margin: 2rem 0; background: #020617; padding: 2rem; border-radius: 12px; border: 1px solid #1e293b; display: flex; flex-direction: column; align-items: center; min-height: 700px; position: relative;">
  <div style="width: 100%; display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; flex-wrap: wrap; gap: 20px;">
    <div style="flex: 1; min-width: 300px;">
      <canvas id="packing-canvas" width="600" height="600" style="width: 100%; height: auto; background: #010409; border-radius: 8px; border: 1px solid #30363d; box-shadow: inset 0 0 20px rgba(0,0,0,0.8);"></canvas>
    </div>
    
    <div style="flex: 0 0 300px; background: #010409; padding: 1.5rem; border-radius: 10px; border: 1px solid #1e3a8a; font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; box-shadow: 0 10px 30px rgba(0,0,0,0.3);">
      <div style="color: #60a5fa; margin-bottom: 15px; font-weight: bold; border-bottom: 1px solid #1e3a8a; padding-bottom: 5px; letter-spacing: 1px; font-size: 0.65rem;">POSTGRESQL_DDL.SQL</div>
      
      <div style="color: #818cf8; margin-bottom: 10px;">ALTER TABLE <span style="color: #fff;">sensor_data</span> SET (</div>
      
      <div style="margin: 0 0 15px 15px;">
        <label style="color: #94a3b8; display: block; margin-bottom: 5px; font-size: 0.65rem;">spiral.tenant = <span style="color: #fbbf24;">'sensor_id'</span>,</label>
        <div style="color: #64748b; font-size: 0.6rem; margin-bottom: 5px;">-- Scale: <span id="val-tenants" style="color: #fff;">50</span> tenants</div>
        <input type="range" id="cfg-tenants" min="1" max="100" value="50" style="width: 100%;" />
      </div>

      <div style="margin: 0 0 15px 15px;">
        <label style="color: #475569; display: block; margin-bottom: 5px; font-size: 0.65rem;">-- bundle_size: <span id="val-dist" style="color: #94a3b8;">100</span> pts/block (internal — not a DDL option)</label>
        <input type="range" id="cfg-dist" min="50" max="1000" step="50" value="100" style="width: 100%;" />
      </div>
      
      <div style="margin: 0 0 15px 15px;">
        <label style="color: #94a3b8; display: block; margin-bottom: 5px; font-size: 0.65rem;">spiral.frames = <span style="color: #fbbf24;">'1m,1h,1d'</span></label>
        <div style="color: #64748b; font-size: 0.6rem; margin-bottom: 5px;">-- visual lane width: <span id="val-width" style="color: #94a3b8;">20</span> (simulation)</div>
        <input type="range" id="cfg-width" min="10" max="50" value="20" style="width: 100%;" />
      </div>

      <div style="color: #818cf8; margin-bottom: 20px;">);</div>

      <div style="color: #4ade80; margin-bottom: 15px; font-weight: bold; border-bottom: 1px solid #1e3a8a; padding-bottom: 5px; letter-spacing: 1px; font-size: 0.65rem;">QUERY_ENGINE</div>
      
      <div style="margin-bottom: 15px;">
        <label style="color: #94a3b8; display: block; margin-bottom: 5px; font-size: 0.65rem;">WHERE t &gt;= <span id="val-tstart" style="color: #4ade80;">20%</span></label>
        <input type="range" id="cfg-tstart" min="0" max="100" value="20" style="width: 100%;" />
      </div>
      
      <div>
        <label style="color: #94a3b8; display: block; margin-bottom: 5px; font-size: 0.65rem;">AND t &lt; <span id="val-tend" style="color: #4ade80;">50%</span></label>
        <input type="range" id="cfg-tend" min="0" max="100" value="50" style="width: 100%;" />
      </div>
    </div>
  </div>

  <div style="width: 100%; background: #010409; border: 1px solid #1e3a8a; padding: 12px; border-radius: 6px; font-family: 'JetBrains Mono', monospace; font-size: 0.65rem; color: #475569; display: flex; justify-content: space-between; align-items: center;">
    <div><span style="color: #4ade80;">[RUNNING]</span> <span id="packing-stats">Calculating offsets...</span></div>
    <div style="color: #1e3a8a;">ARCHIMEDEAN_V2.0_MORTON_OPT</div>
  </div>
</div>

<script>
(function() {
  document.addEventListener('DOMContentLoaded', function() {
    const canvas = document.getElementById('packing-canvas');
    if (!canvas) return;
    const ctx = canvas.getContext('2d');
    
    const cfgDist = document.getElementById('cfg-dist');
    const cfgWidth = document.getElementById('cfg-width');
    const cfgTenants = document.getElementById('cfg-tenants');
    const cfgTStart = document.getElementById('cfg-tstart');
    const cfgTEnd = document.getElementById('cfg-tend');
    
    const valDist = document.getElementById('val-dist');
    const valWidth = document.getElementById('val-width');
    const valTenants = document.getElementById('val-tenants');
    const valTStart = document.getElementById('val-tstart');
    const valTEnd = document.getElementById('val-tend');
    const stats = document.getElementById('packing-stats');

    let time = 0;
    const colors = [];
    for(let i=0; i<100; i++) {
      colors.push(`hsl(${(i * 137.5) % 360}, 70%, 60%)`);
    }

    function draw() {
      const w = canvas.width;
      const h = canvas.height;
      const centerX = w / 2;
      const centerY = h / 2;
      
      ctx.clearRect(0, 0, w, h);
      time += 0.003;

      const resolution = parseInt(cfgDist.value); 
      const laneWidth = parseInt(cfgWidth.value);
      const tenantCount = parseInt(cfgTenants.value);
      const tStart = parseInt(cfgTStart.value) / 100;
      const tEnd = parseInt(cfgTEnd.value) / 100;

      valDist.textContent = resolution;
      valWidth.textContent = laneWidth;
      valTenants.textContent = tenantCount;
      valTStart.textContent = (tStart * 100).toFixed(0) + '%';
      valTEnd.textContent = (tEnd * 100).toFixed(0) + '%';

      // Draw TRUE Archimedean Spiral Grid
      ctx.strokeStyle = 'rgba(30, 41, 59, 0.6)';
      ctx.lineWidth = 1;
      ctx.setLineDash([2, 8]);
      ctx.beginPath();
      for (let a = 0; a < Math.PI * 2 * 10; a += 0.1) {
        const age = a / (Math.PI * 2 * 10);
        const r = (30 + age * (laneWidth * 8));
        const theta = a + time * 0.5;
        const x = centerX + Math.cos(theta) * r;
        const y = centerY + Math.sin(theta) * r;
        if (a === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
      }
      ctx.stroke();
      ctx.setLineDash([]);

      let highlightedPoints = 0;
      let totalPoints = 0;

      for (let t = 0; t < tenantCount; t++) {
        const color = colors[t];
        const phase = (t * Math.PI * 2) / tenantCount;
        
        // Ensure step is small enough to show a continuous spiral line
        const step = 1 / Math.max(200, resolution / 2);
        
        for (let age = 0; age < 1; age += step) {
          totalPoints++;
          const isInWindow = age >= tStart && age <= tEnd;
          
          const r = (30 + age * (laneWidth * 8));
          // Multiply age by 12 * Math.PI to ensure multiple rotations
          const theta = age * Math.PI * 6 + phase + time;
          
          const x = centerX + Math.cos(theta) * r;
          const y = centerY + Math.sin(theta) * r;
          
          if (isInWindow) {
            highlightedPoints++;
            ctx.fillStyle = color;
            if (tenantCount < 20) {
              ctx.shadowBlur = 5;
              ctx.shadowColor = color;
            }
          } else {
            ctx.fillStyle = 'rgba(30, 41, 59, 0.2)';
            ctx.shadowBlur = 0;
          }
          
          ctx.beginPath();
          const pSize = isInWindow ? (tenantCount > 50 ? 1.5 : 2.5) : (tenantCount > 50 ? 0.5 : 1);
          ctx.arc(x, y, pSize, 0, Math.PI * 2);
          ctx.fill();
        }
      }
      
      ctx.shadowBlur = 0;
      stats.innerHTML = `SCANNING [N=${tenantCount}] | BUNDLE_SIZE: ${resolution} | HITS: ${highlightedPoints} | TOTAL_OFFSETS: ${totalPoints}`;

      requestAnimationFrame(draw);
    }
    
    draw();
  });
})();
</script>

<h1 id="the-engineering-problem-entropy-and-page-overhead">The Engineering Problem: Entropy and Page Overhead</h1>

<p>PostgreSQL organizes data into fixed-size <strong>8KB Pages</strong>. For analytical queries on massive time-series data, this creates a significant “IO Tax.”</p>

<div class="mermaid">
graph TD
    subgraph "Spiral 8KB Page — src/storage.rs"
    H["PageHeaderData · 24 bytes"] --&gt; DA
    DA["Data Area · 1018 × 8-byte slots<br />(8192 − 24 − 24) / 8 = 1018 f64 values"] --&gt; OP
    OP["SpiralPageOpaque · 24 bytes<br />window_start_t · window_end_t<br />tenant_scale · magic 0x50495241"]
    end
    subgraph "Standard Heap 8KB Page"
    PH["PageHeaderData · 24 bytes"] --&gt; LP
    LP["Line Pointers array"] --&gt; T1
    LP --&gt; T2
    T1["Tuple 1 (header + columns)"] --&gt; FS
    T2["Tuple 2"] --&gt; FS
    FS["Free Space (variable)"]
    end
</div>

<h1 id="the-b-tree-trap-in-multidimensional-queries">The B-Tree Trap in Multidimensional Queries</h1>

<p>B-Trees are the workhorse of Postgres, but they are fundamentally one-dimensional. When you create a composite index on <code class="language-plaintext highlighter-rouge">(tenant_id, time)</code>, you are prioritizing one dimension over the other.</p>

<ul>
  <li><strong>Query A</strong> (<code class="language-plaintext highlighter-rouge">WHERE tenant_id = 1 AND t &gt; ...</code>): <strong>Fast</strong>.</li>
  <li><strong>Query B</strong> (<code class="language-plaintext highlighter-rouge">WHERE t &gt; ...</code> across multiple tenants): <strong>Slow</strong>.</li>
</ul>

<h1 id="how-spiral-differs-from-existing-solutions">How Spiral Differs from Existing Solutions</h1>

<p>Several mature tools address time-series at scale. Spiral explores a different set of trade-offs.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left"> </th>
      <th style="text-align: left">TimescaleDB</th>
      <th style="text-align: left">pg_partman</th>
      <th style="text-align: left">Spiral</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>Storage</strong></td>
      <td style="text-align: left">Hypertable chunks</td>
      <td style="text-align: left">Native partitions</td>
      <td style="text-align: left">Custom TAM + standard heap</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Rollups</strong></td>
      <td style="text-align: left">Continuous aggregates (explicit)</td>
      <td style="text-align: left">None</td>
      <td style="text-align: left">Auto-derived from magic comments</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Query rewrite</strong></td>
      <td style="text-align: left">Manual view queries</td>
      <td style="text-align: left">None</td>
      <td style="text-align: left">Transparent planner hook</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Dirty-aware slicing</strong></td>
      <td style="text-align: left">No</td>
      <td style="text-align: left">No</td>
      <td style="text-align: left">Yes — clean → rollup, dirty → raw</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Mergeable stats</strong></td>
      <td style="text-align: left">Limited</td>
      <td style="text-align: left">None</td>
      <td style="text-align: left">Welford moments + sketch/tdigest</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Multi-tenant isolation</strong></td>
      <td style="text-align: left">Schema-per-tenant or manual</td>
      <td style="text-align: left">Manual</td>
      <td style="text-align: left">Built-in <code class="language-plaintext highlighter-rouge">spiral.tenant</code> lane</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Background maintenance</strong></td>
      <td style="text-align: left">Yes (jobs)</td>
      <td style="text-align: left">Yes (cron)</td>
      <td style="text-align: left">Yes (autonomous bgworker, 1s tick)</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Extension type</strong></td>
      <td style="text-align: left">C + custom storage</td>
      <td style="text-align: left">Pure SQL</td>
      <td style="text-align: left">Rust (pgrx) + TAM + planner hook</td>
    </tr>
  </tbody>
</table>

<p>The key architectural difference: <strong>you never change your query</strong>. TimescaleDB continuous aggregates are separate views that you must remember to query. Spiral intercepts the original query and routes it transparently — the application code never changes when a new rollup tier is added or a bucket becomes dirty.</p>

<h1 id="geometry-aware-indexing-with-z-order">Geometry-Aware Indexing with Z-Order</h1>

<p>B-Trees excel at single-dimension queries, but fall apart when you filter on <code class="language-plaintext highlighter-rouge">tenant_id</code> <strong>and</strong> <code class="language-plaintext highlighter-rouge">t</code> together. Z-ordering maps both dimensions into one number so that rows close in both dimensions stay close in storage — turning expensive multi-column scans into tight range scans.</p>

<p><strong>Try it:</strong> sort by Color, Size, or Z-Order, then run a query and watch how many pages load:</p>

<div id="toybox-demo" class="interactive-widget" style="margin: 1.5rem 0; background: #f8fafc; border-radius: 12px; border: 1px solid #e2e8f0; padding: 1.25rem;">
  <div style="font-family:'JetBrains Mono',monospace;font-size:0.6rem;color:#94a3b8;margin-bottom:0.75rem;letter-spacing:1px;">TOY BOX SORTER · 12 shapes · 4 pages · circle=red  square=blue  diamond=green  triangle=orange</div>
  <canvas id="tb-canvas" width="576" height="200" style="background:#fff;border:1px solid #e2e8f0;border-radius:6px;display:block;margin-bottom:0.75rem;max-width:100%;"></canvas>
  <div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px;align-items:center;">
    <span style="font-size:0.6rem;color:#64748b;font-family:monospace;min-width:55px;">SORT:</span>
    <button id="tb-shuffle" style="background:#f1f5f9;color:#0f172a;border:1px solid #cbd5e1;padding:4px 10px;border-radius:4px;font-size:0.65rem;cursor:pointer;font-family:monospace;font-weight:700;">⇄ SHUFFLE</button>
    <button id="tb-color" style="background:#fef2f2;color:#b91c1c;border:1px solid #fca5a5;padding:4px 10px;border-radius:4px;font-size:0.65rem;cursor:pointer;font-family:monospace;font-weight:700;">BY COLOR</button>
    <button id="tb-size" style="background:#eff6ff;color:#1d4ed8;border:1px solid #93c5fd;padding:4px 10px;border-radius:4px;font-size:0.65rem;cursor:pointer;font-family:monospace;font-weight:700;">BY SIZE</button>
    <button id="tb-zorder" style="background:#f0fdf4;color:#15803d;border:1px solid #86efac;padding:4px 10px;border-radius:4px;font-size:0.65rem;cursor:pointer;font-family:monospace;font-weight:700;">⚡ Z-ORDER</button>
  </div>
  <div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;align-items:center;">
    <span style="font-size:0.6rem;color:#64748b;font-family:monospace;min-width:55px;">QUERY:</span>
    <button id="tb-q-small" style="background:#fafafa;color:#374151;border:1px solid #d1d5db;padding:4px 10px;border-radius:4px;font-size:0.65rem;cursor:pointer;font-family:monospace;">FIND SMALL</button>
    <button id="tb-q-red" style="background:#fafafa;color:#374151;border:1px solid #d1d5db;padding:4px 10px;border-radius:4px;font-size:0.65rem;cursor:pointer;font-family:monospace;">FIND RED</button>
    <button id="tb-q-both" style="background:#fafafa;color:#374151;border:1px solid #d1d5db;padding:4px 10px;border-radius:4px;font-size:0.65rem;cursor:pointer;font-family:monospace;">FIND SMALL+RED</button>
    <button id="tb-q-clear" style="background:#fafafa;color:#94a3b8;border:1px solid #e2e8f0;padding:4px 10px;border-radius:4px;font-size:0.65rem;cursor:pointer;font-family:monospace;">CLEAR</button>
  </div>
  <div id="tb-info" style="font-family:'JetBrains Mono',monospace;font-size:0.65rem;color:#374151;background:#f1f5f9;padding:8px 12px;border-radius:6px;border-left:3px solid #94a3b8;min-height:2rem;line-height:1.5;"></div>
</div>

<script>
(function() {
  document.addEventListener('DOMContentLoaded', function() {
    var canvas = document.getElementById('tb-canvas');
    if (!canvas) return;
    var ctx = canvas.getContext('2d');
    var dpr = window.devicePixelRatio || 1;
    var W = 576, H = 200;
    canvas.width  = W * dpr;
    canvas.height = H * dpr;
    canvas.style.width  = W + 'px';
    canvas.style.height = H + 'px';
    canvas.style.maxWidth = '100%';
    ctx.scale(dpr, dpr);
    var N = 12, PAGE_SIZE = 3, NUM_PAGES = N / PAGE_SIZE;
    var SLOT = W / N;
    var SY = Math.floor(H * 0.42);

    var COLORS = ['#ef4444','#3b82f6','#22c55e','#f97316'];
    var SIZE_PX = [10, 17, 25];

    // 4 colors × 3 sizes = 12 shapes
    var shapes = [];
    for (var c = 0; c < 4; c++)
      for (var s = 0; s < 3; s++)
        shapes.push({ color: c, size: s });

    function morton(x, y) {
      var z = 0;
      for (var i = 0; i < 4; i++)
        z |= ((x & (1 << i)) << i) | ((y & (1 << i)) << (i + 1));
      return z;
    }

    var positions = shapes.map(function(_, i) { return i; });
    var targets   = positions.slice();
    var raf = null, highlightFn = null, activeSort = '', activeQuery = '';

    function drawShape(cx, cy, colorHex, sizeIdx, type, alpha) {
      var r = SIZE_PX[sizeIdx];
      ctx.save();
      ctx.globalAlpha = alpha;
      ctx.fillStyle = colorHex;
      ctx.strokeStyle = 'rgba(0,0,0,0.35)';
      ctx.lineWidth = 1.5;
      if (type === 0) {
        ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
      } else if (type === 1) {
        var s = r * 1.35;
        ctx.fillRect(cx-s, cy-s, s*2, s*2); ctx.strokeRect(cx-s, cy-s, s*2, s*2);
      } else if (type === 2) {
        ctx.beginPath();
        ctx.moveTo(cx, cy - r*1.4); ctx.lineTo(cx + r*1.15, cy);
        ctx.lineTo(cx, cy + r*1.4); ctx.lineTo(cx - r*1.15, cy);
        ctx.closePath(); ctx.fill(); ctx.stroke();
      } else {
        ctx.beginPath();
        ctx.moveTo(cx, cy - r*1.3);
        ctx.lineTo(cx + r*1.2, cy + r*0.95);
        ctx.lineTo(cx - r*1.2, cy + r*0.95);
        ctx.closePath(); ctx.fill(); ctx.stroke();
      }
      ctx.restore();
    }

    function render() {
      ctx.clearRect(0, 0, W, H);
      var loadedPages = new Set();
      if (highlightFn)
        shapes.forEach(function(sh, i) {
          if (highlightFn(sh)) loadedPages.add(Math.floor(Math.round(positions[i]) / PAGE_SIZE));
        });

      for (var p = 0; p < NUM_PAGES; p++) {
        var px = p * SLOT * PAGE_SIZE, pw = SLOT * PAGE_SIZE;
        var hit = loadedPages.has(p);
        ctx.fillStyle = hit ? 'rgba(251,191,36,0.18)' : (p % 2 === 0 ? '#f8fafc' : '#f1f5f9');
        ctx.fillRect(px, 0, pw, H - 28);
        if (hit) {
          ctx.strokeStyle = '#f59e0b'; ctx.lineWidth = 2;
          ctx.strokeRect(px+1, 1, pw-2, H-30);
        }
        ctx.fillStyle = hit ? '#d97706' : '#94a3b8';
        ctx.font = (hit ? 'bold ' : '') + '10px monospace';
        ctx.fillText('pg' + p, px + 5, H - 10);
      }
      // dividers
      for (var d = 1; d < NUM_PAGES; d++) {
        ctx.strokeStyle = '#cbd5e1'; ctx.lineWidth = 1;
        ctx.setLineDash([4,4]);
        ctx.beginPath(); ctx.moveTo(d*SLOT*PAGE_SIZE, 0); ctx.lineTo(d*SLOT*PAGE_SIZE, H-28); ctx.stroke();
        ctx.setLineDash([]);
      }
      // shapes
      shapes.forEach(function(sh, i) {
        var x = (positions[i] + 0.5) * SLOT;
        var alpha = highlightFn ? (highlightFn(sh) ? 1.0 : 0.08) : 1.0;
        drawShape(x, SY, COLORS[sh.color], sh.size, sh.color, alpha);
      });
      // page-load counter
      if (highlightFn && loadedPages.size > 0) {
        var n = loadedPages.size;
        ctx.fillStyle = n <= 1 ? '#15803d' : n <= 2 ? '#d97706' : '#dc2626';
        ctx.font = 'bold 12px monospace';
        var txt = 'Pages loaded: ' + n + ' / ' + NUM_PAGES;
        ctx.fillText(txt, W - ctx.measureText(txt).width - 8, H - 10);
      }
    }

    function tick() {
      var done = true;
      shapes.forEach(function(_, i) {
        var d = targets[i] - positions[i];
        if (Math.abs(d) > 0.005) { positions[i] += d * 0.18; done = false; }
        else positions[i] = targets[i];
      });
      render();
      raf = done ? null : requestAnimationFrame(tick);
    }

    function startAnim() { if (!raf) raf = requestAnimationFrame(tick); }

    function applySort(cmpFn) {
      var arr = shapes.map(function(s, i) { return Object.assign({}, s, {_i: i}); }).sort(cmpFn);
      arr.forEach(function(s, pos) { targets[s._i] = pos; });
      startAnim();
    }

    function shuffleIt() {
      var arr = shapes.map(function(_, i) { return i; });
      for (var i = arr.length - 1; i > 0; i--) {
        var j = Math.floor(Math.random() * (i + 1));
        var tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
      }
      shapes.forEach(function(_, i) { targets[i] = arr[i]; });
      startAnim();
    }

    var MSGS = {
      shuffle: 'Shuffled — data arrives in random order (real INSERT stream). Any query scans all 4 pages.',
      color:   'Sorted by COLOR (≈ tenant_id). "Find RED" = 1 page. "Find SMALL" must visit every color group — 4 pages.',
      size:    'Sorted by SIZE (≈ time bucket). "Find SMALL" = 1 page. "Find RED" must visit every size group — 4 pages.',
      zorder:  'Z-Order — no single dimension is fully contiguous, but shapes near in BOTH color+size land in the same page. "Find SMALL+RED" = 1 page.'
    };

    function updateInfo() {
      var el = document.getElementById('tb-info');
      if (!el) return;
      var msg = MSGS[activeSort] || '';
      if (highlightFn && activeSort) {
        var lp = new Set();
        shapes.forEach(function(sh, i) {
          if (highlightFn(sh)) lp.add(Math.floor(Math.round(targets[i]) / PAGE_SIZE));
        });
        var qname = activeQuery === 'small' ? 'FIND SMALL' : activeQuery === 'red' ? 'FIND RED' : 'FIND SMALL+RED';
        msg += '  →  [' + qname + '] loads ' + lp.size + '/' + NUM_PAGES + ' pages.';
      }
      el.textContent = msg;
    }

    function wire(id, sortKey, fn) {
      var el = document.getElementById(id); if (!el) return;
      el.onclick = function() { activeSort = sortKey; fn(); updateInfo(); };
    }
    function qwire(id, key, fn) {
      var el = document.getElementById(id); if (!el) return;
      el.onclick = function() { activeQuery = key; highlightFn = fn; startAnim(); updateInfo(); };
    }

    wire('tb-shuffle', 'shuffle', shuffleIt);
    wire('tb-color',   'color',   function() { applySort(function(a,b){ return a.color-b.color || a.size-b.size; }); });
    wire('tb-size',    'size',    function() { applySort(function(a,b){ return a.size-b.size   || a.color-b.color; }); });
    wire('tb-zorder',  'zorder',  function() { applySort(function(a,b){ return morton(a.color,a.size)-morton(b.color,b.size); }); });

    qwire('tb-q-small', 'small', function(s){ return s.size===0; });
    qwire('tb-q-red',   'red',   function(s){ return s.color===0; });
    qwire('tb-q-both',  'small+red', function(s){ return s.color===0 && s.size===0; });

    var clearBtn = document.getElementById('tb-q-clear');
    if (clearBtn) clearBtn.onclick = function() { highlightFn=null; activeQuery=''; render(); updateInfo(); };

    // start sorted by color
    activeSort = 'color';
    applySort(function(a,b){ return a.color-b.color || a.size-b.size; });
    updateInfo();
  });
})();
</script>

<p>That is exactly what <code class="language-plaintext highlighter-rouge">spiral_zorder</code> does in SQL. It encodes both dimensions into a single number so that values close in <strong>both</strong> tenant and time land close together on the number line — enabling a single <code class="language-plaintext highlighter-rouge">BETWEEN lo AND hi</code> index scan to retrieve a 2D rectangle without touching unrelated rows.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- Create a z-order index on the rollup view</span>
<span class="k">CREATE</span> <span class="k">INDEX</span> <span class="k">ON</span> <span class="n">sensor_data_1m</span> <span class="k">USING</span> <span class="n">btree</span> <span class="p">(</span>
    <span class="n">spiral_zorder</span><span class="p">(</span><span class="n">spiral</span><span class="p">(</span><span class="n">t</span><span class="p">),</span> <span class="n">ARRAY</span><span class="p">[</span><span class="s1">'sensor_id'</span><span class="p">])</span>
<span class="p">);</span>

<span class="c1">-- Spiral injects this automatically for tenant-scoped queries:</span>
<span class="c1">-- WHERE spiral_zorder(spiral(t), ARRAY['sensor_id']) BETWEEN &lt;lo&gt; AND &lt;hi&gt;</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">spiral_zorder</code> uses FNV-1a hashing — stable and deterministic across restarts, so index values written today still match values computed at query time tomorrow.</p>

<h1 id="interactive-proof-of-locality">Interactive Proof of Locality</h1>

<p>Compare how <strong>Standard Row-Major</strong> and <strong>Z-Order Morton</strong> handle memory access. Select a range and watch the <strong>Page Loads</strong> metric.</p>

<div id="js-locality-root" class="interactive-widget" style="margin: 2rem 0; background: #0f172a; padding: 2rem; border-radius: 12px; border: 1px solid #1e293b; display: flex; flex-direction: column; align-items: center;">
  <div style="display: flex; gap: 2rem; width: 100%; justify-content: center; flex-wrap: wrap;">
    <div style="text-align: center;">
      <div style="font-size: 0.7rem; color: #94a3b8; margin-bottom: 5px;">STANDARD (ROW-MAJOR)</div>
      <canvas id="canvas-std" width="200" height="200" style="background: #020617; border: 1px solid #334155; cursor: crosshair;"></canvas>
      <div id="jumps-std" style="font-family: monospace; font-size: 0.7rem; color: #ef4444; margin-top: 5px;">Page Loads: 0</div>
    </div>
    <div style="text-align: center;">
      <div style="font-size: 0.7rem; color: #0ea5e9; margin-bottom: 5px;">Z-ORDER (MORTON)</div>
      <canvas id="canvas-z" width="200" height="200" style="background: #020617; border: 1px solid #334155; cursor: crosshair;"></canvas>
      <div id="jumps-z" style="font-family: monospace; font-size: 0.7rem; color: #0ea5e9; margin-top: 5px;">Page Loads: 0</div>
    </div>
  </div>
</div>

<script>
(function() {
  document.addEventListener('DOMContentLoaded', function() {
    const cStd = document.getElementById('canvas-std'); const cZ = document.getElementById('canvas-z');
    const jStd = document.getElementById('jumps-std'); const jZ = document.getElementById('jumps-z');
    if (!cStd || !cZ) return;
    const ctxS = cStd.getContext('2d'); const ctxZ = cZ.getContext('2d');
    const size = 16; const cell = 200 / size; const PAGE_SIZE = 8;
    let isDrawing = false; let start = null;
    function getZ(x, y) { let z = 0; for (let i = 0; i < 4; i++) { z |= ((x & (1 << i)) << i) | ((y & (1 << i)) << (i + 1)); } return z; }
    function drawGrid(ctx) { ctx.clearRect(0,0,200,200); ctx.strokeStyle = '#1e293b'; for(let i=0; i<=size; i++) { ctx.beginPath(); ctx.moveTo(i*cell, 0); ctx.lineTo(i*cell, 200); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, i*cell); ctx.lineTo(200, i*cell); ctx.stroke(); } }
    async function visualize(x1, y1, x2, y2) {
      drawGrid(ctxS); drawGrid(ctxZ);
      const minX = Math.min(x1, x2); const maxX = Math.max(x1, x2);
      const minY = Math.min(y1, y2); const maxY = Math.max(y1, y2);
      let cells = []; for(let y=minY; y<=maxY; y++) { for(let x=minX; x<=maxX; x++) { cells.push({x, y, std: y * size + x, z: getZ(x, y)}); } }
      const stdOrder = [...cells].sort((a,b) => a.std - b.std); let activePagesS = new Set();
      for(let i=0; i<stdOrder.length; i++) {
        const p = stdOrder[i]; ctxS.fillStyle = '#ef4444'; ctxS.fillRect(p.x*cell+1, p.y*cell+1, cell-2, cell-2);
        activePagesS.add(Math.floor(p.std / PAGE_SIZE)); jStd.textContent = `Page Loads: ${activePagesS.size}`; await new Promise(r => setTimeout(r, 20));
      }
      const zOrder = [...cells].sort((a,b) => a.z - b.z); let activePagesZ = new Set();
      for(let i=0; i<zOrder.length; i++) {
        const p = zOrder[i]; ctxZ.fillStyle = '#0ea5e9'; ctxZ.fillRect(p.x*cell+1, p.y*cell+1, cell-2, cell-2);
        activePagesZ.add(Math.floor(p.z / PAGE_SIZE)); jZ.textContent = `Page Loads: ${activePagesZ.size}`; await new Promise(r => setTimeout(r, 20));
      }
    }
    const handleInput = (e) => { 
      const rect = cStd.getBoundingClientRect(); 
      const x = Math.max(0, Math.min(size-1, Math.floor((e.clientX - rect.left) / cell))); 
      const y = Math.max(0, Math.min(size-1, Math.floor((e.clientY - rect.top) / cell))); 
      if(e.type === 'mousedown') { isDrawing = true; start = {x, y}; } 
      if(e.type === 'mouseup' && isDrawing) { isDrawing = false; visualize(start.x, start.y, x, y); } 
    };
    cStd.addEventListener('mousedown', handleInput); window.addEventListener('mouseup', handleInput); drawGrid(ctxS); drawGrid(ctxZ);
  });
})();
</script>

<h1 id="prototyping-a-custom-table-access-method-tam">Prototyping a Custom Table Access Method (TAM)</h1>

<p>PostgreSQL 12 decoupled the internal storage from the executor through the <strong>Table Access Method (TAM)</strong> API. This is where the research got practical. Using <strong>Rust and <code class="language-plaintext highlighter-rouge">pgrx</code></strong>, I implemented a prototype handler that bypasses the standard Heap.</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// src/tam.rs — TAM registration</span>
<span class="k">pub</span> <span class="k">unsafe</span> <span class="k">fn</span> <span class="nf">spiral_tam_handler</span><span class="p">(</span><span class="n">_fcinfo</span><span class="p">:</span> <span class="nn">pg_sys</span><span class="p">::</span><span class="n">FunctionCallInfo</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nn">pgrx</span><span class="p">::</span><span class="nn">datum</span><span class="p">::</span><span class="n">Internal</span> <span class="p">{</span>
    <span class="k">let</span> <span class="n">routine</span> <span class="o">=</span> <span class="nn">PgMemoryContexts</span><span class="p">::</span><span class="n">TopMemoryContext</span><span class="py">.palloc_struct</span><span class="p">::</span><span class="o">&lt;</span><span class="nn">pg_sys</span><span class="p">::</span><span class="n">TableAmRoutine</span><span class="o">&gt;</span><span class="p">();</span>
    <span class="p">(</span><span class="o">*</span><span class="n">routine</span><span class="p">)</span><span class="py">.type_</span> <span class="o">=</span> <span class="nn">pg_sys</span><span class="p">::</span><span class="nn">NodeTag</span><span class="p">::</span><span class="n">T_TableAmRoutine</span><span class="p">;</span>

    <span class="c1">// Scan path — fully implemented</span>
    <span class="p">(</span><span class="o">*</span><span class="n">routine</span><span class="p">)</span><span class="py">.scan_begin</span>       <span class="o">=</span> <span class="nf">Some</span><span class="p">(</span><span class="n">spiral_scan_begin</span><span class="p">);</span>
    <span class="p">(</span><span class="o">*</span><span class="n">routine</span><span class="p">)</span><span class="py">.scan_getnextslot</span> <span class="o">=</span> <span class="nf">Some</span><span class="p">(</span><span class="n">spiral_scan_getnextslot</span><span class="p">);</span>
    <span class="p">(</span><span class="o">*</span><span class="n">routine</span><span class="p">)</span><span class="py">.scan_end</span>         <span class="o">=</span> <span class="nf">Some</span><span class="p">(</span><span class="n">spiral_scan_end</span><span class="p">);</span>

    <span class="c1">// Insert path — registered but body is an empty stub; TAM insert is not yet</span>
    <span class="c1">// implemented. Live writes reach Spiral via standard heap + track_changes_stmt trigger.</span>
    <span class="p">(</span><span class="o">*</span><span class="n">routine</span><span class="p">)</span><span class="py">.tuple_insert</span> <span class="o">=</span> <span class="nf">Some</span><span class="p">(</span><span class="n">spiral_slot_insert</span><span class="p">);</span>

    <span class="nn">pgrx</span><span class="p">::</span><span class="nn">datum</span><span class="p">::</span><span class="nn">Internal</span><span class="p">::</span><span class="nf">from</span><span class="p">(</span><span class="nf">Some</span><span class="p">(</span><span class="nn">pg_sys</span><span class="p">::</span><span class="nn">Datum</span><span class="p">::</span><span class="nf">from</span><span class="p">(</span><span class="n">routine</span> <span class="k">as</span> <span class="nb">usize</span><span class="p">)))</span>
<span class="p">}</span>
</code></pre></div></div>

<h1 id="binary-packing--direct-seeks-the-real-implementation">Binary Packing &amp; Direct Seeks: The Real Implementation</h1>

<p>Spiral uses <strong>PostgreSQL’s own buffer manager</strong> — not flat files. The innovation is computing an exact <code class="language-plaintext highlighter-rouge">(block_number, byte_offset)</code> for any <code class="language-plaintext highlighter-rouge">(t, tenant_id)</code> pair in O(1), bypassing B-Tree traversal entirely.</p>

<p>Two core <code class="language-plaintext highlighter-rouge">#[repr(C)]</code> structures from <code class="language-plaintext highlighter-rouge">src/storage.rs</code>:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">#[repr(C)]</span>
<span class="k">struct</span> <span class="n">CompressedBlock</span> <span class="p">{</span>
    <span class="n">first_val</span><span class="p">:</span> <span class="nb">f64</span><span class="p">,</span>       <span class="c1">// 8 bytes — anchor point</span>
    <span class="n">data</span><span class="p">:</span> <span class="p">[</span><span class="nb">u8</span><span class="p">;</span> <span class="mi">120</span><span class="p">],</span>      <span class="c1">// 60 × 2-byte XOR deltas (Gorilla encoding)</span>
<span class="p">}</span>                         <span class="c1">// 128 bytes total → stores 64 time-series points</span>

<span class="nd">#[repr(C)]</span>
<span class="k">pub</span> <span class="k">struct</span> <span class="n">SpiralPageOpaque</span> <span class="p">{</span>
    <span class="k">pub</span> <span class="n">window_start_t</span><span class="p">:</span> <span class="nb">i64</span><span class="p">,</span>  <span class="c1">// time window covered by this page</span>
    <span class="k">pub</span> <span class="n">window_end_t</span><span class="p">:</span>   <span class="nb">i64</span><span class="p">,</span>
    <span class="k">pub</span> <span class="n">tenant_scale</span><span class="p">:</span>   <span class="nb">i32</span><span class="p">,</span>  <span class="c1">// number of tenant lanes</span>
    <span class="k">pub</span> <span class="n">magic</span><span class="p">:</span>          <span class="nb">u32</span><span class="p">,</span>  <span class="c1">// 0x50495241 = 'SPRA'</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The address formula (mode 1 — direct <code class="language-plaintext highlighter-rouge">f64</code>, single value column):</p>

<div class="math-formula">\[\text{logical\_offset} = (t_{\text{rel}} \times \text{tenant\_scale} \times 8) + (\text{tenant\_id} \times 8)\]
</div>

<p>Where <code class="language-plaintext highlighter-rouge">t_rel = spiral(t) - kickoff_epoch</code> normalizes any <code class="language-plaintext highlighter-rouge">timestamptz</code> to a dense integer. The offset then maps to a page via:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// src/storage.rs — exact implementation</span>
<span class="k">const</span> <span class="n">DATA_PER_PAGE</span><span class="p">:</span> <span class="nb">usize</span> <span class="o">=</span> <span class="p">(</span><span class="mi">8192</span> <span class="o">-</span> <span class="mi">24</span> <span class="o">-</span> <span class="mi">24</span><span class="p">)</span> <span class="o">/</span> <span class="mi">8</span><span class="p">;</span> <span class="c1">// = 1018 f64 values per page</span>

<span class="k">pub</span> <span class="k">fn</span> <span class="nf">logical_to_physical_offset</span><span class="p">(</span><span class="n">logical_offset</span><span class="p">:</span> <span class="nb">i64</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="p">(</span><span class="nb">u32</span><span class="p">,</span> <span class="nb">u32</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">let</span> <span class="n">index</span>          <span class="o">=</span> <span class="n">logical_offset</span> <span class="o">/</span> <span class="mi">8</span><span class="p">;</span>
    <span class="k">let</span> <span class="n">blkno</span>          <span class="o">=</span> <span class="p">(</span><span class="n">index</span> <span class="o">/</span> <span class="n">DATA_PER_PAGE</span> <span class="k">as</span> <span class="nb">i64</span><span class="p">)</span> <span class="k">as</span> <span class="nb">u32</span><span class="p">;</span>
    <span class="k">let</span> <span class="n">offset_in_page</span> <span class="o">=</span> <span class="p">(</span><span class="mi">24</span> <span class="o">+</span> <span class="p">(</span><span class="n">index</span> <span class="o">%</span> <span class="n">DATA_PER_PAGE</span> <span class="k">as</span> <span class="nb">i64</span><span class="p">)</span> <span class="o">*</span> <span class="mi">8</span><span class="p">)</span> <span class="k">as</span> <span class="nb">u32</span><span class="p">;</span>
    <span class="p">(</span><span class="n">blkno</span><span class="p">,</span> <span class="n">offset_in_page</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">tenant_scale</code> comes from a one-character cardinality hint: <code class="language-plaintext highlighter-rouge">'d'</code>→10, <code class="language-plaintext highlighter-rouge">'h'</code>→100, <code class="language-plaintext highlighter-rouge">'k'</code>→1,000, <code class="language-plaintext highlighter-rouge">'M'</code>→1,000,000, <code class="language-plaintext highlighter-rouge">'B'</code>→1,000,000,000, <code class="language-plaintext highlighter-rouge">'T'</code>→1,000,000,000,000.</p>

<h1 id="three-storage-modes-matching-structure-to-workload">Three Storage Modes: Matching Structure to Workload</h1>

<p>Spiral ships three packing functions, each trading precision for density. The page map below shows exactly how each fills an 8KB page — and what the savings look like compared to a standard PostgreSQL heap table.</p>

<p><strong>How to read the page map:</strong> Each small square = one 8-byte slot. A full page has 1024 slots arranged in a 32×32 grid.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Cell color</th>
      <th style="text-align: left">Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left">Gray (top-left 3 cells)</td>
      <td style="text-align: left">24-byte <code class="language-plaintext highlighter-rouge">PageHeaderData</code> — PostgreSQL page header</td>
    </tr>
    <tr>
      <td style="text-align: left">Dark blue (bottom-right 3 cells)</td>
      <td style="text-align: left">24-byte <code class="language-plaintext highlighter-rouge">SpiralPageOpaque</code> — Spiral metadata (time window, tenant scale, magic)</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Rainbow / colored</strong></td>
      <td style="text-align: left">Data slot occupied by a tenant. Each hue = one tenant lane. With many tenants the hues cycle, producing the rainbow gradient</td>
    </tr>
    <tr>
      <td style="text-align: left">Near-black</td>
      <td style="text-align: left">Empty or wasted slot (visible in Compact mode where every other slot is overhead)</td>
    </tr>
  </tbody>
</table>

<p>The three pages shown side-by-side let you see how the tenant pattern repeats as data fills successive 8KB blocks.</p>

<div id="storage-carousel" class="interactive-widget" style="margin: 2rem 0; background: #020617; border-radius: 12px; border: 1px solid #1e293b; overflow: hidden;">
  <div style="display:flex;justify-content:space-between;align-items:center;padding:0.75rem 1.25rem;border-bottom:1px solid #1e293b;background:#010409;">
    <span style="font-family:'JetBrains Mono',monospace;font-size:0.6rem;color:#64748b;letter-spacing:1px;">STORAGE MODE EXPLORER · src/storage.rs</span>
    <div style="display:flex;align-items:center;gap:10px;">
      <div id="sc-dots" style="display:flex;gap:6px;"></div>
      <button id="sc-prev" style="background:#1e293b;color:#94a3b8;border:1px solid #334155;width:26px;height:26px;border-radius:4px;cursor:pointer;font-size:0.75rem;line-height:1;">&lt;</button>
      <button id="sc-next" style="background:#1e293b;color:#94a3b8;border:1px solid #334155;width:26px;height:26px;border-radius:4px;cursor:pointer;font-size:0.75rem;line-height:1;">&gt;</button>
    </div>
  </div>
  <div id="sc-content" style="padding:1.25rem;min-height:420px;"></div>
</div>

<script>
(function() {
  // ── Constants from src/storage.rs (exact match) ───────────────
  var BLCKSZ        = 8192;
  var HDR           = 24;   // sizeof(PageHeaderData)
  var SPECIAL       = 24;   // sizeof(SpiralPageOpaque)
  var DATA_PER_PAGE = Math.floor((BLCKSZ - HDR - SPECIAL) / 8); // 1018
  var BLOCK_SIZE    = 128;  // sizeof(CompressedBlock)
  var PPB           = 64;   // POINTS_PER_BLOCK

  var EXAMPLES = [
    {
      id: 0, title: 'IoT Sensor Network', subtitle: 'Direct f64 · 8 bytes per slot',
      accent: '#0ea5e9', mode: 'direct', S: 10, C: 1, T: 10000,
      card: "'d' → 10 sensors",
      ddl: 'CREATE TABLE sensor_data (\n  t           timestamptz NOT NULL,\n  sensor_id   int4        NOT NULL,\n  temperature float8\n) WITH (\n  spiral.tenant      = \'sensor_id\',\n  spiral.cardinality = \'d\'  -- 10 lanes\n);'
    },
    {
      id: 1, title: 'Financial Tick Data', subtitle: 'Compact u32+f32 · 16 bytes per slot',
      accent: '#818cf8', mode: 'compact', S: 1000, C: 1, T: 10000,
      card: "'k' → 1 000 symbols",
      ddl: 'CREATE TABLE asset_ticks (\n  t         timestamptz NOT NULL,\n  symbol_id int4        NOT NULL,\n  price     float8\n) WITH (\n  spiral.tenant      = \'symbol_id\',\n  spiral.cardinality = \'k\'  -- 1 000 lanes\n);'
    },
    {
      id: 2, title: 'Multi-Sensor Environmental', subtitle: 'XOR Delta Blocks · 128 bytes / 64 points',
      accent: '#10b981', mode: 'blocks', S: 100, C: 3, T: 10000,
      card: "'h' → 100 devices · 3 cols",
      ddl: 'CREATE TABLE env_sensors (\n  t           timestamptz NOT NULL,\n  device_id   int4        NOT NULL,\n  temperature float8,      -- Spiral: ohlcv\n  humidity    float8,      -- Spiral: stats\n  co2_ppm     float8       -- Spiral: sum\n) WITH (\n  spiral.tenant      = \'device_id\',\n  spiral.cardinality = \'h\'  -- 100 lanes\n);'
    },
    {
      id: 3, title: 'Custom Configuration', subtitle: 'tune all parameters live',
      accent: '#f59e0b', mode: null
    }
  ];

  function spiralPages(mode, S, T, C) {
    if (mode === 'direct')  return Math.ceil(T * S * C / DATA_PER_PAGE);
    if (mode === 'compact') return Math.ceil(T * S * C / Math.floor(DATA_PER_PAGE / 2));
    if (mode === 'blocks') {
      var bpp = Math.floor(DATA_PER_PAGE / 16); // 63 blocks per page
      return Math.ceil(Math.ceil(T / PPB) * S * C / bpp);
    }
    return 0;
  }

  function heapPages(S, T, C) {
    var rowSize = 24 + 8 + 4 + 4 + C * 8; // header + t + tenant_id + pad + cols
    var rpp = Math.floor((BLCKSZ - HDR) / (rowSize + 4));
    return Math.ceil(T * S / rpp);
  }

  function fmt(n) {
    if (n >= 1073741824) return (n/1073741824).toFixed(1)+' GB';
    if (n >= 1048576)    return (n/1048576).toFixed(1)+' MB';
    if (n >= 1024)       return (n/1024).toFixed(1)+' KB';
    return n+' B';
  }
  function fmtN(n) {
    if (n >= 1e9) return (n/1e9).toFixed(1)+'B';
    if (n >= 1e6) return (n/1e6).toFixed(1)+'M';
    if (n >= 1e3) return (n/1e3).toFixed(0)+'K';
    return n;
  }

  function drawPageMap(canvas, mode, S, T, C, accent) {
    if (!canvas) return;
    var dpr  = window.devicePixelRatio || 1;
    var W    = canvas.offsetWidth  || 330;
    var H    = canvas.offsetHeight || Math.round(W * 220 / 330);
    canvas.width  = Math.round(W * dpr);
    canvas.height = Math.round(H * dpr);
    var ctx = canvas.getContext('2d');
    ctx.scale(dpr, dpr);
    ctx.clearRect(0, 0, W, H);

    // ── Color palette: max 16 evenly-spaced hues ───────────────
    // Cap at 16 so the audience sees clear, named lanes — not noise.
    // With S > 16 we show "tenant_id % 16" and note groups in legend.
    var DCAP = Math.min(S, 16);
    function laneHue(lane) { return (lane * (360 / DCAP)) % 360; }
    function laneColor(lane, lightness) {
      return 'hsl('+laneHue(lane % DCAP)+',70%,'+(lightness||55)+'%)';
    }

    // ── Layout ─────────────────────────────────────────────────
    var LEGEND_H = 30;
    var gridH = H - LEGEND_H;
    var PAGES = 3, gap = 4;
    var pageW = Math.floor((W - gap*(PAGES-1)) / PAGES);
    var COLS = 32, ROWS = 32;
    var cs = Math.floor(Math.min(pageW/COLS, gridH/ROWS));

    ctx.fillStyle = '#010409';
    ctx.fillRect(0, 0, W, H);

    for (var p = 0; p < PAGES; p++) {
      var ox = p * (pageW + gap);

      ctx.strokeStyle = '#1e3a5f';
      ctx.lineWidth = 1;
      ctx.strokeRect(ox+0.5, 0.5, pageW-1, gridH-1);

      for (var i = 0; i < 1024; i++) {
        var col = i % COLS;
        var row = Math.floor(i / COLS);
        var x = ox + col * cs;
        var y = row * cs;
        var sz = Math.max(cs-1, 1);

        // Header = gray, SpiralPageOpaque = dark blue
        if (i < 3)    { ctx.fillStyle = '#475569'; ctx.fillRect(x,y,sz,sz); continue; }
        if (i >= 1021) { ctx.fillStyle = '#1e3a8a'; ctx.fillRect(x,y,sz,sz); continue; }

        var di       = i - 3;
        var globalDi = p * DATA_PER_PAGE + di;
        var color    = '#0a1020';

        if (mode === 'direct') {
          // Each slot = one (t, tenant_id) point. Lane cycles with period S.
          color = laneColor(globalDi % S);
        } else if (mode === 'compact') {
          // Every other slot is wasted (16B logical, but only 8B used per entry).
          if (di % 2 === 0) {
            var eid = p * Math.floor(DATA_PER_PAGE/2) + Math.floor(di/2);
            color = laneColor(eid % S);
          } else {
            color = '#12202e'; // wasted — darker, shows overhead
          }
        } else if (mode === 'blocks') {
          // 16 slots = one 128B CompressedBlock (64 pts). Anchor brighter.
          var slotGlobal = p * DATA_PER_PAGE + di;
          var blkIdx    = Math.floor(slotGlobal / 16);
          var posInBlk  = slotGlobal % 16;
          var lane      = blkIdx % S;
          color = laneColor(lane, posInBlk === 0 ? 60 : 30);
        }

        ctx.fillStyle = color;
        ctx.fillRect(x, y, sz, sz);
      }

      // Page number label at bottom-left of each page
      ctx.fillStyle = '#334155';
      ctx.font = 'bold 9px monospace';
      ctx.fillText('pg '+p, ox+3, gridH-4);
    }

    // ── Legend strip ───────────────────────────────────────────
    var ly = gridH + 6;
    ctx.font = '8px monospace';

    // Header swatch
    ctx.fillStyle = '#475569';
    ctx.fillRect(2, ly, 8, 8);
    ctx.fillStyle = '#64748b';
    ctx.fillText('Hdr', 13, ly+8);

    // SpiralPageOpaque swatch
    ctx.fillStyle = '#1e3a8a';
    ctx.fillRect(38, ly, 8, 8);
    ctx.fillStyle = '#64748b';
    ctx.fillText('Opaque', 49, ly+8);

    // Total pages counter — updates with T and C
    var totalPgs = (T && C) ? spiralPages(mode, S, T, C) : null;
    if (totalPgs) {
      ctx.fillStyle = '#64748b';
      ctx.fillText('showing 3 of '+fmtN(totalPgs)+' pages'+(C>1?' × '+C+'col':''), 100, ly+8);
    }

    // Tenant lane swatches (up to 16) — right side
    var swX = W - DCAP * 11 - 2;
    for (var t = 0; t < DCAP; t++) {
      ctx.fillStyle = laneColor(t);
      ctx.fillRect(swX + t*11, ly, 10, 8);
    }
    ctx.fillStyle = S > DCAP ? '#f59e0b' : '#64748b';
    ctx.font = '7px monospace';
    ctx.fillText(S > DCAP ? 'lane groups ('+S+' total)' : S+' tenant lanes', swX - 4 - (S > DCAP ? 90 : 70), ly+8);
  }

  function statsHtml(mode, S, T, C, accent) {
    var sp = spiralPages(mode, S, T, C);
    var hp = heapPages(S, T, C);
    var reduction = Math.round((1 - (sp / hp)) * 100);
    var rowSize = 24 + 8 + 4 + 4 + C * 8;
    var modeSlotBytes = mode==='direct'?8 : mode==='compact'?16 : 128;
    var modePointsPer = mode==='blocks'?PPB:1;

    return '<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:10px;">'
      +'<div style="background:#0f172a;padding:10px;border-radius:6px;border-left:3px solid '+accent+';">'
        +'<div style="font-size:0.55rem;color:#64748b;font-family:monospace;">SPIRAL PAGES</div>'
        +'<div style="font-size:1.3rem;color:#fff;font-weight:700;">'+fmtN(sp)+'</div>'
        +'<div style="font-size:0.5rem;color:#64748b;">'+fmt(sp*BLCKSZ)+'</div></div>'
      +'<div style="background:#0f172a;padding:10px;border-radius:6px;border-left:3px solid #ef4444;">'
        +'<div style="font-size:0.55rem;color:#64748b;font-family:monospace;">HEAP PAGES</div>'
        +'<div style="font-size:1.3rem;color:#fff;font-weight:700;">'+fmtN(hp)+'</div>'
        +'<div style="font-size:0.5rem;color:#64748b;">'+fmt(hp*BLCKSZ)+'</div></div>'
      +'<div style="background:#0f172a;padding:10px;border-radius:6px;border-left:3px solid #4ade80;">'
        +'<div style="font-size:0.55rem;color:#64748b;font-family:monospace;">REDUCTION</div>'
        +'<div style="font-size:1.3rem;color:'+(reduction>0?'#4ade80':'#f87171')+';font-weight:700;">'+reduction+'%</div>'
        +'<div style="font-size:0.5rem;color:#64748b;">'+(hp/Math.max(sp,1)).toFixed(1)+'× fewer pages</div></div>'
      +'</div>'
      +'<div style="font-size:0.6rem;color:#475569;font-family:monospace;border-top:1px solid #1e293b;padding-top:8px;">'
        +'slot = '+modeSlotBytes+'B'+(modePointsPer>1?' / '+modePointsPer+' pts':'')+' · '
        +'heap row = '+rowSize+'B · '
        +fmtN(T*S)+' data points total'
      +'</div>';
  }

  function renderSlide(ex) {
    var sp = spiralPages(ex.mode, ex.S, ex.T, ex.C);
    var hp = heapPages(ex.S, ex.T, ex.C);
    document.getElementById('sc-content').innerHTML =
      '<div style="display:flex;gap:1.25rem;flex-wrap:wrap;">'
        +'<div style="flex:1;min-width:240px;">'
          +'<div style="font-family:\'JetBrains Mono\',monospace;font-size:0.65rem;color:'+ex.accent+';margin-bottom:6px;font-weight:700;letter-spacing:1px;">'+ex.title.toUpperCase()+'</div>'
          +'<div style="font-size:0.7rem;color:#64748b;margin-bottom:10px;">'+ex.subtitle+'</div>'
          +'<pre style="background:#010409;border:1px solid #1e3a8a;border-radius:6px;padding:10px;font-size:0.6rem;color:#94a3b8;overflow-x:auto;margin:0 0 10px 0;white-space:pre;">'+ex.ddl+'</pre>'
          +'<div style="font-size:0.6rem;color:#475569;font-family:monospace;">cardinality: <span style="color:#fbbf24;">'+ex.card+'</span></div>'
        +'</div>'
        +'<div style="flex:1;min-width:240px;">'
          +'<div style="font-family:\'JetBrains Mono\',monospace;font-size:0.6rem;color:#64748b;margin-bottom:6px;letter-spacing:1px;">PAGE MAP — 3 × 8 KB</div>'
          +'<canvas id="sc-canvas" width="330" height="220" style="width:100%;background:#010409;border:1px solid #1e293b;border-radius:4px;display:block;"></canvas>'
          +'<div style="font-size:0.55rem;color:#64748b;font-family:monospace;margin-top:4px;">each cell = 8B · 32×32 grid = 1 page · '+(ex.mode==='compact'?'dark cells = wasted slots':'')+'</div>'
        +'</div>'
        +'<div style="flex:0 0 100%;background:#010409;border:1px solid #1e293b;border-radius:8px;padding:1rem;font-family:\'JetBrains Mono\',monospace;">'
          +'<div style="font-size:0.6rem;color:#64748b;margin-bottom:10px;letter-spacing:1px;">STORAGE COMPARISON · '+fmtN(ex.T)+'t × '+ex.S+'s × '+ex.C+'c</div>'
          +statsHtml(ex.mode, ex.S, ex.T, ex.C, ex.accent)
        +'</div>'
      +'</div>';
    drawPageMap(document.getElementById('sc-canvas'), ex.mode, ex.S, ex.T, ex.C, ex.accent);
  }

  function renderCustom() {
    var SCALES = [10, 100, 1000, 1000000];
    var SKEYS  = ["'d'", "'h'", "'k'", "'M'"];
    var SDESC  = ['10 tenants', '100 tenants', '1 000 tenants', '1 M tenants'];
    var TIMES  = [1000, 10000, 100000, 1000000];
    var TLABELS = ['1K', '10K', '100K', '1M'];

    function cardOpt(idx, checked) {
      return '<label style="flex:1;min-width:60px;cursor:pointer;">'
        +'<input type="radio" name="cc-s" value="'+idx+'"'+(checked?' checked':'')+' style="display:none;">'
        +'<div id="cc-card-'+idx+'" style="border:1px solid '+(checked?'#f59e0b':'#334155')+';border-radius:6px;padding:8px 4px;text-align:center;background:'+(checked?'#1c1407':'#0f172a')+';transition:border 0.15s,background 0.15s;font-family:monospace;">'
          +'<div style="font-size:1rem;color:#fbbf24;font-weight:700;">'+SKEYS[idx]+'</div>'
          +'<div style="font-size:0.5rem;color:#64748b;margin-top:2px;">'+SDESC[idx]+'</div>'
        +'</div>'
      +'</label>';
    }

    document.getElementById('sc-content').innerHTML =
      '<div>'
        +'<div style="font-family:\'JetBrains Mono\',monospace;font-size:0.65rem;color:#f59e0b;margin-bottom:12px;font-weight:700;letter-spacing:1px;">CUSTOM CONFIGURATION</div>'

        +'<div style="margin-bottom:1rem;">'
          +'<div style="font-size:0.6rem;color:#94a3b8;font-family:monospace;margin-bottom:6px;">spiral.cardinality</div>'
          +'<div style="display:flex;gap:6px;">'
            +cardOpt(0,false)+cardOpt(1,true)+cardOpt(2,false)+cardOpt(3,false)
          +'</div>'
        +'</div>'

        +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">'
          +'<div><label style="font-size:0.6rem;color:#94a3b8;font-family:monospace;display:block;margin-bottom:4px;">time_slots: <span id="cc-tv" style="color:#fbbf24;">10K</span></label><input type="range" id="cc-t" min="0" max="3" value="1" style="width:100%;"></div>'
          +'<div><label style="font-size:0.6rem;color:#94a3b8;font-family:monospace;display:block;margin-bottom:4px;">columns: <span id="cc-cv" style="color:#fbbf24;">1</span></label><input type="range" id="cc-c" min="1" max="8" value="1" style="width:100%;"></div>'
          +'<div style="grid-column:1/-1;"><label style="font-size:0.6rem;color:#94a3b8;font-family:monospace;display:block;margin-bottom:4px;">mode</label>'
            +'<select id="cc-m" style="background:#1e293b;color:#fff;border:1px solid #334155;padding:4px 6px;border-radius:4px;font-family:monospace;font-size:0.65rem;width:100%;">'
              +'<option value="direct">Direct f64 — 8B/slot · 1018 slots/page</option>'
              +'<option value="compact">Compact u32+f32 — 16B/slot · 509 slots/page</option>'
              +'<option value="blocks">XOR Delta Blocks — 128B/block · 63 blocks/page · 64 pts/block</option>'
            +'</select></div>'
        +'</div>'

        +'<canvas id="cc-canvas" width="330" height="110" style="width:100%;background:#010409;border:1px solid #1e293b;border-radius:4px;display:block;margin-bottom:1rem;"></canvas>'
        +'<div id="cc-stats" style="background:#010409;border:1px solid #1e293b;border-radius:8px;padding:1rem;font-family:\'JetBrains Mono\',monospace;"></div>'
      +'</div>';

    function getScaleIdx() {
      var r = document.querySelector('input[name="cc-s"]:checked');
      return r ? +r.value : 1;
    }

    function syncCardStyles(si) {
      for (var i = 0; i < 4; i++) {
        var el = document.getElementById('cc-card-'+i);
        if (!el) continue;
        el.style.border = i === si ? '1px solid #f59e0b' : '1px solid #334155';
        el.style.background = i === si ? '#1c1407' : '#0f172a';
      }
    }

    function update() {
      var si   = getScaleIdx();
      var ti   = +document.getElementById('cc-t').value;
      var C    = +document.getElementById('cc-c').value;
      var mode = document.getElementById('cc-m').value;
      var S = SCALES[si], T = TIMES[ti];
      syncCardStyles(si);
      document.getElementById('cc-tv').textContent = TLABELS[ti];
      document.getElementById('cc-cv').textContent = C;
      document.getElementById('cc-stats').innerHTML = statsHtml(mode, S, T, C, '#f59e0b');
      drawPageMap(document.getElementById('cc-canvas'), mode, S, T, C, '#f59e0b');
    }

    document.querySelectorAll('input[name="cc-s"]').forEach(function(r) {
      r.addEventListener('change', update);
    });
    ['cc-t','cc-c','cc-m'].forEach(function(id) {
      document.getElementById(id).addEventListener('input', update);
    });
    update();
  }

  document.addEventListener('DOMContentLoaded', function() {
    var dots    = document.getElementById('sc-dots');
    var prevBtn = document.getElementById('sc-prev');
    var nextBtn = document.getElementById('sc-next');
    if (!dots) return;
    var current = 0;
    var N = EXAMPLES.length;

    for (var i = 0; i < N; i++) {
      (function(idx) {
        var d = document.createElement('div');
        d.style.cssText = 'width:6px;height:6px;border-radius:50%;cursor:pointer;transition:background 0.2s;background:#334155;';
        d.onclick = function() { go(idx); };
        dots.appendChild(d);
      })(i);
    }

    function go(n) {
      current = ((n % N) + N) % N;
      var dotEls = dots.childNodes;
      for (var i = 0; i < dotEls.length; i++) {
        dotEls[i].style.background = i === current ? (EXAMPLES[i].accent || '#f59e0b') : '#334155';
      }
      var ex = EXAMPLES[current];
      if (ex.mode) { renderSlide(ex); } else { renderCustom(); }
    }

    prevBtn.onclick = function() { go(current - 1); };
    nextBtn.onclick = function() { go(current + 1); };
    go(0);
  });
})();
</script>

<h1 id="automating-the-pipeline-hierarchical-rollups">Automating the Pipeline: Hierarchical Rollups</h1>

<p>To make the system usable, I wanted to automate the creation of hierarchies. In Spiral, a single table definition with magic comments can generate an entire analytical pipeline:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">asset_ticks</span> <span class="p">(</span>
    <span class="n">t</span> <span class="n">timestamptz</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">price</span> <span class="nb">double</span> <span class="nb">precision</span><span class="p">,</span> <span class="c1">-- Spiral: ohlc, stats, sketch</span>
    <span class="n">vol</span> <span class="nb">int</span>                 <span class="c1">-- Spiral: sum</span>
<span class="p">);</span>
</code></pre></div></div>

<h1 id="manual-control-custom-hierarchies">Manual Control: Custom Hierarchies</h1>

<p>While “Magic Comments” are great, power users can manually define rollups using standard Postgres <code class="language-plaintext highlighter-rouge">MATERIALIZED VIEW</code> syntax with Spiral options:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="n">MATERIALIZED</span> <span class="k">VIEW</span> <span class="n">asset_ohlcv_1m</span> 
<span class="k">WITH</span> <span class="p">(</span>
    <span class="n">spiral</span><span class="p">.</span><span class="n">frames</span> <span class="o">=</span> <span class="s1">'5m,1h,1d'</span><span class="p">,</span>
    <span class="n">spiral</span><span class="p">.</span><span class="n">tenant</span> <span class="o">=</span> <span class="s1">'symbol_id'</span>
<span class="p">)</span> <span class="k">AS</span> <span class="k">SELECT</span> <span class="p">...</span> <span class="k">FROM</span> <span class="n">ticks</span> <span class="k">GROUP</span> <span class="k">BY</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">;</span>
</code></pre></div></div>

<h1 id="calendar-aligned-rollup-tiers">Calendar-Aligned Rollup Tiers</h1>

<p>Beyond fixed-second intervals, Spiral supports <strong>calendar-aligned</strong> tiers that snap to month, quarter, and year boundaries — essential for financial and business reporting where “monthly” means January 1st, not “30 days ago.”</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">asset_ticks</span> <span class="p">(</span>
    <span class="n">t</span>         <span class="n">timestamptz</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">symbol_id</span> <span class="nb">int</span>         <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">price</span>     <span class="nb">double</span> <span class="nb">precision</span><span class="p">,</span> <span class="c1">-- Spiral: ohlcv</span>
    <span class="n">vol</span>       <span class="nb">bigint</span>            <span class="c1">-- Spiral: sum</span>
<span class="p">)</span> <span class="k">WITH</span> <span class="p">(</span>
    <span class="n">spiral</span><span class="p">.</span><span class="n">frames</span> <span class="o">=</span> <span class="s1">'1h,1d,1mon,1qtr,1year'</span><span class="p">,</span>
    <span class="n">spiral</span><span class="p">.</span><span class="n">tenant</span> <span class="o">=</span> <span class="s1">'symbol_id'</span>
<span class="p">);</span>
</code></pre></div></div>

<p>Valid calendar suffixes:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Suffix</th>
      <th style="text-align: left">Meaning</th>
      <th style="text-align: left">Snaps to</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">1mon</code> / <code class="language-plaintext highlighter-rouge">1month</code></td>
      <td style="text-align: left">Calendar month</td>
      <td style="text-align: left">1st of each month</td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">1qtr</code> / <code class="language-plaintext highlighter-rouge">1quarter</code></td>
      <td style="text-align: left">Calendar quarter</td>
      <td style="text-align: left">Jan 1, Apr 1, Jul 1, Oct 1</td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">1year</code> / <code class="language-plaintext highlighter-rouge">1Y</code></td>
      <td style="text-align: left">Calendar year</td>
      <td style="text-align: left">Jan 1</td>
    </tr>
  </tbody>
</table>

<p>Under the hood, calendar tiers use <code class="language-plaintext highlighter-rouge">date_trunc('month', t)</code> (or <code class="language-plaintext highlighter-rouge">quarter</code>/<code class="language-plaintext highlighter-rouge">year</code>) in the rollup <code class="language-plaintext highlighter-rouge">GROUP BY</code>, so they respect timezone-aware truncation. Fixed-second tiers (like <code class="language-plaintext highlighter-rouge">1h</code>, <code class="language-plaintext highlighter-rouge">1d</code>) use epoch arithmetic and are always UTC-aligned.</p>

<h1 id="the-anatomy-of-a-rollup-aggregation-inheritance">The Anatomy of a Rollup (Aggregation Inheritance)</h1>

<p>How does Spiral know how to roll up your data? It uses deterministic rules for column inheritance:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Column Suffix</th>
      <th style="text-align: left">Child Aggregate</th>
      <th style="text-align: left">Reason</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">_h</code> / <code class="language-plaintext highlighter-rouge">_max</code></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">max()</code></td>
      <td style="text-align: left">Peak is peak.</td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">_l</code> / <code class="language-plaintext highlighter-rouge">_min</code></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">min()</code></td>
      <td style="text-align: left">Low is low.</td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">_sum</code> / <code class="language-plaintext highlighter-rouge">_count</code></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">sum()</code></td>
      <td style="text-align: left">Accumulate totals.</td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">_sketch</code></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">merge()</code></td>
      <td style="text-align: left">Mathematically unified sketches.</td>
    </tr>
    <tr>
      <td style="text-align: left">any <code class="language-plaintext highlighter-rouge">timestamptz</code> offset col</td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">range_max_end()</code></td>
      <td style="text-align: left">Keeps the running max of a time range endpoint — used for open/close windows.</td>
    </tr>
  </tbody>
</table>

<h1 id="incremental-maintenance-tracking-changes-via-changelog">Incremental Maintenance: Tracking Changes via Changelog</h1>

<p>Standard materialized views require a full rebuild. Spiral explores <strong>Incremental View Maintenance (IVM)</strong> using a transactional changelog. Every update flags a specific time bucket as “dirty.”</p>

<div class="mermaid">
graph LR
    A[INSERT/UPDATE] --&gt; B[Trigger]
    B --&gt; C[(spiral.changelog)]
    C --&gt; D[Background Worker]
    D --&gt; E[Surgical Patch]
</div>

<p>A background worker written in Rust monitors this log and performs surgical updates—healing the “orbits” of the spiral without rebuilding the world.</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// In src/worker.rs - The Healing Loop</span>
<span class="k">pub</span> <span class="k">fn</span> <span class="nf">perform_healing</span><span class="p">()</span> <span class="k">-&gt;</span> <span class="nb">Result</span><span class="o">&lt;</span><span class="p">(),</span> <span class="nn">pgrx</span><span class="p">::</span><span class="nn">spi</span><span class="p">::</span><span class="n">Error</span><span class="o">&gt;</span> <span class="p">{</span>
    <span class="k">let</span> <span class="n">dirty_buckets</span> <span class="o">=</span> <span class="nn">Spi</span><span class="p">::</span><span class="nf">connect</span><span class="p">(|</span><span class="n">client</span><span class="p">|</span> <span class="p">{</span>
        <span class="n">client</span><span class="nf">.select</span><span class="p">(</span>
            <span class="s">"SELECT start_t, end_t FROM spiral.changelog WHERE processed = false"</span><span class="p">,</span>
            <span class="nb">None</span><span class="p">,</span> <span class="nb">None</span>
        <span class="p">)</span><span class="o">?</span><span class="nf">.map</span><span class="p">(|</span><span class="n">row</span><span class="p">|</span> <span class="p">(</span><span class="n">row</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="py">.value</span><span class="p">::</span><span class="o">&lt;</span><span class="nb">i64</span><span class="o">&gt;</span><span class="p">(),</span> <span class="n">row</span><span class="p">[</span><span class="mi">2</span><span class="p">]</span><span class="py">.value</span><span class="p">::</span><span class="o">&lt;</span><span class="nb">i64</span><span class="o">&gt;</span><span class="p">()))</span><span class="py">.collect</span><span class="p">::</span><span class="o">&lt;</span><span class="nb">Vec</span><span class="o">&lt;</span><span class="n">_</span><span class="o">&gt;&gt;</span><span class="p">()</span>
    <span class="p">});</span>

    <span class="k">for</span> <span class="p">(</span><span class="n">start</span><span class="p">,</span> <span class="n">end</span><span class="p">)</span> <span class="k">in</span> <span class="n">dirty_buckets</span> <span class="p">{</span>
        <span class="c1">// Surgical rollup for this specific bucket</span>
        <span class="nf">rollup_bucket</span><span class="p">(</span><span class="n">start</span><span class="p">,</span> <span class="n">end</span><span class="p">)</span><span class="o">?</span><span class="p">;</span>
        
        <span class="nn">Spi</span><span class="p">::</span><span class="nf">run_with_args</span><span class="p">(</span>
            <span class="s">"UPDATE spiral.changelog SET processed = true WHERE start_t = $1 AND end_t = $2"</span><span class="p">,</span>
            <span class="nf">Some</span><span class="p">(</span><span class="nd">vec!</span><span class="p">[</span>
                <span class="p">(</span><span class="nn">PgBuiltInOids</span><span class="p">::</span><span class="n">INT8OID</span><span class="nf">.oid</span><span class="p">(),</span> <span class="n">start</span><span class="nf">.into_datum</span><span class="p">()),</span>
                <span class="p">(</span><span class="nn">PgBuiltInOids</span><span class="p">::</span><span class="n">INT8OID</span><span class="nf">.oid</span><span class="p">(),</span> <span class="n">end</span><span class="nf">.into_datum</span><span class="p">())</span>
            <span class="p">])</span>
        <span class="p">)</span><span class="o">?</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="nf">Ok</span><span class="p">(())</span>
<span class="p">}</span>
</code></pre></div></div>

<h1 id="the-adaptive-query-slicer">The Adaptive Query Slicer</h1>

<p>One of the most complex parts of this research was exploring the <strong>PostgreSQL Planner Hook</strong>. The idea is to query the raw table and have the system automatically “slice” the query between different storage tiers based on data freshness and availability.</p>

<p>Select a time range on the timeline below, showing the last 7 days from past to present. Watch how Spiral would theoretically “slice” your query across storage tiers: <strong>Daily</strong>, <strong>Hourly</strong>, and <strong>Minutely</strong> rollups, with an automatic fallback to <strong>Raw Data</strong> for segments marked as “dirty” in the changelog. You can also simulate real-time traffic and see the background worker ‘healing’ the orbits.</p>

<div id="query-slicer-root" class="interactive-widget" style="margin: 2rem 0; background: #0f172a; padding: 2rem; border-radius: 12px; border: 1px solid #1e293b; min-height: 500px;">
  <div style="margin-bottom: 2rem;">
    <div id="timeline-labels" style="display: flex; justify-content: space-between; margin-bottom: 10px; font-size: 0.7rem; color: #64748b; font-family: monospace; font-weight: bold;">
      <!-- Labels will be injected here -->
    </div>
    <div id="timeline-bg" style="height: 60px; background: #020617; border: 1px solid #334155; border-radius: 4px; position: relative; cursor: crosshair; overflow: hidden;">
       <div id="timeline-select" style="position: absolute; height: 100%; background: rgba(14, 165, 233, 0.2); border-left: 2px solid #0ea5e9; border-right: 2px solid #0ea5e9; pointer-events: none; left: 10%; width: 70%; z-index: 5;"></div>
       <div id="dirty-container" style="position: absolute; inset: 0; pointer-events: none;"></div>
       <div id="hour-markers" style="position: absolute; inset: 0; pointer-events: none; display: flex; justify-content: space-between;">
         <!-- Hour markers injected here -->
       </div>
    </div>
  </div>

  <div style="display: flex; gap: 10px; margin-bottom: 2rem; flex-wrap: wrap;">
    <button id="btn-realtime" style="background: #1e293b; color: #fff; border: 1px solid #334155; padding: 5px 12px; border-radius: 4px; font-size: 0.7rem; cursor: pointer; font-family: monospace;">+ INCOMING DATA</button>
    <button id="btn-backfill" style="background: #1e293b; color: #fff; border: 1px solid #334155; padding: 5px 12px; border-radius: 4px; font-size: 0.7rem; cursor: pointer; font-family: monospace;">⚡ RUN WORKER (BACKFILL)</button>
    <div id="sim-status" style="font-size: 0.6rem; color: #64748b; align-self: center; font-family: monospace;">STATUS: IDLE</div>
  </div>

  <div id="slice-report" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px;"></div>

  <div style="margin-top: 2rem; padding: 1rem; background: #020617; border-radius: 6px; border: 1px solid #1e3a8a; font-family: 'JetBrains Mono', monospace; font-size: 0.7rem;">
    <div style="color: #64748b; border-bottom: 1px solid #1e3a8a; padding-bottom: 5px; margin-bottom: 10px; font-weight: bold;">PLANNER REWRITE: HIERARCHICAL UNION ALL</div>
    <div id="union-sql" style="color: #4ade80; line-height: 1.4;"></div>
  </div>
</div>

<script>
(function() {
  document.addEventListener('DOMContentLoaded', function() {
    const bg = document.getElementById('timeline-bg'); 
    const select = document.getElementById('timeline-select'); 
    const dirtyContainer = document.getElementById('dirty-container');
    const labelsRow = document.getElementById('timeline-labels');
    const hourMarkers = document.getElementById('hour-markers');
    const report = document.getElementById('slice-report'); 
    const sql = document.getElementById('union-sql');
    const btnRealtime = document.getElementById('btn-realtime');
    const btnBackfill = document.getElementById('btn-backfill');
    const simStatus = document.getElementById('sim-status');

    if (!bg) return;

    let isDragging = false; 
    let startX = 10; 
    let currentSelect = { left: 10, width: 70 };
    let dirtyRanges = [{s: 30, e: 32}, {s: 85, e: 93}]; 
    
    const today = new Date();
    today.setHours(0, 0, 0, 0);
    const startDate = new Date(today.getTime() - 6 * 24 * 60 * 60 * 1000);

    const formatDate = (d) => d.toISOString().split('T')[0];
    const formatFull = (d) => d.toISOString().replace('T', ' ').split('.')[0];

    // Initialize Labels — one per day for 7 days
    labelsRow.innerHTML = [0, 1, 2, 3, 4, 5, 6].map(i => {
      const d = new Date(startDate.getTime() + i * 24 * 60 * 60 * 1000);
      return `<span>${formatDate(d)}</span>`;
    }).join('');
    
    // Initialize Hour Markers & Rule — every 12h over 168h (7 days)
    for (let i = 0; i <= 168; i += 6) {
      const marker = document.createElement('div');
      marker.style.width = '1px';
      marker.style.height = i % 24 === 0 ? '100%' : '25%';
      marker.style.background = i % 24 === 0 ? '#334155' : '#1e293b';
      marker.style.position = 'relative';
      
      if (i % 24 === 0 && i < 168) {
        const hLabel = document.createElement('span');
        hLabel.textContent = (i / 24) + 'd';
        hLabel.style.position = 'absolute';
        hLabel.style.top = '100%';
        hLabel.style.left = '0';
        hLabel.style.fontSize = '0.5rem';
        hLabel.style.color = '#475569';
        hLabel.style.transform = 'translateX(-50%)';
        marker.appendChild(hLabel);
      }
      hourMarkers.appendChild(marker);
    }

    function renderDirty() {
      dirtyContainer.innerHTML = '';
      dirtyRanges.forEach(range => {
        const div = document.createElement('div');
        div.style.position = 'absolute';
        div.style.height = '100%';
        div.style.left = range.s + '%';
        div.style.width = (range.e - range.s) + '%';
        div.style.background = 'repeating-linear-gradient(45deg, transparent, transparent 5px, rgba(239, 68, 68, 0.3) 5px, rgba(239, 68, 68, 0.3) 10px)';
        div.style.borderLeft = '1px solid #ef4444';
        div.style.borderRight = '1px solid #ef4444';
        dirtyContainer.appendChild(div);
      });
    }

    function updateSlices() {
      const { left, width } = currentSelect;
      const startP = left; const endP = left + width; 
      report.innerHTML = '';
      let sqlParts = [];

      const totalMs = 7 * 24 * 60 * 60 * 1000;
      const globalStart = startDate.getTime();
      const queryStart = globalStart + (startP / 100) * totalMs;
      const queryEnd = globalStart + (endP / 100) * totalMs;

      if (width <= 0.01) {
        sql.innerHTML = '-- No range selected';
        return;
      }

      const TIERS = [
        { name: 'DAILY ROLLUP', table: 'ticks_1d', ms: 24 * 60 * 60 * 1000, color: '#10b981' },
        { name: 'HOURLY ROLLUP', table: 'ticks_1h', ms: 60 * 60 * 1000, color: '#0ea5e9' },
        { name: 'MINUTELY ROLLUP', table: 'ticks_1m', ms: 60 * 1000, color: '#818cf8' },
        { name: 'RAW DATA', table: 'raw_ticks', ms: 1000, color: '#ef4444' }
      ];

      let segments = [];
      let current = queryStart;

      while (current < queryEnd - 500) {
        let bestTier = TIERS[3]; // Default to Raw
        let sliceEnd = queryEnd;

        const currentP = ((current - globalStart) / totalMs) * 100;
        let dirtyIdx = dirtyRanges.findIndex(d => currentP < d.e && (currentP + 0.1) > d.s);
        
        if (dirtyIdx !== -1) {
          bestTier = TIERS[3];
          const dirtyEndMs = globalStart + (dirtyRanges[dirtyIdx].e / 100) * totalMs;
          sliceEnd = Math.min(queryEnd, dirtyEndMs);
        } else {
          for (let tier of TIERS) {
            const isAligned = Math.floor(current / tier.ms) * tier.ms === current;
            const nextAlignment = (Math.floor(current / tier.ms) + 1) * tier.ms;
            
            if (isAligned && nextAlignment <= queryEnd) {
              const nextP = ((nextAlignment - globalStart) / totalMs) * 100;
              const hasDirty = dirtyRanges.some(d => d.s < nextP && d.e > currentP);
              if (!hasDirty) {
                bestTier = tier;
                sliceEnd = nextAlignment;
                break;
              }
            }
          }
          if (bestTier.table === 'raw_ticks') {
            const nextMin = (Math.floor(current / 60000) + 1) * 60000;
            const nextDirty = dirtyRanges.find(d => d.s > currentP);
            const nextDirtyMs = nextDirty ? globalStart + (nextDirty.s / 100) * totalMs : queryEnd;
            sliceEnd = Math.min(queryEnd, nextMin, nextDirtyMs);
          }
        }
        segments.push({ tier: bestTier, start: current, end: sliceEnd });
        current = sliceEnd;
      }

      // Normalize: Merge contiguous segments of the same tier
      let normalized = [];
      if (segments.length > 0) {
        let last = segments[0];
        for (let i = 1; i < segments.length; i++) {
          if (segments[i].tier.table === last.tier.table) {
            last.end = segments[i].end;
          } else {
            normalized.push(last);
            last = segments[i];
          }
        }
        normalized.push(last);
      }

      // Group normalized ranges by tier and sort Daily -> Raw (climb and descend)
      const groups = TIERS.map(t => ({
        tier: t,
        ranges: normalized.filter(s => s.tier.table === t.table)
      }))
      .filter(g => g.ranges.length > 0)
      .sort((a, b) => b.tier.ms - a.tier.ms);

      let cteParts = [];
      groups.forEach((group, idx) => {
        const div = document.createElement('div'); 
        div.style.borderLeft = `4px solid ${group.tier.color}`; 
        div.style.background = 'rgba(255,255,255,0.03)'; 
        div.style.padding = '8px';
        div.innerHTML = `<div style="font-family: monospace; font-size: 0.6rem; color: #94a3b8;">LEVEL: ${group.tier.name}</div>
                         <div style="font-family: monospace; font-size: 0.7rem; color: #fff;">MULTIRANGE ACCELERATION: ${group.ranges.length} BIN(S)</div>`;
        report.appendChild(div);

        const scanName = group.tier.table + '_scan';
        const sortedRanges = group.ranges.slice().sort((a, b) => a.start - b.start);
        const multirange = '{' + sortedRanges.map(r => 
          `[${formatFull(new Date(r.start))}, ${formatFull(new Date(r.end))})`
        ).join(',\n     ') + '}';

        cteParts.push(`<span style="color: #64748b;">  ${scanName} AS (</span><br>    SELECT * FROM ${group.tier.table} <br>    WHERE t <span style="color: #818cf8;"><@</span> <span style="color: #fbbf24;">'${multirange}'</span>::tstzmultirange<br><span style="color: #64748b;">  )</span>`);
      });

      if (cteParts.length > 0) {
        let finalSql = `<span style="color: #64748b;">-- Optimized Hierarchical Query Plan</span><br><span style="color: #818cf8;">WITH</span><br>${cteParts.join(',<br>')}<br>`;
        finalSql += groups.map(g => `<span style="color: #818cf8;">SELECT</span> * <span style="color: #818cf8;">FROM</span> ${g.tier.table}_scan`).join('<br><span style="color: #334155; font-weight: bold;">UNION ALL</span><br>');
        finalSql += `<br><span style="color: #818cf8;">ORDER BY</span> t; <span style="color: #64748b;">-- Ensure chronological consistency</span>`;
        sql.innerHTML = finalSql;
      }
    }

    bg.addEventListener('mousedown', (e) => { 
      isDragging = true; 
      const rect = bg.getBoundingClientRect();
      startX = ((e.clientX - rect.left) / rect.width) * 100; 
      currentSelect = { left: startX, width: 0 };
      select.style.left = startX + '%';
      select.style.width = '0%'; 
    });

    window.addEventListener('mousemove', (e) => { 
      if (!isDragging) return; 
      const rect = bg.getBoundingClientRect();
      let cur = ((e.clientX - rect.left) / rect.width) * 100; 
      cur = Math.max(0, Math.min(100, cur)); 
      const left = Math.min(startX, cur); 
      const width = Math.abs(cur - startX); 
      currentSelect = { left, width };
      select.style.left = left + '%'; 
      select.style.width = width + '%'; 
      updateSlices(); 
    });

    window.addEventListener('mouseup', () => { isDragging = false; });

    btnRealtime.addEventListener('click', () => {
      simStatus.textContent = 'STATUS: RECEIVING TRAFFIC...';
      const last = dirtyRanges[dirtyRanges.length-1];
      if (last && last.e > 95) {
        last.e = 100;
      } else {
        dirtyRanges.push({s: 98, e: 100});
      }
      renderDirty();
      updateSlices();
      setTimeout(() => simStatus.textContent = 'STATUS: IDLE', 1000);
    });

    btnBackfill.addEventListener('click', () => {
      if (dirtyRanges.length === 0) return;
      simStatus.textContent = 'STATUS: WORKER PROCESSING...';
      
      const interval = setInterval(() => {
        let changed = false;
        dirtyRanges = dirtyRanges.map(r => {
          if (r.e - r.s > 1) {
            changed = true;
            return { s: r.s + 1, e: r.e };
          }
          return null;
        }).filter(Boolean);

        renderDirty();
        updateSlices();

        if (!changed || dirtyRanges.length === 0) {
          clearInterval(interval);
          simStatus.textContent = 'STATUS: ALL ORBITS HEALED';
          setTimeout(() => simStatus.textContent = 'STATUS: IDLE', 2000);
        }
      }, 100);
    });

    renderDirty();
    updateSlices();
  });
})();
</script>

<h1 id="live-demo-the-modern-spiral-setup">Live Demo: The Modern Spiral Setup</h1>

<p>Let’s see this in action using PostgreSQL’s native <code class="language-plaintext highlighter-rouge">WITH</code> syntax.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">DROP</span> <span class="n">EXTENSION</span> <span class="n">IF</span> <span class="k">EXISTS</span> <span class="n">spiral</span> <span class="k">CASCADE</span><span class="p">;</span>
<span class="k">CREATE</span> <span class="n">EXTENSION</span> <span class="n">spiral</span><span class="p">;</span>

<span class="c1">-- The WITH clause tells Spiral to track this table,</span>
<span class="c1">-- set up specific rollups, and isolate data by 'sensor_id'.</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">sensor_data</span> <span class="p">(</span>
    <span class="n">t</span> <span class="n">timestamptz</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">sensor_id</span> <span class="nb">int</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">temperature</span> <span class="nb">double</span> <span class="nb">precision</span><span class="p">,</span> <span class="c1">-- Spiral: ohlcv</span>
    <span class="n">humidity</span> <span class="nb">double</span> <span class="nb">precision</span><span class="p">,</span>    <span class="c1">-- Spiral: sum</span>
    <span class="n">power_usage</span> <span class="nb">double</span> <span class="nb">precision</span>  <span class="c1">-- Spiral: stats</span>
<span class="p">)</span> <span class="k">WITH</span> <span class="p">(</span>
    <span class="n">spiral</span><span class="p">.</span><span class="n">frames</span> <span class="o">=</span> <span class="s1">'1m,1h'</span><span class="p">,</span>
    <span class="n">spiral</span><span class="p">.</span><span class="n">tenant</span> <span class="o">=</span> <span class="s1">'sensor_id'</span>
<span class="p">);</span>
</code></pre></div></div>

<h1 id="behind-the-scenes-auto-created-views">Behind the Scenes: Auto-created Views</h1>

<p>Spiral immediately registers the table and creates the hierarchical views.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">view_name</span><span class="p">,</span> <span class="n">parent_view</span><span class="p">,</span> <span class="n">frame_seconds</span><span class="p">,</span> <span class="n">scope_columns</span> 
<span class="k">FROM</span> <span class="n">spiral</span><span class="p">.</span><span class="n">metadata</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">frame_seconds</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>   view_name    |  parent_view   | frame_seconds | scope_columns 
----------------+----------------+---------------+---------------
 sensor_data    | BASE           |             0 | {sensor_id}
 sensor_data_1m | sensor_data    |            60 | {sensor_id}
 sensor_data_1h | sensor_data_1m |          3600 | {sensor_id}
(3 rows)
</code></pre></div></div>

<p>Notice it registered the base table and used the magic comments to build the 1m and 1h schema dynamically!</p>

<h1 id="what-you-get-sql-from-day-one">What You Get: SQL From Day One</h1>

<p>Once the table is created and the first <code class="language-plaintext highlighter-rouge">spiral_refresh</code> runs, you get three things without writing any extra code:</p>

<p><strong>1. Pre-aggregated views at every declared tier:</strong></p>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- Raw sub-minute ticks</span>
<span class="k">SELECT</span> <span class="o">*</span> <span class="k">FROM</span> <span class="n">sensor_data</span> <span class="k">WHERE</span> <span class="n">sensor_id</span> <span class="o">=</span> <span class="mi">1</span> <span class="k">AND</span> <span class="n">t</span> <span class="o">&gt;=</span> <span class="n">now</span><span class="p">()</span> <span class="o">-</span> <span class="n">interval</span> <span class="s1">'5m'</span><span class="p">;</span>

<span class="c1">-- 1-minute rollup — columns auto-derived from magic comments</span>
<span class="k">SELECT</span> <span class="n">t</span><span class="p">,</span> <span class="n">sensor_id</span><span class="p">,</span> <span class="n">temperature_ohlcv_o</span><span class="p">,</span> <span class="n">temperature_ohlcv_h</span><span class="p">,</span>
       <span class="n">temperature_ohlcv_l</span><span class="p">,</span> <span class="n">temperature_ohlcv_c</span><span class="p">,</span> <span class="n">humidity</span><span class="p">,</span> <span class="n">power_usage_stats</span>
<span class="k">FROM</span> <span class="n">sensor_data_1m</span>
<span class="k">WHERE</span> <span class="n">sensor_id</span> <span class="o">=</span> <span class="mi">1</span> <span class="k">AND</span> <span class="n">t</span> <span class="o">&gt;=</span> <span class="n">now</span><span class="p">()</span> <span class="o">-</span> <span class="n">interval</span> <span class="s1">'1h'</span><span class="p">;</span>

<span class="c1">-- 1-hour rollup for dashboards</span>
<span class="k">SELECT</span> <span class="n">t</span><span class="p">,</span> <span class="n">sensor_id</span><span class="p">,</span> <span class="n">temperature_ohlcv_h</span> <span class="k">AS</span> <span class="n">max_temp</span><span class="p">,</span> <span class="n">humidity</span>
<span class="k">FROM</span> <span class="n">sensor_data_1h</span>
<span class="k">WHERE</span> <span class="n">t</span> <span class="o">&gt;=</span> <span class="n">now</span><span class="p">()</span> <span class="o">-</span> <span class="n">interval</span> <span class="s1">'7d'</span><span class="p">;</span>
</code></pre></div></div>

<p><strong>2. Transparent query routing</strong> — queries against <code class="language-plaintext highlighter-rouge">sensor_data</code> automatically rewrite to the right rollup tier.</p>

<p><strong>3. Dirty-aware fallback</strong> — during a backfill or late-arriving data window, Spiral serves rollup for clean buckets and raw data for dirty buckets. Zero stale reads, zero full-table scans.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- This is all you need to keep rollups fresh:</span>
<span class="k">SELECT</span> <span class="n">spiral_refresh</span><span class="p">(</span><span class="s1">'sensor_data'</span><span class="p">);</span>

<span class="c1">-- Or scope to one tenant after a targeted backfill:</span>
<span class="k">SELECT</span> <span class="n">spiral_refresh</span><span class="p">(</span><span class="s1">'sensor_data'</span><span class="p">,</span> <span class="s1">'sensor_id = 3'</span><span class="p">);</span>
</code></pre></div></div>

<h1 id="data-ingestion-spanning-timeframes">Data Ingestion spanning Timeframes</h1>

<p>Let’s insert some raw data.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">sensor_data</span> <span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">sensor_id</span><span class="p">,</span> <span class="n">temperature</span><span class="p">,</span> <span class="n">humidity</span><span class="p">,</span> <span class="n">power_usage</span><span class="p">)</span> <span class="k">VALUES</span>
<span class="p">(</span><span class="s1">'2026-05-03 20:15:00'</span><span class="p">::</span><span class="n">timestamptz</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">22</span><span class="p">.</span><span class="mi">5</span><span class="p">,</span> <span class="mi">45</span><span class="p">.</span><span class="mi">0</span><span class="p">,</span> <span class="mi">100</span><span class="p">.</span><span class="mi">5</span><span class="p">),</span>
<span class="p">(</span><span class="s1">'2026-05-03 20:15:00'</span><span class="p">::</span><span class="n">timestamptz</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">22</span><span class="p">.</span><span class="mi">7</span><span class="p">,</span> <span class="mi">45</span><span class="p">.</span><span class="mi">2</span><span class="p">,</span> <span class="mi">101</span><span class="p">.</span><span class="mi">0</span><span class="p">),</span>
<span class="p">(</span><span class="s1">'2026-05-03 20:15:00'</span><span class="p">::</span><span class="n">timestamptz</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="mi">19</span><span class="p">.</span><span class="mi">5</span><span class="p">,</span> <span class="mi">50</span><span class="p">.</span><span class="mi">0</span><span class="p">,</span> <span class="mi">80</span><span class="p">.</span><span class="mi">0</span><span class="p">),</span>
<span class="p">(</span><span class="s1">'2026-05-03 20:16:00'</span><span class="p">::</span><span class="n">timestamptz</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">23</span><span class="p">.</span><span class="mi">0</span><span class="p">,</span> <span class="mi">44</span><span class="p">.</span><span class="mi">0</span><span class="p">,</span> <span class="mi">105</span><span class="p">.</span><span class="mi">0</span><span class="p">),</span>
<span class="p">(</span><span class="s1">'2026-05-03 20:16:00'</span><span class="p">::</span><span class="n">timestamptz</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="mi">19</span><span class="p">.</span><span class="mi">8</span><span class="p">,</span> <span class="mi">51</span><span class="p">.</span><span class="mi">0</span><span class="p">,</span> <span class="mi">82</span><span class="p">.</span><span class="mi">0</span><span class="p">),</span>
<span class="p">(</span><span class="s1">'2026-05-03 21:15:00'</span><span class="p">::</span><span class="n">timestamptz</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">25</span><span class="p">.</span><span class="mi">0</span><span class="p">,</span> <span class="mi">40</span><span class="p">.</span><span class="mi">0</span><span class="p">,</span> <span class="mi">110</span><span class="p">.</span><span class="mi">0</span><span class="p">);</span>
</code></pre></div></div>

<h1 id="incremental-cascading-refresh">Incremental Cascading Refresh</h1>

<p>Refreshing the 1-minute rollup incrementally processes the new raw data.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">spiral_refresh</span><span class="p">(</span><span class="s1">'sensor_data'</span><span class="p">);</span>
</code></pre></div></div>

<p>The column names are dynamically derived from the magic comments:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">t</span><span class="p">,</span> <span class="n">sensor_id</span><span class="p">,</span> 
       <span class="n">temperature_ohlcv_o</span><span class="p">,</span> <span class="n">temperature_ohlcv_h</span><span class="p">,</span> 
       <span class="n">temperature_ohlcv_l</span><span class="p">,</span> <span class="n">temperature_ohlcv_c</span><span class="p">,</span>
       <span class="n">humidity</span><span class="p">,</span> <span class="n">power_usage_stats</span>
<span class="k">FROM</span> <span class="n">sensor_data_1m</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">t</span><span class="p">,</span> <span class="n">sensor_id</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>           t            | sensor_id | temperature_ohlcv_o | temperature_ohlcv_h | temperature_ohlcv_l | temperature_ohlcv_c | humidity |                              power_usage_stats
------------------------+-----------+---------------------+---------------------+---------------------+---------------------+----------+--------------------------------------------------------------
 2026-05-03 20:15:00+00 |         1 |                22.5 |                22.7 |                22.5 |                22.7 |     90.2 | {"n": 2.0, "m1": 100.75, "m2": 0.125, "m3": 0.0, ...}
 2026-05-03 20:15:00+00 |         2 |                19.5 |                19.5 |                19.5 |                19.5 |       50 | {"n": 1.0, "m1": 80.0, "m2": 0.0, ...}
 2026-05-03 20:16:00+00 |         1 |                  23 |                  23 |                  23 |                  23 |       44 | {"n": 1.0, "m1": 105.0, "m2": 0.0, ...}
 2026-05-03 20:16:00+00 |         2 |                19.8 |                19.8 |                19.8 |                19.8 |       51 | {"n": 1.0, "m1": 82.0, "m2": 0.0, ...}
 2026-05-03 21:15:00+00 |         1 |                  25 |                  25 |                  25 |                  25 |       40 | {"n": 1.0, "m1": 110.0, "m2": 0.0, ...}
(5 rows)
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">humidity</code> is summed per minute bucket. <code class="language-plaintext highlighter-rouge">power_usage_stats</code> stores the Welford moments as JSONB — mean (<code class="language-plaintext highlighter-rouge">m1</code>), variance accumulator (<code class="language-plaintext highlighter-rouge">m2</code>), etc. — ready to merge up to the hourly tier without raw data.</p>

<p>Refresh cascades to the 1-hour rollup automatically because Spiral knows the hierarchy!</p>

<h1 id="partial-refresh-by-tenant-scope">Partial Refresh by Tenant Scope</h1>

<p>In high-cardinality deployments you often need to refresh only one tenant’s data — e.g., after a backfill for <code class="language-plaintext highlighter-rouge">sensor_id = 3</code> without disturbing others. <code class="language-plaintext highlighter-rouge">spiral_refresh</code> accepts an optional <code class="language-plaintext highlighter-rouge">WHERE</code>-style scope predicate:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- Refresh only sensor 3's dirty buckets</span>
<span class="k">SELECT</span> <span class="n">spiral_refresh</span><span class="p">(</span><span class="s1">'sensor_data'</span><span class="p">,</span> <span class="s1">'sensor_id = 3'</span><span class="p">);</span>
</code></pre></div></div>

<p>Spiral intersects that predicate with the changelog, processes only the matching dirty buckets, and leaves every other tenant’s rollup untouched. The changelog entries for other tenants survive the partial refresh and are picked up on their own schedule.</p>

<h1 id="transparent-query-acceleration">Transparent Query Acceleration</h1>

<p>Spiral intercepts queries to the base table and rewrites them to use the pre-aggregated rollups seamlessly — no query changes needed.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- You write this:</span>
<span class="k">SELECT</span> <span class="n">date_trunc</span><span class="p">(</span><span class="s1">'hour'</span><span class="p">,</span> <span class="n">t</span><span class="p">)</span> <span class="k">AS</span> <span class="n">hour</span><span class="p">,</span> <span class="n">sensor_id</span><span class="p">,</span> <span class="k">max</span><span class="p">(</span><span class="n">temperature</span><span class="p">)</span>
<span class="k">FROM</span> <span class="n">sensor_data</span>
<span class="k">WHERE</span> <span class="n">t</span> <span class="o">&gt;=</span> <span class="s1">'2026-05-03 19:00:00'</span><span class="p">::</span><span class="n">timestamptz</span> 
  <span class="k">AND</span> <span class="n">t</span> <span class="o">&lt;</span> <span class="s1">'2026-05-03 23:00:00'</span><span class="p">::</span><span class="n">timestamptz</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">;</span>

<span class="c1">-- Spiral rewrites it to something like:</span>
<span class="k">SELECT</span> <span class="n">date_trunc</span><span class="p">(</span><span class="s1">'hour'</span><span class="p">,</span> <span class="n">t</span><span class="p">)</span> <span class="k">AS</span> <span class="n">hour</span><span class="p">,</span> <span class="n">sensor_id</span><span class="p">,</span> <span class="k">max</span><span class="p">(</span><span class="n">temperature_ohlcv_h</span><span class="p">)</span>
<span class="k">FROM</span> <span class="n">sensor_data_1h</span>
<span class="k">WHERE</span> <span class="n">t</span> <span class="o">&gt;=</span> <span class="s1">'2026-05-03 19:00:00'</span><span class="p">::</span><span class="n">timestamptz</span>
  <span class="k">AND</span> <span class="n">t</span> <span class="o">&lt;</span> <span class="s1">'2026-05-03 23:00:00'</span><span class="p">::</span><span class="n">timestamptz</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">;</span>
</code></pre></div></div>

<p>The rewrite selects the coarsest rollup tier whose granularity fits the query’s <code class="language-plaintext highlighter-rouge">date_trunc(...)</code>. A 4-hour window that matches the 1h tier reads ~4 rows instead of potentially thousands of raw ticks — without any application-level change.</p>

<p><strong>Multi-dimension GROUP BY acceleration.</strong> When the query groups by both a tenant column and <code class="language-plaintext highlighter-rouge">date_trunc(...)</code>, the planner handles both dimensions together — matching the best rollup tier for the granularity, scoped to that tenant.</p>

<p><strong>Z-order index push-down.</strong> For tenant-scoped rollup sub-queries, Spiral auto-injects a <code class="language-plaintext highlighter-rouge">BETWEEN</code> predicate on the z-order index:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- Spiral rewrites this internally:</span>
<span class="k">WHERE</span> <span class="n">spiral_zorder</span><span class="p">(</span><span class="n">spiral</span><span class="p">(</span><span class="n">t</span><span class="p">),</span> <span class="n">ARRAY</span><span class="p">[</span><span class="s1">'sensor_id'</span><span class="p">])</span>
      <span class="k">BETWEEN</span> <span class="o">&lt;</span><span class="n">lo</span><span class="o">&gt;</span> <span class="k">AND</span> <span class="o">&lt;</span><span class="n">hi</span><span class="o">&gt;</span>
</code></pre></div></div>

<p>Z-order is monotone in <code class="language-plaintext highlighter-rouge">t</code> for a fixed tenant hash, so the BETWEEN exactly covers that tenant’s time range without scanning other tenants’ rows. This turns full-rollup scans into tight index range scans.</p>

<h1 id="multi-table-acceleration-join-constraint-propagation">Multi-Table Acceleration: Join Constraint Propagation</h1>

<p>The planner hook doesn’t stop at single-table rewrites. When Spiral detects an equijoin on <code class="language-plaintext highlighter-rouge">t</code> between two tracked tables, it <strong>propagates the time constraint</strong> from the constrained side to the unconstrained side, accelerating both simultaneously.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- Two independent time-series, joined by time</span>
<span class="k">SELECT</span> <span class="n">s</span><span class="p">.</span><span class="n">t</span><span class="p">,</span> <span class="n">s</span><span class="p">.</span><span class="n">sensor_id</span><span class="p">,</span> <span class="n">s</span><span class="p">.</span><span class="n">temperature_ohlcv_h</span><span class="p">,</span>
       <span class="n">a</span><span class="p">.</span><span class="n">asset_id</span><span class="p">,</span>       <span class="n">a</span><span class="p">.</span><span class="n">price_ohlcv_h</span>
<span class="k">FROM</span> <span class="n">sensor_data</span> <span class="n">s</span>
<span class="k">JOIN</span> <span class="n">asset_ticks</span>  <span class="n">a</span> <span class="k">ON</span> <span class="n">s</span><span class="p">.</span><span class="n">t</span> <span class="o">=</span> <span class="n">a</span><span class="p">.</span><span class="n">t</span>
<span class="k">WHERE</span> <span class="n">s</span><span class="p">.</span><span class="n">t</span> <span class="o">&gt;=</span> <span class="s1">'2026-05-03 19:00:00'</span><span class="p">::</span><span class="n">timestamptz</span>
  <span class="k">AND</span> <span class="n">s</span><span class="p">.</span><span class="n">t</span> <span class="o">&lt;</span>  <span class="s1">'2026-05-03 23:00:00'</span><span class="p">::</span><span class="n">timestamptz</span><span class="p">;</span>
</code></pre></div></div>

<p>Without Spiral: <code class="language-plaintext highlighter-rouge">asset_ticks</code> has no time predicate — full scan.</p>

<p>With Spiral: the planner walks the JoinTree, detects <code class="language-plaintext highlighter-rouge">s.t = a.t</code>, propagates <code class="language-plaintext highlighter-rouge">WHERE t &gt;= ... AND t &lt; ...</code> to <code class="language-plaintext highlighter-rouge">asset_ticks</code>, then rewrites <strong>both</strong> sides to their respective rollup tiers:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- Effective plan:</span>
<span class="k">SELECT</span> <span class="n">s</span><span class="p">.</span><span class="n">t</span><span class="p">,</span> <span class="n">s</span><span class="p">.</span><span class="n">sensor_id</span><span class="p">,</span> <span class="n">s</span><span class="p">.</span><span class="n">temperature_ohlcv_h</span><span class="p">,</span>
       <span class="n">a</span><span class="p">.</span><span class="n">asset_id</span><span class="p">,</span>       <span class="n">a</span><span class="p">.</span><span class="n">price_ohlcv_h</span>
<span class="k">FROM</span>   <span class="n">sensor_data_1h</span> <span class="n">s</span>          <span class="c1">-- ← accelerated</span>
<span class="k">JOIN</span>   <span class="n">asset_ticks_1h</span>  <span class="n">a</span> <span class="k">ON</span> <span class="n">s</span><span class="p">.</span><span class="n">t</span> <span class="o">=</span> <span class="n">a</span><span class="p">.</span><span class="n">t</span>   <span class="c1">-- ← also accelerated via propagation</span>
<span class="k">WHERE</span>  <span class="n">s</span><span class="p">.</span><span class="n">t</span> <span class="o">&gt;=</span> <span class="s1">'2026-05-03 19:00:00'</span><span class="p">::</span><span class="n">timestamptz</span>
  <span class="k">AND</span>  <span class="n">s</span><span class="p">.</span><span class="n">t</span> <span class="o">&lt;</span>  <span class="s1">'2026-05-03 23:00:00'</span><span class="p">::</span><span class="n">timestamptz</span><span class="p">;</span>
</code></pre></div></div>

<p>Both tables independently benefit from dirty-aware slicing. A dirty bucket in <code class="language-plaintext highlighter-rouge">sensor_data</code> does not force a raw scan of <code class="language-plaintext highlighter-rouge">asset_ticks</code> — each side is sliced independently against its own changelog.</p>

<h1 id="handling-late-arriving-data">Handling Late-Arriving Data</h1>

<p>Let’s insert an hour of randomized, late data for all tenants.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">sensor_data</span> <span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">sensor_id</span><span class="p">,</span> <span class="n">temperature</span><span class="p">,</span> <span class="n">humidity</span><span class="p">,</span> <span class="n">power_usage</span><span class="p">)</span>
<span class="k">SELECT</span> 
    <span class="s1">'2026-05-03 22:00:00'</span><span class="p">::</span><span class="n">timestamptz</span> <span class="o">+</span> <span class="p">(</span><span class="n">random</span><span class="p">()</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">||</span> <span class="s1">' minutes'</span><span class="p">)::</span><span class="n">interval</span><span class="p">,</span>
    <span class="n">id</span><span class="p">,</span>
    <span class="mi">20</span> <span class="o">+</span> <span class="n">random</span><span class="p">()</span> <span class="o">*</span> <span class="mi">10</span><span class="p">,</span>
    <span class="mi">40</span> <span class="o">+</span> <span class="n">random</span><span class="p">()</span> <span class="o">*</span> <span class="mi">20</span><span class="p">,</span>
    <span class="mi">90</span> <span class="o">+</span> <span class="n">random</span><span class="p">()</span> <span class="o">*</span> <span class="mi">30</span>
<span class="k">FROM</span> <span class="n">generate_series</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span> <span class="k">AS</span> <span class="n">id</span><span class="p">,</span> <span class="n">generate_series</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">60</span><span class="p">);</span>
</code></pre></div></div>

<p>Spiral tracks exactly which time buckets and tenants are “dirty”.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">base_view</span><span class="p">,</span> <span class="n">t_start</span><span class="p">,</span> <span class="n">t_end</span><span class="p">,</span> <span class="n">scope_values</span> 
<span class="k">FROM</span> <span class="n">spiral</span><span class="p">.</span><span class="n">changelog</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">t_start</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  base_view  |  t_start   |   t_end    |   scope_values
-------------+------------+------------+------------------
 sensor_data | 1777856400 | 1777856460 | {"sensor_id": 1}
 sensor_data | 1777856520 | 1777856580 | {"sensor_id": 2}
 sensor_data | 1777856760 | 1777856820 | {"sensor_id": 1}
 ...
(76 rows)
</code></pre></div></div>

<p>Each row is one 60-second bucket per tenant. Only the buckets that actually received new data are marked dirty — the rest stay clean and will continue serving from the rollup.</p>

<h1 id="smart-query-slicing-in-action">Smart Query Slicing in Action</h1>

<p>Spiral’s planner intercepts the query, consults the changelog, and rewrites to a <code class="language-plaintext highlighter-rouge">UNION ALL</code> that:</p>
<ul>
  <li>routes <strong>clean</strong> hour-aligned buckets to <code class="language-plaintext highlighter-rouge">sensor_data_1h</code> (pre-aggregated, fast)</li>
  <li>routes <strong>dirty</strong> sub-minute windows back to <code class="language-plaintext highlighter-rouge">sensor_data</code> (raw, accurate)</li>
</ul>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- You write:</span>
<span class="k">SELECT</span> <span class="n">date_trunc</span><span class="p">(</span><span class="s1">'hour'</span><span class="p">,</span> <span class="n">t</span><span class="p">)</span> <span class="k">AS</span> <span class="n">hour</span><span class="p">,</span> <span class="n">sensor_id</span><span class="p">,</span> <span class="k">max</span><span class="p">(</span><span class="n">temperature</span><span class="p">)</span>
<span class="k">FROM</span> <span class="n">sensor_data</span>
<span class="k">WHERE</span> <span class="n">t</span> <span class="o">&gt;=</span> <span class="s1">'2026-05-03 19:00:00'</span><span class="p">::</span><span class="n">timestamptz</span> 
  <span class="k">AND</span> <span class="n">t</span> <span class="o">&lt;</span> <span class="s1">'2026-05-03 23:00:00'</span><span class="p">::</span><span class="n">timestamptz</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- Spiral actually runs (conceptual rewrite):</span>
<span class="k">SELECT</span> <span class="n">date_trunc</span><span class="p">(</span><span class="s1">'hour'</span><span class="p">,</span> <span class="n">t</span><span class="p">)</span> <span class="k">AS</span> <span class="n">hour</span><span class="p">,</span> <span class="n">sensor_id</span><span class="p">,</span>
       <span class="k">max</span><span class="p">(</span><span class="n">temperature_ohlcv_h</span><span class="p">)</span> <span class="k">AS</span> <span class="n">max_temperature</span>
<span class="k">FROM</span> <span class="n">sensor_data_1h</span>                       <span class="c1">-- ← pre-aggregated tier</span>
<span class="k">WHERE</span> <span class="n">t</span> <span class="o">&gt;=</span> <span class="s1">'2026-05-03 19:00:00'</span><span class="p">::</span><span class="n">timestamptz</span>
  <span class="k">AND</span> <span class="n">t</span> <span class="o">&lt;</span> <span class="s1">'2026-05-03 22:00:00'</span><span class="p">::</span><span class="n">timestamptz</span>  <span class="c1">-- 3 clean hours</span>
  <span class="k">AND</span> <span class="n">spiral_zorder</span><span class="p">(</span><span class="n">spiral</span><span class="p">(</span><span class="n">t</span><span class="p">),</span> <span class="n">ARRAY</span><span class="p">[</span><span class="s1">'sensor_id'</span><span class="p">])</span>
      <span class="k">BETWEEN</span> <span class="mi">1777843200000000</span> <span class="k">AND</span> <span class="mi">1777857600999999</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">2</span>

<span class="k">UNION</span> <span class="k">ALL</span>

<span class="k">SELECT</span> <span class="n">date_trunc</span><span class="p">(</span><span class="s1">'hour'</span><span class="p">,</span> <span class="n">t</span><span class="p">)</span> <span class="k">AS</span> <span class="n">hour</span><span class="p">,</span> <span class="n">sensor_id</span><span class="p">,</span>
       <span class="k">max</span><span class="p">(</span><span class="n">temperature</span><span class="p">)</span> <span class="k">AS</span> <span class="n">max_temperature</span>
<span class="k">FROM</span> <span class="n">sensor_data</span>                          <span class="c1">-- ← raw tier (dirty window)</span>
<span class="k">WHERE</span> <span class="n">t</span> <span class="o">&gt;=</span> <span class="s1">'2026-05-03 22:00:00'</span><span class="p">::</span><span class="n">timestamptz</span>
  <span class="k">AND</span> <span class="n">t</span> <span class="o">&lt;</span> <span class="s1">'2026-05-03 23:00:00'</span><span class="p">::</span><span class="n">timestamptz</span>  <span class="c1">-- 1 dirty hour</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">;</span>
</code></pre></div></div>

<p>The key insight: <strong>Spiral never re-aggregates clean data.</strong> The 3 clean hours each contribute exactly 1 row from <code class="language-plaintext highlighter-rouge">sensor_data_1h</code>. Only the dirty hour scans raw rows. As dirty buckets are healed, they graduate back to the rollup tier automatically.</p>

<p><strong>Tier selection rules:</strong></p>
<ul>
  <li>A bucket is eligible for a rollup tier if it is <strong>aligned</strong> to that tier’s frame and <strong>clean</strong> in the changelog.</li>
  <li>The coarsest aligned tier wins (1h beats 1m, 1m beats raw).</li>
  <li>Adjacent segments from the same source are merged into one range scan to minimize plan nodes.</li>
</ul>

<p>You can force <code class="language-plaintext highlighter-rouge">EXPLAIN</code> to show the rewrite:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">EXPLAIN</span> <span class="p">(</span><span class="k">VERBOSE</span><span class="p">)</span>
<span class="k">SELECT</span> <span class="n">date_trunc</span><span class="p">(</span><span class="s1">'hour'</span><span class="p">,</span> <span class="n">t</span><span class="p">)</span> <span class="k">AS</span> <span class="n">hour</span><span class="p">,</span> <span class="n">sensor_id</span><span class="p">,</span> <span class="k">max</span><span class="p">(</span><span class="n">temperature</span><span class="p">)</span>
<span class="k">FROM</span> <span class="n">sensor_data</span>
<span class="k">WHERE</span> <span class="n">t</span> <span class="o">&gt;=</span> <span class="s1">'2026-05-03 19:00:00'</span><span class="p">::</span><span class="n">timestamptz</span> 
  <span class="k">AND</span> <span class="n">t</span> <span class="o">&lt;</span> <span class="s1">'2026-05-03 23:00:00'</span><span class="p">::</span><span class="n">timestamptz</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">;</span>
</code></pre></div></div>

<p>The plan will show a <code class="language-plaintext highlighter-rouge">HashAggregate</code> over an <code class="language-plaintext highlighter-rouge">Append</code> (the UNION ALL) with separate <code class="language-plaintext highlighter-rouge">SeqScan</code> or <code class="language-plaintext highlighter-rouge">IndexScan</code> nodes — one per segment. After <code class="language-plaintext highlighter-rouge">spiral_refresh</code>, all four hours are clean and the plan collapses to a single scan of <code class="language-plaintext highlighter-rouge">sensor_data_1h</code>.</p>

<h1 id="healing-the-orbits--handling-deletions">Healing the Orbits &amp; Handling Deletions</h1>

<p>We heal the dirty buckets. It only processes the changes!</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">spiral_refresh</span><span class="p">(</span><span class="s1">'sensor_data'</span><span class="p">);</span>
</code></pre></div></div>

<p>The entire time range is now clean. The same query now generates a single-tier plan:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- After refresh, Spiral rewrites to a single rollup scan:</span>
<span class="k">SELECT</span> <span class="n">date_trunc</span><span class="p">(</span><span class="s1">'hour'</span><span class="p">,</span> <span class="n">t</span><span class="p">)</span> <span class="k">AS</span> <span class="n">hour</span><span class="p">,</span> <span class="n">sensor_id</span><span class="p">,</span>
       <span class="k">max</span><span class="p">(</span><span class="n">temperature_ohlcv_h</span><span class="p">)</span> <span class="k">AS</span> <span class="n">max_temperature</span>
<span class="k">FROM</span> <span class="n">sensor_data_1h</span>
<span class="k">WHERE</span> <span class="n">t</span> <span class="o">&gt;=</span> <span class="s1">'2026-05-03 19:00:00'</span><span class="p">::</span><span class="n">timestamptz</span>
  <span class="k">AND</span> <span class="n">t</span> <span class="o">&lt;</span> <span class="s1">'2026-05-03 23:00:00'</span><span class="p">::</span><span class="n">timestamptz</span>
  <span class="k">AND</span> <span class="n">spiral_zorder</span><span class="p">(</span><span class="n">spiral</span><span class="p">(</span><span class="n">t</span><span class="p">),</span> <span class="n">ARRAY</span><span class="p">[</span><span class="s1">'sensor_id'</span><span class="p">])</span>
      <span class="k">BETWEEN</span> <span class="mi">1777843200000000</span> <span class="k">AND</span> <span class="mi">1777857600999999</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">;</span>
<span class="c1">-- 4 rows read instead of thousands of raw ticks.</span>
</code></pre></div></div>

<p>If we delete data, it isolates the dirty fallback to ONLY the affected tenant (<code class="language-plaintext highlighter-rouge">sensor_id = 1</code>) for that specific time bucket — other tenants’ rollups stay pristine.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">DELETE</span> <span class="k">FROM</span> <span class="n">sensor_data</span> 
<span class="k">WHERE</span> <span class="n">sensor_id</span> <span class="o">=</span> <span class="mi">1</span> 
  <span class="k">AND</span> <span class="n">t</span> <span class="o">&gt;=</span> <span class="s1">'2026-05-03 20:15:00'</span><span class="p">::</span><span class="n">timestamptz</span> 
  <span class="k">AND</span> <span class="n">t</span> <span class="o">&lt;</span> <span class="s1">'2026-05-03 20:25:00'</span><span class="p">::</span><span class="n">timestamptz</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">base_view</span><span class="p">,</span> <span class="n">t_start</span><span class="p">,</span> <span class="n">t_end</span><span class="p">,</span> <span class="n">scope_values</span> 
<span class="k">FROM</span> <span class="n">spiral</span><span class="p">.</span><span class="n">changelog</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">t_start</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  base_view  |  t_start   |   t_end    |   scope_values
-------------+------------+------------+------------------
 sensor_data | 1777850100 | 1777850160 | {"sensor_id": 1}
 sensor_data | 1777850160 | 1777850220 | {"sensor_id": 1}
(2 rows)
</code></pre></div></div>

<p>Only <code class="language-plaintext highlighter-rouge">sensor_id = 1</code> is dirty. <code class="language-plaintext highlighter-rouge">sensor_id = 2</code>’s rollups are completely untouched — a targeted dirty mark, not a full-table invalidation.</p>

<p>You can inspect the lag at any time:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="o">*</span> <span class="k">FROM</span> <span class="n">spiral</span><span class="p">.</span><span class="n">status</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  base_view  | tier_count | dirty_entries | dirty_scopes | dirty_seconds | oldest_dirty_ts        | newest_dirty_ts        | lag
-------------+------------+---------------+--------------+---------------+------------------------+------------------------+-------------------------
 sensor_data |          2 |             2 |            1 |           120 | 2026-05-03 20:15:00+00 | 2026-05-03 20:17:00+00 | 21 days 01:23:37
(1 row)
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">lag</code> tells you how stale your oldest dirty bucket is.</p>

<p>In a multi-tenant deployment, <code class="language-plaintext highlighter-rouge">spiral.status</code> aggregates across all tenants. For per-tenant visibility, <code class="language-plaintext highlighter-rouge">spiral.scope_status</code> breaks it down:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="o">*</span> <span class="k">FROM</span> <span class="n">spiral</span><span class="p">.</span><span class="n">scope_status</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">lag</span> <span class="k">DESC</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  base_view  |    scope_values    | dirty_entries | dirty_seconds | oldest_dirty_ts        | lag
-------------+--------------------+---------------+---------------+------------------------+--------------------
 sensor_data | {"sensor_id": "1"} |            12 |           720 | 2026-05-03 20:15:00+00 | 21 days 01:23:37
 sensor_data | {"sensor_id": "2"} |             3 |           180 | 2026-05-03 22:10:00+00 | 20 days 23:05:12
(2 rows)
</code></pre></div></div>

<p>Sensor 1 has more dirty buckets than sensor 2 — a targeted <code class="language-plaintext highlighter-rouge">spiral_refresh(‘sensor_data’, ‘sensor_id = 1’)</code> heals only that tenant’s backlog without touching sensor 2.</p>

<p>For a single-number health check, <code class="language-plaintext highlighter-rouge">spiral_lag()</code> returns the lag as an <code class="language-plaintext highlighter-rouge">interval</code>:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">spiral_lag</span><span class="p">(</span><span class="err">‘</span><span class="n">sensor_data</span><span class="err">’</span><span class="p">);</span>
<span class="c1">-- → 21 days 01:23:37   (NULL when fully current)</span>
</code></pre></div></div>

<p>Pipe this into any monitoring system. <code class="language-plaintext highlighter-rouge">NULL</code> means clean; a growing interval means the worker is falling behind.</p>

<h1 id="autonomous-background-worker">Autonomous Background Worker</h1>

<p>You don’t need to call <code class="language-plaintext highlighter-rouge">spiral_refresh</code> manually. When <code class="language-plaintext highlighter-rouge">spiral.frames</code> is set, Spiral registers a <strong>background worker</strong> that wakes every second, claims dirty <code class="language-plaintext highlighter-rouge">(base_view, scope)</code> pairs from the changelog, and heals them — automatically.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- postgresql.conf (or SET for session)</span>
<span class="n">spiral</span><span class="p">.</span><span class="n">worker_enabled</span>   <span class="o">=</span> <span class="k">on</span>    <span class="c1">-- default: on</span>
<span class="n">spiral</span><span class="p">.</span><span class="n">max_workers</span>      <span class="o">=</span> <span class="mi">2</span>     <span class="c1">-- parallel workers per DB</span>
<span class="n">spiral</span><span class="p">.</span><span class="n">worker_batch_size</span> <span class="o">=</span> <span class="mi">10</span>   <span class="c1">-- scopes refreshed per 1s tick</span>
</code></pre></div></div>

<p>Workers use advisory locks to divide scopes without conflicts: worker A refreshes <code class="language-plaintext highlighter-rouge">sensor_id = 1</code>, worker B refreshes <code class="language-plaintext highlighter-rouge">sensor_id = 2</code> — no coordination overhead, no duplicate work.</p>

<p>The planner hook is similarly tunable:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">spiral</span><span class="p">.</span><span class="n">enable_planner_hook</span>   <span class="o">=</span> <span class="k">on</span>    <span class="c1">-- disable to compare plans</span>
<span class="n">spiral</span><span class="p">.</span><span class="n">planner_max_segments</span>  <span class="o">=</span> <span class="mi">50</span>    <span class="c1">-- fall back to RAW if UNION ALL would exceed N parts</span>
</code></pre></div></div>

<p>When <code class="language-plaintext highlighter-rouge">planner_max_segments</code> is exceeded (highly fragmented dirty ranges), Spiral falls back to a full raw scan and logs a notice — trading query rewrite overhead for a simpler plan.</p>

<h1 id="mathematical-engine-welfords-algorithm">Mathematical Engine: Welford’s Algorithm</h1>

<h2 id="the-problem-with-standard-stddev">The Problem with Standard <code class="language-plaintext highlighter-rouge">stddev()</code></h2>

<p>PostgreSQL’s built-in <code class="language-plaintext highlighter-rouge">stddev()</code> cannot be rolled up. Given two pre-aggregated hourly stddev values you cannot compute the combined stddev without the original rows. This breaks the rollup chain.</p>

<p>Spiral needs a <strong>mergeable</strong> statistical state — one where <code class="language-plaintext highlighter-rouge">rollup(rollup(raw → 1m) → 1h)</code> gives the same answer as <code class="language-plaintext highlighter-rouge">rollup(raw → 1h)</code>.</p>

<h2 id="the-solution-store-moments-not-final-values">The Solution: Store Moments, Not Final Values</h2>

<p>Welford’s online algorithm accumulates four <strong>central moments</strong> <code class="language-plaintext highlighter-rouge">(n, m1, m2, m3, m4)</code> rather than a computed stddev. From those moments you can derive mean, variance, stddev, skewness, and kurtosis at any time. Crucially, two <code class="language-plaintext highlighter-rouge">StatsState</code> structs can be <strong>merged exactly</strong> using Chan et al.’s parallel algorithm — no raw data needed.</p>

<div class="math-formula">\[\bar{x}_n = \bar{x}_{n-1} + \frac{x_n - \bar{x}_{n-1}}{n}\]
</div>

<div class="math-formula">\[M_{2,n} = M_{2,n-1} + (x_n - \bar{x}_{n-1})(x_n - \bar{x}_n)\]
</div>

<h2 id="the-rust-implementation-srcstatsrs">The Rust Implementation (<code class="language-plaintext highlighter-rouge">src/stats.rs</code>)</h2>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// src/stats.rs</span>
<span class="nd">#[derive(Serialize,</span> <span class="nd">Deserialize,</span> <span class="nd">Default,</span> <span class="nd">Clone,</span> <span class="nd">Copy)]</span>
<span class="k">pub</span> <span class="k">struct</span> <span class="n">StatsState</span> <span class="p">{</span>
    <span class="k">pub</span> <span class="n">n</span><span class="p">:</span>  <span class="nb">f64</span><span class="p">,</span>   <span class="c1">// count</span>
    <span class="k">pub</span> <span class="n">m1</span><span class="p">:</span> <span class="nb">f64</span><span class="p">,</span>   <span class="c1">// mean</span>
    <span class="k">pub</span> <span class="n">m2</span><span class="p">:</span> <span class="nb">f64</span><span class="p">,</span>   <span class="c1">// sum of squared deviations  (→ variance)</span>
    <span class="k">pub</span> <span class="n">m3</span><span class="p">:</span> <span class="nb">f64</span><span class="p">,</span>   <span class="c1">// sum of cubed deviations    (→ skewness)</span>
    <span class="k">pub</span> <span class="n">m4</span><span class="p">:</span> <span class="nb">f64</span><span class="p">,</span>   <span class="c1">// sum of quartic deviations  (→ kurtosis)</span>
<span class="p">}</span>

<span class="k">impl</span> <span class="n">StatsState</span> <span class="p">{</span>
    <span class="k">pub</span> <span class="k">fn</span> <span class="nf">add</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">x</span><span class="p">:</span> <span class="nb">f64</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">let</span> <span class="n">n1</span> <span class="o">=</span> <span class="k">self</span><span class="py">.n</span><span class="p">;</span>
        <span class="k">self</span><span class="py">.n</span> <span class="o">+=</span> <span class="mf">1.0</span><span class="p">;</span>
        <span class="k">let</span> <span class="n">delta</span>   <span class="o">=</span> <span class="n">x</span> <span class="o">-</span> <span class="k">self</span><span class="py">.m1</span><span class="p">;</span>
        <span class="k">let</span> <span class="n">delta_n</span> <span class="o">=</span> <span class="n">delta</span> <span class="o">/</span> <span class="k">self</span><span class="py">.n</span><span class="p">;</span>
        <span class="k">let</span> <span class="n">term1</span>   <span class="o">=</span> <span class="n">delta</span> <span class="o">*</span> <span class="n">delta_n</span> <span class="o">*</span> <span class="n">n1</span><span class="p">;</span>
        <span class="k">self</span><span class="py">.m1</span> <span class="o">+=</span> <span class="n">delta_n</span><span class="p">;</span>
        <span class="k">self</span><span class="py">.m2</span> <span class="o">+=</span> <span class="n">term1</span><span class="p">;</span>
        <span class="c1">// m3 and m4 updated similarly — O(1) per value, numerically stable</span>
    <span class="p">}</span>

    <span class="c1">// Chan’s parallel algorithm: merge two independent StatsState structs</span>
    <span class="c1">// Used when rolling up 1m → 1h → 1d without re-scanning raw rows.</span>
    <span class="k">pub</span> <span class="k">fn</span> <span class="nf">merge</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">other</span><span class="p">:</span> <span class="o">&amp;</span><span class="k">Self</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">let</span> <span class="n">combined_n</span> <span class="o">=</span> <span class="k">self</span><span class="py">.n</span> <span class="o">+</span> <span class="n">other</span><span class="py">.n</span><span class="p">;</span>
        <span class="k">let</span> <span class="n">delta</span>  <span class="o">=</span> <span class="n">other</span><span class="py">.m1</span> <span class="o">-</span> <span class="k">self</span><span class="py">.m1</span><span class="p">;</span>
        <span class="k">let</span> <span class="n">delta2</span> <span class="o">=</span> <span class="n">delta</span> <span class="o">*</span> <span class="n">delta</span><span class="p">;</span>
        <span class="k">let</span> <span class="n">m1</span> <span class="o">=</span> <span class="p">(</span><span class="k">self</span><span class="py">.n</span> <span class="o">*</span> <span class="k">self</span><span class="py">.m1</span> <span class="o">+</span> <span class="n">other</span><span class="py">.n</span> <span class="o">*</span> <span class="n">other</span><span class="py">.m1</span><span class="p">)</span> <span class="o">/</span> <span class="n">combined_n</span><span class="p">;</span>
        <span class="k">let</span> <span class="n">m2</span> <span class="o">=</span> <span class="k">self</span><span class="py">.m2</span> <span class="o">+</span> <span class="n">other</span><span class="py">.m2</span> <span class="o">+</span> <span class="n">delta2</span> <span class="o">*</span> <span class="k">self</span><span class="py">.n</span> <span class="o">*</span> <span class="n">other</span><span class="py">.n</span> <span class="o">/</span> <span class="n">combined_n</span><span class="p">;</span>
        <span class="c1">// m3, m4 follow the same pattern with higher-order delta terms</span>
        <span class="k">self</span><span class="py">.n</span> <span class="o">=</span> <span class="n">combined_n</span><span class="p">;</span> <span class="k">self</span><span class="py">.m1</span> <span class="o">=</span> <span class="n">m1</span><span class="p">;</span> <span class="k">self</span><span class="py">.m2</span> <span class="o">=</span> <span class="n">m2</span><span class="p">;</span> <span class="cm">/* ... */</span>
    <span class="p">}</span>

    <span class="k">pub</span> <span class="k">fn</span> <span class="nf">mean</span><span class="p">(</span><span class="o">&amp;</span><span class="k">self</span><span class="p">)</span>     <span class="k">-&gt;</span> <span class="nb">f64</span> <span class="p">{</span> <span class="k">self</span><span class="py">.m1</span> <span class="p">}</span>
    <span class="k">pub</span> <span class="k">fn</span> <span class="nf">variance</span><span class="p">(</span><span class="o">&amp;</span><span class="k">self</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nb">f64</span> <span class="p">{</span> <span class="k">self</span><span class="py">.m2</span> <span class="o">/</span> <span class="p">(</span><span class="k">self</span><span class="py">.n</span> <span class="o">-</span> <span class="mf">1.0</span><span class="p">)</span> <span class="p">}</span>
    <span class="k">pub</span> <span class="k">fn</span> <span class="nf">stddev</span><span class="p">(</span><span class="o">&amp;</span><span class="k">self</span><span class="p">)</span>   <span class="k">-&gt;</span> <span class="nb">f64</span> <span class="p">{</span> <span class="k">self</span><span class="nf">.variance</span><span class="p">()</span><span class="nf">.sqrt</span><span class="p">()</span> <span class="p">}</span>
    <span class="k">pub</span> <span class="k">fn</span> <span class="nf">skewness</span><span class="p">(</span><span class="o">&amp;</span><span class="k">self</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nb">f64</span> <span class="p">{</span> <span class="k">self</span><span class="py">.n</span><span class="nf">.sqrt</span><span class="p">()</span> <span class="o">*</span> <span class="k">self</span><span class="py">.m3</span> <span class="o">/</span> <span class="k">self</span><span class="py">.m2</span><span class="nf">.powf</span><span class="p">(</span><span class="mf">1.5</span><span class="p">)</span> <span class="p">}</span>
    <span class="k">pub</span> <span class="k">fn</span> <span class="nf">kurtosis</span><span class="p">(</span><span class="o">&amp;</span><span class="k">self</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nb">f64</span> <span class="p">{</span> <span class="k">self</span><span class="py">.n</span> <span class="o">*</span> <span class="k">self</span><span class="py">.m4</span> <span class="o">/</span> <span class="p">(</span><span class="k">self</span><span class="py">.m2</span> <span class="o">*</span> <span class="k">self</span><span class="py">.m2</span><span class="p">)</span> <span class="o">-</span> <span class="mf">3.0</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="how-pgrx-hooks-connect-rust-to-postgresql-aggregates">How <code class="language-plaintext highlighter-rouge">pgrx</code> Hooks Connect Rust to PostgreSQL Aggregates</h2>

<p><code class="language-plaintext highlighter-rouge">pgrx</code> lets Rust functions register directly as PostgreSQL functions via the <code class="language-plaintext highlighter-rouge">#[pg_extern]</code> attribute. Spiral uses two functions to form a full PostgreSQL aggregate:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Accumulation step — called once per raw row during spiral_refresh</span>
<span class="nd">#[pg_extern(immutable,</span> <span class="nd">parallel_safe)]</span>
<span class="k">pub</span> <span class="k">fn</span> <span class="nf">spiral_stats_accum</span><span class="p">(</span><span class="n">state</span><span class="p">:</span> <span class="nb">Option</span><span class="o">&lt;</span><span class="nn">pgrx</span><span class="p">::</span><span class="n">JsonB</span><span class="o">&gt;</span><span class="p">,</span> <span class="n">val</span><span class="p">:</span> <span class="nb">f64</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nn">pgrx</span><span class="p">::</span><span class="n">JsonB</span> <span class="p">{</span>
    <span class="k">let</span> <span class="k">mut</span> <span class="n">s</span> <span class="o">=</span> <span class="n">state</span>
        <span class="nf">.map</span><span class="p">(|</span><span class="n">j</span><span class="p">|</span> <span class="nn">serde_json</span><span class="p">::</span><span class="nn">from_value</span><span class="p">::</span><span class="o">&lt;</span><span class="n">StatsState</span><span class="o">&gt;</span><span class="p">(</span><span class="n">j</span><span class="na">.0</span><span class="p">)</span><span class="nf">.unwrap</span><span class="p">())</span>
        <span class="nf">.unwrap_or_default</span><span class="p">();</span>
    <span class="n">s</span><span class="nf">.add</span><span class="p">(</span><span class="n">val</span><span class="p">);</span>
    <span class="nn">pgrx</span><span class="p">::</span><span class="nf">JsonB</span><span class="p">(</span><span class="nn">serde_json</span><span class="p">::</span><span class="nf">to_value</span><span class="p">(</span><span class="n">s</span><span class="p">)</span><span class="nf">.unwrap</span><span class="p">())</span>
<span class="p">}</span>

<span class="c1">// Combine step — called when merging two rollup tiers (1m → 1h)</span>
<span class="c1">// `parallel_safe` tells PostgreSQL this can run across parallel workers</span>
<span class="nd">#[pg_extern(immutable,</span> <span class="nd">parallel_safe)]</span>
<span class="k">pub</span> <span class="k">fn</span> <span class="nf">spiral_stats_combine</span><span class="p">(</span>
    <span class="n">state1</span><span class="p">:</span> <span class="nb">Option</span><span class="o">&lt;</span><span class="nn">pgrx</span><span class="p">::</span><span class="n">JsonB</span><span class="o">&gt;</span><span class="p">,</span>
    <span class="n">state2</span><span class="p">:</span> <span class="nb">Option</span><span class="o">&lt;</span><span class="nn">pgrx</span><span class="p">::</span><span class="n">JsonB</span><span class="o">&gt;</span><span class="p">,</span>
<span class="p">)</span> <span class="k">-&gt;</span> <span class="nn">pgrx</span><span class="p">::</span><span class="n">JsonB</span> <span class="p">{</span>
    <span class="k">let</span> <span class="k">mut</span> <span class="n">s1</span> <span class="o">=</span> <span class="n">state1</span><span class="nf">.map</span><span class="p">(|</span><span class="n">j</span><span class="p">|</span> <span class="nn">serde_json</span><span class="p">::</span><span class="nn">from_value</span><span class="p">::</span><span class="o">&lt;</span><span class="n">StatsState</span><span class="o">&gt;</span><span class="p">(</span><span class="n">j</span><span class="na">.0</span><span class="p">)</span><span class="nf">.unwrap</span><span class="p">())</span>
        <span class="nf">.unwrap_or_default</span><span class="p">();</span>
    <span class="k">let</span> <span class="n">s2</span>     <span class="o">=</span> <span class="n">state2</span><span class="nf">.map</span><span class="p">(|</span><span class="n">j</span><span class="p">|</span> <span class="nn">serde_json</span><span class="p">::</span><span class="nn">from_value</span><span class="p">::</span><span class="o">&lt;</span><span class="n">StatsState</span><span class="o">&gt;</span><span class="p">(</span><span class="n">j</span><span class="na">.0</span><span class="p">)</span><span class="nf">.unwrap</span><span class="p">())</span>
        <span class="nf">.unwrap_or_default</span><span class="p">();</span>
    <span class="n">s1</span><span class="nf">.merge</span><span class="p">(</span><span class="o">&amp;</span><span class="n">s2</span><span class="p">);</span>
    <span class="nn">pgrx</span><span class="p">::</span><span class="nf">JsonB</span><span class="p">(</span><span class="nn">serde_json</span><span class="p">::</span><span class="nf">to_value</span><span class="p">(</span><span class="n">s1</span><span class="p">)</span><span class="nf">.unwrap</span><span class="p">())</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Why this fits the rollup architecture perfectly:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Property</th>
      <th style="text-align: left">Why it matters</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>Online</strong> — processes one value at a time</td>
      <td style="text-align: left">Matches IVM: only new changelog rows need processing, not full re-scan</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Mergeable</strong> — two states combine exactly</td>
      <td style="text-align: left">Enables <code class="language-plaintext highlighter-rouge">1m → 1h → 1d</code> rollup chains without raw data</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Numerically stable</strong> — Welford avoids catastrophic cancellation</td>
      <td style="text-align: left">Large sensor datasets with tight value ranges stay accurate</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong><code class="language-plaintext highlighter-rouge">parallel_safe</code></strong> — PostgreSQL can split across workers</td>
      <td style="text-align: left">Rollup refreshes parallelise automatically at no cost</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>JSONB state</strong> — opaque to PostgreSQL</td>
      <td style="text-align: left">Schema-free; adding m5 or percentiles needs no DDL change</td>
    </tr>
  </tbody>
</table>

<p>The <code class="language-plaintext highlighter-rouge">-- Spiral: stats</code> magic comment wires this up: when building a rollup view Spiral emits <code class="language-plaintext highlighter-rouge">spiral_stats_accum(col)</code> for the 1m layer and <code class="language-plaintext highlighter-rouge">spiral_stats_combine(col_stats)</code> for every higher tier.</p>

<h1 id="approximate-quantiles-sketch-and-t-digest">Approximate Quantiles: Sketch and T-Digest</h1>

<p>Welford gives exact moments (mean, stddev, skewness). But <code class="language-plaintext highlighter-rouge">percentile_cont(0.95)</code> cannot be rolled up — there is no exact mergeable form. Spiral provides two mergeable approximate quantile sketches instead.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Magic comment</th>
      <th style="text-align: left">Aggregate</th>
      <th style="text-align: left">Merge fn</th>
      <th style="text-align: left">Accuracy</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">-- Spiral: sketch</code></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">spiral_sketch(col)</code></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">spiral_sketch_merge(col_sketch)</code></td>
      <td style="text-align: left">DDSketch, relative error ≤ 1%</td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">-- Spiral: tdigest</code></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">spiral_tdigest(col)</code></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">spiral_tdigest_merge(col_tdigest)</code></td>
      <td style="text-align: left">t-digest, better tail accuracy</td>
    </tr>
  </tbody>
</table>

<p>Like <code class="language-plaintext highlighter-rouge">stats</code>, both store a JSONB blob at the 1m tier and merge it exactly as it rolls up to 1h, 1d — no raw data needed.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">api_requests</span> <span class="p">(</span>
    <span class="n">t</span>          <span class="n">timestamptz</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">service_id</span> <span class="nb">int</span>         <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">latency_ms</span> <span class="nb">double</span> <span class="nb">precision</span> <span class="c1">-- Spiral: tdigest</span>
<span class="p">)</span> <span class="k">WITH</span> <span class="p">(</span>
    <span class="n">spiral</span><span class="p">.</span><span class="n">frames</span> <span class="o">=</span> <span class="s1">'1m,1h,1d'</span><span class="p">,</span>
    <span class="n">spiral</span><span class="p">.</span><span class="n">tenant</span> <span class="o">=</span> <span class="s1">'service_id'</span>
<span class="p">);</span>
</code></pre></div></div>

<p>After refresh, the rollup stores <code class="language-plaintext highlighter-rouge">latency_ms_tdigest</code> at every tier. Query approximate percentiles at any granularity:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- p50, p95, p99 from the hourly rollup — no raw rows needed</span>
<span class="k">SELECT</span> <span class="n">t</span><span class="p">,</span> <span class="n">service_id</span><span class="p">,</span>
       <span class="n">spiral_tdigest_quantile</span><span class="p">(</span><span class="n">latency_ms_tdigest</span><span class="p">,</span> <span class="mi">0</span><span class="p">.</span><span class="mi">50</span><span class="p">)</span> <span class="k">AS</span> <span class="n">p50</span><span class="p">,</span>
       <span class="n">spiral_tdigest_quantile</span><span class="p">(</span><span class="n">latency_ms_tdigest</span><span class="p">,</span> <span class="mi">0</span><span class="p">.</span><span class="mi">95</span><span class="p">)</span> <span class="k">AS</span> <span class="n">p95</span><span class="p">,</span>
       <span class="n">spiral_tdigest_quantile</span><span class="p">(</span><span class="n">latency_ms_tdigest</span><span class="p">,</span> <span class="mi">0</span><span class="p">.</span><span class="mi">99</span><span class="p">)</span> <span class="k">AS</span> <span class="n">p99</span>
<span class="k">FROM</span> <span class="n">api_requests_1h</span>
<span class="k">WHERE</span> <span class="n">t</span> <span class="o">&gt;=</span> <span class="n">now</span><span class="p">()</span> <span class="o">-</span> <span class="n">interval</span> <span class="s1">'24h'</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">t</span><span class="p">,</span> <span class="n">service_id</span><span class="p">;</span>
</code></pre></div></div>

<p>This is the key advantage over standard PostgreSQL: a 1h bucket carries a compact sketch (~1 KB) that can answer any quantile query, and two hourly sketches merge into a daily sketch in microseconds. No raw rows ever need to be re-read.</p>

<h1 id="z-order-indexing-for-existing-tables">Z-Order Indexing for Existing Tables</h1>

<p>Spiral’s <code class="language-plaintext highlighter-rouge">WITH (spiral.frames=...)</code> wires everything automatically for new tables. For an <strong>existing</strong> table without Spiral TAM, <code class="language-plaintext highlighter-rouge">cluster_table()</code> creates the z-order index in one call:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- Add z-order index to any table:</span>
<span class="k">SELECT</span> <span class="n">cluster_table</span><span class="p">(</span><span class="s1">'sensor_data'</span><span class="p">,</span> <span class="s1">'t'</span><span class="p">,</span> <span class="n">ARRAY</span><span class="p">[</span><span class="s1">'sensor_id'</span><span class="p">]);</span>
<span class="c1">-- Creates: idx_z_sensor_data ON sensor_data</span>
<span class="c1">--   USING btree (spiral_zorder(spiral(t), ARRAY['sensor_id']))</span>
</code></pre></div></div>

<p>The planner hook then uses this index for <code class="language-plaintext highlighter-rouge">BETWEEN</code> pushdown on any query that filters by both <code class="language-plaintext highlighter-rouge">t</code> and <code class="language-plaintext highlighter-rouge">sensor_id</code>. No data migration, no DDL changes to existing schemas.</p>

<p>The <code class="language-plaintext highlighter-rouge">spiral(t)</code> function used inside the index is an identity for <code class="language-plaintext highlighter-rouge">bigint</code> and extracts the microsecond epoch for <code class="language-plaintext highlighter-rouge">timestamptz</code>. It exists so the index expression is stable regardless of which type <code class="language-plaintext highlighter-rouge">t</code> carries:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">spiral</span><span class="p">(</span><span class="s1">'2026-05-03 20:15:00'</span><span class="p">::</span><span class="n">timestamptz</span><span class="p">);</span>
<span class="c1">-- → 1746303300000000  (microseconds since Unix epoch)</span>

<span class="k">SELECT</span> <span class="n">to_timestamptz</span><span class="p">(</span><span class="mi">1746303300000000</span><span class="p">);</span>
<span class="c1">-- → 2026-05-03 20:15:00+00</span>
</code></pre></div></div>

<p>Round-tripping through <code class="language-plaintext highlighter-rouge">spiral()</code> / <code class="language-plaintext highlighter-rouge">to_timestamptz()</code> lets you embed raw epoch arithmetic in SQL when needed — for example, to manually verify a z-order range or debug a planner rewrite.</p>

<h1 id="experimental-results-prototype-benchmarks">Experimental Results: Prototype Benchmarks</h1>

<p>In my local environment, with a <strong>10 million row dataset</strong>, I saw results that justified continuing this research.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Phase</th>
      <th style="text-align: left">Ingest Rate</th>
      <th style="text-align: left">Duration</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>Bulk Load</strong></td>
      <td style="text-align: left">1,940,482 rows/s</td>
      <td style="text-align: left">0.51s (1M sample)</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>Backfill</strong></td>
      <td style="text-align: left">302,413 rows/s</td>
      <td style="text-align: left">3.30s (1M sample)</td>
    </tr>
  </tbody>
</table>

<h1 id="building-postgresql-extensions-in-rust-what-i-learned">Building PostgreSQL Extensions in Rust: What I Learned</h1>

<p>Spiral is built with <a href="https://github.com/pgcentralfoundation/pgrx"><code class="language-plaintext highlighter-rouge">pgrx</code></a> — a framework that lets you write PostgreSQL extensions entirely in Rust. If you have ever wanted to extend PostgreSQL but found C intimidating, pgrx is worth serious attention.</p>

<h2 id="the-api-surface-spiral-uses">The API Surface Spiral Uses</h2>

<p>Every major PostgreSQL extension mechanism is available through pgrx:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Mechanism</th>
      <th style="text-align: left">What it does</th>
      <th style="text-align: left">How Spiral uses it</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">#[pg_extern]</code></td>
      <td style="text-align: left">Export a Rust fn as a SQL function</td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">spiral_refresh</code>, <code class="language-plaintext highlighter-rouge">spiral_zorder</code>, <code class="language-plaintext highlighter-rouge">spiral_lag</code></td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">extension_sql!</code></td>
      <td style="text-align: left">Embed raw SQL in the extension</td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">spiral.changelog</code>, <code class="language-plaintext highlighter-rouge">spiral.status</code>, operators</td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">#[pg_aggregate]</code> / <code class="language-plaintext highlighter-rouge">CREATE AGGREGATE</code></td>
      <td style="text-align: left">Custom aggregates</td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">spiral_stats_accum/combine</code>, sketch/tdigest</td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">TableAmRoutine</code> (TAM)</td>
      <td style="text-align: left">Custom storage engine</td>
      <td style="text-align: left">Binary-packed page layout, O(1) seeks</td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">planner_hook</code></td>
      <td style="text-align: left">Intercept &amp; rewrite query plans</td>
      <td style="text-align: left">UNION ALL rewrites, join propagation, z-order pushdown</td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">BackgroundWorker</code></td>
      <td style="text-align: left">Autonomous server processes</td>
      <td style="text-align: left">1-second changelog poller + scope-affinity refresh</td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">GucRegistry</code></td>
      <td style="text-align: left">Custom <code class="language-plaintext highlighter-rouge">postgresql.conf</code> settings</td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">spiral.worker_enabled</code>, <code class="language-plaintext highlighter-rouge">spiral.max_workers</code>, etc.</td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">Spi</code></td>
      <td style="text-align: left">Execute SQL from Rust</td>
      <td style="text-align: left">Changelog queries, metadata lookups, rollup SQL emission</td>
    </tr>
  </tbody>
</table>

<h2 id="what-pgrx-makes-easy">What pgrx Makes Easy</h2>

<p><strong>Zero-cost type mapping.</strong> Rust <code class="language-plaintext highlighter-rouge">f64</code> maps to PostgreSQL <code class="language-plaintext highlighter-rouge">float8</code>, <code class="language-plaintext highlighter-rouge">i64</code> to <code class="language-plaintext highlighter-rouge">bigint</code>, <code class="language-plaintext highlighter-rouge">Vec&lt;Option&lt;String&gt;&gt;</code> to <code class="language-plaintext highlighter-rouge">text[]</code>. No manual <code class="language-plaintext highlighter-rouge">Datum</code> casting needed.</p>

<p><strong>Memory safety in unsafe territory.</strong> PostgreSQL’s C API is deeply unsafe — raw pointers to <code class="language-plaintext highlighter-rouge">pg_sys::Query</code>, <code class="language-plaintext highlighter-rouge">pg_sys::PlannedStmt</code>, page buffers. pgrx wraps enough to make the common paths safe, while letting you drop to <code class="language-plaintext highlighter-rouge">unsafe</code> where needed (TAM scan callbacks, page manipulation).</p>

<p><strong>Aggregate functions with combine support.</strong> The <code class="language-plaintext highlighter-rouge">parallel_safe</code> attribute on <code class="language-plaintext highlighter-rouge">#[pg_extern]</code> tells PostgreSQL the function can run across parallel workers — enabling automatic parallelism for rollup refreshes at no extra code cost.</p>

<h2 id="what-is-still-hard">What is Still Hard</h2>

<p><strong>The planner hook is raw C.</strong> <code class="language-plaintext highlighter-rouge">planner_hook_type</code> takes an <code class="language-plaintext highlighter-rouge">unsafe extern "C-unwind" fn</code>. Traversing <code class="language-plaintext highlighter-rouge">pg_sys::Query</code>, <code class="language-plaintext highlighter-rouge">pg_sys::RangeTblEntry</code>, and the JoinTree requires pointer arithmetic and intimate knowledge of PostgreSQL internals. pgrx does not abstract this — you read the PostgreSQL source to understand the node shapes.</p>

<p><strong>TAM callbacks must not panic.</strong> Any Rust panic inside a TAM callback (<code class="language-plaintext highlighter-rouge">scan_getnextslot</code>, <code class="language-plaintext highlighter-rouge">tuple_insert</code>) crashes the backend. You must catch all errors before they unwind past the C ABI boundary.</p>

<p><strong>Testing requires a running PostgreSQL.</strong> <code class="language-plaintext highlighter-rouge">#[pg_test]</code> spins up a real Postgres instance per test run. The setup is automatic with pgrx but CI times are longer than unit tests — integration is the only meaningful test boundary.</p>

<h2 id="starting-point-for-your-own-extension">Starting Point for Your Own Extension</h2>

<p>If you want to build a PostgreSQL extension in Rust:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cargo <span class="nb">install </span>cargo-pgrx
cargo pgrx new my_extension
cargo pgrx run pg17   <span class="c"># drops into psql with your extension loaded</span>
</code></pre></div></div>

<p>The <a href="https://github.com/pgcentralfoundation/pgrx/tree/develop/pgrx-examples">pgrx examples</a> cover custom types, aggregates, operators, and background workers. Spiral’s source is also heavily commented — the TAM implementation in <code class="language-plaintext highlighter-rouge">src/tam.rs</code> and the planner hook in <code class="language-plaintext highlighter-rouge">src/hooks.rs</code> are good starting points for those two harder topics.</p>

<p>The PostgreSQL extension ecosystem needs more Rust. The barrier is lower than it looks.</p>

<h1 id="open-for-collaboration">Open for Collaboration</h1>

<p>Building Spiral has been a rewarding learning experience. This is a <strong>work in progress</strong>. If you are a PostgreSQL internals expert, a Rustacean, or someone who loves high-performance storage, I invite you to contribute.</p>

<hr />
<p><em>Spiral is open source. <a href="https://github.com/jonatas/spiral">Check it out on GitHub</a> and let’s explore the future of storage together.</em></p>]]></content><author><name>Jônatas Davi Paganini</name><email>jonatasdp@gmail.com</email></author><category term="postgresql" /><category term="rust" /><category term="engineering" /><category term="spiral" /><category term="research" /><category term="database-internals" /><category term="z-order" /><category term="time-series" /><summary type="html"><![CDATA[A look into Spiral: my research project on multidimensional storage engines, built as a way to learn PostgreSQL internals through Rust.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/images/postgresql-performance-workshop.webp" /><media:content medium="image" url="/images/postgresql-performance-workshop.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">MCP and Fast: Grepping SQL Code Like a Boss Part 2</title><link href="/mcp-sql-cte-optimization" rel="alternate" type="text/html" title="MCP and Fast: Grepping SQL Code Like a Boss Part 2" /><published>2026-04-17T00:00:00+00:00</published><updated>2026-04-17T00:00:00+00:00</updated><id>/mcp-sql-cte-optimization</id><content type="html" xml:base="/mcp-sql-cte-optimization"><![CDATA[<p>Two years ago, I gave a talk at Lambda Days 2024 titled <em>“Grepping SQL Code like a boss”</em>. In that talk, I explored how we could use the <a href="https://github.com/jonatas/fast"><code class="language-plaintext highlighter-rouge">fast</code></a> gem—originally designed for Ruby ASTs—to parse, navigate, and refactor SQL code using PostgreSQL’s native parser.</p>

<p>Since then, the AI landscape has shifted massively, and the tools we use to navigate codebases have evolved. Today, I’m thrilled to announce a game-changing update to the <code class="language-plaintext highlighter-rouge">fast</code> gem: <strong>Model Context Protocol (MCP) Support for SQL</strong>.</p>

<h2 id="expanding-fast-with-mcp">Expanding Fast with MCP</h2>

<p>We recently integrated full MCP support directly into the <code class="language-plaintext highlighter-rouge">fast</code> CLI. This allows modern AI assistants (like Claude, Gemini, or custom agents) to seamlessly interface with your SQL and Ruby codebase through structured, semantic queries rather than fragile regular expressions.</p>

<p>The new MCP server provides three incredibly powerful SQL tools:</p>
<ol>
  <li><code class="language-plaintext highlighter-rouge">search_sql_ast</code>: Search SQL files using Fast AST patterns.</li>
  <li><code class="language-plaintext highlighter-rouge">rewrite_sql</code>: Preview structural SQL transformations safely.</li>
  <li><code class="language-plaintext highlighter-rouge">rewrite_sql_file</code>: Apply AST pattern replacements to SQL files in-place.</li>
</ol>

<p>By coupling MCP with <code class="language-plaintext highlighter-rouge">fast</code>, AI agents can understand the deep semantic structure of your SQL queries, not just the text. They can find specific nodes (like <code class="language-plaintext highlighter-rouge">create_hypertable</code> in TimescaleDB) or locate poorly optimized queries, and even rewrite them autonomously.</p>

<h2 id="the-problem-massive-cte-duplication">The Problem: Massive CTE Duplication</h2>

<p>Let’s look at a concrete optimization problem where AST analysis shines. In massive analytical queries, it’s common to use Common Table Expressions (CTEs) to organize logic. However, developers or ORMs often accidentally duplicate the inner logic of a CTE later in the query.</p>

<p>Consider this example:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">WITH</span> <span class="n">sales_2023</span> <span class="k">AS</span> <span class="p">(</span>
  <span class="k">SELECT</span> <span class="n">region</span><span class="p">,</span> <span class="k">SUM</span><span class="p">(</span><span class="n">amount</span><span class="p">)</span> <span class="k">as</span> <span class="n">total_sales</span>
  <span class="k">FROM</span> <span class="n">sales</span>
  <span class="k">WHERE</span> <span class="k">extract</span><span class="p">(</span><span class="nb">year</span> <span class="k">from</span> <span class="nb">date</span><span class="p">)</span> <span class="o">=</span> <span class="mi">2023</span>
  <span class="k">GROUP</span> <span class="k">BY</span> <span class="n">region</span>
<span class="p">),</span>
<span class="n">top_regions</span> <span class="k">AS</span> <span class="p">(</span>
  <span class="k">SELECT</span> <span class="n">region</span>
  <span class="k">FROM</span> <span class="p">(</span>
    <span class="c1">-- This inner query is identical to the sales_2023 CTE!</span>
    <span class="c1">-- The database might re-compute this unnecessarily.</span>
    <span class="k">SELECT</span> <span class="n">region</span><span class="p">,</span> <span class="k">SUM</span><span class="p">(</span><span class="n">amount</span><span class="p">)</span> <span class="k">as</span> <span class="n">total_sales</span>
    <span class="k">FROM</span> <span class="n">sales</span>
    <span class="k">WHERE</span> <span class="k">extract</span><span class="p">(</span><span class="nb">year</span> <span class="k">from</span> <span class="nb">date</span><span class="p">)</span> <span class="o">=</span> <span class="mi">2023</span>
    <span class="k">GROUP</span> <span class="k">BY</span> <span class="n">region</span>
  <span class="p">)</span> <span class="n">sub</span>
  <span class="k">WHERE</span> <span class="n">total_sales</span> <span class="o">&gt;</span> <span class="mi">100000</span>
<span class="p">)</span>
<span class="k">SELECT</span> <span class="n">s</span><span class="p">.</span><span class="n">region</span><span class="p">,</span> <span class="n">s</span><span class="p">.</span><span class="n">total_sales</span>
<span class="k">FROM</span> <span class="n">sales_2023</span> <span class="n">s</span>
<span class="k">JOIN</span> <span class="n">top_regions</span> <span class="n">t</span> <span class="k">ON</span> <span class="n">s</span><span class="p">.</span><span class="n">region</span> <span class="o">=</span> <span class="n">t</span><span class="p">.</span><span class="n">region</span><span class="p">;</span>
</code></pre></div></div>

<p>This query re-compiles the exact same aggregation twice. In a large database, this is an expensive mistake.</p>

<h2 id="automating-cte-optimization-with-fast">Automating CTE Optimization with Fast</h2>

<p>Using <code class="language-plaintext highlighter-rouge">fast</code>, we can write a script to automatically map all defined CTEs and then scan the rest of the AST to see if any subqueries are structurally identical to the CTEs we just declared.</p>

<p>Here is the <code class="language-plaintext highlighter-rouge">Fastfile</code> shortcut we built to prove the concept:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">Fast</span><span class="p">.</span><span class="nf">shortcut</span> <span class="ss">:find_duplicate_ctes</span> <span class="k">do</span>
  <span class="nb">require</span> <span class="s1">'fast/sql'</span>
  <span class="n">file</span> <span class="o">=</span> <span class="no">ARGV</span><span class="p">.</span><span class="nf">last</span>
  <span class="n">ast</span> <span class="o">=</span> <span class="no">Fast</span><span class="p">.</span><span class="nf">parse_sql_file</span><span class="p">(</span><span class="n">file</span><span class="p">)</span>

  <span class="c1"># Map all CTEs</span>
  <span class="n">ctes</span> <span class="o">=</span> <span class="p">{}</span>
  <span class="no">Fast</span><span class="p">.</span><span class="nf">search</span><span class="p">(</span><span class="s1">'(common_table_expr ...)'</span><span class="p">,</span> <span class="n">ast</span><span class="p">).</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">node</span><span class="o">|</span>
    <span class="n">name_node</span> <span class="o">=</span> <span class="n">node</span><span class="p">.</span><span class="nf">search</span><span class="p">(</span><span class="s1">'(ctename $_)'</span><span class="p">).</span><span class="nf">first</span>
    <span class="n">query_wrapper</span> <span class="o">=</span> <span class="n">node</span><span class="p">.</span><span class="nf">search</span><span class="p">(</span><span class="s1">'(ctequery $_)'</span><span class="p">).</span><span class="nf">first</span>
    <span class="k">if</span> <span class="n">name_node</span> <span class="o">&amp;&amp;</span> <span class="n">query_wrapper</span>
      <span class="nb">name</span> <span class="o">=</span> <span class="n">name_node</span><span class="p">.</span><span class="nf">children</span><span class="p">.</span><span class="nf">first</span> <span class="c1"># extract string name</span>
      <span class="n">ctes</span><span class="p">[</span><span class="nb">name</span><span class="p">]</span> <span class="o">=</span> <span class="n">query_wrapper</span><span class="p">.</span><span class="nf">children</span><span class="p">.</span><span class="nf">first</span> <span class="c1"># the actual select_stmt</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="c1"># Helper to recursively walk the AST for all SELECT statements</span>
  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">walk</span><span class="p">(</span><span class="n">node</span><span class="p">,</span> <span class="n">results</span> <span class="o">=</span> <span class="p">[])</span>
    <span class="n">results</span> <span class="o">&lt;&lt;</span> <span class="n">node</span> <span class="k">if</span> <span class="n">node</span><span class="p">.</span><span class="nf">respond_to?</span><span class="p">(</span><span class="ss">:type</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="n">node</span><span class="p">.</span><span class="nf">type</span> <span class="o">==</span> <span class="ss">:select_stmt</span>
    <span class="k">if</span> <span class="n">node</span><span class="p">.</span><span class="nf">respond_to?</span><span class="p">(</span><span class="ss">:children</span><span class="p">)</span>
      <span class="n">node</span><span class="p">.</span><span class="nf">children</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">child</span><span class="o">|</span>
        <span class="k">if</span> <span class="n">child</span><span class="p">.</span><span class="nf">is_a?</span><span class="p">(</span><span class="no">Array</span><span class="p">)</span>
          <span class="n">child</span><span class="p">.</span><span class="nf">each</span> <span class="p">{</span> <span class="o">|</span><span class="n">c</span><span class="o">|</span> <span class="n">walk</span><span class="p">(</span><span class="n">c</span><span class="p">,</span> <span class="n">results</span><span class="p">)</span> <span class="p">}</span>
        <span class="k">else</span>
          <span class="n">walk</span><span class="p">(</span><span class="n">child</span><span class="p">,</span> <span class="n">results</span><span class="p">)</span>
        <span class="k">end</span>
      <span class="k">end</span>
    <span class="k">end</span>
    <span class="n">results</span>
  <span class="k">end</span>

  <span class="c1"># Compare inner subqueries against defined CTEs</span>
  <span class="n">walk</span><span class="p">(</span><span class="n">ast</span><span class="p">).</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">node</span><span class="o">|</span>
    <span class="n">ctes</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="nb">name</span><span class="p">,</span> <span class="n">query</span><span class="o">|</span>
      <span class="c1"># Deep AST structural comparison ignoring whitespace formatting</span>
      <span class="k">if</span> <span class="n">node</span><span class="p">.</span><span class="nf">to_s</span><span class="p">.</span><span class="nf">gsub</span><span class="p">(</span><span class="sr">/\s+/</span><span class="p">,</span> <span class="s1">' '</span><span class="p">)</span> <span class="o">==</span> <span class="n">query</span><span class="p">.</span><span class="nf">to_s</span><span class="p">.</span><span class="nf">gsub</span><span class="p">(</span><span class="sr">/\s+/</span><span class="p">,</span> <span class="s1">' '</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="n">node</span><span class="p">.</span><span class="nf">object_id</span> <span class="o">!=</span> <span class="n">query</span><span class="p">.</span><span class="nf">object_id</span>
        <span class="nb">puts</span> <span class="s2">"Found duplication of CTE '</span><span class="si">#{</span><span class="nb">name</span><span class="si">}</span><span class="s2">'!"</span>
        <span class="nb">puts</span> <span class="s2">"Lines: </span><span class="si">#{</span><span class="n">node</span><span class="p">.</span><span class="nf">loc</span><span class="p">.</span><span class="nf">expression</span><span class="p">.</span><span class="nf">first_line</span><span class="si">}</span><span class="s2">..</span><span class="si">#{</span><span class="n">node</span><span class="p">.</span><span class="nf">loc</span><span class="p">.</span><span class="nf">expression</span><span class="p">.</span><span class="nf">last_line</span><span class="si">}</span><span class="s2">"</span>
      <span class="k">end</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Running this via <code class="language-plaintext highlighter-rouge">fast .find_duplicate_ctes cte_example.sql</code> instantaneously finds the duplicated logic without getting confused by varying indentation or line breaks.</p>

<h2 id="the-future-with-mcp">The Future with MCP</h2>

<p>With the <code class="language-plaintext highlighter-rouge">fast</code> MCP server, you no longer have to write these Ruby scripts manually every time. An AI assistant can perform the AST structural analysis for you on the fly.</p>

<p>We can instruct the AI to:</p>
<blockquote>
  <p>“Analyze all <code class="language-plaintext highlighter-rouge">.sql</code> files in the repository. Identify any subqueries that are identical to existing CTEs, and rewrite the file to use the CTE reference instead.”</p>
</blockquote>

<p>The agent will leverage <code class="language-plaintext highlighter-rouge">search_sql_ast</code> and <code class="language-plaintext highlighter-rouge">rewrite_sql_file</code> to perform surgical, AST-aware refactoring that is magnitudes safer than any Regex approach could ever be.</p>

<h2 id="conclusion">Conclusion</h2>

<p>The integration of MCP into the <code class="language-plaintext highlighter-rouge">fast</code> gem marks a significant milestone in how we interact with SQL and Ruby codebases. By providing AI agents with the ability to understand and manipulate ASTs directly, we unlock new levels of automation and optimization that were previously too complex or risky.</p>

<p>Whether you’re hunting for duplicate CTEs or refactoring legacy queries, the combination of structured analysis and AI intelligence makes the process faster, safer, and much more intuitive.</p>

<p>Happy hacking!</p>]]></content><author><name>Jônatas Davi Paganini</name><email>jonatasdp@gmail.com</email></author><category term="programming" /><category term="sql" /><category term="ruby" /><category term="automation" /><category term="mcp" /><category term="ast" /><category term="optimization" /><summary type="html"><![CDATA[Discover the newest MCP integration in the Fast gem, expanding our ability to grep and optimize massive SQL structures, including CTE duplication detection.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/images/banner-mcp-sql-cte-optimization.png" /><media:content medium="image" url="/images/banner-mcp-sql-cte-optimization.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Where is your mind?</title><link href="/where-is-your-mind" rel="alternate" type="text/html" title="Where is your mind?" /><published>2026-04-14T00:00:00+00:00</published><updated>2026-04-14T00:00:00+00:00</updated><id>/where-is-your-mind</id><content type="html" xml:base="/where-is-your-mind"><![CDATA[<p>I see there’s an infinite cognitive unload now that we can proxy everything to
LLMs. But then, the question arises: where is my mind? How am I keeping
up?</p>

<h2 id="the-no-excuses-era">The No-Excuses Era</h2>

<p>In my <a href="/no-excuses">previous post</a>, I shared the idea that we reached the no-excuses era,
as we evolve and don’t have the same barriers we had before. Now, I focus on what we’re
doing with our time and how this delegation is fragmenting and stretching the
distance between those that are leveraging AI to become lazy and just reduce the
working time, and on the other side, we have people that are really learning from
it.</p>

<p>This is a classic problem of <a href="/time-economics">Time Economics</a>. If we’re 
unloading the cognitive weight of low-level tasks, we must be intentional about 
how we reinvest that saved time. Are we investing it in “Future Value” 
activities—learning foundational concepts that AI still struggles with—or are we 
just increasing our consumption of “Low Authorship” reactive work?</p>

<p>So, if you’re learning fast with AI, what exactly are you learning? Are you just
adding more skills, connecting agents, or are you really getting smarter?</p>

<p>Better prompts, a set of tools that will make you use less tokens? Settings
that can be tweaked internally in the next interaction.</p>

<p>How do you judge it as you learn, and how is learning going to change as systems
become more advanced and need less human input?</p>

<h2 id="are-we-getting-smarter">Are We Getting Smarter?</h2>

<p>I’m curious about a few things:</p>

<ul>
  <li>Am I just QA-ing for AI?</li>
  <li>Do we have a set of tools that needs to be created by humans?</li>
</ul>

<p>I see AI will turn the full-stack developer into a holo-developer getting
involved from conception, research, and validation to other areas.</p>

<p>The path you get is the path to fast adaptation. Find the gaps where AI is still not good
and play hard with it. Get better and know exactly what you can leverage from it
and what is still in your power as a proxy for the intent, holding the objectives,
validating, and reiterating.</p>

<h2 id="the-holo-developer">The Holo-Developer</h2>

<p>I’m testing and validating the <a href="/yoga">yoga</a> app, and I’m certainly improving the
way I interact, not only building better prompts but iterating better.</p>

<p>Every time you think about your wording and how it will impact the way the
LLM sets the work for the agents. Every time you iterate over it and
observe, learn, and understand the reasoning, you can use it faster and more
accurately.</p>

<p>But use it for what? Now, it seems building systems will become far easier than in
the past, so what new things are you going to build?</p>

<p>For some of us, <a href="/programming-is-a-drug">programming is a drug</a>. The dopamine hit 
of seeing code work is now amplified by AI speed. But if we’re just chasing that 
high without deep understanding, we’re not becoming better engineers; we’re 
just becoming more efficient addicts. The real challenge is maintaining 
the “thinking programmer” mindset when the tool can do most of the “doing.”</p>

<h2 id="the-migration-to-agentic-workflows">The Migration to Agentic Workflows</h2>

<p>We’ll evolve our mindset to more agent-driven workflows, where we set the
boundaries of the challenges and let AI decide and validate the options
instead of thinking our mere single POV is worth everything.</p>

<p>Now, we’re in the migration to the agentic era. Fewer developers, higher
throughput, easier to experiment, adopt, and reiterate.</p>

<p>So, if you’re not loading the same cognitive weight, where is your mind?</p>]]></content><author><name>Jônatas Davi Paganini</name><email>jonatasdp@gmail.com</email></author><category term="philosophy" /><category term="productivity" /><category term="technology" /><category term="ai" /><category term="learning" /><category term="cognitive-load" /><category term="agentic-workflows" /><summary type="html"><![CDATA[Reflecting on the cognitive unload in the AI era and how we keep up with learning and adaptation.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/images/monk-thinking-ai.png" /><media:content medium="image" url="/images/monk-thinking-ai.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">No Excuses</title><link href="/no-excuses" rel="alternate" type="text/html" title="No Excuses" /><published>2026-04-09T00:00:00+00:00</published><updated>2026-04-09T00:00:00+00:00</updated><id>/no-excuses</id><content type="html" xml:base="/no-excuses"><![CDATA[<p><em>This is my personal take — and I know it’s an intense one. I’m aware that for many people, the flood of LLMs generating content and code is more overwhelming than liberating. I don’t want to bring my bubble as your reality. This is just one point of view, from someone who gets genuinely excited about this stuff.</em></p>

<p>I don’t know how you feel when you see time flying but I feel it’s the best
time ever to be a developer and see time being stretched immensely with the
use of AI to build software.</p>

<p>I never feel so happy as when I have an idea and I can just code it at the speed of
my ability to put it in words in detail.</p>

<p>When I don’t have all the words, most of it works very well for simple scenarios
and it’s been amazing to prototype ideas.</p>

<p>Well, I’m a big fan of prototypes. I even had a company in the past that was
solely dedicated to patents and prototypes, and we had built quite a few
prototypes that involve hardware and software and sketching the main backbones
of software behavior. I think my ability to document my steps was one of the
most important steps in my career and it helped me to build my own authority
online and become a big fan of documenting all the things.</p>

<p>I feel it’s never been easier to prototype and prove new ideas. In the past,
even when we had good ideas, prototyping full scenarios was hard because we were
overwhelmed by the amount of new things happening around us.</p>

<p>Now, I don’t feel we have excuses to not do things.
For quite a while, several times in a week, we were surrounded by blockers like:</p>

<ol>
  <li>I got stuck on test coverage</li>
  <li>I was not able to reproduce</li>
  <li>I could not inspect it properly</li>
  <li>I don’t know exactly how X works</li>
</ol>

<p>Too long days where your energy was simply drained out from a big
problem out of our scope domain, and now we can just learn it on the fly or rely
on the expertise without going too deep.</p>

<p>What I love about it is that we have the ability to zoom in and out on every
rabbit hole and choose topics and fill learning gaps, to become better and better engineers.</p>

<p>The ability to write your code with the AI should never surrender your cognitive
perception but work to amplify it. Learn to filter what overwhelms and focus on
continuing to learn.</p>

<p>Do you think just plugging more skills will make you smarter?</p>

<p>It’s not about installing more skills but better using them. Thinking more creatively
on the future of our careers and how we’re expanding the potential into another
scale.</p>

<p>If you get stuck, it does not take weeks but hours to move forward. Monthly achievements
now are weekly and I love to see how agents are finally cooperating.</p>

<p>All big challenges seem like they’re becoming much easier to explore. To dive into the
challenges, organize, brainstorm, understand alternatives and collaborate like a
pro, you don’t need a big team, you can just sit with your favorite set of LLMs
and have fun seeing your agents working and digesting, comparing, benchmarking,
and trying to deeply get “the best” from your input.</p>

<p>Well, we have only a single valid excuse: we’ve reached the rate limit.</p>]]></content><author><name>Jônatas Davi Paganini</name><email>jonatasdp@gmail.com</email></author><category term="programming" /><category term="career" /><summary type="html"><![CDATA[On how AI has removed most of the excuses we used to have for not building things — and what that means for how we grow as engineers.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/images/no-excuses-solo-developer.png" /><media:content medium="image" url="/images/no-excuses-solo-developer.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Saving LLM Tokens with Fast: AST Folding &amp;amp; Dependency Free</title><link href="/fast-ast-folding-token-savings" rel="alternate" type="text/html" title="Saving LLM Tokens with Fast: AST Folding &amp;amp; Dependency Free" /><published>2026-03-29T00:00:00+00:00</published><updated>2026-03-29T00:00:00+00:00</updated><id>/fast-ast-folding-token-savings</id><content type="html" xml:base="/fast-ast-folding-token-savings"><![CDATA[<p>If you’ve been following my progress with the <a href="https://github.com/jonatas/fast">Fast gem</a>, you probably know I’m a big fan  of exploring code with Abstract Syntax Trees (ASTs) and using them to search and refactor code like a boss. But lately, I’ve had a new challenge on my plate: making <code class="language-plaintext highlighter-rouge">fast</code> a first-class citizen for AI agents.</p>

<p>When you’re building an LLM agent to interact with a massive Rails codebase, giving it full file contents can quickly devour your token limits. A 2000-line model file isn’t just hard for humans to read; it’s expensive and noisy for an AI.</p>

<p>I wanted a way for LLMs to easily grab the “skeleton” of a file—just the class definitions, methods, validations, and associations—without reading every inner block of logic. And as I started building this, I realized it was also the perfect time to pay off some long-standing technical debt.</p>

<h3 id="dropping-the-parser-gem-dependency">Dropping the <code class="language-plaintext highlighter-rouge">parser</code> Gem Dependency</h3>

<p>For years, <code class="language-plaintext highlighter-rouge">fast</code> was married to the <code class="language-plaintext highlighter-rouge">parser</code> gem. It’s an incredible piece of software, but it has some downsides. It’s an external dependency that frequently needs to catch up with new Ruby syntaxes.</p>

<p>Recently, Codex, Claude and Gemini helped me to made a massive architectural shift: I removed the <code class="language-plaintext highlighter-rouge">parser</code> gem dependency entirely in favor of <a href="https://github.com/ruby/prism">Prism</a> (and Ruby’s native syntax parser).</p>

<p>This refactoring wasn’t just about reducing the dependency graph. It was about making <code class="language-plaintext highlighter-rouge">fast</code> leaner and deeply integrated with modern Ruby internals.</p>

<p>The best part? I managed to do this while keeping the core <code class="language-plaintext highlighter-rouge">fast</code> search API fully backward-compatible. All the node patterns you’re used to—things like <code class="language-plaintext highlighter-rouge">(send (int _) :+ (int _))</code> or <code class="language-plaintext highlighter-rouge">{int float}</code>—continue to work effortlessly because <code class="language-plaintext highlighter-rouge">fast</code> elegantly adapts the Prism AST output back into the familiar <code class="language-plaintext highlighter-rouge">parser</code>-like node structures. Every single tutorial and script I’ve written over the years still works!</p>

<h3 id="ast-folding-the-llm-token-saver">AST Folding: The LLM Token Saver</h3>

<p>With the dependencies cleaned up, I focused on the LLM challenge: token efficiency. I introduced an <strong>AST folding</strong> feature (<code class="language-plaintext highlighter-rouge">--level N</code>) into the CLI.</p>

<p>To see it in action, let’s use <a href="https://github.com/rubyvideo/rubyevents">RubyEvents</a> as a playground and benchmark. Take the <code class="language-plaintext highlighter-rouge">Talk</code> model (<code class="language-plaintext highlighter-rouge">app/models/talk.rb</code>), which handles a lot of business logic. The regular file is massive:</p>
<ul>
  <li><strong>723 lines of code</strong></li>
  <li><strong>~23,000 characters</strong></li>
</ul>

<p>Dumping that straight into an LLM context is expensive and mostly noise. The AI doesn’t need the inner workings of <code class="language-plaintext highlighter-rouge">def video_available?</code> immediately—it just needs the class signature to understand the architecture.</p>

<p>When we use the <code class="language-plaintext highlighter-rouge">.summary</code> shortcut built into my local <code class="language-plaintext highlighter-rouge">Fastfile</code> (which leverages AST folding), we can generate a perfectly condensed skeleton of the class:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>fast .summary ../rubyevents/app/models/talk.rb
</code></pre></div></div>

<p>Instead of thousands of lines, the agent receives a beautifully clean architectural map:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Talk</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>

  <span class="no">WATCHABLE_PROVIDERS</span> <span class="o">=</span> <span class="p">[</span><span class="o">...</span><span class="p">]</span>
  <span class="no">KIND_LABELS</span> <span class="o">=</span> <span class="p">{</span><span class="o">...</span><span class="p">}</span>

  <span class="kp">include</span> <span class="no">Rollupable</span>
  <span class="c1"># ... (6 includes)</span>

  <span class="n">belongs_to</span> <span class="ss">:event</span><span class="p">,</span> <span class="ss">optional: </span><span class="kp">true</span><span class="p">,</span> <span class="ss">counter_cache: :talks_count</span><span class="p">,</span> <span class="ss">touch: </span><span class="kp">true</span>
  <span class="n">has_many</span> <span class="ss">:child_talks</span><span class="p">,</span> <span class="ss">class_name: </span><span class="s2">"Talk"</span><span class="p">,</span> <span class="ss">foreign_key: :parent_talk_id</span><span class="p">,</span> <span class="ss">dependent: :destroy</span>
  <span class="c1"># ... (30+ associations cleanly grouped)</span>

  <span class="no">Scopes</span><span class="p">:</span>
    <span class="n">without_raw_transcript</span>
    <span class="n">with_raw_transcript</span>
    <span class="n">for_topic</span><span class="p">(</span><span class="n">topic_slug</span><span class="p">)</span>
    <span class="c1"># ...</span>

  <span class="no">Hooks</span><span class="p">:</span> <span class="n">before_validation</span> <span class="ss">:set_kind</span><span class="p">,</span> <span class="ss">if: </span><span class="o">-&gt;</span> <span class="p">{</span> <span class="o">!</span><span class="n">kind_changed?</span> <span class="p">}</span>

  <span class="no">Validations</span><span class="p">:</span>
    <span class="ss">:title</span><span class="p">,</span> <span class="ss">presence: </span><span class="kp">true</span>
    <span class="ss">:date</span><span class="p">,</span> <span class="ss">presence: </span><span class="kp">true</span>

  <span class="no">Macros</span><span class="p">:</span>
    <span class="n">configure_slug</span><span class="p">(</span><span class="ss">attribute: :title</span><span class="p">,</span> <span class="ss">auto_suffix_on_collision: </span><span class="kp">true</span><span class="p">)</span>
    <span class="c1"># ...</span>

  <span class="k">def</span> <span class="nf">published?</span>
  <span class="k">def</span> <span class="nf">video_available?</span>
  <span class="k">def</span> <span class="nf">thumbnail_url</span><span class="p">(</span><span class="n">size</span><span class="p">:,</span> <span class="n">request</span><span class="p">:)</span>
  <span class="c1"># ... (40+ method signatures without their bodies)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>What just happened? Setting the proper folding levels provides extreme token savings for large language models:</p>
<ol>
  <li><strong>No comments:</strong> We strip out comment clutter automatically. The LLM gets raw architectural design.</li>
  <li><strong>No deep details, only on-demand unfolding:</strong> All method implementations are suppressed, leaving only signatures.</li>
</ol>

<p>The payload shrinks down to barely <strong>130 lines (~4,300 chars)</strong>. We get over an <strong>80% reduction in tokens</strong> while retaining 100% of the class’s structure. If the agent decides it needs the deep details of <code class="language-plaintext highlighter-rouge">video_available?</code>, it can query for that method’s specific body rather than paying for the entire 700-line file.</p>

<h3 id="mcp-inline-experiments-and-refactoring">MCP: Inline Experiments and Refactoring</h3>

<p>To truly scale LLM-driven coding on huge Ruby projects, token savings aren’t enough; we need seamless interaction. That’s why I’ve repurposed <code class="language-plaintext highlighter-rouge">fast</code>’s core into an <strong>MCP (Model Context Protocol)</strong> server tool.</p>

<p>By exposing the <code class="language-plaintext highlighter-rouge">.summary</code>, <code class="language-plaintext highlighter-rouge">.scan</code>, and direct node-pattern queries to the LLM via MCP, we establish an interactive playground:</p>
<ol>
  <li><strong>Navigating Big Codebases:</strong> Instead of <code class="language-plaintext highlighter-rouge">cat</code>ing huge files, the agent uses <code class="language-plaintext highlighter-rouge">.scan</code> across <code class="language-plaintext highlighter-rouge">lib</code> or <code class="language-plaintext highlighter-rouge">app/models</code>.</li>
  <li><strong>Inline Experiments:</strong> The AI uses <code class="language-plaintext highlighter-rouge">fast</code> patterns to test assumptions against the live AST.</li>
  <li><strong>Refactoring Confirmation:</strong> When the LLM proposes a structural mutation, it uses the MCP loop to confirm structurally what it will change. Because <code class="language-plaintext highlighter-rouge">fast</code> understands the AST, the agent can dry-run a rewrite and verify the updated output—applying precise, safe updates recursively without context bloat or syntax hallucination.</li>
</ol>

<p>I see more and more that the bottleneck for AI isn’t capability; it’s the quality of the context we feed it. By trimming the fat with AST folding and exposing a dependency-free core through MCP, <code class="language-plaintext highlighter-rouge">fast</code> is turning out to be one of the best sidekicks an AI agent could ask for.</p>

<p>If you are exploring agents and working with Ruby, give it a try and feel free to reach out. I’m having a blast building this foundation.</p>

<p>Happy hacking!</p>]]></content><author><name>Jônatas Davi Paganini</name><email>jonatasdp@gmail.com</email></author><category term="ruby" /><category term="ast" /><category term="programming" /><category term="technology" /><category term="fast" /><category term="llm" /><category term="agents" /><category term="refactoring" /><category term="prism" /><summary type="html"><![CDATA[How removing the parser gem dependency and introducing AST folding in the Fast gem helps LLM agents navigate huge codebases efficiently while saving massive amounts of tokens.]]></summary></entry><entry><title type="html">Ethics &amp;amp; Environment in Computing</title><link href="/ethics" rel="alternate" type="text/html" title="Ethics &amp;amp; Environment in Computing" /><published>2026-03-24T00:00:00+00:00</published><updated>2026-03-24T00:00:00+00:00</updated><id>/ethics</id><content type="html" xml:base="/ethics"><![CDATA[<p><strong>A talk for CS beginners</strong></p>

<p>Jônatas Davi Paganini</p>

<ul>
  <li><a href="https://ideia.me">ideia.me</a></li>
  <li><a href="https://github.com/jonatas">github.com/jonatas</a></li>
  <li><a href="https://www.linkedin.com/in/jonatasdp/">linkedin.com/jonatasdp</a></li>
</ul>

<h2 id="before-we-start--a-question">Before We Start — A Question</h2>

<p>Raise your hand if you’ve used software today.</p>

<p>Now keep it raised if that software made a decision <em>about</em> you — what you see, what you’re offered, whether you qualify for something.</p>

<p>That’s what we’re here to talk about. You’re learning to build the thing that just raised everyone’s hand.</p>

<blockquote>
  <p>The code you write will touch people you’ll never meet, in situations you never imagined.</p>
</blockquote>

<h2 id="who-am-i">Who Am I</h2>

<ul>
  <li>Developer since 2004</li>
  <li>Worked across Brazil and internationally, mostly remote</li>
  <li>Currently Senior Software Engineer at Hubstaff</li>
  <li>Previously Developer Advocate at Timescale — time-series data</li>
  <li>Passionate about open source, teaching, and living close to nature</li>
  <li>Building a permaculture farm while writing database code</li>
</ul>

<p>→ <a href="https://ideia.me/resume">ideia.me/resume</a></p>

<h2 id="a-frame-three-permaculture-ethics">A Frame: Three Permaculture Ethics</h2>

<p>Before we talk about code — a small detour into farming.</p>

<p>Permaculture is a design philosophy for sustainable human habitats modeled on nature. A forest garden doesn’t need watering, pesticides, or weekly maintenance. You design <em>with</em> nature, not against it.</p>

<p>Its foundation is three simple ethics:</p>

<ul>
  <li><strong>Earth Care</strong> — protect and restore natural systems</li>
  <li><strong>People Care</strong> — support human wellbeing and community</li>
  <li><strong>Fair Share</strong> — distribute surplus, limit consumption</li>
</ul>

<p>I’ll use these as a lens throughout this talk. Because they map surprisingly well to software.</p>

<h2 id="time-is-your-most-valuable-resource">Time Is Your Most Valuable Resource</h2>

<p><em>(People Care starts with you)</em></p>

<ul>
  <li>You have finite hours — every line of code costs time</li>
  <li>Learning something once vs. doing something manually 1000 times</li>
  <li>Automation is fundamentally about reclaiming human time</li>
</ul>

<blockquote>
  <p>Time Economics: think about the <em>return</em> on your time investment.</p>
</blockquote>

<p>→ <a href="https://ideia.me/time-economics">ideia.me/time-economics</a></p>

<h2 id="what-to-build-matters">What to Build Matters</h2>

<ul>
  <li>Not all software is worth building</li>
  <li>Who benefits? Who pays the hidden cost?</li>
  <li>The <em>why</em> behind your code shapes its impact</li>
  <li>Aligning your work with your values is a career strategy</li>
</ul>

<blockquote>
  <p>Your choices about <em>what to build</em> matter as much as <em>how to build it</em>.</p>
</blockquote>

<h2 id="what-does-ethics-mean-in-computing">What Does Ethics Mean in Computing</h2>

<p><em>(People Care at scale)</em></p>

<ul>
  <li>Who benefits from the software you write?</li>
  <li>Who is excluded or harmed?</li>
  <li>Ethics isn’t a feature — it’s a design decision from the start</li>
  <li>The consequences of code extend far beyond the codebase</li>
</ul>

<h2 id="bias-in-algorithms">Bias in Algorithms</h2>

<ul>
  <li>Algorithms trained on biased data reproduce that bias</li>
  <li>Real-world examples: hiring tools, credit scoring, predictive policing</li>
  <li>“Neutral” systems are rarely neutral</li>
  <li>Bias compounds over time and scale</li>
</ul>

<blockquote>
  <p>If your training data reflects injustice, your model will too.</p>
</blockquote>

<h2 id="privacy--data-power">Privacy &amp; Data Power</h2>

<p><em>(Fair Share — who controls the resource?)</em></p>

<ul>
  <li>Data collected is power held over users</li>
  <li>Most users don’t read privacy policies — and companies know it</li>
  <li>Data breaches are not just technical failures, they’re ethical ones</li>
  <li>Minimum viable data: collect only what you truly need</li>
</ul>

<h2 id="the-move-fast-problem">The “Move Fast” Problem</h2>

<ul>
  <li>“Move fast and break things” has real victims</li>
  <li>Speed at scale means mistakes affect millions</li>
  <li>Responsible engineering includes slowdowns for review</li>
  <li>Ethical debt is harder to pay than technical debt</li>
</ul>

<blockquote>
  <p>The “move fast” motto was never meant for systems that touch real lives.</p>
</blockquote>

<h2 id="the-scale-of-the-problem">The Scale of the Problem</h2>

<p><em>(Earth Care — the invisible infrastructure)</em></p>

<ul>
  <li>Global data centers consume ~1–2% of world electricity</li>
  <li>That’s comparable to the entire aviation industry</li>
  <li>Demand is growing faster than renewable capacity</li>
  <li>Software is invisible infrastructure — but it has a physical footprint</li>
</ul>

<h2 id="ai--energy">AI &amp; Energy</h2>

<ul>
  <li>A single large AI training run can emit as much CO₂ as 5 cars over their lifetime</li>
  <li>Inference at scale is also costly — every query has an energy cost</li>
  <li>Crypto mining and NFTs: enormous computation, narrow benefit</li>
  <li>Streaming video: largest share of global internet traffic</li>
</ul>

<h2 id="efficient-code-is-green-code">Efficient Code Is Green Code</h2>

<ul>
  <li>Your choice of algorithm literally affects the planet</li>
  <li>O(n²) vs O(n log n) isn’t just academic — at scale it’s energy</li>
  <li>Use efficient queries — don’t load what you don’t need</li>
  <li>Avoid spinning up cloud resources you don’t actually need</li>
  <li>Renewable energy is growing — but demand is growing faster</li>
  <li>“Carbon neutral” claims often rely on offsets, not reduction</li>
</ul>

<blockquote>
  <p>Every CPU cycle is a tiny combustion. Write thoughtful code.</p>
</blockquote>

<h2 id="permaculture-principles-side-by-side">Permaculture Principles Side by Side</h2>

<p>The three ethics show up everywhere in software — once you learn to see them.</p>

<table>
  <thead>
    <tr>
      <th>Permaculture Principle</th>
      <th>Software Equivalent</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Observe and interact</td>
      <td>Understand the problem before coding</td>
    </tr>
    <tr>
      <td>Catch and store energy</td>
      <td>Cache, don’t recompute</td>
    </tr>
    <tr>
      <td>Use small &amp; slow solutions</td>
      <td>Incremental delivery, MVPs</td>
    </tr>
    <tr>
      <td>Produce no waste</td>
      <td>Don’t over-engineer; delete dead code</td>
    </tr>
    <tr>
      <td>Use edges and value the marginal</td>
      <td>Edge cases matter</td>
    </tr>
    <tr>
      <td>Design from patterns to details</td>
      <td>Architecture before implementation</td>
    </tr>
    <tr>
      <td>Integrate rather than segregate</td>
      <td>Composition over isolation</td>
    </tr>
  </tbody>
</table>

<h2 id="the-mapping-holds">The Mapping Holds</h2>

<ul>
  <li><strong>Earth Care</strong> → write efficient code, minimize waste, think about the full compute lifecycle</li>
  <li><strong>People Care</strong> → build for real users, protect their data, avoid systems that harm</li>
  <li><strong>Fair Share</strong> → open source, accessible tools, don’t hoard data or power</li>
</ul>

<div class="mermaid">
mindmap
  root((Ethics))
    Earth Care
      Efficient Code
      Minimize Waste
      Compute Lifecycle
    People Care
      Real Users
      Protect Data
      Avoid Harm
    Fair Share
      Open Source
      Accessible Tools
      Don't Hoard Power
</div>

<blockquote>
  <p>Sustainable design in nature and sustainable design in software come from the same instinct: <em>don’t take more than you give back</em>.</p>
</blockquote>

<h2 id="remote-work-reduces-emissions">Remote Work Reduces Emissions</h2>

<p><em>(Earth Care in your daily work life)</em></p>

<ul>
  <li>Commuting is one of the biggest individual CO₂ sources</li>
  <li>Remote work eliminates daily travel for millions of people</li>
  <li>Less office space = less heating, cooling, and construction</li>
  <li>It enables working from places with lower cost of living or closer to nature</li>
</ul>

<p>→ <a href="https://ideia.me/disciplina-no-home-office">ideia.me/disciplina-no-home-office</a></p>

<h2 id="paths-that-combine-code--values">Paths That Combine Code &amp; Values</h2>

<ul>
  <li><strong>Environmental tech</strong>: climate modeling, energy grid optimization, precision agriculture</li>
  <li><strong>Open source</strong>: building public goods, not just private products</li>
  <li><strong>Education tech</strong>: democratizing access to learning</li>
  <li><strong>Health informatics</strong>: data that saves lives when handled responsibly</li>
  <li><strong>Policy &amp; regulation</strong>: CS people are needed in government too</li>
</ul>

<p>You don’t have to choose between a meaningful career and a technical one.</p>

<h2 id="the-ethical-developer-checklist">The Ethical Developer Checklist</h2>

<div id="ethical-checklist" class="interactive-widget">
  <div aria-live="polite" id="checklist-progress">Progress: 0/6</div>
  <form id="ethics-form">
    <div class="checkbox-wrapper"><input type="checkbox" id="chk-1" /><label for="chk-1">Who are the users of this system? Who is invisible to it?</label></div>
    <div class="checkbox-wrapper"><input type="checkbox" id="chk-2" /><label for="chk-2">What data are we collecting, and do users know?</label></div>
    <div class="checkbox-wrapper"><input type="checkbox" id="chk-3" /><label for="chk-3">What happens when this system fails or is misused?</label></div>
    <div class="checkbox-wrapper"><input type="checkbox" id="chk-4" /><label for="chk-4">Could this replace human judgment where it shouldn't?</label></div>
    <div class="checkbox-wrapper"><input type="checkbox" id="chk-5" /><label for="chk-5">Is the environmental cost of this computation justified?</label></div>
    <div class="checkbox-wrapper"><input type="checkbox" id="chk-6" /><label for="chk-6">Would it be ok if the people affected could see the code?</label></div>
  </form>
</div>

<script>
document.addEventListener('DOMContentLoaded', () => {
    const form = document.getElementById('ethics-form');
    const progress = document.getElementById('checklist-progress');
    const checkboxes = form.querySelectorAll('input[type="checkbox"]');

    const updateProgress = () => {
        let checkedCount = 0;
        checkboxes.forEach(cb => {
            if (cb.checked) checkedCount++;
        });
        progress.textContent = `Progress: ${checkedCount}/${checkboxes.length}`;
    };

    checkboxes.forEach(cb => {
        cb.addEventListener('change', updateProgress);
    });
});
</script>

<style>
.interactive-widget {
    background: rgba(0, 0, 0, 0.2);
    padding: 20px;
    border-radius: 8px;
    margin-bottom: 20px;
}
#checklist-progress {
    font-weight: bold;
    margin-bottom: 15px;
}
.checkbox-wrapper {
    margin-bottom: 10px;
    display: flex;
    align-items: flex-start;
}
.checkbox-wrapper input {
    margin-top: 4px;
    margin-right: 10px;
}
</style>

<h2 id="start-now">Start Now</h2>

<ul>
  <li>Learn to estimate the computational complexity of your code</li>
  <li>Use efficient queries — don’t load what you don’t need</li>
  <li>Read the privacy policies of the apps you build (and use)</li>
  <li>Contribute to an open source project you care about</li>
  <li>Choose an employer whose values match yours</li>
</ul>

<blockquote>
  <p>The best time to think about ethics is before you write the first line.</p>
</blockquote>

<h2 id="long-term-habits">Long-Term Habits</h2>

<ul>
  <li>Build a practice of asking “who is harmed?” before shipping</li>
  <li>Stay curious about the social context of the systems you work on</li>
  <li>Join or create communities that take ethics seriously</li>
  <li>Advocate internally — developers have more influence than they think</li>
  <li>Keep learning: CS ethics is a growing field with real careers</li>
</ul>

<h2 id="youre-early">You’re Early</h2>

<p>You haven’t shipped a broken hiring algorithm yet.
You haven’t collected data you didn’t need yet.
You haven’t written O(n³) code running on a million requests yet.</p>

<p>You’re at the beginning. That’s a gift.</p>

<p>The developers who shaped the internet mostly learned these lessons <em>after</em> the damage was done. You get to start with them.</p>

<blockquote>
  <p>The field needs people who care about what they build, not just how they build it. That’s you — if you choose it.</p>
</blockquote>

<h2 id="lets-talk">Let’s Talk</h2>

<p><strong>Jônatas Davi Paganini</strong></p>

<ul>
  <li><a href="https://ideia.me">ideia.me</a></li>
  <li><a href="https://github.com/jonatas">github.com/jonatas</a></li>
  <li>Senior Software Engineer at Hubstaff</li>
</ul>

<p><em>What questions do you have?</em></p>]]></content><author><name>Jônatas Davi Paganini</name><email>jonatasdp@gmail.com</email></author><category term="ethics" /><category term="programming" /><category term="career" /><category term="philosophy" /><category term="community" /><category term="ethics" /><category term="sustainability" /><category term="permaculture" /><category term="career" /><category term="beginners" /><summary type="html"><![CDATA[A talk for CS beginners about the ethical and environmental dimensions of software — and why the code you write shapes the world more than you think.]]></summary></entry><entry><title type="html">The Math Behind the Yoga SVG</title><link href="/math-behind-yoga-svg" rel="alternate" type="text/html" title="The Math Behind the Yoga SVG" /><published>2026-03-21T00:00:00+00:00</published><updated>2026-03-21T00:00:00+00:00</updated><id>/math-behind-yoga-svg</id><content type="html" xml:base="/math-behind-yoga-svg"><![CDATA[<p>When I set out to build an <a href="/yoga.html">Interactive Yoga Poses App</a>, I had virtually no prior experience working with complex animations and only the most basic knowledge of SVG.</p>

<p>This project, however, was about more than just web development. Over the last few nights, I’ve been testing and integrating with the <strong>Antigravity platform</strong>—using it to rescue a graveyard of abandoned, hard-to-maintain old projects and migrating them into a brand-new <a href="/apps">Apps</a> section on this website. I wanted to bring their essence back to life.</p>

<p>Building this animated yoga app became my way to deeply test the Antigravity platform. I am leveraging my curiosity to learn more about the things I feel attracted to—mixing my personal interests in yoga and math with code. I am absolutely loving the new capabilities and interactions this process is unlocking.</p>

<p>If you’ve ever tried to animate a vector graphic from scratch, you know the struggle: limbs detach, elbows bend the wrong way, and figures often look like they’re breaking apart mid-animation. But the solution here wasn’t a heavy JavaScript animation library. It was pure geometry.</p>

<p>By anchoring an SVG figure correctly and relying entirely on CSS rotational variables, you can create a skeleton that inherently understands its own joints.</p>

<h2 id="the-coordinate-system">The Coordinate System</h2>

<p>To understand how the stickman moves, we first need to understand the SVG coordinate system. Unlike standard Cartesian math where the Y-axis goes <em>up</em>, in an SVG, <strong>the Y-axis goes down</strong>.</p>

<p>This flips our intuition for rotation. If you point a line straight down (the positive Y-axis) and apply a positive rotation in CSS (<code class="language-plaintext highlighter-rouge">transform: rotate(90deg)</code>), it rotates <strong>clockwise</strong>—meaning the line swings out to the left (the negative X-axis).</p>

<p>Understanding this flipped rotational space is critical when positioning our figure for complex asymmetrical poses.</p>

<h2 id="structuring-the-joints">Structuring the Joints</h2>

<p>Instead of redrawing paths for every pose, our stickman is built once using <code class="language-plaintext highlighter-rouge">&lt;g&gt;</code> (group) elements. Every limb acts as a nested pendulum.</p>

<p>For example, the arm is built like this:</p>
<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;g</span> <span class="na">id=</span><span class="s">"upper-arm"</span> <span class="na">style=</span><span class="s">"transform-origin: shoulder-X shoulder-Y; transform: rotate(var(--upper-arm-rot))"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;line</span> <span class="err">...</span> <span class="nt">/&gt;</span> <span class="c">&lt;!-- The actual bicep --&gt;</span>
    <span class="nt">&lt;g</span> <span class="na">id=</span><span class="s">"lower-arm"</span> <span class="na">style=</span><span class="s">"transform-origin: elbow-X elbow-Y; transform: rotate(var(--lower-arm-rot))"</span><span class="nt">&gt;</span>
        <span class="nt">&lt;line</span> <span class="err">...</span> <span class="nt">/&gt;</span> <span class="c">&lt;!-- The forearm --&gt;</span>
    <span class="nt">&lt;/g&gt;</span>
<span class="nt">&lt;/g&gt;</span>
</code></pre></div></div>
<p>By placing the <code class="language-plaintext highlighter-rouge">transform-origin</code> exactly at the joints, the <code class="language-plaintext highlighter-rouge">lower-arm</code> automatically travels wherever the <code class="language-plaintext highlighter-rouge">upper-arm</code> goes. When the <code class="language-plaintext highlighter-rouge">--upper-arm-rot</code> changes, the entire nested group swings flawlessly.</p>

<p>Try it out here:</p>

<style>
.blog-debug-panel {
    margin-top: 20px;
    padding: 15px;
    background: rgba(0,0,0,0.4);
    border-radius: 8px;
    display: flex;
    flex-wrap: wrap;
    gap: 10px;
}
.blog-debug-row {
    display: flex;
    align-items: center;
    width: 200px;
    font-size: 0.8rem;
    color: #94A3B8;
}
.blog-debug-row label {
    width: 90px;
}
.blog-debug-row input[type=range] {
    flex: 1;
}
.blog-debug-row input[type=number] {
    width: 45px;
    background: transparent;
    color: white;
    border: 1px solid #333;
    margin-left: 5px;
    text-align: center;
}
</style>

<div style="background: #0f172a; padding: 20px; border-radius: 12px; margin: 20px 0; text-align: center; border: 1px solid rgba(255,255,255,0.1)">
    <!-- Yoga Figure Playground -->
    <svg viewBox="-300 -100 800 600" style="width: 100%; max-width: 600px; height: 350px; filter: drop-shadow(0 0 10px rgba(13, 148, 136, 0.3));" id="demo-svg">
        <line x1="-500" y1="368" x2="1000" y2="368" stroke="rgba(255,255,255,0.1)" stroke-width="2" />
        <g id="figure" style="transition: transform 1.5s cubic-bezier(0.4, 0.0, 0.2, 1); transform-box: view-box; transform-origin: 100px 200px; transform: translate(var(--fig-x, 0px), var(--fig-y, 0px)) rotate(var(--fig-rot, 0deg));">
            <!-- Left Leg -->
            <g style="transition: transform 1.5s cubic-bezier(0.4, 0.0, 0.2, 1); transform-box: view-box; transform-origin: 100px 200px; transform: rotate(var(--ll-rot, 0deg));">
                <line x1="100" y1="200" x2="100" y2="280" stroke="#94A3B8" stroke-width="14" stroke-linecap="round" />
                <g style="transition: transform 1.5s cubic-bezier(0.4, 0.0, 0.2, 1); transform-box: view-box; transform-origin: 100px 280px; transform: rotate(var(--lk-rot, 0deg));">
                    <line x1="100" y1="280" x2="100" y2="360" stroke="#94A3B8" stroke-width="14" stroke-linecap="round" />
                </g>
            </g>
            <!-- Right Leg -->
            <g style="transition: transform 1.5s cubic-bezier(0.4, 0.0, 0.2, 1); transform-box: view-box; transform-origin: 100px 200px; transform: rotate(var(--rl-rot, 0deg));">
                <line x1="100" y1="200" x2="100" y2="280" stroke="#F8FAFC" stroke-width="14" stroke-linecap="round" />
                <g style="transition: transform 1.5s cubic-bezier(0.4, 0.0, 0.2, 1); transform-box: view-box; transform-origin: 100px 280px; transform: rotate(var(--rk-rot, 0deg));">
                    <line x1="100" y1="280" x2="100" y2="360" stroke="#F8FAFC" stroke-width="14" stroke-linecap="round" />
                </g>
            </g>
            <!-- Upper Body -->
            <g style="transition: transform 1.5s cubic-bezier(0.4, 0.0, 0.2, 1); transform-box: view-box; transform-origin: 100px 200px; transform: rotate(var(--ub-rot, 0deg));">
                <!-- Left Arm -->
                <g style="transition: transform 1.5s cubic-bezier(0.4, 0.0, 0.2, 1); transform-box: view-box; transform-origin: 100px 100px; transform: rotate(var(--la-rot, 0deg));">
                    <line x1="100" y1="100" x2="100" y2="160" stroke="#94A3B8" stroke-width="14" stroke-linecap="round" />
                    <g style="transition: transform 1.5s cubic-bezier(0.4, 0.0, 0.2, 1); transform-box: view-box; transform-origin: 100px 160px; transform: rotate(var(--le-rot, 0deg));">
                        <line x1="100" y1="160" x2="100" y2="220" stroke="#94A3B8" stroke-width="14" stroke-linecap="round" />
                    </g>
                </g>
                <line x1="100" y1="200" x2="100" y2="100" stroke="#F8FAFC" stroke-width="16" stroke-linecap="round" />
                <g style="transition: transform 1.5s cubic-bezier(0.4, 0.0, 0.2, 1); transform-box: view-box; transform-origin: 100px 100px; transform: rotate(var(--head-rot, 0deg));">
                    <line x1="100" y1="100" x2="100" y2="80" stroke="#F8FAFC" stroke-width="10" stroke-linecap="round" />
                    <circle cx="100" cy="65" r="20" fill="#F8FAFC" />
                </g>
                <!-- Right Arm -->
                <g style="transition: transform 1.5s cubic-bezier(0.4, 0.0, 0.2, 1); transform-box: view-box; transform-origin: 100px 100px; transform: rotate(var(--ra-rot, 0deg));">
                    <line x1="100" y1="100" x2="100" y2="160" stroke="#F8FAFC" stroke-width="14" stroke-linecap="round" />
                    <g style="transition: transform 1.5s cubic-bezier(0.4, 0.0, 0.2, 1); transform-box: view-box; transform-origin: 100px 160px; transform: rotate(var(--re-rot, 0deg));">
                        <line x1="100" y1="160" x2="100" y2="220" stroke="#F8FAFC" stroke-width="14" stroke-linecap="round" />
                    </g>
                </g>
            </g>
        </g>
    </svg>
    
    <div style="display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-top: 10px;">
        <button onclick="setPose({'--ub-rot':0, '--ll-rot':0, '--rl-rot':0, '--lk-rot':0,'--rk-rot':0, '--la-rot':0, '--ra-rot':0, '--le-rot':0, '--re-rot':0, '--head-rot':0, '--fig-y':0, '--fig-x':0})" style="padding: 8px 16px; background: #0d9488; color: white; border: none; border-radius: 20px; cursor: pointer;">Tadasana</button>
        <button onclick="setPose({'--ub-rot':0, '--head-rot':-20, '--la-rot':180, '--ra-rot':180, '--ll-rot':0, '--rl-rot':0, '--lk-rot':0, '--rk-rot':0, '--le-rot':0, '--re-rot':0, '--fig-y':0, '--fig-x':0})" style="padding: 8px 16px; background: #0d9488; color: white; border: none; border-radius: 20px; cursor: pointer;">Upward Salute</button>
        <button onclick="setPose({'--ub-rot':180, '--head-rot':0, '--la-rot':60, '--le-rot':-120, '--ra-rot':60, '--re-rot':-120, '--ll-rot':0, '--rl-rot':0, '--lk-rot':0, '--rk-rot':0, '--fig-y':8, '--fig-x':0})" style="padding: 8px 16px; background: #0d9488; color: white; border: none; border-radius: 20px; cursor: pointer;">Forward Fold</button>
        <button onclick="setPose({'--fig-rot': 0, '--fig-x': -110, '--fig-y': 168, '--ub-rot': 90, '--head-rot': 0, '--la-rot': 0, '--le-rot': 0, '--ra-rot': 0, '--re-rot': 0, '--ll-rot': -90, '--lk-rot': 180, '--rl-rot': -90, '--rk-rot': 180})" style="padding: 8px 16px; background: #0d9488; color: white; border: none; border-radius: 20px; cursor: pointer;">Child's Pose</button>
    </div>

    <!-- The Debug Panel -->
    <div class="blog-debug-panel" id="blog-debug-panel"></div>
</div>
<script>
const varsDef = [
    { id: '--fig-rot', min: -180, max: 180, label: 'Fig Rot' },
    { id: '--fig-x', min: -200, max: 200, label: 'Fig X' },
    { id: '--fig-y', min: -200, max: 200, label: 'Fig Y' },
    { id: '--ub-rot', min: -180, max: 180, label: 'Hip Bend' },
    { id: '--head-rot', min: -90, max: 90, label: 'Head' },
    { id: '--la-rot', min: -360, max: 360, label: 'L Arm Up' },
    { id: '--le-rot', min: -180, max: 180, label: 'L Arm Low' },
    { id: '--ra-rot', min: -360, max: 360, label: 'R Arm Up' },
    { id: '--re-rot', min: -180, max: 180, label: 'R Arm Low' },
    { id: '--ll-rot', min: -180, max: 180, label: 'L Leg Up' },
    { id: '--lk-rot', min: -180, max: 180, label: 'L Leg Low' },
    { id: '--rl-rot', min: -180, max: 180, label: 'R Leg Up' },
    { id: '--rk-rot', min: -180, max: 180, label: 'R Leg Low' },
];

let currentVars = {};
const svgMain = document.getElementById('demo-svg');
const panel = document.getElementById('blog-debug-panel');

varsDef.forEach(v => {
    const row = document.createElement('div');
    row.className = 'blog-debug-row';
    
    const lbl = document.createElement('label');
    lbl.textContent = v.label;
    
    const range = document.createElement('input');
    range.type = 'range';
    range.id = 'inp_' + v.id;
    range.min = v.min;
    range.max = v.max;
    range.value = 0;
    
    const num = document.createElement('input');
    num.type = 'number';
    num.id = 'num_' + v.id;
    num.value = 0;

    const update = (val) => {
        range.value = val;
        num.value = val;
        currentVars[v.id] = parseInt(val);
        const cssVal = v.id.includes('rot') ? `${val}deg` : `${val}px`;
        svgMain.style.setProperty(v.id, cssVal);
    };

    range.addEventListener('input', (e) => update(e.target.value));
    num.addEventListener('input', (e) => update(e.target.value));

    row.appendChild(lbl);
    row.appendChild(range);
    row.appendChild(num);
    panel.appendChild(row);
});

function setPose(vars, targetId = 'demo-svg') {
    const targetSvg = document.getElementById(targetId);
    for (let k in vars) {
        if (targetId === 'demo-svg') {
            currentVars[k] = vars[k];
            const inp = document.getElementById('inp_' + k);
            const num = document.getElementById('num_' + k);
            if (inp && num) {
                inp.value = vars[k];
                num.value = vars[k];
            }
        }
        const cssVal = k.includes('rot') ? `${vars[k]}deg` : `${vars[k]}px`;
        targetSvg.style.setProperty(k, cssVal);
    }
}
</script>

<h2 id="the-trigonometry-of-chaturanga">The Trigonometry of Chaturanga</h2>

<p>Defining a standing pose is easy—you keep everything roughly at zero. But what happens when the body needs to hover entirely above the floor, supported only by the hands and toes?</p>

<p>In <strong>Chaturanga Dandasana (Low Plank)</strong>, the body must form a perfectly rigid incline plane. Since the toes rest on the floor (Y = 368) but the arms are bent 90 degrees at the elbow, the shoulders hover slightly lower than the hips.</p>

<p>Here is the math I used to solve this so the figure perfectly connected its toes and hands to the mat:</p>

<ol>
  <li><strong>Calculate the Vertical Drop</strong>: The upper arm goes straight back, and the lower arm goes straight down. Since each arm segment is 60px, the vertical drop from Shoulder to Hand is exactly 60px.</li>
  <li><strong>Find the Slope</strong>: If the hands are at the floor (Y = 368), the shoulders must be exactly at <code class="language-plaintext highlighter-rouge">Y = 368 - 60 = 308</code>. The toes are also at the floor (Y = 368). This means the body drops 60 vertical pixels over its entire length.</li>
  <li><strong>Trig to the Rescue</strong>: The length of the Torso (100px) + the Legs (160px) creates a hypotenuse of 260px.</li>
</ol>

<p>Because we know the opposite and the hypotenuse, we can solve for (\theta):</p>

\[\sin(\theta) = \frac{\text{Opposite}}{\text{Hypotenuse}} = \frac{60}{260} \approx 0.2307\]

<p>Taking the arcsine gives us our angle:</p>

\[\theta \approx \arcsin(0.2307) \approx 13.34^\circ\]

<p>Because the torso points up from the hips, and the hips sit in the middle of our 260px line, we can apply this <code class="language-plaintext highlighter-rouge">13.34°</code> absolute rotation directly to the Torso and Legs:</p>

<ul>
  <li>The Torso points Right (90° from UP), tilted <em>up</em> by 13.34° = <strong>76.66°</strong>.</li>
  <li>The Legs point Left (-90° from DOWN), tilted <em>down</em> by 13.34° = <strong>76.66°</strong>.</li>
</ul>

<p>When you apply these calculated CSS angles, the stickman seamlessly lowers itself into a geometrically flawless plank.</p>

<div style="background: #0f172a; padding: 20px; border-radius: 12px; margin: 20px 0; text-align: center; border: 1px solid rgba(255,255,255,0.1)">
    <div style="margin-bottom: 20px;">
        <button onclick="setPose({'--ub-rot':76.66, '--ll-rot':76.66, '--rl-rot':76.66, '--lk-rot':0,'--rk-rot':0, '--la-rot':-166.66, '--ra-rot':-166.66, '--le-rot':90, '--re-rot':90, '--head-rot':0, '--fig-y':131, '--fig-x':-50}, 'demo-svg-2')" style="padding: 8px 16px; background: #ea580c; font-weight: bold; color: white; border: none; border-radius: 20px; cursor: pointer; box-shadow: 0 4px 14px rgba(234, 88, 12, 0.4);">Execute Chaturanga</button>
        <button onclick="setPose({'--ub-rot':0, '--ll-rot':0, '--rl-rot':0, '--lk-rot':0,'--rk-rot':0, '--la-rot':0, '--ra-rot':0, '--le-rot':0, '--re-rot':0, '--head-rot':0, '--fig-y':0, '--fig-x':0}, 'demo-svg-2')" style="padding: 8px 16px; background: transparent; color: #94a3b8; border: 1px solid rgba(255,255,255,0.2); border-radius: 20px; cursor: pointer; margin-left: 10px;">Reset</button>
    </div>
    
    <svg viewBox="-300 -100 800 600" style="width: 100%; max-width: 600px; height: 350px; filter: drop-shadow(0 0 10px rgba(13, 148, 136, 0.3));" id="demo-svg-2">
        <line x1="-500" y1="368" x2="1000" y2="368" stroke="rgba(255,255,255,0.1)" stroke-width="2" />
        <g id="figure2" style="transition: transform 1.5s cubic-bezier(0.4, 0.0, 0.2, 1); transform-box: view-box; transform-origin: 100px 200px; transform: translate(var(--fig-x, 0px), var(--fig-y, 0px)) rotate(var(--fig-rot, 0deg));">
            <!-- Left Leg -->
            <g style="transition: transform 1.5s cubic-bezier(0.4, 0.0, 0.2, 1); transform-box: view-box; transform-origin: 100px 200px; transform: rotate(var(--ll-rot, 0deg));">
                <line x1="100" y1="200" x2="100" y2="280" stroke="#94A3B8" stroke-width="14" stroke-linecap="round" />
                <g style="transition: transform 1.5s cubic-bezier(0.4, 0.0, 0.2, 1); transform-box: view-box; transform-origin: 100px 280px; transform: rotate(var(--lk-rot, 0deg));">
                    <line x1="100" y1="280" x2="100" y2="360" stroke="#94A3B8" stroke-width="14" stroke-linecap="round" />
                </g>
            </g>
            <!-- Right Leg -->
            <g style="transition: transform 1.5s cubic-bezier(0.4, 0.0, 0.2, 1); transform-box: view-box; transform-origin: 100px 200px; transform: rotate(var(--rl-rot, 0deg));">
                <line x1="100" y1="200" x2="100" y2="280" stroke="#F8FAFC" stroke-width="14" stroke-linecap="round" />
                <g style="transition: transform 1.5s cubic-bezier(0.4, 0.0, 0.2, 1); transform-box: view-box; transform-origin: 100px 280px; transform: rotate(var(--rk-rot, 0deg));">
                    <line x1="100" y1="280" x2="100" y2="360" stroke="#F8FAFC" stroke-width="14" stroke-linecap="round" />
                </g>
            </g>
            <!-- Upper Body -->
            <g style="transition: transform 1.5s cubic-bezier(0.4, 0.0, 0.2, 1); transform-box: view-box; transform-origin: 100px 200px; transform: rotate(var(--ub-rot, 0deg));">
                <!-- Left Arm -->
                <g style="transition: transform 1.5s cubic-bezier(0.4, 0.0, 0.2, 1); transform-box: view-box; transform-origin: 100px 100px; transform: rotate(var(--la-rot, 0deg));">
                    <line x1="100" y1="100" x2="100" y2="160" stroke="#94A3B8" stroke-width="14" stroke-linecap="round" />
                    <g style="transition: transform 1.5s cubic-bezier(0.4, 0.0, 0.2, 1); transform-box: view-box; transform-origin: 100px 160px; transform: rotate(var(--le-rot, 0deg));">
                        <line x1="100" y1="160" x2="100" y2="220" stroke="#94A3B8" stroke-width="14" stroke-linecap="round" />
                    </g>
                </g>
                <line x1="100" y1="200" x2="100" y2="100" stroke="#F8FAFC" stroke-width="16" stroke-linecap="round" />
                <g style="transition: transform 1.5s cubic-bezier(0.4, 0.0, 0.2, 1); transform-box: view-box; transform-origin: 100px 100px; transform: rotate(var(--head-rot, 0deg));">
                    <line x1="100" y1="100" x2="100" y2="80" stroke="#F8FAFC" stroke-width="10" stroke-linecap="round" />
                    <circle cx="100" cy="65" r="20" fill="#F8FAFC" />
                </g>
                <!-- Right Arm -->
                <g style="transition: transform 1.5s cubic-bezier(0.4, 0.0, 0.2, 1); transform-box: view-box; transform-origin: 100px 100px; transform: rotate(var(--ra-rot, 0deg));">
                    <line x1="100" y1="100" x2="100" y2="160" stroke="#F8FAFC" stroke-width="14" stroke-linecap="round" />
                    <g style="transition: transform 1.5s cubic-bezier(0.4, 0.0, 0.2, 1); transform-box: view-box; transform-origin: 100px 160px; transform: rotate(var(--re-rot, 0deg));">
                        <line x1="100" y1="160" x2="100" y2="220" stroke="#F8FAFC" stroke-width="14" stroke-linecap="round" />
                    </g>
                </g>
            </g>
        </g>
    </svg>
</div>

<p>Math doesn’t just ensure algorithms run efficiently; it gives us the power to orchestrate beautiful physical geometry. Take a look at the final <a href="/yoga.html">Yoga Poses application</a> to see the full Ashtanga sequence running entirely on these trigonometric angles!</p>]]></content><author><name>Jônatas Davi Paganini</name><email>jonatasdp@gmail.com</email></author><category term="programming" /><category term="math" /><category term="yoga" /><category term="svg" /><category term="svg" /><category term="geometry" /><category term="trigonometry" /><category term="css-animations" /><category term="front-end" /><summary type="html"><![CDATA[How simple trigonometry and CSS variables combine to create anatomically connected, fully animatable SVG stick figures for yoga.]]></summary></entry><entry><title type="html">The Validation Model</title><link href="/the-validation-model" rel="alternate" type="text/html" title="The Validation Model" /><published>2026-03-18T00:00:00+00:00</published><updated>2026-03-18T00:00:00+00:00</updated><id>/the-validation-model</id><content type="html" xml:base="/the-validation-model"><![CDATA[<p>Today I’m thinking how interesting it’s been to be freelancing again. Well, I have a
full time job at <a href="/first-week-at-hubstaff">Hubstaff</a>, which we’re basically paid by hour.</p>

<p>Then, I need to track the work and I get some insights about the way I work.</p>

<p>That was the easy part, software showing me metrics about how I’m doing my work, 
how much I’m active, how much I’m focused and so on.</p>

<p>But now, in the third week, I already see so many benefits of tracking and
committing to the time I was expected to work.</p>

<p>Well, for me distraction means I’m lacking meaning and I just get distracted
when I’m unsatisfied or use distraction to control my anxiety.</p>

<p>But now I’m looking to the validation model that my brain is offering me. Yes, I
see myself more validated by the time as time is the first commitment expected
when you have a job. Later come, focus, deliveries and goal achievements.</p>

<p>I feel I work purely disconnected to the work when I leave the computer. I know
I’m not tracking my time. This is a very important factor on my brain. If I’m
really not working, I’m not working.</p>

<p>I was really not expecting that, but as the timer keeps rolling during the day I
got myself thinking much less in work and much more in optimize my free time as
I optimize my daily time.</p>

<p>In my off work week schedule, I have:</p>

<p>Monday 1.5 hours of night workout.
Tuesday 2 hours of Yoga.
Wednesday I split between Kayaking or land work. Around 2 hours.
Thursday 1 hour gym.
Friday 2 hours in the waterfall near by my house. I rest and always have some yoga there.</p>

<p>I’m always looking for ways to <a href="/time-economics">optimize and use very well my time</a>. It takes 40
min to round trip to the city I do my exercises. As I live in the country side,
and it’s around 47 km to round trip to my appointments.</p>

<p>So, when I’m going, most of the days I’m heading to a Yoga class, and my mind
already start watching the mind state purely.</p>

<p>I see my selective brain working, moving from repressive thoughts about my
performance with ok, I did my part, I focused all the time I promised and now
I’m engaging on my personal life.</p>

<p>It takes time to make it, but I observed that this time tracker is helping me to
remember about what I’m doing and what was my days. In the past few months I did
several days of 10-12 hours in the computer and felt regretful to invest too long hours.</p>

<p>That was not healthy and it was just driven by the <strong>validation</strong> that we need
to live and feel belonging. It starts with your mom saying yes or no and then
family, teachers, and later co-workers.</p>

<p>When the company culture is purely async, you don’t feel this validation. Nobody
is chasing to find you online, or value your instant answer. Your commitment is
just based on your actions, the way you interact and move your tasks forward.</p>

<p>I absolutely love it. This relief the stress of thinking about “being available”
and open room for <a href="/the-proxy-life-trap">“being focused”</a>. I always admire the way we shape our focus
and now I feel this small rewarding numbers that we chase everyday, worked as the
validation I was looking for.</p>

<p>I’m not here to convince it will work for you, but I feel that when I was not
tracking my time I was always working more, thinking more about work in the
off-working hours and never empowering my real creative idleness. Because I don’t
have any previous evidences or metrics, and the absence of human validation
makes me fall into a psychological trap to reduce my perception of my efforts.</p>

<p>I think that my work-mind relationship now is much healthier just because I found a
way to say, ok, I did it!</p>

<blockquote>
  <p>And no, I haven’t watched the series Severance but I know it exists ;)</p>
</blockquote>]]></content><author><name>Jônatas Davi Paganini</name><email>jonatasdp@gmail.com</email></author><category term="personal" /><category term="productivity" /><category term="career" /><summary type="html"><![CDATA[How a simple time tracker changed my relationship with work by giving my brain the validation it was unconsciously looking for.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/images/the-validation-model-stopwatch-split.png" /><media:content medium="image" url="/images/the-validation-model-stopwatch-split.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Learning to Breathe</title><link href="/learning-to-breathe" rel="alternate" type="text/html" title="Learning to Breathe" /><published>2026-03-16T00:00:00+00:00</published><updated>2026-03-16T00:00:00+00:00</updated><id>/learning-to-breathe</id><content type="html" xml:base="/learning-to-breathe"><![CDATA[<p>My first real encounter with breathing as a <em>practice</em> was not peaceful. It was not quiet. It was four hours of hyperventilation in a room full of strangers, sometime in my twenties.</p>

<p>Holotropic breathing — a technique developed by psychiatrist Stanislav Grof — involves sustained, rhythmic, deep breathing that induces altered states of consciousness. A facilitator leads the session. You lie down, close your eyes, and breathe intensely for extended periods. Strange things happen to the body and mind when you flood them with oxygen for that long. Some people cry. Some laugh. Some report visions.</p>

<p>What happened to me was arguably funnier than any of that.</p>

<p>About four hours in — or so I thought — I was <em>completely conscious</em>. I could feel my legs on the floor. I could hear the music in the room. I was breathing hard, present and standing, fully engaged in the exercise. I was not having visions. I was just here, aware, going through it.</p>

<p>Then someone tapped my shoulder.</p>

<p>I opened my eyes. I was lying on the mat. It was over an hour later than I thought. I had been <em>asleep</em> for more than an hour — not the gentle, drowsy kind of sleep, but the fully unconscious, gone kind — and my body had somehow maintained the posture of standing in the exercise, in my mind, the entire time. A lucid dream so complete that I had no awareness of being anywhere other than exactly where I thought I was.</p>

<p>I have never been so confused about the location of my own body.</p>

<hr />

<p>That experience planted something. The idea that breath could carry consciousness somewhere else entirely — that it was not just gas exchange but a lever for the whole nervous system — stayed with me even as I filed it under “weird twenties stuff” and moved on.</p>

<p>Four years ago I started practicing yoga seriously. And yoga is, in many ways, just structured breathing with movement attached. Every pose is an invitation to track where the breath goes, how it changes when you are in discomfort, what happens when you consciously slow it down. I have been doing this four days a week for years now.</p>

<p>And I still struggle. Not with the poses. With the breathing itself.</p>

<p>Sitting in meditation, just watching the breath — no technique, no count, just observation — is deceptively hard. The mind wanders in about four seconds. You bring it back. It wanders again. You spend the whole session not meditating but <em>noticing that you stopped meditating</em>, which is, apparently, still the practice. My teacher tells me this is fine. I believe her and also remain annoyed by it.</p>

<p>What I have found useful is that when pure observation is too slippery, a technique gives the mind something concrete to hold onto. A count. A rhythm. A physical shape to follow. The breath becomes a task, and the mind, which loves tasks, actually pays attention.</p>

<p>Here are seven techniques in the order I’d suggest learning them. Each one is slightly more involved than the last. The animations below are interactive — click the circle to begin, click again to pause.</p>

<hr />

<h2 id="1-belly-breathing--the-foundation">1. Belly Breathing — The Foundation</h2>

<p>Before any technique, there is the question of <em>where</em> the breath lands.</p>

<p>Most people under stress breathe into the chest. Shallow, fast, the collarbones rising. That pattern signals urgency to the nervous system, which responds accordingly. Belly breathing — drawing air into the diaphragm so the belly expands outward on the inhale — does the opposite. It activates the vagus nerve and tells the body it is safe.</p>

<p>To find it: lie down, one hand on your chest, one on your belly. Inhale. The hand on your belly should rise; the one on your chest should barely move. That is it. That is the whole thing.</p>

<p>It sounds obvious. Under stress, when the chest-breathing pattern is most ingrained, it is also the hardest to access. The animation below uses 4-second counts — slow enough to feel the expansion, fast enough to be comfortable.</p>

<div id="bw0" class="bw-widget"></div>

<p>Once this is natural, every other technique works better. You are breathing correctly first, then adding rhythm on top.</p>

<hr />

<h2 id="2-physiological-sigh--the-instant-reset">2. Physiological Sigh — The Instant Reset</h2>

<p>This one takes about ten seconds and requires no practice at all.</p>

<p>A physiological sigh: one normal inhale through the nose, then immediately a second shorter inhale on top of it — filling the lungs completely — followed by a long, slow exhale through the mouth.</p>

<p>The double inhale is the whole mechanism. Your alveoli — the small air sacs in the lungs — partially collapse when you are under stress or have been breathing shallowly for a while. The second inhale pops them open. The long exhale then drives out accumulated CO2 and activates the parasympathetic system hard.</p>

<p>Andrew Huberman at Stanford calls this the fastest known way to reduce acute physiological stress. The body does it spontaneously when crying, which is why crying reliably makes you feel less terrible — it is not the tears, it is the breathing pattern they come with.</p>

<div id="bw1" class="bw-widget"></div>

<hr />

<h2 id="3-the-3-2-5--gentle-calming">3. The 3-2-5 — Gentle Calming</h2>

<p>Inhale for 3 seconds, hold for 2, exhale for 5.</p>

<p>The extended exhale is the mechanism. A longer exhale than inhale shifts the autonomic nervous system toward parasympathetic — rest, digest, not fight-or-flight. The hold lets oxygen exchange fully before you release. The short inhale keeps things manageable without a feeling of overbreathing.</p>

<p>Ten rounds takes about ninety seconds. Good for anytime the body is tense for no reason you can identify.</p>

<div id="bw2" class="bw-widget"></div>

<hr />

<h2 id="4-box-breathing--symmetry-and-focus">4. Box Breathing — Symmetry and Focus</h2>

<p>Four equal sides: inhale 4 seconds, hold 4, exhale 4, hold empty 4.</p>

<p>The symmetry is deliberate. Equal phases force a mental regularity that interrupts thought loops. You cannot be scattered and also be carefully counting to four across four phases at once.</p>

<p>The part people consistently forget is the second hold — the pause after the exhale, lungs empty. It feels uncomfortable. That discomfort is the point. You are training the nervous system to tolerate a gap without panicking into the next inhale.</p>

<p>Reportedly used by Navy SEALs before high-pressure situations. Five rounds takes about two minutes.</p>

<div id="bw3" class="bw-widget"></div>

<hr />

<h2 id="5-resonant-breathing--coherence">5. Resonant Breathing — Coherence</h2>

<p>The target: approximately 5.5 breaths per minute. About 5.5 seconds inhale, 5.5 seconds exhale, no holds.</p>

<p>Research on heart rate variability suggests that breathing at this exact rate synchronizes the cardiovascular and respiratory systems in a way that maximizes nervous system resilience and adaptability. Most people at rest breathe 12 to 20 times per minute. Slowing to 5.5 feels strange at first — the inhale seems too long, the exhale never quite enough — and then the body adjusts and it feels more like the breath it was supposed to be taking all along.</p>

<p>A simple entry: inhale to a slow count of five, exhale to a slow count of six. Close enough to the target. Five minutes and the effect is cumulative — a settling, like water that was disturbed going still.</p>

<div id="bw4" class="bw-widget"></div>

<hr />

<h2 id="6-4-7-8--for-sleep-and-deep-anxiety">6. 4-7-8 — For Sleep and Deep Anxiety</h2>

<p>Inhale 4 seconds, hold 7, exhale 8.</p>

<p>The proportions matter more than the absolute seconds. The long hold — 7 counts — builds a small amount of CO2 in the bloodstream. This is counterintuitive: CO2, the thing you are trying to expel, in the right concentration triggers a calming response. The exhale at nearly double the inhale then pushes the nervous system hard toward rest.</p>

<p>It can make you slightly light-headed the first few times. This is normal. Four rounds is usually enough to feel the effect.</p>

<div id="bw5" class="bw-widget"></div>

<hr />

<h2 id="7-alternate-nostril-breathing-nadi-shodhana">7. Alternate Nostril Breathing (Nadi Shodhana)</h2>

<p>This one comes from yoga and feels strange to describe — it is also the kind of thing you probably do not want to do in a meeting.</p>

<p>The cycle: close the right nostril with the right thumb, inhale through the left for 4 counts. Close both briefly. Release the right, exhale through the right for 4 counts. Inhale right for 4 counts. Close both. Exhale left for 4. That is one full round.</p>

<p>The physiological claim is that it balances activity between brain hemispheres. I cannot verify that and I am skeptical of single-mechanism explanations. What I can say is that it produces a distinctive quality of focused calm — possibly because attending to which nostril you are using requires enough specific attention that the rest of the mental noise has nowhere to go. It just has to stop for a minute.</p>

<p>The asymmetry keeps the mind engaged. There is no autopilot available here.</p>

<div id="bw6" class="bw-widget"></div>

<hr />

<p>The holotropic experience — the one that started this — is something I would not recommend casually. It is not a technique you do at your desk. It requires a trained facilitator, a safe setting, and a willingness to not know where your body is for a while. I mention it here because it was, for me, the proof-of-concept: breath has reach. It touches places in the nervous system that logic and intention do not easily access.</p>

<p>The seven techniques above are not that. They are quieter, more portable, more everyday. But they are on the same continuum. Same lever, smaller pull.</p>

<p>The practice is noticing. The technique is just what you do after you notice.</p>

<p>We breathe about 20,000 times a day. Most of those are fine without any intervention. It is the moments when the breath is holding something — tension, dread, the vague weight of being slightly behind — where it helps to have a tool. And it only takes one deliberate breath to remember that the rest of the body is still here, still fine, still breathing.</p>

<style>
.bw-widget {
  background: #161b27;
  border: 1px solid rgba(255,255,255,0.07);
  border-radius: 16px;
  padding: 40px 24px 32px;
  text-align: center;
  margin: 32px 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  user-select: none;
}
.bw-circle-wrap {
  position: relative;
  width: 160px;
  height: 160px;
  margin: 0 auto 24px;
  cursor: pointer;
}
.bw-glow {
  position: absolute;
  inset: -14px;
  border-radius: 50%;
  pointer-events: none;
  transition: box-shadow 0.1s linear;
}
.bw-ring {
  width: 160px;
  height: 160px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
}
.bw-count {
  color: rgba(255,255,255,0.92);
  font-size: 2rem;
  font-weight: 700;
  line-height: 1;
  pointer-events: none;
}
.bw-phase {
  font-size: 0.95rem;
  font-weight: 700;
  letter-spacing: 0.1em;
  margin-bottom: 6px;
  min-height: 1.4em;
}
.bw-hint {
  color: #8892b0;
  font-size: 0.8rem;
  min-height: 1.3em;
}
.bw-circle-wrap:hover .bw-ring {
  filter: brightness(1.12);
}
</style>

<script>
(function () {
  var CONFIGS = [
    {
      id: 'bw0', name: 'BELLY BREATHING', idle: '#0d9488',
      phases: [
        { label: 'BELLY RISE', hint: 'let the belly expand outward', ms: 4000, color: '#0d9488', s0: 0.54, s1: 1.00 },
        { label: 'BELLY FALL', hint: 'let the belly fall gently',    ms: 4000, color: '#2563eb', s0: 1.00, s1: 0.54 }
      ]
    },
    {
      id: 'bw1', name: 'PHYSIOLOGICAL SIGH', idle: '#0EA5E9',
      phases: [
        { label: 'INHALE',       hint: 'inhale through nose',          ms: 2000, color: '#0EA5E9', s0: 0.54, s1: 0.82 },
        { label: '+ INHALE',     hint: 'quick second inhale on top',   ms: 900,  color: '#FBBF24', s0: 0.82, s1: 1.00 },
        { label: 'LONG EXHALE',  hint: 'slow exhale through mouth',    ms: 6000, color: '#7c3aed', s0: 1.00, s1: 0.54 },
        { label: 'PAUSE',        hint: 'rest before next sigh',        ms: 2100, color: '#374151', s0: 0.54, s1: 0.54 }
      ]
    },
    {
      id: 'bw2', name: '3 · 2 · 5 BREATHING', idle: '#0EA5E9',
      phases: [
        { label: 'BREATHE IN',  hint: 'slow inhale through the nose',  ms: 3000, color: '#0EA5E9', s0: 0.54, s1: 1.00 },
        { label: 'HOLD',        hint: 'hold gently',                   ms: 2000, color: '#FBBF24', s0: 1.00, s1: 1.00 },
        { label: 'BREATHE OUT', hint: 'slow exhale through the mouth', ms: 5000, color: '#7c3aed', s0: 1.00, s1: 0.54 }
      ]
    },
    {
      id: 'bw3', name: 'BOX BREATHING', idle: '#0EA5E9',
      phases: [
        { label: 'INHALE',      hint: 'in through the nose',     ms: 4000, color: '#0EA5E9', s0: 0.54, s1: 1.00 },
        { label: 'HOLD FULL',   hint: 'hold at the top',         ms: 4000, color: '#FBBF24', s0: 1.00, s1: 1.00 },
        { label: 'EXHALE',      hint: 'out through the mouth',   ms: 4000, color: '#7c3aed', s0: 1.00, s1: 0.54 },
        { label: 'HOLD EMPTY',  hint: 'hold at the bottom',      ms: 4000, color: '#EF4444', s0: 0.54, s1: 0.54 }
      ]
    },
    {
      id: 'bw4', name: 'RESONANT BREATHING', idle: '#0d9488',
      phases: [
        { label: 'BREATHE IN',  hint: 'slow — count to 5.5',     ms: 5500, color: '#0d9488', s0: 0.54, s1: 1.00 },
        { label: 'BREATHE OUT', hint: 'slow — count to 5.5',     ms: 5500, color: '#2563eb', s0: 1.00, s1: 0.54 }
      ]
    },
    {
      id: 'bw5', name: '4 · 7 · 8 BREATHING', idle: '#0EA5E9',
      phases: [
        { label: 'INHALE',  hint: 'quiet inhale through the nose',   ms: 4000, color: '#0EA5E9', s0: 0.54, s1: 1.00 },
        { label: 'HOLD',    hint: 'hold — feel the stillness',       ms: 7000, color: '#FBBF24', s0: 1.00, s1: 1.00 },
        { label: 'EXHALE',  hint: 'exhale fully through the mouth',  ms: 8000, color: '#7c3aed', s0: 1.00, s1: 0.54 }
      ]
    },
    {
      id: 'bw6', name: 'ALTERNATE NOSTRIL', idle: '#0EA5E9',
      phases: [
        { label: 'INHALE LEFT',  hint: 'close right nostril · inhale left',  ms: 4000, color: '#0EA5E9', s0: 0.54, s1: 1.00 },
        { label: 'HOLD',         hint: 'close both nostrils',                ms: 1000, color: '#FBBF24', s0: 1.00, s1: 1.00 },
        { label: 'EXHALE RIGHT', hint: 'open right · exhale',                ms: 4000, color: '#0d9488', s0: 1.00, s1: 0.54 },
        { label: 'INHALE RIGHT', hint: 'inhale through right nostril',       ms: 4000, color: '#0EA5E9', s0: 0.54, s1: 1.00 },
        { label: 'HOLD',         hint: 'close both nostrils',                ms: 1000, color: '#FBBF24', s0: 1.00, s1: 1.00 },
        { label: 'EXHALE LEFT',  hint: 'open left · exhale',                 ms: 4000, color: '#7c3aed', s0: 1.00, s1: 0.54 }
      ]
    }
  ];

  function ease(t) { return t < 0.5 ? 2*t*t : -1 + (4-2*t)*t; }
  function lerp(a, b, t) { return a + (b-a)*t; }

  CONFIGS.forEach(function (cfg) {
    var el = document.getElementById(cfg.id);
    if (!el) return;

    var CYCLE = cfg.phases.reduce(function (s, p) { return s + p.ms; }, 0);

    el.innerHTML =
      '<div class="bw-circle-wrap" id="' + cfg.id + '-wrap">' +
        '<div class="bw-glow" id="' + cfg.id + '-glow"></div>' +
        '<div class="bw-ring" id="' + cfg.id + '-ring" style="background: radial-gradient(circle at 38% 32%, ' + cfg.idle + ', #1E3A8A); transform: scale(0.72);">' +
          '<span class="bw-count" id="' + cfg.id + '-count"></span>' +
        '</div>' +
      '</div>' +
      '<div class="bw-phase" id="' + cfg.id + '-phase" style="color: #8892b0;">' + cfg.name + '</div>' +
      '<div class="bw-hint" id="' + cfg.id + '-hint">click to breathe</div>';

    var ring  = document.getElementById(cfg.id + '-ring');
    var glow  = document.getElementById(cfg.id + '-glow');
    var count = document.getElementById(cfg.id + '-count');
    var phase = document.getElementById(cfg.id + '-phase');
    var hint  = document.getElementById(cfg.id + '-hint');

    var active = false, raf = null, t0 = null;

    document.getElementById(cfg.id + '-wrap').addEventListener('click', function () {
      active = !active;
      if (typeof gtag === 'function') {
        gtag('event', 'breathing_exercise', {
          event_category: 'breathing',
          event_label: cfg.name,
          exercise_action: active ? 'start' : 'stop',
          page_path: window.location.pathname
        });
      }
      if (active) {
        t0 = null;
        raf = requestAnimationFrame(tick);
      } else {
        cancelAnimationFrame(raf);
        ring.style.transform = 'scale(0.72)';
        ring.style.background = 'radial-gradient(circle at 38% 32%, ' + cfg.idle + ', #1E3A8A)';
        glow.style.boxShadow = 'none';
        count.textContent = '';
        phase.textContent = cfg.name;
        phase.style.color = '#8892b0';
        hint.textContent = 'click to breathe';
      }
    });

    function tick(now) {
      if (!active) return;
      if (!t0) t0 = now;
      var elapsed = (now - t0) % CYCLE;
      var acc = 0, ph, pe;
      for (var i = 0; i < cfg.phases.length; i++) {
        if (elapsed < acc + cfg.phases[i].ms) { ph = cfg.phases[i]; pe = elapsed - acc; break; }
        acc += cfg.phases[i].ms;
      }
      var t  = ease(pe / ph.ms);
      var sc = lerp(ph.s0, ph.s1, t);
      var rem = Math.ceil((ph.ms - pe) / 1000);
      ring.style.transform = 'scale(' + sc + ')';
      ring.style.background = 'radial-gradient(circle at 38% 32%, ' + ph.color + ', #1E3A8A)';
      glow.style.boxShadow = '0 0 ' + Math.round(38 * sc) + 'px ' + ph.color + '55';
      count.textContent = rem > 0 ? rem : '';
      phase.textContent = ph.label;
      phase.style.color = ph.color;
      hint.textContent = ph.hint;
      raf = requestAnimationFrame(tick);
    }
  });
}());
</script>]]></content><author><name>Jônatas Davi Paganini</name><email>jonatasdp@gmail.com</email></author><category term="personal" /><category term="health" /><category term="mindfulness" /><category term="breathing" /><category term="meditation" /><category term="stress" /><category term="mindfulness" /><category term="wellness" /><summary type="html"><![CDATA[Seven breathing exercises, a story about holotropic sleep-standing, and four years of yoga teaching me that the simplest things are the hardest.]]></summary></entry></feed>