<?xml version="1.0" encoding="utf-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>forge</title><link>https://blog.aidantraboulay.dev</link><description>a developer blog</description><item><title>hunting concurrency bugs in vinext</title><link>https://blog.aidantraboulay.dev/posts/vinext-development</link><description><![CDATA[contributing to an open-source vite plugin that reimplements next.js - finding and fixing real async isolation bugs in the pages router]]></description><pubDate>2026-03-13</pubDate><content:encoded><![CDATA[<p>i wanted to contribute to something bigger than my own projects. vinext caught my eye - it's cloudflare's experiment in reimplementing the entire next.js api surface on top of vite. almost every line was written by ai, and they're very open about that. the codebase is genuinely interesting to read through.</p>
<p>the issue i picked up was <a href="https://github.com/cloudflare/vinext/issues/478" rel="noopener noreferrer">#478</a> - "phase 2/2: finalize als rollout with parity tests, cleanup, and docs". the previous pr (#450) had just landed a massive refactor that consolidated 5-6 separate <code>AsyncLocalStorage</code> instances into a single unified request context. my job was to prove it actually works under concurrent load, document the architecture, and clean up the leftovers.</p>
<h2>what is asynclocalstorage and why does it matter here</h2>
<p>quick context if you haven't worked with this before. <code>AsyncLocalStorage</code> (als) is a node.js api that lets you store data that follows an async call chain - think of it like thread-local storage but for javascript's single-threaded async model. when request a comes in and kicks off some async work, and request b arrives before a finishes, als makes sure each request sees its own data.</p>
<p>in a framework like next.js (or vinext), every request needs its own headers, cookies, navigation state, router context, cache tags, etc. without als, concurrent requests on something like cloudflare workers would stomp on each other's state. request a's <code>&lt;Head&gt;</code> title shows up in request b's response. that kind of thing.</p>
<h2>the setup</h2>
<p>vinext is a pnpm monorepo. the key commands:</p>
<pre style="background-color:#2b303b;"><span style="color:#8fa1b3;">pnpm</span><span style="color:#c0c5ce;"> test tests/some-file.test.ts   </span><span style="color:#65737e;"># targeted tests (always do this, not the full suite)
</span><span style="color:#8fa1b3;">pnpm</span><span style="color:#c0c5ce;"> run typecheck                    </span><span style="color:#65737e;"># tsgo
</span><span style="color:#8fa1b3;">pnpm</span><span style="color:#c0c5ce;"> run lint                         </span><span style="color:#65737e;"># oxlint
</span><span style="color:#8fa1b3;">pnpm</span><span style="color:#c0c5ce;"> run fmt:check                    </span><span style="color:#65737e;"># oxfmt
</span></pre>
<p>they use conventional commits (<code>fix:</code>, <code>feat:</code>, <code>test:</code>, <code>docs:</code>), and every pr gets reviewed by an ai agent called bigbonk (claude opus with max thinking). their bias is towards merging, which is refreshing.</p>
<h2>writing the concurrency tests</h2>
<p>the first thing i needed was fixture pages that expose request-scoped state in the html output. i created two pages in the test fixture:</p>
<p><strong><code>concurrent-head.tsx</code></strong> - takes a <code>?id=N</code> query param via <code>getServerSideProps</code> and sets a <code>&lt;title&gt;</code> and <code>&lt;meta&gt;</code> tag with that id:</p>
<pre style="background-color:#2b303b;"><span style="color:#c0c5ce;">export default function ConcurrentHeadPage({ reqId }: Props) {
</span><span style="color:#c0c5ce;">  return (
</span><span style="color:#c0c5ce;">    &lt;div&gt;
</span><span style="color:#c0c5ce;">      &lt;Head&gt;
</span><span style="color:#c0c5ce;">        &lt;title&gt;{`req-${reqId}`}&lt;/title&gt;
</span><span style="color:#c0c5ce;">        &lt;meta name="req-id" content={reqId} /&gt;
</span><span style="color:#c0c5ce;">      &lt;/Head&gt;
</span><span style="color:#c0c5ce;">      &lt;h1 data-testid="req-id"&gt;{reqId}&lt;/h1&gt;
</span><span style="color:#c0c5ce;">    &lt;/div&gt;
</span><span style="color:#c0c5ce;">  );
</span><span style="color:#c0c5ce;">}
</span></pre>
<p><strong><code>concurrent-router.tsx</code></strong> - echoes back the ssr pathname and query from <code>getServerSideProps</code> plus <code>useRouter()</code>.</p>
<p>the test fires 15 concurrent requests at each page and verifies every response contains only its own data. if head state leaks between requests, request 0's response would have request 14's title.</p>
<p>small gotcha i hit along the way - <code>&lt;title&gt;req-{reqId}&lt;/title&gt;</code> in jsx produces children as an array <code>["req-", "42"]</code>, not a single string. vinext's head shim only serializes string children for title tags, so the title rendered empty. switching to <code>&lt;title&gt;{`req-${reqId}`}&lt;/title&gt;</code> produces a single string child and works fine. not a bug i introduced - it's pre-existing in the head shim - but it tripped me up for a bit.</p>
<h2>finding a real bug</h2>
<p>the router isolation test passed immediately. the head isolation test did not.</p>
<pre style="background-color:#2b303b;"><span style="color:#c0c5ce;">expected 'req-13' to be 'req-0'
</span></pre>
<p>the body content was correct (request 0 showed <code>req-id=0</code>), but the <code>&lt;title&gt;</code> showed <code>req-13</code> - another request's head state had leaked into this response. this is exactly the kind of bug the issue asked me to verify.</p>
<h2>the root cause</h2>
<p>this one took some digging. the architecture has a registration pattern - <code>head.ts</code> (the <code>next/head</code> shim) has module-level defaults for collecting ssr head children:</p>
<pre style="background-color:#2b303b;"><span style="color:#c0c5ce;">let _ssrHeadChildren: React.ReactNode[] = [];
</span><span style="color:#c0c5ce;">let _getSSRHeadChildren = (): React.ReactNode[] =&gt; _ssrHeadChildren;
</span></pre>
<p>and <code>head-state.ts</code> registers als-backed replacements:</p>
<pre style="background-color:#2b303b;"><span style="color:#c0c5ce;">_registerHeadStateAccessors({
</span><span style="color:#c0c5ce;">  getSSRHeadChildren(): React.ReactNode[] {
</span><span style="color:#c0c5ce;">    return _getState().ssrHeadChildren; // reads from per-request ALS scope
</span><span style="color:#c0c5ce;">  },
</span><span style="color:#c0c5ce;">  // ...
</span><span style="color:#c0c5ce;">});
</span></pre>
<p>the problem: vite's dev server has <strong>separate module graphs</strong> for different environments. the dev-server imports <code>head-state.ts</code> as a static import (node context), which registers the als accessors on the node context's copy of <code>head.ts</code>. but the <code>Head</code> react component runs during ssr rendering in <strong>vite's ssr module graph</strong> - a completely different module instance. that ssr instance of <code>head.ts</code> never had <code>_registerHeadStateAccessors</code> called on it, so it was still using the shared module-level <code>_ssrHeadChildren</code> array.</p>
<p>every concurrent request's <code>Head</code> component was pushing elements into the same array.</p>
<p>this is the kind of thing that doesn't surface in serial tests. you need real concurrent load to catch it.</p>
<h2>the fix</h2>
<p>two lines:</p>
<pre style="background-color:#2b303b;"><span style="color:#c0c5ce;">await server.ssrLoadModule("vinext/head-state");
</span><span style="color:#c0c5ce;">await server.ssrLoadModule("vinext/router-state");
</span></pre>
<p>added right after the unified request context is created, before any rendering happens. this loads the state modules in vite's ssr module graph, which triggers the accessor registration on the correct module instance. the same pattern was already used for <code>vinext/i18n-state</code> - just nobody had done it for head and router state.</p>
<p>the prod server doesn't have this problem because everything gets compiled into one bundle where the imports resolve to the same module instance.</p>
<p>i also checked the other server files for parity (agents.md is very clear about this - if you touch one server file, check all four). the app router rsc entry doesn't use pages router head/router state, and the generated prod entry already has the correct imports. the bug was dev-only.</p>
<h2>prod concurrency tests</h2>
<p>the prod build is a different beast. in dev, vite has separate module graphs for different environments (node vs ssr). in prod, everything gets compiled into a single bundle - so the module-level singleton problem that caused the head leak in dev doesn't exist.</p>
<p>but we still need to prove that <code>getServerSideProps</code> data is isolated between concurrent requests. the test builds the fixture to a temp directory, starts the prod server on a random port, and fires the same 15 concurrent requests.</p>
<p>setting this up was its own adventure. the <code>pages-basic</code> fixture has an <code>alias-test.tsx</code> page with a <code>@/components/heavy</code> import that breaks when building outside the original directory structure. filtered it out during the temp dir copy - it's not relevant to what we're testing.</p>
<p>interesting discovery: the prod server has some known limitations compared to dev. <code>&lt;Head&gt;</code> component children don't get injected into the html <code>&lt;head&gt;</code> section, and <code>useRouter().pathname</code> returns <code>/</code> instead of the actual route during ssr. these aren't concurrency bugs - they're consistent behavior regardless of load. so the prod tests focus on what matters: verifying <code>getServerSideProps</code> data and ssr props don't leak between requests.</p>
<p>all four tests pass - head isolation in dev, router isolation in dev, data isolation in prod (both pages).</p>
<h2>things i learned</h2>
<ul>
<li>vite's multi-environment module graphs mean you can have the same module loaded multiple times with completely different state. this is by design for rsc/ssr/client separation, but it creates subtle bugs when server-side code assumes module singletons.</li>
<li><code>AsyncLocalStorage</code> works great for request isolation, but only if the als-backed accessors are registered in every module instance that needs them.</li>
<li>jsx <code>&lt;title&gt;text-{variable}&lt;/title&gt;</code> produces an array of children, not a string. <code>&lt;title&gt;{`text-${variable}`}&lt;/title&gt;</code> produces a single string. matters when the consumer only handles string children.</li>
<li>writing concurrency tests that actually catch isolation bugs requires real parallel <code>Promise.all</code> with enough requests to trigger interleaving. serial tests will never catch these.</li>
</ul>
]]></content:encoded></item><item><title>svdex: svd image compression</title><link>https://blog.aidantraboulay.dev/posts/svdex:-svd-image-compression</link><description><![CDATA[exploring image compression through singular value decomposition]]></description><pubDate>2026-03-09</pubDate><content:encoded><![CDATA[<p>i wanted to understand how linear algebra compresses images. not the "read a wikipedia article" kind of understanding - the "build it from scratch and watch it work" kind. so i wrote <a href="https://github.com/aidantrabs/svdex" rel="noopener noreferrer">svdex</a>, a little rust cli that compresses images using singular value decomposition.</p>
<p>here's what i learned along the way.</p>
<h2>the big idea</h2>
<p>every matrix can be broken into three pieces. this is svd:</p>
<p>$$A = U \Sigma V^T$$</p>
<p>$U$ and $V^T$ are rotation matrices. $\Sigma$ is a diagonal matrix of "singular values" - numbers that tell you how important each component is. they come sorted from biggest to smallest: $\sigma_1 \geq \sigma_2 \geq \dots \geq \sigma_r &gt; 0$.</p>
<p>the insight that makes compression possible: the first few singular values are usually <em>way</em> bigger than the rest. most of the information lives in a small number of components.</p>
<p>so what if we just... kept the top $k$ and threw the rest away?</p>
<p>$$A_k = \sum_{i=1}^{k} \sigma_i \mathbf{u}_i \mathbf{v}_i^T$$</p>
<p>turns out this is provably optimal. the eckart-young theorem says this rank-$k$ approximation is the best you can do - no other method using $k$ components will get you closer to the original. that's not a vague claim, it's a mathematical guarantee.</p>
<p>i got a lot of my initial intuition from <a href="https://zerobone.net/blog/cs/svd-image-compression/" rel="noopener noreferrer">zerobone's post on svd image compression</a>, which does a great job of connecting the math to what's actually happening with pixels.</p>
<h2>so how does this compress an image?</h2>
<p>an image is just three matrices stacked on top of each other - one for red, one for green, one for blue. each entry is a pixel value from 0 to 255.</p>
<p>the plan is simple:</p>
<ol>
<li>pull apart the rgb channels</li>
<li>run svd on each one</li>
<li>keep only the top $k$ singular values</li>
<li>put it back together</li>
</ol>
<p>in code, the truncation step looks like this:</p>
<pre style="background-color:#2b303b;"><span style="color:#b48ead;">pub fn </span><span style="color:#8fa1b3;">low_rank_approx</span><span style="color:#c0c5ce;">(</span><span style="color:#bf616a;">svd</span><span style="color:#c0c5ce;">: &amp;SvdResult, </span><span style="color:#bf616a;">k</span><span style="color:#c0c5ce;">: </span><span style="color:#b48ead;">usize</span><span style="color:#c0c5ce;">) -&gt; Array2&lt;</span><span style="color:#b48ead;">f64</span><span style="color:#c0c5ce;">&gt; {
</span><span style="color:#c0c5ce;">    </span><span style="color:#b48ead;">let</span><span style="color:#c0c5ce;"> k = k.</span><span style="color:#96b5b4;">min</span><span style="color:#c0c5ce;">(svd.s.</span><span style="color:#96b5b4;">len</span><span style="color:#c0c5ce;">());
</span><span style="color:#c0c5ce;">
</span><span style="color:#c0c5ce;">    </span><span style="color:#b48ead;">let</span><span style="color:#c0c5ce;"> u_k = svd.u.</span><span style="color:#96b5b4;">slice</span><span style="color:#c0c5ce;">(s![.., ..k]).</span><span style="color:#96b5b4;">to_owned</span><span style="color:#c0c5ce;">();
</span><span style="color:#c0c5ce;">    </span><span style="color:#b48ead;">let</span><span style="color:#c0c5ce;"> s_k = &amp;svd.s.</span><span style="color:#96b5b4;">slice</span><span style="color:#c0c5ce;">(s![..k]);
</span><span style="color:#c0c5ce;">    </span><span style="color:#b48ead;">let</span><span style="color:#c0c5ce;"> vt_k = svd.vt.</span><span style="color:#96b5b4;">slice</span><span style="color:#c0c5ce;">(s![..k, ..]).</span><span style="color:#96b5b4;">to_owned</span><span style="color:#c0c5ce;">();
</span><span style="color:#c0c5ce;">
</span><span style="color:#c0c5ce;">    </span><span style="color:#b48ead;">let</span><span style="color:#c0c5ce;"> u_scaled = &amp;u_k * s_k;
</span><span style="color:#c0c5ce;">    u_scaled.</span><span style="color:#96b5b4;">dot</span><span style="color:#c0c5ce;">(&amp;vt_k)
</span><span style="color:#c0c5ce;">}
</span></pre>
<p>three slices and a dot product. that's the whole thing.</p>
<p>one gotcha - the reconstructed values can land outside $[0, 255]$. if you don't clamp them before saving, you get weird wrapping artifacts. learned that one the hard way.</p>
<h2>how much space do we actually save?</h2>
<p>original storage: $3 \cdot h \cdot w$ values (three full matrices).</p>
<p>compressed storage per channel: $h \times k$ for the truncated $U$, $k$ for the singular values, $k \times w$ for the truncated $V^T$. so:</p>
<p>$$\text{ratio} = \frac{3hw}{3k(h + 1 + w)}$$</p>
<p>for a $1200 \times 797$ image at rank 50, that's about $9.6\times$ compression. not bad for some matrix math.</p>
<h2>the numbers</h2>
<p>i ran experiments across a bunch of ranks to see what actually happens:</p>
<table><thead><tr><th>rank</th><th>ratio</th><th>mse</th><th>psnr</th></tr></thead><tbody>
<tr><td>1</td><td>478.68x</td><td>2937.52</td><td>13.45 dB</td></tr>
<tr><td>5</td><td>95.74x</td><td>1265.33</td><td>17.11 dB</td></tr>
<tr><td>10</td><td>47.87x</td><td>878.57</td><td>18.69 dB</td></tr>
<tr><td>20</td><td>23.93x</td><td>620.23</td><td>20.21 dB</td></tr>
<tr><td>50</td><td>9.57x</td><td>361.29</td><td>22.55 dB</td></tr>
<tr><td>100</td><td>4.79x</td><td>212.83</td><td>24.85 dB</td></tr>
<tr><td>200</td><td>2.39x</td><td>92.52</td><td>28.47 dB</td></tr>
</tbody></table>
<p>(mse is mean squared error - lower is better. psnr is peak signal-to-noise ratio in decibels - higher is better.)</p>
<p>$$\text{MSE} = \frac{1}{N} \sum_{i} (x_i - \hat{x}_i)^2 \qquad \text{PSNR} = 10 \cdot \log_{10}\left(\frac{255^2}{\text{MSE}}\right)$$</p>
<p>some things jumped out at me.</p>
<p><strong>the first few components do most of the heavy lifting.</strong> going from rank 1 to rank 20 cuts the error by almost $5\times$. going from rank 100 to rank 200 only halves it. the early gains are massive, then you hit diminishing returns fast.</p>
<p><strong>there's no magic number.</strong> i kept expecting to find some rank where the image suddenly "clicks" into looking good. that doesn't happen. quality improves smoothly - you just pick where on the curve you're comfortable.</p>
<p><strong>below 20 dB it looks rough.</strong> around 25 dB it starts looking fine for most purposes. the rank 50-100 range is the sweet spot for this image.</p>
<h2>the decay curve tells the whole story</h2>
<p>svdex plots the singular values for all three channels. the shape is always the same - a steep initial drop, then a long flat tail.</p>
<p>the red channel's first singular value was ~79,000. by the 10th, it dropped to ~7,400. blue started highest at ~128,000 (lots of sky in the test image). by the time you're past the first hundred or so values, everything is close to zero.</p>
<p>this decay is <em>the entire reason svd compression works</em>. if singular values were spread out evenly, throwing any of them away would hurt equally and compression would be pointless. the steep drop means most of them barely matter.</p>
<h2>one implementation detail that matters</h2>
<p>when running experiments across multiple ranks, compute svd once and reuse it:</p>
<pre style="background-color:#2b303b;"><span style="color:#b48ead;">pub fn </span><span style="color:#8fa1b3;">compress_with_svds</span><span style="color:#c0c5ce;">(</span><span style="color:#bf616a;">svds</span><span style="color:#c0c5ce;">: &amp;[SvdResult; 3], </span><span style="color:#bf616a;">k</span><span style="color:#c0c5ce;">: </span><span style="color:#b48ead;">usize</span><span style="color:#c0c5ce;">) -&gt; [Array2&lt;</span><span style="color:#b48ead;">f64</span><span style="color:#c0c5ce;">&gt;; </span><span style="color:#d08770;">3</span><span style="color:#c0c5ce;">] {
</span><span style="color:#c0c5ce;">    [
</span><span style="color:#c0c5ce;">        </span><span style="color:#96b5b4;">low_rank_approx</span><span style="color:#c0c5ce;">(&amp;svds[</span><span style="color:#d08770;">0</span><span style="color:#c0c5ce;">], k),
</span><span style="color:#c0c5ce;">        </span><span style="color:#96b5b4;">low_rank_approx</span><span style="color:#c0c5ce;">(&amp;svds[</span><span style="color:#d08770;">1</span><span style="color:#c0c5ce;">], k),
</span><span style="color:#c0c5ce;">        </span><span style="color:#96b5b4;">low_rank_approx</span><span style="color:#c0c5ce;">(&amp;svds[</span><span style="color:#d08770;">2</span><span style="color:#c0c5ce;">], k),
</span><span style="color:#c0c5ce;">    ]
</span><span style="color:#c0c5ce;">}
</span></pre>
<p>the svd factorization is $O(\min(m, n) \cdot mn)$ - that's the expensive part. truncation is just slicing arrays. computing svd nine times instead of three because you forgot to cache it is a mistake you only make once (i made it once).</p>
<h2>what stuck with me</h2>
<p>the eckart-young theorem went from "cool abstract result" to something i can see. you look at a rank-50 compressed image and know - mathematically, provably - that no other 50-component approximation could look better. that's wild.</p>
<p>and the singular value decay curve is the single most informative thing about any matrix you're trying to compress. everything about the quality-size tradeoff is encoded in that shape.</p>
<p>the source code is at <a href="https://github.com/aidantrabs/svdex" rel="noopener noreferrer">github.com/aidantrabs/svdex</a>.</p>
]]></content:encoded></item><item><title>saba: production vpc infrastructure with terraform</title><link>https://blog.aidantraboulay.dev/posts/saba:-production-vpc-infrastructure-with-terraform</link><description><![CDATA[building a multi-az aws vpc from scratch with terraform - vpc networking, nat gateways, bastion hosts, and infrastructure as code]]></description><pubDate>2026-03-09</pubDate><content:encoded><![CDATA[<h2>the problem</h2>
<p>you need to run workloads on aws. you could click through the console and manually create a vpc, subnets, route tables, security groups - but then what? you can't reproduce it, you can't version it, and you definitely can't tear it down and rebuild it with confidence.</p>
<p>terraform solves this. you describe your infrastructure as code, and terraform figures out how to make reality match your description. saba is a terraform project that provisions a production-ready, multi-az vpc on aws - the kind of network architecture you'd actually use in a real environment.</p>
<h2>terraform fundamentals</h2>
<p>before building anything, three concepts matter:</p>
<p><strong>state</strong> is how terraform tracks what it manages. it maps your config to real aws resources. lose the state file and terraform has no idea those resources belong to it - you'd have to import them manually or risk creating duplicates. this is why remote state backends exist.</p>
<p><strong>providers</strong> are plugins that teach terraform how to talk to specific apis. terraform core is just an engine - it knows nothing about aws, azure, or anything else until you configure a provider.</p>
<p><strong>resources vs data sources</strong> - resources are things terraform manages (create, update, delete). data sources are read-only lookups of things that already exist outside your config.</p>
<h2>networking from first principles</h2>
<h3>cidr blocks and ip addressing</h3>
<p>a cidr block defines a range of ip addresses. <code>10.0.0.0/16</code> means the first 16 bits are the network prefix, leaving 16 bits for host addresses - that's 65,536 addresses.</p>
<p>a vpc needs a cidr block to define what ip range is available. subnets carve that range into smaller pieces:</p>
<pre style="background-color:#2b303b;"><span style="color:#c0c5ce;">VPC: 10.0.0.0/16 (65,536 addresses)
</span><span style="color:#c0c5ce;">├── public subnet a:  10.0.1.0/24  (256 addresses)
</span><span style="color:#c0c5ce;">├── public subnet b:  10.0.2.0/24  (256 addresses)
</span><span style="color:#c0c5ce;">├── private subnet a: 10.0.10.0/24 (256 addresses)
</span><span style="color:#c0c5ce;">└── private subnet b: 10.0.20.0/24 (256 addresses)
</span></pre>
<h3>public vs private subnets</h3>
<p>the distinction is purely about routing:</p>
<ul>
<li>a <strong>public subnet</strong> has a route table that points <code>0.0.0.0/0</code> to an internet gateway. resources get public ips and can communicate with the internet bidirectionally.</li>
<li>a <strong>private subnet</strong> has no route to an internet gateway. resources can't be reached from the internet.</li>
</ul>
<p>anything that doesn't need to face the public internet belongs in a private subnet - application servers, databases, internal services. this is the most basic form of network isolation.</p>
<h3>routing - igw vs nat gateway</h3>
<p>public subnets route to an internet gateway (igw) so the internet can reach them. private subnets route to a nat gateway so they can reach the internet but the internet can't reach them.</p>
<p>the nat gateway translates private ips to its own public ip and only allows responses to outbound requests back in. the traffic flow looks like:</p>
<pre style="background-color:#2b303b;"><span style="color:#c0c5ce;">public:  Internet ⇄ IGW ⇄ EC2 (public IP)
</span><span style="color:#c0c5ce;">private: Private EC2 → NAT → IGW → Internet
</span><span style="color:#c0c5ce;">         Internet ✖→ Private EC2
</span></pre>
<p>private resources still need outbound internet access - pulling updates, container images, calling external apis. the nat gateway enables this without exposing them to inbound traffic.</p>
<p>a nat gateway needs an elastic ip (eip) because it provides a static public address. if you recreated the nat gateway without one, you'd get a random ip and break any ip-based allowlists.</p>
<h2>building the vpc module</h2>
<p>the networking module creates everything: vpc, internet gateway, four subnets across two availability zones, elastic ips, nat gateways, and all the route tables and associations.</p>
<p>the root module orchestrates the child modules:</p>
<pre style="background-color:#2b303b;"><span style="color:#c0c5ce;">module "networking" {
</span><span style="color:#c0c5ce;">    source      = "./modules/networking"
</span><span style="color:#c0c5ce;">    environment = var.environment
</span><span style="color:#c0c5ce;">    vpc_cidr    = var.vpc_cidr
</span><span style="color:#c0c5ce;">    az_a        = var.az_a
</span><span style="color:#c0c5ce;">    az_b        = var.az_b
</span><span style="color:#c0c5ce;">}
</span><span style="color:#c0c5ce;">
</span><span style="color:#c0c5ce;">module "bastion" {
</span><span style="color:#c0c5ce;">    source           = "./modules/bastion"
</span><span style="color:#c0c5ce;">    environment      = var.environment
</span><span style="color:#c0c5ce;">    vpc_id           = module.networking.vpc_id
</span><span style="color:#c0c5ce;">    subnet_id        = module.networking.public_subnet_a_id
</span><span style="color:#c0c5ce;">    instance_type    = var.instance_type
</span><span style="color:#c0c5ce;">    public_key_path  = var.public_key_path
</span><span style="color:#c0c5ce;">    allowed_ssh_cidr = var.allowed_ssh_cidr
</span><span style="color:#c0c5ce;">}
</span></pre>
<p>notice how the bastion module references <code>module.networking.vpc_id</code> and <code>module.networking.public_subnet_a_id</code>. terraform builds a dependency graph from these references and handles sequencing automatically - vpc first, then subnets, then anything that depends on them.</p>
<h3>multi-az for high availability</h3>
<p>subnets are placed in two availability zones (us-east-1a and us-east-1b). if one az has an outage, resources in the other az continue running. each az gets its own nat gateway so private subnets aren't sharing a single point of failure:</p>
<pre style="background-color:#2b303b;"><span style="color:#c0c5ce;">┌─────────────────────┐    ┌─────────────────────┐
</span><span style="color:#c0c5ce;">│   us-east-1a        │    │   us-east-1b        │
</span><span style="color:#c0c5ce;">│                     │    │                     │
</span><span style="color:#c0c5ce;">│  ┌───────────────┐  │    │  ┌───────────────┐  │
</span><span style="color:#c0c5ce;">│  │  public_a     │  │    │  │  public_b     │  │
</span><span style="color:#c0c5ce;">│  │  NAT-A + EIP  │  │    │  │  NAT-B + EIP  │  │
</span><span style="color:#c0c5ce;">│  └───────────────┘  │    │  └───────────────┘  │
</span><span style="color:#c0c5ce;">│         ▲           │    │         ▲           │
</span><span style="color:#c0c5ce;">│  ┌───────────────┐  │    │  ┌───────────────┐  │
</span><span style="color:#c0c5ce;">│  │  private_a    │  │    │  │  private_b    │  │
</span><span style="color:#c0c5ce;">│  │  routes here  │  │    │  │  routes here  │  │
</span><span style="color:#c0c5ce;">│  └───────────────┘  │    │  └───────────────┘  │
</span><span style="color:#c0c5ce;">└─────────────────────┘    └─────────────────────┘
</span></pre>
<h2>the bastion host</h2>
<p>a vpc with no compute is just an empty network. the bastion host is an ec2 instance in the public subnet that acts as a secure jump point to reach private resources.</p>
<h3>security groups</h3>
<p>security groups are virtual firewalls attached to individual resources. they're stateful - if you allow inbound traffic on a port, the return traffic is automatically allowed without an explicit outbound rule.</p>
<p>this differs from network acls (nacls), which operate at the subnet level, support both allow and deny rules, and are stateless.</p>
<p>the bastion's security group allows inbound ssh (port 22) and all outbound traffic:</p>
<pre style="background-color:#2b303b;"><span style="color:#c0c5ce;">resource "aws_security_group" "bastion" {
</span><span style="color:#c0c5ce;">    name        = "${var.environment}-bastion-sg"
</span><span style="color:#c0c5ce;">    vpc_id      = var.vpc_id
</span><span style="color:#c0c5ce;">
</span><span style="color:#c0c5ce;">    ingress {
</span><span style="color:#c0c5ce;">        from_port   = 22
</span><span style="color:#c0c5ce;">        to_port     = 22
</span><span style="color:#c0c5ce;">        protocol    = "tcp"
</span><span style="color:#c0c5ce;">        cidr_blocks = [var.allowed_ssh_cidr]
</span><span style="color:#c0c5ce;">    }
</span><span style="color:#c0c5ce;">
</span><span style="color:#c0c5ce;">    egress {
</span><span style="color:#c0c5ce;">        from_port   = 0
</span><span style="color:#c0c5ce;">        to_port     = 0
</span><span style="color:#c0c5ce;">        protocol    = "-1"
</span><span style="color:#c0c5ce;">        cidr_blocks = ["0.0.0.0/0"]
</span><span style="color:#c0c5ce;">    }
</span><span style="color:#c0c5ce;">}
</span></pre>
<p>in production, you'd restrict <code>allowed_ssh_cidr</code> to your ip rather than <code>0.0.0.0/0</code>.</p>
<h3>dynamic ami lookup</h3>
<p>rather than hardcoding an ami id (which varies by region and changes over time), the bastion uses a data source to find the latest amazon linux 2023 image dynamically:</p>
<pre style="background-color:#2b303b;"><span style="color:#c0c5ce;">data "aws_ami" "bastion" {
</span><span style="color:#c0c5ce;">    most_recent = true
</span><span style="color:#c0c5ce;">    owners      = [var.ami_owner]
</span><span style="color:#c0c5ce;">
</span><span style="color:#c0c5ce;">    filter {
</span><span style="color:#c0c5ce;">        name   = "name"
</span><span style="color:#c0c5ce;">        values = [var.ami_name_filter]
</span><span style="color:#c0c5ce;">    }
</span><span style="color:#c0c5ce;">}
</span></pre>
<p>this also makes it easy to swap operating systems by overriding the variables:</p>
<pre style="background-color:#2b303b;"><span style="color:#65737e;"># amazon linux 2023 (default)
</span><span style="color:#8fa1b3;">terraform</span><span style="color:#c0c5ce;"> apply
</span><span style="color:#c0c5ce;">
</span><span style="color:#65737e;"># ubuntu 24.04
</span><span style="color:#8fa1b3;">terraform</span><span style="color:#c0c5ce;"> apply \
</span><span style="color:#bf616a;">    -var</span><span style="color:#c0c5ce;">="</span><span style="color:#a3be8c;">ami_name_filter=ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*</span><span style="color:#c0c5ce;">" \
</span><span style="color:#bf616a;">    -var</span><span style="color:#c0c5ce;">="</span><span style="color:#a3be8c;">ami_owner=099720109477</span><span style="color:#c0c5ce;">"
</span></pre>
<h3>connecting</h3>
<p>ssh into the bastion, then jump to private resources:</p>
<pre style="background-color:#2b303b;"><span style="color:#65737e;"># direct connection to bastion
</span><span style="color:#8fa1b3;">ssh</span><span style="color:#bf616a;"> -i ~</span><span style="color:#c0c5ce;">/.ssh/bastion-key ec2-user@$(</span><span style="color:#8fa1b3;">terraform</span><span style="color:#c0c5ce;"> output</span><span style="color:#bf616a;"> -raw</span><span style="color:#c0c5ce;"> bastion_public_ip)
</span><span style="color:#c0c5ce;">
</span><span style="color:#65737e;"># jump through bastion to a private instance
</span><span style="color:#8fa1b3;">ssh</span><span style="color:#bf616a;"> -i ~</span><span style="color:#c0c5ce;">/.ssh/bastion-key</span><span style="color:#bf616a;"> -J</span><span style="color:#c0c5ce;"> ec2-user@&lt;bastion-ip&gt; ec2-user@&lt;private-ip&gt;
</span></pre>
<h2>the terraform lifecycle</h2>
<p>running <code>plan</code>, <code>apply</code>, and <code>destroy</code> is the full lifecycle. a few things stood out while working through it.</p>
<p><strong>dependency resolution is automatic.</strong> terraform inferred that the subnet depends on the vpc from <code>vpc_id = aws_vpc.main.id</code>. on create, it builds the vpc first. on destroy, it tears down the subnet first. you never specify ordering manually.</p>
<p><strong><code>(known after apply)</code> values</strong> are things aws generates at creation time - arns, ids, availability zones. they can't be known until the resource exists, so terraform marks them as pending.</p>
<p><strong>state is everything.</strong> after <code>apply</code>, terraform writes a <code>terraform.tfstate</code> file. this is how it knows what to update or destroy. the state file is the single source of truth for what terraform manages.</p>
<p><strong>credentials matter early.</strong> my first <code>terraform plan</code> failed because i was logged into the terraform cli but not aws. the error was clear enough - no valid credential sources found. logging into aws fixed it immediately.</p>
<h2>what i learned</h2>
<p>this project was about understanding vpc networking from first principles rather than clicking through the aws console. the key takeaways:</p>
<ul>
<li>network isolation is just routing. public vs private is determined by whether a route table points to an igw or a nat gateway</li>
<li>nat gateways are the bridge that lets private resources reach the internet without being reachable from it</li>
<li>multi-az is about eliminating single points of failure, including having one nat gateway per az</li>
<li>security groups are stateful firewalls at the resource level. nacls are stateless firewalls at the subnet level</li>
<li>terraform's dependency graph handles sequencing automatically from resource references</li>
<li>state files are critical infrastructure - treat them accordingly</li>
</ul>
<p>the source code is at <a href="https://github.com/aidantrabs/saba" rel="noopener noreferrer">github.com/aidantrabs/saba</a>.</p>
]]></content:encoded></item><item><title>designing a feature flag control plane</title><link>https://blog.aidantraboulay.dev/posts/designing-a-feature-flag-control-plane</link><description><![CDATA[the research and architecture behind building a self-hosted feature flag system from scratch]]></description><pubDate>2026-03-09</pubDate><content:encoded><![CDATA[<p>i've been thinking about feature flags a lot lately. not the "just use an if statement" kind - the kind where you need to roll out a payment flow to 5% of users in canada on the premium plan, watch it for a week, then crank it to 50% without touching a deploy pipeline. the kind where someone on your team can flip a kill switch at 2am when something goes sideways.</p>
<p>so i'm building <a href="https://github.com/aidantrabs/switchboard" rel="noopener noreferrer">switchboard</a> - a feature flag control plane. this post is the research and design thinking before i write a single line of code.</p>
<h2>why build one</h2>
<p>the obvious question. launchdarkly exists. unleash exists. flagsmith exists. why build another one?</p>
<p>partly because i want to understand the problem space deeply - the same way you don't really understand databases until you've tried to write one. partly because most feature flag systems are either too simple (a json file you check into your repo and pray) or too complex (a whole platform with pricing tiers and a sales team). i want to find the middle ground: something an engineering team could self-host, understand completely, and extend when they need to.</p>
<p>the target is internal platform teams. the kind of team that runs a handful of microservices and wants centralized flag management without sending their evaluation data to a third-party saas. the kind of team where "we need to be able to run this air-gapped" is a real requirement and not just a checkbox on a compliance form.</p>
<h2>the evaluation problem</h2>
<p>i started by researching how flag evaluation actually works under the hood. it sounds simple - "is this flag on?" - until you start layering requirements.</p>
<p>here's the evaluation order i've landed on after reading through how launchdarkly, unleash, and openfeature approach it:</p>
<ol>
<li>if the flag is globally disabled, return the default variant. easy.</li>
<li>evaluate targeting rules in priority order. each rule has conditions (AND logic) and a served variant. first match wins.</li>
<li>if no rules match but there's a rollout percentage, hash the user into a deterministic bucket and check if they're under the threshold.</li>
<li>if nothing hits, return the default variant.</li>
</ol>
<p>the rollout hashing is the part that tripped me up. you can't use <code>Math.random()</code> - the same user needs to get the same result every time for the same flag. otherwise you get someone who sees the new checkout flow, refreshes the page, and gets the old one. that's worse than not having flags at all.</p>
<p>the standard approach is consistent hashing. take the flag key + user id, run it through something like murmurhash3, mod 100, check if the result lands under your rollout percentage:</p>
<pre style="background-color:#2b303b;"><span style="color:#c0c5ce;">hash("new-checkout" + "user-123") mod 100 = 37
</span><span style="color:#c0c5ce;">rollout = 50%
</span><span style="color:#c0c5ce;">37 &lt; 50 → user gets the "on" variant
</span></pre>
<p>same inputs, same output, every time. and here's the property that took me a minute to appreciate: if you change the rollout from 50% to 60%, user-123 still gets "on" - you're only <em>adding</em> new users, never removing existing ones. that monotonicity matters a lot more than i initially realized.</p>
<h2>hexagonal architecture (or: am i overengineering this?)</h2>
<p>i'm going with spring boot for the server, but i want to try something i've been reading about for a while - hexagonal architecture. the idea is that your business logic lives in a core that knows nothing about the outside world. no spring annotations, no jpa, no kafka. just plain java.</p>
<pre style="background-color:#2b303b;"><span style="color:#c0c5ce;">domain/          ← pure java. no framework imports. ever.
</span><span style="color:#c0c5ce;">application/
</span><span style="color:#c0c5ce;">  port/
</span><span style="color:#c0c5ce;">    input/       ← interfaces: what the system can do
</span><span style="color:#c0c5ce;">    output/      ← interfaces: what the system needs
</span><span style="color:#c0c5ce;">  service/       ← use case implementations
</span><span style="color:#c0c5ce;">adapter/
</span><span style="color:#c0c5ce;">  input/rest/    ← spring controllers
</span><span style="color:#c0c5ce;">  output/
</span><span style="color:#c0c5ce;">    persistence/ ← jpa (implements output ports)
</span><span style="color:#c0c5ce;">    messaging/   ← kafka (implements output ports)
</span><span style="color:#c0c5ce;">    cache/       ← redis (implements output ports)
</span></pre>
<p>the domain says "i need to save a flag" by defining an interface. a jpa adapter implements that interface. the domain never knows jpa exists. want to swap postgres for something else? write a new adapter, domain doesn't change.</p>
<p>i'll be honest - part of me thinks this is overkill for a project i'm building from scratch. "just put <code>@Entity</code> on your domain class, it's fine." but i keep reading post-mortems from teams that started that way and regretted it two years later when their domain was welded to hibernate. i'd rather pay the cost of indirection now while the codebase is small and i can actually understand the boundaries.</p>
<p>the plan is to enforce this with archunit tests - if someone (me, inevitably) accidentally imports a spring annotation in the domain layer, the build fails. trust but verify, especially when you don't trust yourself.</p>
<h2>real-time updates: kafka or bust (or maybe not)</h2>
<p>when someone toggles a flag, every service consuming that flag needs to know. the naive approach is polling - every sdk hits the server every few seconds asking "anything change?" this works, it's simple, but it's wasteful and adds latency.</p>
<p>after looking at how other systems handle this, i'm planning to use kafka:</p>
<ol>
<li>flag gets toggled → database updated</li>
<li>application service publishes a change event</li>
<li>kafka carries it to a topic per project/environment</li>
<li>sdks consuming that topic update their local cache immediately</li>
</ol>
<p>but here's my concern: kafka is heavy. for a small team just trying out feature flags, "also run kafka" is a tough ask. so the sdk needs a fallback - polling on a configurable interval if kafka isn't available. and if the server itself is unreachable, use the last known cached state. graceful degradation at every level.</p>
<p>this is the part of the design i'm least confident about. distributed cache invalidation is one of those problems that sounds straightforward and then eats your weekend. but the alternative - a network round-trip for every flag evaluation in a hot code path - isn't acceptable.</p>
<h2>the sdk layer cake</h2>
<p>i want the sdk to work in three modes, layered on top of each other:</p>
<p><strong>pure java sdk</strong>: zero spring dependencies. construct a client with a builder, pass in your api url, call <code>isEnabled("flag-key", context)</code>. works in any jvm application - spring, dropwizard, plain old <code>public static void main</code>.</p>
<p><strong>spring boot starter</strong>: wraps the sdk with auto-configuration. one dependency in your <code>build.gradle.kts</code>, two lines in <code>application.yml</code>, and you get a wired-up client bean, health indicators, metrics, the works. zero boilerplate.</p>
<p><strong>openfeature provider</strong>: for teams that don't want to couple to a proprietary api. <a href="https://openfeature.dev/" rel="noopener noreferrer">openfeature</a> is an emerging standard for feature flag evaluation - you code against the standard interface, swap providers behind it. switchboard becomes just another provider you can plug in or rip out.</p>
<p>and then there's local mode. this one i feel strongly about. for development and testing, the sdk should load flags from a json file:</p>
<pre style="background-color:#2b303b;"><span style="color:#c0c5ce;">{
</span><span style="color:#c0c5ce;">    "</span><span style="color:#a3be8c;">flags</span><span style="color:#c0c5ce;">": {
</span><span style="color:#c0c5ce;">        "</span><span style="color:#a3be8c;">new-checkout</span><span style="color:#c0c5ce;">": { "</span><span style="color:#a3be8c;">enabled</span><span style="color:#c0c5ce;">": </span><span style="color:#d08770;">true</span><span style="color:#c0c5ce;">, "</span><span style="color:#a3be8c;">variant</span><span style="color:#c0c5ce;">": "</span><span style="color:#a3be8c;">on</span><span style="color:#c0c5ce;">" },
</span><span style="color:#c0c5ce;">        "</span><span style="color:#a3be8c;">dark-mode</span><span style="color:#c0c5ce;">": { "</span><span style="color:#a3be8c;">enabled</span><span style="color:#c0c5ce;">": </span><span style="color:#d08770;">false</span><span style="color:#c0c5ce;">, "</span><span style="color:#a3be8c;">variant</span><span style="color:#c0c5ce;">": "</span><span style="color:#a3be8c;">off</span><span style="color:#c0c5ce;">" }
</span><span style="color:#c0c5ce;">    }
</span><span style="color:#c0c5ce;">}
</span></pre>
<p>check it into your repo, use it in tests, run in ci with no external dependencies. if your feature flag system requires a running server to run unit tests, something has gone wrong.</p>
<h2>the data model question</h2>
<p>this took me a few iterations on paper. the key realization: a flag's <em>definition</em> is project-scoped, but its <em>state</em> is per-environment.</p>
<p>"new-checkout" exists once as a concept - it has a key, a name, some variants. but it can be enabled in dev, 50% rolled out in staging, and disabled in production, each with completely different targeting rules. this is how teams actually work. you don't want a flag that's either globally on or globally off everywhere.</p>
<p>so the model splits into <code>FeatureFlag</code> (the definition) and <code>FlagEnvironmentConfig</code> (the per-environment state). targeting rules hang off the environment config, not the flag itself. this felt weird at first but the more i thought about it the more it made sense - you target differently in dev vs production.</p>
<p>i'm also planning for four flag types: <strong>release</strong> (ship a feature incrementally), <strong>experiment</strong> (a/b testing), <strong>operational</strong> (circuit breakers, maintenance mode), and <strong>permission</strong> (entitlement gating). they all evaluate the same way mechanically, but the type gives you metadata for lifecycle management - release flags should eventually be cleaned up, operational flags might live forever.</p>
<h2>four interfaces, one source of truth</h2>
<p>the system needs to be operable through:</p>
<ul>
<li><strong>rest api</strong>: the source of truth. write endpoints for management, read endpoints for sdks. separated so they could theoretically scale independently.</li>
<li><strong>java sdk</strong>: how services consume flags. evaluates locally from cache, syncs in the background.</li>
<li><strong>cli</strong>: for terminal-first workflows. <code>switchboard flags toggle new-checkout --env production</code>. json output for scripting.</li>
<li><strong>dashboard</strong>: a react spa for visual management. tanstack router, tanstack query, tanstack table, tailwind. it's a pure client-side app that talks to the rest api.</li>
</ul>
<p>the dashboard being optional is deliberate. the api and cli are primary. if your team lives in the terminal, you never need to open a browser. the dashboard is there for the people who want to see a rollout slider and a toggle switch.</p>
<p>for the dashboard stack specifically - i'm going heavy on tanstack. router gives type-safe routing with inferred params (no manual type assertions). query handles all the server state with caching and background refetching. table gives headless primitives for the flag lists and audit logs. it's a lot of one ecosystem but they're designed to work together and it avoids the usual glue code.</p>
<h2>things i haven't figured out yet</h2>
<p><strong>stale flag detection.</strong> flags accumulate. teams create them for a release, ship it, forget to clean up. i want to surface warnings when flags haven't been modified in a while, but the ux of "hey this flag might be dead" without being annoying is an unsolved problem in my head.</p>
<p><strong>audit log growth.</strong> every write operation should produce an audit entry with before/after state as json. great for debugging, but the table grows unboundedly. partitioning by time and project is the obvious answer but i haven't thought through the query patterns enough yet.</p>
<p><strong>the kafka question.</strong> i keep going back and forth. kafka gives me real-time propagation but it's a heavy dependency. server-sent events would be simpler for small deployments. maybe i support both and let teams pick. or maybe i start with polling and add kafka later. this is the kind of decision that's hard to reverse so i want to get it right.</p>
<p><strong>how small can each commit actually be?</strong> i'm planning to build this in tiny increments - domain model first, then ports, then adapters, then sdk, then cli, then dashboard. each step should be a handful of files. i've never actually tried to be this disciplined about it on a project this size. we'll see if i can stick to it.</p>
<h2>the end goal</h2>
<p>clone the repo, run <code>docker compose up</code>, and have a fully working feature flag platform - server, dashboard, database, cache, message broker, and a demo service showing flags being evaluated in real time. under two minutes from git clone to toggling your first flag.</p>
<p>that's the bar. if it takes longer than that to evaluate switchboard, the developer experience has failed.</p>
<p>the source code is at <a href="https://github.com/aidantrabs/switchboard" rel="noopener noreferrer">github.com/aidantrabs/switchboard</a>.</p>
]]></content:encoded></item><item><title>bitgrid: elementary cellular automata</title><link>https://blog.aidantraboulay.dev/posts/bitgrid:-elementary-cellular-automata</link><description><![CDATA[exploring elementary cellular automata from first principles]]></description><pubDate>2026-03-08</pubDate><content:encoded><![CDATA[<h2>what is a cellular automaton?</h2>
<p>a cellular automaton is a discrete computational system. you have a row of cells, each in one of a finite number of states, and a rule that determines how each cell updates based on its neighborhood.</p>
<p>an elementary cellular automaton is the simplest nontrivial case:</p>
<ul>
<li>the grid is one-dimensional - a row of cells</li>
<li>each cell has exactly two states: 0 or 1</li>
<li>each cell's next state depends on three cells: itself and its two immediate neighbors (left, center, right)</li>
</ul>
<p>at each time step, every cell reads the triple (left, self, right) and produces a new value. that's the entire system.</p>
<h2>why exactly 256 rules?</h2>
<p>this is pure combinatorics.</p>
<p>a neighborhood is a triple of binary values. each value is 0 or 1, so there are:</p>
<p>$$2^3 = 8 \text{ possible neighborhood patterns}$$</p>
<p>the 8 patterns, ordered from 111 down to 000:</p>
<table><thead><tr><th>pattern</th><th>111</th><th>110</th><th>101</th><th>100</th><th>011</th><th>010</th><th>001</th><th>000</th></tr></thead><tbody>
<tr><td>index</td><td>7</td><td>6</td><td>5</td><td>4</td><td>3</td><td>2</td><td>1</td><td>0</td></tr>
</tbody></table>
<p>a rule assigns an output bit (0 or 1) to each of these 8 patterns. a rule is a function:</p>
<p>$$f: \{0,1\}^3 \rightarrow \{0,1\}$$</p>
<p>the number of such functions is:</p>
<p>$$2^8 = 256$$</p>
<p>there are exactly 256 elementary cellular automata. no more, no less.</p>
<h2>binary rule encoding</h2>
<p>wolfram's naming scheme is elegant. the rule number, expressed in binary, directly encodes the output table.</p>
<p>take rule 30:</p>
<p>$$30_{10} = 00011110_2$$</p>
<p>each bit corresponds to one neighborhood pattern:</p>
<table><thead><tr><th>pattern</th><th>111</th><th>110</th><th>101</th><th>100</th><th>011</th><th>010</th><th>001</th><th>000</th></tr></thead><tbody>
<tr><td>bit index</td><td>7</td><td>6</td><td>5</td><td>4</td><td>3</td><td>2</td><td>1</td><td>0</td></tr>
<tr><td>rule 30 output</td><td>0</td><td>0</td><td>0</td><td>1</td><td>1</td><td>1</td><td>1</td><td>0</td></tr>
</tbody></table>
<p>to compute the output for a neighborhood (l, c, r):</p>
<ol>
<li>interpret the triple as a 3-bit number: $i = l \cdot 4 + c \cdot 2 + r$</li>
<li>extract bit $i$ from the rule number: $\text{output} = (\text{rule} \gg i) \;\&amp;\; 1$</li>
</ol>
<p>the entire rule engine fits in one expression. in rust:</p>
<pre style="background-color:#2b303b;"><span style="color:#b48ead;">pub fn </span><span style="color:#8fa1b3;">apply</span><span style="color:#c0c5ce;">(&amp;</span><span style="color:#bf616a;">self</span><span style="color:#c0c5ce;">, </span><span style="color:#bf616a;">left</span><span style="color:#c0c5ce;">: </span><span style="color:#b48ead;">u8</span><span style="color:#c0c5ce;">, </span><span style="color:#bf616a;">center</span><span style="color:#c0c5ce;">: </span><span style="color:#b48ead;">u8</span><span style="color:#c0c5ce;">, </span><span style="color:#bf616a;">right</span><span style="color:#c0c5ce;">: </span><span style="color:#b48ead;">u8</span><span style="color:#c0c5ce;">) -&gt; </span><span style="color:#b48ead;">u8 </span><span style="color:#c0c5ce;">{
</span><span style="color:#c0c5ce;">    </span><span style="color:#b48ead;">let</span><span style="color:#c0c5ce;"> index = (left &lt;&lt; </span><span style="color:#d08770;">2</span><span style="color:#c0c5ce;">) | (center &lt;&lt; </span><span style="color:#d08770;">1</span><span style="color:#c0c5ce;">) | right;
</span><span style="color:#c0c5ce;">    (</span><span style="color:#bf616a;">self</span><span style="color:#c0c5ce;">.number &gt;&gt; index) &amp; </span><span style="color:#d08770;">1
</span><span style="color:#c0c5ce;">}
</span></pre>
<p>two lines. no lookup table, no conditionals. the rule number is the lookup table, and bitwise operations are the query.</p>
<h2>running a simulation</h2>
<p>the automaton starts with a row of zeros and a single 1 in the center. each generation, we apply the rule to every cell using its left and right neighbors:</p>
<pre style="background-color:#2b303b;"><span style="color:#b48ead;">pub fn </span><span style="color:#8fa1b3;">step</span><span style="color:#c0c5ce;">(&amp;</span><span style="color:#b48ead;">mut </span><span style="color:#bf616a;">self</span><span style="color:#c0c5ce;">) {
</span><span style="color:#c0c5ce;">    </span><span style="color:#b48ead;">let</span><span style="color:#c0c5ce;"> len = </span><span style="color:#bf616a;">self</span><span style="color:#c0c5ce;">.cells.</span><span style="color:#96b5b4;">len</span><span style="color:#c0c5ce;">();
</span><span style="color:#c0c5ce;">    </span><span style="color:#b48ead;">let</span><span style="color:#c0c5ce;"> prev = </span><span style="color:#bf616a;">self</span><span style="color:#c0c5ce;">.cells.</span><span style="color:#96b5b4;">clone</span><span style="color:#c0c5ce;">();
</span><span style="color:#c0c5ce;">    </span><span style="color:#b48ead;">for</span><span style="color:#c0c5ce;"> i in </span><span style="color:#d08770;">0</span><span style="color:#c0c5ce;">..len {
</span><span style="color:#c0c5ce;">        </span><span style="color:#b48ead;">let</span><span style="color:#c0c5ce;"> left = </span><span style="color:#b48ead;">if</span><span style="color:#c0c5ce;"> i == </span><span style="color:#d08770;">0 </span><span style="color:#c0c5ce;">{ </span><span style="color:#d08770;">0 </span><span style="color:#c0c5ce;">} </span><span style="color:#b48ead;">else </span><span style="color:#c0c5ce;">{ prev[i - </span><span style="color:#d08770;">1</span><span style="color:#c0c5ce;">] };
</span><span style="color:#c0c5ce;">        </span><span style="color:#b48ead;">let</span><span style="color:#c0c5ce;"> center = prev[i];
</span><span style="color:#c0c5ce;">        </span><span style="color:#b48ead;">let</span><span style="color:#c0c5ce;"> right = </span><span style="color:#b48ead;">if</span><span style="color:#c0c5ce;"> i == len - </span><span style="color:#d08770;">1 </span><span style="color:#c0c5ce;">{ </span><span style="color:#d08770;">0 </span><span style="color:#c0c5ce;">} </span><span style="color:#b48ead;">else </span><span style="color:#c0c5ce;">{ prev[i + </span><span style="color:#d08770;">1</span><span style="color:#c0c5ce;">] };
</span><span style="color:#c0c5ce;">        </span><span style="color:#bf616a;">self</span><span style="color:#c0c5ce;">.cells[i] = </span><span style="color:#bf616a;">self</span><span style="color:#c0c5ce;">.rule.</span><span style="color:#96b5b4;">apply</span><span style="color:#c0c5ce;">(left, center, right);
</span><span style="color:#c0c5ce;">    }
</span><span style="color:#c0c5ce;">}
</span></pre>
<p>boundary cells see 0 beyond the edge. the previous state is cloned so updates within a generation don't interfere with each other.</p>
<h2>three rules, three behaviors</h2>
<p>starting from a single cell, we evolve three rules and get radically different results.</p>
<h3>rule 30 - chaos</h3>
<pre style="background-color:#2b303b;"><span style="color:#c0c5ce;">                    █
</span><span style="color:#c0c5ce;">                   ███
</span><span style="color:#c0c5ce;">                  ██  █
</span><span style="color:#c0c5ce;">                 ██ ████
</span><span style="color:#c0c5ce;">                ██  █   █
</span><span style="color:#c0c5ce;">               ██ ████ ███
</span><span style="color:#c0c5ce;">              ██  █    █  █
</span><span style="color:#c0c5ce;">             ██ ████  ██████
</span><span style="color:#c0c5ce;">            ██  █   ███     █
</span><span style="color:#c0c5ce;">           ██ ████ ██  █   ███
</span></pre>
<p>rule 30 produces chaotic, aperiodic structure. the left side appears disordered while the right side shows faint regularity. despite being fully deterministic, wolfram conjectured it may function as a pseudorandom number generator. no repeating period has been found in the center column.</p>
<h3>rule 90 - the sierpinski triangle</h3>
<pre style="background-color:#2b303b;"><span style="color:#c0c5ce;">                    █
</span><span style="color:#c0c5ce;">                   █ █
</span><span style="color:#c0c5ce;">                  █   █
</span><span style="color:#c0c5ce;">                 █ █ █ █
</span><span style="color:#c0c5ce;">                █       █
</span><span style="color:#c0c5ce;">               █ █     █ █
</span><span style="color:#c0c5ce;">              █   █   █   █
</span><span style="color:#c0c5ce;">             █ █ █ █ █ █ █ █
</span><span style="color:#c0c5ce;">            █               █
</span><span style="color:#c0c5ce;">           █ █             █ █
</span></pre>
<p>rule 90 is equivalent to xor(left, right) - the center cell doesn't even matter. this trivial operation produces the sierpinski triangle, a well-known fractal with hausdorff dimension $\log_2(3) \approx 1.585$.</p>
<p>the self-similarity is exact: zoom into any triangular region and you find a smaller copy of the whole pattern. a one-bit local operation generating a fractal is one of the most striking results in cellular automata.</p>
<p>we can verify the xor equivalence exhaustively:</p>
<pre style="background-color:#2b303b;"><span style="color:#c0c5ce;">#[</span><span style="color:#bf616a;">test</span><span style="color:#c0c5ce;">]
</span><span style="color:#b48ead;">fn </span><span style="color:#8fa1b3;">rule_90_is_xor</span><span style="color:#c0c5ce;">() {
</span><span style="color:#c0c5ce;">    </span><span style="color:#b48ead;">let</span><span style="color:#c0c5ce;"> rule = Rule::new(</span><span style="color:#d08770;">90</span><span style="color:#c0c5ce;">);
</span><span style="color:#c0c5ce;">    </span><span style="color:#b48ead;">for</span><span style="color:#c0c5ce;"> l in </span><span style="color:#d08770;">0</span><span style="color:#c0c5ce;">..=</span><span style="color:#d08770;">1</span><span style="color:#b48ead;">u8 </span><span style="color:#c0c5ce;">{
</span><span style="color:#c0c5ce;">        </span><span style="color:#b48ead;">for</span><span style="color:#c0c5ce;"> c in </span><span style="color:#d08770;">0</span><span style="color:#c0c5ce;">..=</span><span style="color:#d08770;">1</span><span style="color:#b48ead;">u8 </span><span style="color:#c0c5ce;">{
</span><span style="color:#c0c5ce;">            </span><span style="color:#b48ead;">for</span><span style="color:#c0c5ce;"> r in </span><span style="color:#d08770;">0</span><span style="color:#c0c5ce;">..=</span><span style="color:#d08770;">1</span><span style="color:#b48ead;">u8 </span><span style="color:#c0c5ce;">{
</span><span style="color:#c0c5ce;">                assert_eq!(rule.</span><span style="color:#96b5b4;">apply</span><span style="color:#c0c5ce;">(l, c, r), l ^ r);
</span><span style="color:#c0c5ce;">            }
</span><span style="color:#c0c5ce;">        }
</span><span style="color:#c0c5ce;">    }
</span><span style="color:#c0c5ce;">}
</span></pre>
<p>all 8 inputs confirm it. the center cell is irrelevant.</p>
<h3>rule 110 - turing completeness</h3>
<pre style="background-color:#2b303b;"><span style="color:#c0c5ce;">                    █
</span><span style="color:#c0c5ce;">                   ██
</span><span style="color:#c0c5ce;">                  ███
</span><span style="color:#c0c5ce;">                 ██ █
</span><span style="color:#c0c5ce;">                █████
</span><span style="color:#c0c5ce;">               ██   █
</span><span style="color:#c0c5ce;">              ███  ██
</span><span style="color:#c0c5ce;">             ██ █ ███
</span><span style="color:#c0c5ce;">            ███████ █
</span><span style="color:#c0c5ce;">           ██     ███
</span></pre>
<p>rule 110 grows asymmetrically to the left with complex interacting structures. in 2004, matthew cook proved that rule 110 is turing complete - it can simulate any computation given the right initial conditions.</p>
<p>this is one of the most profound results in cellular automata theory. a one-dimensional row of bits with a trivial 8-entry lookup table is capable of universal computation.</p>
<h2>measuring complexity</h2>
<h3>population density</h3>
<p>population density is the fraction of live cells in a generation:</p>
<p>$$\rho(t) = \frac{1}{N} \sum_{i=0}^{N-1} c_i(t)$$</p>
<h3>shannon entropy</h3>
<p>shannon entropy measures the information content of a generation from the frequency of 0s and 1s:</p>
<p>$$H = -\sum_{i} p_i \log_2(p_i)$$</p>
<p>maximum entropy $H = 1.0$ means equal proportions of 0s and 1s. minimum $H = 0.0$ means all cells are in the same state.</p>
<h3>comparing rules</h3>
<p>running 100 generations on a 201-cell grid:</p>
<table><thead><tr><th>rule</th><th>behavior</th><th>final density</th><th>mean density</th><th>final entropy</th><th>mean entropy</th></tr></thead><tbody>
<tr><td>30</td><td>chaotic</td><td>0.5473</td><td>0.2585</td><td>0.9935</td><td>0.7334</td></tr>
<tr><td>90</td><td>fractal</td><td>0.0796</td><td>0.0622</td><td>0.4008</td><td>0.3047</td></tr>
<tr><td>110</td><td>complex</td><td>0.2537</td><td>0.1431</td><td>0.8171</td><td>0.5503</td></tr>
<tr><td>184</td><td>simple</td><td>0.0050</td><td>0.0050</td><td>0.0452</td><td>0.0452</td></tr>
<tr><td>0</td><td>trivial</td><td>0.0000</td><td>0.0000</td><td>0.0000</td><td>0.0005</td></tr>
<tr><td>255</td><td>trivial</td><td>1.0000</td><td>0.9900</td><td>0.0000</td><td>0.0005</td></tr>
</tbody></table>
<p>the numbers reveal the behavioral classes:</p>
<ul>
<li>rule 30 converges to density ~0.5 with entropy near 1.0 - maximum disorder</li>
<li>rule 90 maintains low density with periodic entropy oscillations tied to powers of 2</li>
<li>rule 110 sits between order and chaos - moderate density, high but not maximal entropy</li>
<li>rules 0, 184, 255 are degenerate - they either die out or saturate immediately</li>
</ul>
<p>wolfram classified elementary cellular automata into four classes:</p>
<ol>
<li><strong>class 1</strong> - evolves to a uniform state (rule 0, rule 255)</li>
<li><strong>class 2</strong> - evolves to periodic or stable structures (rule 90)</li>
<li><strong>class 3</strong> - chaotic, aperiodic behavior (rule 30)</li>
<li><strong>class 4</strong> - complex structures, long-lived transients (rule 110)</li>
</ol>
<p>the boundary between class 3 and class 4 is where computation lives.</p>
<h2>what's next</h2>
<p>this is the foundation. from here we can explore:</p>
<ul>
<li>spatial entropy using block decomposition rather than global frequency</li>
<li>mutual information between successive generations</li>
<li>langton's lambda parameter as a predictor of behavioral class</li>
<li>the full atlas of all 256 rules</li>
</ul>
<p>the code is minimal - a rule encoder, a grid stepper, analysis functions, and a renderer. everything follows directly from the mathematics. the entire system is deterministic, pure, and fits in a few hundred lines of rust.</p>
<p>the source code is at <a href="https://github.com/aidantrabs/bitgrid" rel="noopener noreferrer">github.com/aidantrabs/bitgrid</a>.</p>
]]></content:encoded></item><item><title>hello world</title><link>https://blog.aidantraboulay.dev/posts/hello-world</link><description><![CDATA[the first post on forge]]></description><pubDate>2026-03-08</pubDate><content:encoded><![CDATA[<p>this is the first post built with forge.</p>
<h2>why forge</h2>
<p>because every developer needs to build their own blog engine at least once.</p>
<pre style="background-color:#2b303b;"><span style="color:#b48ead;">fn </span><span style="color:#8fa1b3;">main</span><span style="color:#c0c5ce;">() {
</span><span style="color:#c0c5ce;">    println!("</span><span style="color:#a3be8c;">hello from forge</span><span style="color:#c0c5ce;">");
</span><span style="color:#c0c5ce;">}
</span></pre>
]]></content:encoded></item><item><title>building forge</title><link>https://blog.aidantraboulay.dev/posts/building-forge</link><description><![CDATA[how i built a rust static site generator from scratch]]></description><pubDate>2026-03-08</pubDate><content:encoded><![CDATA[<p>every developer eventually builds their own blog engine. this is mine.</p>
<h2>the stack</h2>
<p>forge is a two-part system:</p>
<ul>
<li>a <strong>rust cli</strong> that parses markdown, applies templates, and outputs a static site</li>
<li>a <strong>minimal frontend</strong> built with vite, vanilla typescript, and tailwind v4</li>
</ul>
<p>the goal was simple: fast builds, tiny output, zero runtime complexity.</p>
<h2>why rust</h2>
<blockquote>
<p>i'm a wannabe rustacean</p>
</blockquote>
<p>rust gives us:</p>
<ol>
<li>fast compilation of markdown to html</li>
<li>zero-cost abstractions for template rendering</li>
<li>a single binary with no runtime dependencies</li>
</ol>
<pre style="background-color:#2b303b;"><span style="color:#b48ead;">let</span><span style="color:#c0c5ce;"> posts = </span><span style="color:#96b5b4;">load_posts</span><span style="color:#c0c5ce;">(Path::new("</span><span style="color:#a3be8c;">content</span><span style="color:#c0c5ce;">"));
</span><span style="color:#b48ead;">let</span><span style="color:#c0c5ce;"> renderer = Renderer::new(Path::new("</span><span style="color:#a3be8c;">templates</span><span style="color:#c0c5ce;">"));
</span><span style="color:#c0c5ce;">
</span><span style="color:#b48ead;">for</span><span style="color:#c0c5ce;"> post in &amp;posts {
</span><span style="color:#c0c5ce;">    </span><span style="color:#b48ead;">let</span><span style="color:#c0c5ce;"> html = renderer.</span><span style="color:#96b5b4;">render_post</span><span style="color:#c0c5ce;">(post, &amp;config);
</span><span style="color:#c0c5ce;">    fs::write(post_dir.</span><span style="color:#96b5b4;">join</span><span style="color:#c0c5ce;">("</span><span style="color:#a3be8c;">index.html</span><span style="color:#c0c5ce;">"), html)?;
</span><span style="color:#c0c5ce;">}
</span></pre>
<h2>the frontend</h2>
<p>the entire javascript runtime is under 1kb. it handles:</p>
<ul>
<li>dark mode toggle via a pull-string ui element</li>
<li>scroll reveal animations with <code>IntersectionObserver</code></li>
<li>font loading with fout prevention</li>
</ul>
<blockquote>
<p>the best javascript is the javascript you don't ship.</p>
</blockquote>
<h2>what's next</h2>
<ul>
<li>wasm-powered client-side search</li>
<li>image optimization pipeline</li>
<li>incremental builds</li>
</ul>
]]></content:encoded></item></channel></rss>