<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Ken Reid's Blog</title>
    <link>https://www.kenreid.co.uk/blog.html</link>
    <description>Data science, photography, books, and everything in between.</description>
    <language>en</language>
    <lastBuildDate>Thu, 06 Aug 2026 00:00:00 +0000</lastBuildDate>
    <pubDate>Thu, 06 Aug 2026 00:00:00 +0000</pubDate>
    <docs>https://www.rssboard.org/rss-specification</docs>
    <generator>Ken Reid static site feed</generator>
    <atom:link href="https://www.kenreid.co.uk/feed.xml" rel="self" type="application/rss+xml"/>
    <item>
      <title>Dark Mode That Doesn&#x27;t Flash</title>
      <link>https://www.kenreid.co.uk/blog/dark-mode-that-doesnt-flash.html</link>
      <guid>https://www.kenreid.co.uk/blog/dark-mode-that-doesnt-flash.html</guid>
      <pubDate>Thu, 06 Aug 2026 00:00:00 +0000</pubDate>
      <description>Dark mode is easy; dark mode without a white flash is more difficult. How this site stamps the theme before the first paint, and drags every chart and iframe along with it.</description>
      <category>technology</category>
      <content:encoded><![CDATA[
 <h1>Dark Mode That Doesn't Flash</h1>
 <div class="blog-meta">
 6 August 2026 &middot;
 <span class="blog-tag">technology</span>
 </div>

 <p>I cannot stand light mode for pretty much anything. Many of my hobbies, my full time job, my masters and my honorary research position all require screentime. Wearing anti-glare glasses helps, but light mode just burns my retinas actively. So, one of the first things I did when making my website an active project rather than a passive one, was adding dark mode. What the tutorials tend to skip for "adding dark mode" is you pick dark mode, you click a link, and the next page blasts you with a white screen for a quarter of a second before remembering your choice. In a dark room, at night, that flash is scorching, and it happens way too often, even on big-name sites. This post covers how this site's theme system avoids it. Part of the series on <a href="https://www.kenreid.co.uk/series-how-this-site-is-built.html">how this site is built</a>.</p>

 <div class="plain-english-box">
 <h2>Quick jargon guide</h2>
 <ul>
 <li><strong>FOUC / theme flash:</strong> "flash of unstyled content", the brief moment a page renders wrong (here: bright white) before correcting itself.</li>
 <li><strong>localStorage:</strong> a small key-value store in the browser, per site, that survives across visits. Where the theme choice lives.</li>
 <li><strong>data attribute:</strong> a custom label on an HTML element, like <code>data-theme="dark"</code> on the page's root. CSS rules can target it.</li>
 <li><strong>Render-blocking:</strong> code the browser must run before it paints anything. Usually a dirty word; here it's the fix.</li>
 <li><strong>iframe:</strong> a page embedded in another page. This site's comments are one, and they have opinions about their own colours that I had to navigate around.</li>
 </ul>
 </div>

 <h2>The process</h2>

 <p>The site's theme is a single attribute on the root element: <code>&lt;html data-theme="dark"&gt;</code> or <code>&lt;html data-theme="light"&gt;</code>. The stylesheet's light styles are the defaults, and dark mode is a set of override rules scoped to that attribute. These are the first two rules of this site's, verbatim:</p>

<pre><code class="language-css">body {
  color: #252525;
  background-color: #ffffff; }

[data-theme="dark"] body {
  background-color: #1a1a1a;
  color: #e2e2e2; }</code></pre>

 <p>The toggle button in the header flips the attribute, saves the choice to localStorage under a key (<code>kr-theme</code>), and everything restyles instantly, no reload. A brief cross-fade class softens the swap, and the browser's <code>theme-color</code> meta tag (which tints the address bar on phones) is updated to match. All of that is a 60-line JavaScript file.</p>

 <details class="code-example"><summary>The whole toggle (js/theme.js, 60 lines)</summary>
<pre><code class="language-javascript">/**
 * theme.js — Site-wide dark/light mode toggle.
 *
 * Responsibilities:
 *   - Read stored preference from localStorage on DOMContentLoaded
 *   - Default to dark mode when there is no stored preference
 *   - Apply data-theme="dark"|"light" to &lt;html&gt;
 *   - Keep the #theme-toggle button aria state in sync
 *   - Persist choice across page loads
 *
 * Flash-of-wrong-theme (FODT) prevention is handled by a tiny inline
 * script injected into every page's &lt;head&gt; (see the kr-theme check).
 */
(function () {
  var STORAGE_KEY = 'kr-theme';
  var DEFAULT_THEME = 'dark';

  function stored() {
    try { return localStorage.getItem(STORAGE_KEY); } catch (e) { return null; }
  }

  function save(t) {
    try { localStorage.setItem(STORAGE_KEY, t); } catch (e) {}
  }

  function apply(theme) {
    document.documentElement.setAttribute('data-theme', theme);
    var dark = theme === 'dark';

    // Keep the browser UI (mobile address bar etc.) matching the page background.
    var meta = document.querySelector('meta[name="theme-color"]');
    if (meta) meta.setAttribute('content', dark ? '#1a1a1a' : '#ffffff');

    var btn = document.getElementById('theme-toggle');
    if (!btn) return;
    btn.setAttribute('aria-pressed', String(dark));
    btn.setAttribute('aria-label', dark ? 'Switch to light mode' : 'Switch to dark mode');
    btn.setAttribute('title',      dark ? 'Switch to light mode' : 'Switch to dark mode');
  }

  document.addEventListener('DOMContentLoaded', function () {
    apply(stored() || DEFAULT_THEME);

    // Delegated click — works even if button is injected after this script runs
    var switchTimer = null;
    document.addEventListener('click', function (e) {
      var btn = e.target.closest ? e.target.closest('#theme-toggle') : null;
      if (!btn) return;
      var next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
      // Cross-fade the swap (CSS scopes transitions to .theme-switching)
      document.documentElement.classList.add('theme-switching');
      if (switchTimer) clearTimeout(switchTimer);
      switchTimer = setTimeout(function () {
        document.documentElement.classList.remove('theme-switching');
      }, 350);
      save(next);
      apply(next);
    });
  });
}());</code></pre>
 </details>

 <h2>The hard part</h2>

 <p>The flash happens because of an ordering problem. The browser starts painting your page as soon as it has the HTML and CSS; your theme JavaScript, if it's a normal script loaded politely at the end, runs <em>after</em> that first paint. So the sequence in dark mode is: paint the default (light) page, run the script, discover the visitor wanted dark, restyle. Well, that's no good.</p>

 <p>The fix is to cheat the ordering. The very first thing in this site's <code>&lt;head&gt;</code>, before the stylesheet, before anything, is this inline script (unsquashed here for reading; the shipped version is one line):</p>

<pre><code class="language-html">&lt;script&gt;
(function () {
  var t;
  try { t = localStorage.getItem('kr-theme'); } catch (e) {}
  if (!t) t = 'dark';
  document.documentElement.setAttribute('data-theme', t);
})();
&lt;/script&gt;</code></pre>

 <p>Inline scripts in the head are render-blocking: the browser stops and runs them before painting a single pixel. Normally that's why performance guides tell you never to put scripts there. I broke the rules here because I made sure it's super efficient (it runs in well under a millisecond) and by doing the one job that must happen before paint: stamping the saved theme onto the root element to prevent harming the corneas of my few thousand readers (although the idea of my code having the power to burn eyeballs across the globe is kind of cool sounding). By the time the browser paints, the attribute is already correct, and there is nothing to flash. The <code>try/catch</code> is for browsers that block localStorage entirely; they get the default, which on this site is dark, on the fact that a light flash in a dark room is worse than a dark flash in a bright one.</p>

 <figure>
 <img src="https://www.kenreid.co.uk/img/photography/thumb/20.webp" alt="Old wooden floorboards in near darkness, low light catching the grain, black and white" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
 <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
 </figure>

 <h2>The extras: images and iframes</h2>

 <p><strong>Images with backgrounds don't theme themselves.</strong> My <a href="https://www.kenreid.co.uk/blog/what-50000-scrobbles-say-about-me.html">post about music listening data</a> includes charts rendered as images, with axis labels and backgrounds that only look right on the right theme. The fix is low-tech and bulletproof: export the chart twice, once per theme, put both in the page, and let three CSS rules choose:</p>

<pre><code class="language-html">&lt;img class="theme-img-light" src="https://www.kenreid.co.uk/blog/chart-light.png" alt="Top artists chart"&gt;
&lt;img class="theme-img-dark" src="https://www.kenreid.co.uk/blog/chart-dark.png" alt="Top artists chart"&gt;

/* in the stylesheet */
.theme-img-dark { display: none; }
[data-theme="dark"] .theme-img-light { display: none; }
[data-theme="dark"] .theme-img-dark { display: block; }</code></pre>

 <p>Two images, one visible at a time, swapping instantly with the toggle. It costs a second download for the chart a visitor actually views, which for the occasional plot is nothing. (For photographs none of this applies; a photo is a photo on any background, I don't use frames in photos of dark or white)</p>

 <p><strong>Iframes are other people's pages.</strong> The comments section is an embedded giscus frame (the free-comments-via-GitHub-Discussions setup has <a href="https://www.kenreid.co.uk/blog/free-comments-via-github-discussions.html">a post of its own</a>), and my CSS cannot reach inside it. giscus's answer is a message-passing API: when the toggle flips, the site posts a message into the frame telling it which stylesheet to use, and the comments swap themes in step with the page around them. Every embedded widget needs this conversation, which is one more argument for embedding as few widgets as possible.</p>

<pre><code class="language-javascript">// runs after the toggle is clicked; giscusTheme() returns the stylesheet for the new mode
var frame = document.querySelector('iframe.giscus-frame');
frame.contentWindow.postMessage(
  { giscus: { setConfig: { theme: giscusTheme() } } },
  'https://giscus.app'
);</code></pre>

 <h2>Respect the choice, remember the choice</h2>

 <p>The remaining design decision is what to do for a first-time visitor with no saved preference. The fashionable answer is to read the operating system's setting via a media query, and it's a good answer. This site instead defaults everyone to dark and makes the toggle obvious, partly because the photography looks better on dark, partly because the site had dark-first visuals long before it had a toggle, but mostly because I hate light theme and I force it upon everyone by default, because I care about your optical health, but you can opt into the pain if you prefer.</p>

 <div class="faq-section">
 <h2>Common questions</h2>

 <details class="faq-item">
 <summary>Why not the pure-CSS route with prefers-color-scheme?</summary>
 <p>A media query alone gives you automatic theming with zero JavaScript, and if you don't want a manual toggle it's the simplest answer. The moment you add a toggle (and users do ask for one; OS setting and site preference differ), you need somewhere to store the override and something to apply it before paint, and you're back to the inline script.</p>
 </details>

 <details class="faq-item">
 <summary>Does the render-blocking script hurt performance?</summary>
 <p>Technically yes, in reality no. It's a few dozen bytes reading one localStorage key; the cost is microseconds. The rule against render-blocking scripts is about network fetches and heavy work, not about five lines of inline code doing the job that must precede painting.</p>
 </details>

 <details class="faq-item">
 <summary>Why one data attribute instead of a CSS class?</summary>
 <p>Near-total taste. An attribute reads as "this page has one theme state" while classes invite accumulating several. It also keeps theme selectors visually distinct from styling classes in the stylesheet, which after a few hundred override rules matters more than I expected.</p>
 </details>

 <details class="faq-item">
 <summary>How much CSS did dark mode actually take?</summary>
 <p>More than the tutorials imply: this site's dark theme is hundreds of override rules accumulated over time, because a template built light-first has hardcoded colours everywhere. Building theme-aware from day one (ideally with colour variables) is dramatically less work than retrofitting.</p>
 </details>
 </div>

        

 </main>

 <hr style="margin: 40px 0;">
 <p style="text-align: center;"><a class="post-cta" href="https://www.kenreid.co.uk/blog.html">Back to all posts</a></p>
 ]]></content:encoded>
    </item>
    <item>
      <title>Simulated Annealing, Live</title>
      <link>https://www.kenreid.co.uk/blog/simulated-annealing-live.html</link>
      <guid>https://www.kenreid.co.uk/blog/simulated-annealing-live.html</guid>
      <pubDate>Wed, 05 Aug 2026 00:00:00 +0000</pubDate>
      <description>An interactive introduction to simulated annealing: watch a ball escape local minima as you control the temperature, then race four algorithms live on the Travelling Salesman Problem in your browser.</description>
      <category>ai</category>
      <content:encoded><![CDATA[<p><em>This post includes an interactive demo that runs live in the browser. <a href="https://www.kenreid.co.uk/blog/simulated-annealing-live.html">View it on the site</a> to play with it.</em></p>
 <h1>Simulated Annealing, Live</h1>
 <div class="blog-meta">
 5 August 2026 &middot;
 <span class="blog-tag">ai</span>
 </div>

 

 <p>The star of the show is <strong>simulated annealing</strong>, which I love the metaphor for. When I went to Disney some 12 years ago, I saw a live performance of an artist shaping molten glass into the shape of Mickey Mouse. It's a fascinating art, known as annealing: where you, a blacksmith or glassblower, heat up your material till it's incredibly hot, often a striking red color, then shape it as you please, and cool it strategically. As it cools, it hardens, so you have a more flexible material the hotter it is, and less so the cooler it is. This post shows a metaphor of this in optimization known as Simulated Annealing, it's a sixty-line algorithm that solves problems by exploring a search space of a problem (shaping the material that is wet and goopy), then cooling down as better solutions are found, modifying the medium less and less until you have a solution.</p>

 <div class="plain-english-box">
 <h2>Quick jargon guide</h2>
 <ul>

 <li><strong>Local optimum:</strong> a solution better than all its <em>neighbours</em> but not the best overall. The dip a greedy search falls into and can never leave.</li>
 <li><strong>Simulated annealing (SA):</strong> a search that sometimes accepts <em>worse</em> solutions, with a tolerance ("temperature") that starts high and slowly cools, letting it escape local optima early and commit late.</li>
 <li><strong>Cooling schedule:</strong> the recipe for how fast the temperature drops. Too fast and you're just a hill climber; too slow and you never settle.</li>
 <li><strong>Travelling Salesman Problem (TSP):</strong> given a set of cities, find the shortest round trip visiting each exactly once. The classic hard optimization problem.</li>
 <li><strong>NP-hard:</strong> a class of problems where no known method finds guaranteed-best answers efficiently as size grows, so practical work means excellent-not-certified answers.</li>
 <li><strong>2-opt move:</strong> a small edit to a tour: pick two of its edges, reconnect them the other way round. The basic step all the movers in the race use.</li>
 <li><strong>Evaluation:</strong> one call to the scoring function ("how long is this tour?"). The fair currency for comparing search algorithms, everyone gets the same number of questions.</li>
 <li><strong>Metaheuristic:</strong> the umbrella term for general-purpose search strategies like annealing, hill climbing, and genetic algorithms that make few assumptions about the problem.</li>
 </ul>
 </div>

 <h2>The problem: greed gets stuck</h2>

 <p>Imagine a marble rolling on a bumpy curve, trying to find the lowest point. The obvious strategy is "only ever move downhill". This is the spirit of <a href="https://www.kenreid.co.uk/blog/evolution-live.html">gradient descent and hill climbing</a>, but the first dip the marble lands in, it stays in. Nearby there may be a valley ten times deeper, but it will never see it, because getting there requires briefly going <em>up</em>.</p>

 <p>Metallurgists solved this problem for atoms centuries before computer scientists had it: cool molten metal too fast and its atoms freeze wherever they happen to be, full of internal stress: a brittle mess stuck in a bad arrangement. But cool it <em>slowly</em>, annealing it, and the atoms, still hot enough to jiggle out of bad positions, gradually settle into a low-energy crystal. Randomness, early on, is what lets the system escape bad arrangements; the slow cooling is what lets it eventually commit to a good one.</p>

 <p>In 1983, Kirkpatrick, Gelatt and Vecchi published the algorithmic version in <em>Science</em><sup><a href="#ref-1" class="cite-ref">[1]</a></sup> (Černý found it independently<sup><a href="#ref-2" class="cite-ref">[2]</a></sup>), building on a Monte Carlo method physicists had used since 1953.<sup><a href="#ref-3" class="cite-ref">[3]</a></sup> The recipe: search like a hill climber, but when a proposed move is <em>worse</em>, don't always refuse. Accept it with probability <code>P = exp(&minus;&Delta;/T)</code>, where &Delta; is how much worse and <code>T</code> is a temperature you slowly lower. Hot: almost anything goes, the search bounces freely across the landscape. Cold: only improvements survive, and the algorithm hardens into a plain hill climber.</p>

 <h2>Play with it: the marble and the thermostat</h2>

 <p>Below are two marbles on the same bumpy curve, started at the same spot. The <span style="white-space:nowrap;"><span class="sa-swatch sa-swatch-2"></span> green one</span> is a pure hill climber: it only accepts downhill moves. The <span style="white-space:nowrap;"><span class="sa-swatch sa-swatch-1"></span> blue one</span> anneals: you hold its thermostat. Run it a few times. Watch the greedy marble find the nearest dip and retire there, while the hot marble rattles across the whole curve; then drag the temperature down (or let auto-cool do it) and watch the blue marble crystallise, usually somewhere much deeper. The small dots mark the best point each marble has ever found. The strip under the curve records your run as you go: the temperature you are holding in amber and, in blue, the fraction of uphill moves the annealer is accepting, which is <code>P = exp(&minus;&Delta;/T)</code> made visible.</p>

 

 <p>The blue marble isn't smarter, it's just <em>temporarily tolerant of bad moves</em>, on a schedule. And that single tweak converts the world's dumbest algorithm into one that, given a sensible cooling schedule, provably converges toward global optima and, more importantly, finds excellent solutions to real industrial problems in practice.</p>

 <h2>The main event: four algorithms, one map</h2>

 <p><strong>Travelling Salesman Problem</strong>: given a set of cities, find the shortest round trip that visits each exactly once. It's the classic NP-hard problem, the number of possible tours explodes factorially (30 cities is already ~10<sup>30</sup> tours), and it's the kind of discrete, gradient-free terrain I wrote about in <a href="https://www.kenreid.co.uk/blog/evolution-live.html">the previous post</a>.</p>

 <p>The racers, each in its lane colour:</p>

 <ul>
 <li><span class="sa-swatch sa-swatch-1"></span> <strong>Simulated annealing</strong>: as above; proposes 2-opt moves (pick two edges of the tour, reconnect them the other way<sup><a href="#ref-4" class="cite-ref">[4]</a></sup>), accepting bad ones on the cooling schedule.</li>
 <li><span class="sa-swatch sa-swatch-2"></span> <strong>Hill climber</strong>, same 2-opt moves, zero tolerance: improvements only.</li>
 <li><span class="sa-swatch sa-swatch-3"></span> <strong>Genetic algorithm</strong>: a population of 40 tours; tournament selection, order crossover, swap mutation, elitism. The napkin algorithm from my <a href="https://www.kenreid.co.uk/blog/evolution-live.html">GA post</a>.</li>
 <li><span class="sa-swatch sa-swatch-4"></span> <strong>Random search</strong>, shuffles a fresh tour every try and keeps the best. The floor. Somebody has to be it.</li>
 </ul>

 <p>The race is scored in <strong>evaluations</strong>, not iterations. Every time any algorithm asks "how long is this tour?", its meter ticks. The GA evaluates 40 tours per generation, so it gets fewer generations; the annealer evaluates one tour per step, so it gets many steps.</p>

 

 <h2>What to watch for</h2>

 <p>Every race is different (the map is random, and so are the algorithms), but a few patterns recur:</p>

 <p><strong>The hill climber sprints, then flatlines.</strong> Early on, greed looks brilliant: almost every 2-opt move on a random tour is an improvement, so the green line dives. Then it hits a tour where no single 2-opt move helps, a local optimum, and the line goes horizontal forever. Sometimes it gets lucky and the flatline is a good tour.</p>

 <p><strong>The annealer loses the early race, but that's by design.</strong> While hot, it accepts terrible moves, and its curve dawdles above the hill climber's. It's <em>supposed</em> to look bad early. It's spending the early budget on exploration, when the cooled annealer keeps improving long after the hill climber has frozen, and the blue line crosses below the green one. If you set cooling speed too high in the playground, you can watch that advantage die: quench it, and it becomes a hill climber with a lot more code! The telemetry strip under the chart shows the mechanism firing: while the amber temperature line is up the blue acceptance trace rides high too, and the moment amber flattens near zero, blue dies with it, leaving the annealer greedy from then on.</p>

 <p><strong>The GA is the tortoise.</strong> Forty tours sharing a budget means it always looks slow per-evaluation, and on a problem this small, 2-opt moves are so effective that the population approach rarely wins the sprint. But watch how <em>steadily</em> it improves, and how rarely it gets truly stuck: the population is insurance against exactly the trap that eats the hill climber. On bigger, nastier, more constrained problems, that insurance is essential, which is why <a href="https://www.kenreid.co.uk/blog/evolutionary-computation-identity-crisis.html">my own field</a> lives there.</p>

 <p><strong>Random search is bad.</strong> It might find a great solution but it doesn't follow any strategy to improve upon. Every (real) metaheuristic beats random.</p>

 <h2>Why Simulated Annealing is still relevant</h2>

 <p>Simulated annealing is over forty years old, has no learned components, no data, no gradients, and can be setup in a couple of minutes. It is also, right now, in production: routing chips, scheduling factories and hospitals, packing containers, planning telescope observations. Its longevity comes from the same property that made the marble demo work: it makes almost no assumptions about the problem. Anything you can score and perturb, you can anneal, which in practice means nearly everything with discrete parts and hard constraints, the exact terrain where gradient methods can't get a foothold. It requires no training data or understanding of a problem, just some means to measure "goodness".</p>

 <p>I also like the general philosophy of it: explore wildly at the beginning, become pickier over time, and always remember the most important solution found. It's one of the best examples of "exploration vs. exploitation", a concept applicable to all optimization problems, and to life in general: explore while you can and make use of your search to find the best for you. Settling too early or too late is unwise, and always be willing to consider a better option. </p>

 <div class="downloads-block">
   <p class="downloads-title">Download the standalone demos</p>
   <div class="download-links">
     <a class="download-link" href="https://www.kenreid.co.uk/blog/downloads/simulated-annealing-live-demo.html" download>simulated-annealing-live-demo.html</a>
   </div>
 </div>

 <details class="code-example"><summary>The algorithms (JavaScript, 1062 lines)</summary>
<pre><code class="language-javascript">  /* ============================================================
  Simulated Annealing, Live, all demo code for this post.
  No dependencies. Everything renders to &lt;canvas&gt; and reads its
  colours from the CSS custom properties on.sa-viz, so the
  site's light/dark theme toggle restyles the demos live.
  ============================================================ */
  (function() {
    'use strict';

    function mulberry32(seed) {
      var a = seed &gt;&gt;&gt; 0;
      return function() {
        a |= 0;
        a = (a + 0x6D2B79F5) | 0;
        var t = Math.imul(a ^ (a &gt;&gt;&gt; 15), 1 | a);
        t = (t + Math.imul(t ^ (t &gt;&gt;&gt; 7), 61 | t)) ^ t;
        return ((t ^ (t &gt;&gt;&gt; 14)) &gt;&gt;&gt; 0) / 4294967296;
      };
    }

    function vizColors(el) {
      var cs = getComputedStyle(el);

      function v(name) {
        return cs.getPropertyValue(name).trim();
      }
      return {
        series: [v('--viz-s1'), v('--viz-s2'), v('--viz-s3'), v('--viz-s4')],
        ink: v('--viz-ink'),
        muted: v('--viz-muted'),
        grid: v('--viz-grid'),
        surface: v('--viz-surface')
      };
    }

    function fitCanvas(cv) {
      var dpr = window.devicePixelRatio || 1;
      // the height attribute is the desktop height; on narrow screens clamp tall
      // canvases to a sane aspect (never below 180px) while short telemetry
      // strips keep their declared height
      var w = cv.clientWidth;
      var h = Math.min(parseInt(cv.getAttribute('height'), 10), Math.max(180, Math.round(w * 0.75)));
      cv.style.height = h + 'px';
      cv.width = Math.round(w * dpr);
      cv.height = Math.round(h * dpr);
      var ctx = cv.getContext('2d');
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      return ctx;
    }

    /* ============================================================
    Widget A: the marble and the thermostat
    ============================================================ */
    (function marbleDemo() {
      var wrap = document.getElementById('curve-demo');
      if (!wrap) return;
      var cv = document.getElementById('curveCanvas');
      var runBtn = document.getElementById('curveRun');
      var resetBtn = document.getElementById('curveReset');
      var tempSlider = document.getElementById('curveTemp');
      var tempVal = document.getElementById('curveTempVal');
      var autoCool = document.getElementById('curveAuto');
      var strip = document.getElementById('curveStrip');
      var ctx = fitCanvas(cv);
      var stripCtx = fitCanvas(strip);
      var STRIP_N = 480; // rolling telemetry window, ~8s of frames
      var running = false,
        rafId = null;
      var rng = mulberry32(12345);

      function f(x) {
        return 0.32 * Math.sin(x * 9) +
          0.18 * Math.sin(x * 23 + 1.7) +
          0.10 * Math.sin(x * 41 + 0.4) +
          1.6 * (x - 0.72) * (x - 0.72);
      }
      var F_MIN = -0.55,
        F_MAX = 1.15; // plotting range

      var T_MAX = 0.4;
      var state;

      function reset() {
        var x0 = 0.05 + rng() * 0.9;
        state = {
          hc: {
            x: x0,
            best: x0,
            bestF: f(x0)
          },
          sa: {
            x: x0,
            best: x0,
            bestF: f(x0)
          },
          T: (tempSlider.value / 100) * T_MAX,
          hist: [],
          accEma: null,
          dEma: null
        };
        tempSlider.value = Math.round(state.T / T_MAX * 100);
        draw();
      }

      function px(x, W) {
        return 14 + x * (W - 28);
      }

      function py(y, H) {
        return 16 + (1 - (y - F_MIN) / (F_MAX - F_MIN)) * (H - 44);
      }

      function step() {
        var SIGMA = 0.02,
          STEPS = 26;
        var upProp = 0,
          upAcc = 0,
          upSum = 0;
        for (var i = 0; i &lt; STEPS; i++) {
          // hill climber
          var nx = Math.min(1, Math.max(0, state.hc.x + (rng() * 2 - 1) * SIGMA));
          if (f(nx) &lt;= f(state.hc.x)) state.hc.x = nx;
          if (f(state.hc.x) &lt; state.hc.bestF) {
            state.hc.bestF = f(state.hc.x);
            state.hc.best = state.hc.x;
          }
          // annealer (uphill proposals are tallied for the telemetry strip)
          var sx = Math.min(1, Math.max(0, state.sa.x + (rng() * 2 - 1) * SIGMA));
          var d = f(sx) - f(state.sa.x);
          if (d &lt;= 0) {
            state.sa.x = sx;
          } else {
            upProp++;
            upSum += d;
            if (state.T &gt; 1e-6 &amp;&amp; rng() &lt; Math.exp(-d / state.T)) {
              state.sa.x = sx;
              upAcc++;
            }
          }
          if (f(state.sa.x) &lt; state.sa.bestF) {
            state.sa.bestF = f(state.sa.x);
            state.sa.best = state.sa.x;
          }
          if (autoCool.checked) {
            state.T *= 0.99965;
            tempSlider.value = Math.round(state.T / T_MAX * 100);
          }
        }
        // smooth the tallies into the telemetry (EMA so the traces don't jitter)
        if (upProp) {
          var K = 0.15;
          var rate = upAcc / upProp;
          state.accEma = state.accEma === null ? rate : state.accEma + K * (rate - state.accEma);
          var dbar = upSum / upProp;
          state.dEma = state.dEma === null ? dbar : state.dEma + K * (dbar - state.dEma);
        }
        state.hist.push({
          t: state.T / T_MAX,
          a: state.accEma === null ? 1 : state.accEma
        });
        if (state.hist.length &gt; STRIP_N) state.hist.shift();
      }

      function draw() {
        var c = vizColors(wrap);
        var W = cv.clientWidth,
          H = cv.clientHeight;
        ctx.clearRect(0, 0, W, H);
        // curve
        ctx.beginPath();
        for (var i = 0; i &lt;= 300; i++) {
          var x = i / 300,
            X = px(x, W),
            Y = py(f(x), H);
          if (i === 0) ctx.moveTo(X, Y);
          else ctx.lineTo(X, Y);
        }
        ctx.strokeStyle = c.muted;
        ctx.lineWidth = 2;
        ctx.stroke();
        // best markers
        [
          ['hc', c.series[1]],
          ['sa', c.series[0]]
        ].forEach(function(pair) {
          var a = state[pair[0]];
          ctx.beginPath();
          ctx.arc(px(a.best, W), py(f(a.best), H), 3.5, 0, Math.PI * 2);
          ctx.fillStyle = pair[1];
          ctx.globalAlpha = 0.45;
          ctx.fill();
          ctx.globalAlpha = 1;
        });
        // marbles (annealer drawn second, with a surface ring so overlaps stay legible)
        [
          ['hc', c.series[1]],
          ['sa', c.series[0]]
        ].forEach(function(pair) {
          var a = state[pair[0]];
          ctx.beginPath();
          ctx.arc(px(a.x, W), py(f(a.x), H) - 8, 8, 0, Math.PI * 2);
          ctx.fillStyle = pair[1];
          ctx.fill();
          ctx.lineWidth = 2;
          ctx.strokeStyle = c.surface;
          ctx.stroke();
        });
        // readouts (text in ink, swatch carries identity)
        ctx.font = '12px Poppins, sans-serif';
        ctx.fillStyle = c.series[0];
        ctx.fillRect(14, 10, 9, 9);
        ctx.fillStyle = c.ink;
        ctx.fillText('annealer best: ' + state.sa.bestF.toFixed(3), 28, 19);
        ctx.fillStyle = c.series[1];
        ctx.fillRect(14, 26, 9, 9);
        ctx.fillStyle = c.ink;
        ctx.fillText('greedy best: ' + state.hc.bestF.toFixed(3), 28, 35);
        tempVal.textContent = 'T = ' + state.T.toFixed(3);
        drawStrip();
      }

      // the flight recorder: temperature (amber) and share of uphill proposals
      // accepted (blue) over a rolling window, both on one 0-100% scale
      function drawStrip() {
        var c = vizColors(wrap);
        var W = strip.clientWidth,
          H = strip.clientHeight;
        var padL = 14,
          padR = 78,
          padT = 10,
          padB = 8;
        stripCtx.clearRect(0, 0, W, H);

        function X(i) {
          return padL + (i / (STRIP_N - 1)) * (W - padL - padR);
        }

        function Y(v) {
          return padT + (1 - v) * (H - padT - padB);
        }
        stripCtx.font = '11px Poppins, sans-serif';
        [0, 0.5, 1].forEach(function(v) {
          stripCtx.strokeStyle = c.grid;
          stripCtx.lineWidth = 1;
          stripCtx.beginPath();
          stripCtx.moveTo(padL, Y(v));
          stripCtx.lineTo(W - padR, Y(v));
          stripCtx.stroke();
        });
        var start = STRIP_N - state.hist.length;
        [
          ['t', c.series[2]],
          ['a', c.series[0]]
        ].forEach(function(pair) {
          if (!state.hist.length) return;
          stripCtx.beginPath();
          state.hist.forEach(function(h, i) {
            var x = X(start + i),
              y = Y(h[pair[0]]);
            if (i === 0) stripCtx.moveTo(x, y);
            else stripCtx.lineTo(x, y);
          });
          stripCtx.strokeStyle = pair[1];
          stripCtx.lineWidth = 2;
          stripCtx.stroke();
        });
        // live end labels (ink text, coloured dot), nudged apart if they collide
        var labels = [{
          y: Y(state.T / T_MAX),
          color: c.series[2],
          text: 'T ' + Math.round(state.T / T_MAX * 100) + '%'
        }];
        if (state.accEma !== null) labels.push({
          y: Y(state.accEma),
          color: c.series[0],
          text: 'uphill ' + Math.round(state.accEma * 100) + '%'
        });
        labels.sort(function(p, q) {
          return p.y - q.y;
        });
        labels.forEach(function(l) {
          l.y = Math.max(padT + 5, Math.min(H - padB - 3, l.y));
        });
        if (labels.length === 2 &amp;&amp; labels[1].y - labels[0].y &lt; 13) labels[0].y = labels[1].y - 13;
        labels.forEach(function(l) {
          var x = W - padR + 6;
          stripCtx.beginPath();
          stripCtx.arc(x + 4, l.y, 3.5, 0, Math.PI * 2);
          stripCtx.fillStyle = l.color;
          stripCtx.fill();
          stripCtx.fillStyle = c.ink;
          stripCtx.fillText(l.text, x + 11, l.y + 4);
        });
        // the Metropolis rule with live numbers, for a typical uphill step
        // (surface-coloured halo keeps it legible when the traces run hot)
        if (state.dEma !== null) {
          var p = state.T &gt; 1e-6 ? Math.exp(-state.dEma / state.T) : 0;
          var txt = 'typical uphill Δ ' + state.dEma.toFixed(3) +
            ' · P(accept) ' + Math.round(p * 100) + '%';
          stripCtx.strokeStyle = c.surface;
          stripCtx.lineWidth = 3;
          stripCtx.strokeText(txt, padL + 4, padT + 12);
          stripCtx.fillStyle = c.muted;
          stripCtx.fillText(txt, padL + 4, padT + 12);
        }
      }

      function loop() {
        if (!running) return;
        state.T = (tempSlider.value / 100) * T_MAX;
        step();
        draw();
        rafId = requestAnimationFrame(loop);
      }

      runBtn.addEventListener('click', function() {
        running = !running;
        runBtn.textContent = running ? 'Pause' : 'Run';
        if (running) loop();
        else cancelAnimationFrame(rafId);
      });
      resetBtn.addEventListener('click', function() {
        reset();
      });
      tempSlider.addEventListener('input', function() {
        state.T = (tempSlider.value / 100) * T_MAX;
        if (!running) draw();
      });
      window.addEventListener('resize', function() {
        ctx = fitCanvas(cv);
        stripCtx = fitCanvas(strip);
        draw();
      });
      new MutationObserver(function() {
          if (!running) draw();
        })
        .observe(document.documentElement, {
          attributes: true,
          attributeFilter: ['data-theme']
        });
      reset();
    })();

    /* ============================================================
    Widget B: the four-way TSP race
    ============================================================ */
    (function raceDemo() {
      var wrap = document.getElementById('race-demo');
      if (!wrap) return;
      var NAMES = ['Simulated annealing', 'Hill climber', 'Genetic algorithm', 'Random search'];
      var SHORT = ['SA', 'Hill', 'GA', 'Rand'];
      var MAX_EVALS = 200000;
      var runBtn = document.getElementById('raceRun');
      var resetBtn = document.getElementById('raceReset');
      var speedSlider = document.getElementById('raceSpeed');
      var statusEl = document.getElementById('raceStatus');
      var tourCanvases = Array.prototype.slice.call(wrap.querySelectorAll('.tourCanvas'));
      var scoreEls = Array.prototype.slice.call(wrap.querySelectorAll('.sa-score'));
      var chartCv = document.getElementById('raceChart');
      var stripCv = document.getElementById('raceStrip');
      var tooltip = document.getElementById('raceTooltip');
      var tableBody = document.getElementById('raceTable');
      var pgCities = document.getElementById('pgCities'),
        pgCitiesVal = document.getElementById('pgCitiesVal');
      var pgCool = document.getElementById('pgCool'),
        pgCoolVal = document.getElementById('pgCoolVal');
      var pgMut = document.getElementById('pgMut'),
        pgMutVal = document.getElementById('pgMutVal');

      var tourCtx = tourCanvases.map(fitCanvas);
      var chartCtx = fitCanvas(chartCv);
      var stripCtx = fitCanvas(stripCv);
      var seed = 20260706,
        rng = mulberry32(seed);
      var cities = [],
        algs = [],
        running = false,
        rafId = null,
        finished = false;

      function tourLen(t) {
        var L = 0;
        for (var i = 0; i &lt; t.length; i++) {
          var a = cities[t[i]],
            b = cities[t[(i + 1) % t.length]];
          L += Math.hypot(a[0] - b[0], a[1] - b[1]);
        }
        return L;
      }

      function randTour(r) {
        var t = [];
        for (var i = 0; i &lt; cities.length; i++) t.push(i);
        for (var j = t.length - 1; j &gt; 0; j--) {
          var k = Math.floor(r() * (j + 1)),
            tmp = t[j];
          t[j] = t[k];
          t[k] = tmp;
        }
        return t;
      }

      function twoOpt(t, r) {
        var n = t.length;
        var i = 1 + Math.floor(r() * (n - 2));
        var j = i + 1 + Math.floor(r() * (n - i - 1));
        var nt = t.slice(0, i).concat(t.slice(i, j + 1).reverse(), t.slice(j + 1));
        return nt;
      }

      function makeAlgs() {
        var r1 = mulberry32(seed + 1),
          r2 = mulberry32(seed + 2),
          r3 = mulberry32(seed + 3),
          r4 = mulberry32(seed + 4);
        var start = randTour(mulberry32(seed + 9));
        var startLen = tourLen(start);
        var coolSpeed = parseInt(pgCool.value, 10); // decay exponent over full budget
        var mutRate = parseInt(pgMut.value, 10) / 100; // per-child swap probability
        var T0 = 2 * startLen / cities.length;

        // Each algorithm: { evals, best, bestLen, hist:[{e,len}], step(budget) }
        function record(a) {
          a.hist.push({
            e: a.evals,
            len: a.bestLen
          });
        }

        var sa = {
          evals: 0,
          cur: start.slice(),
          curLen: startLen,
          best: start.slice(),
          bestLen: startLen,
          hist: [],
          pstrip: [],
          upP: 0,
          upA: 0,
          tempAt: function(e) {
            // deterministic schedule: T as a fraction of T0 after e evaluations
            return Math.exp(-coolSpeed * e / MAX_EVALS);
          },
          rng: r1,
          step: function(budget) {
            for (var i = 0; i &lt; budget &amp;&amp; this.evals &lt; MAX_EVALS; i++) {
              var cand = twoOpt(this.cur, this.rng);
              var len = tourLen(cand);
              this.evals++;
              var T = T0 * this.tempAt(this.evals);
              var d = len - this.curLen;
              if (d &lt;= 0) {
                this.cur = cand;
                this.curLen = len;
              } else {
                this.upP++;
                if (T &gt; 1e-9 &amp;&amp; this.rng() &lt; Math.exp(-d / T)) {
                  this.cur = cand;
                  this.curLen = len;
                  this.upA++;
                }
              }
              if (len &lt; this.bestLen) {
                this.best = cand;
                this.bestLen = len;
              }
            }
          }
        };
        var hc = {
          evals: 0,
          cur: start.slice(),
          curLen: startLen,
          best: start.slice(),
          bestLen: startLen,
          hist: [],
          rng: r2,
          step: function(budget) {
            for (var i = 0; i &lt; budget &amp;&amp; this.evals &lt; MAX_EVALS; i++) {
              var cand = twoOpt(this.cur, this.rng);
              var len = tourLen(cand);
              this.evals++;
              if (len &lt;= this.curLen) {
                this.cur = cand;
                this.curLen = len;
              }
              if (len &lt; this.bestLen) {
                this.best = cand;
                this.bestLen = len;
              }
            }
          }
        };
        var ga = (function() {
          var POP = 40,
            ELITE = 2,
            TOUR = 3;
          var pop = [],
            fit = [];
          for (var i = 0; i &lt; POP; i++) pop.push(randTour(r3));
          var g = {
            evals: 0,
            best: null,
            bestLen: Infinity,
            hist: [],
            rng: r3,
            pending: 0
          };

          function evalPop() {
            fit = pop.map(function(t) {
              g.evals++;
              var L = tourLen(t);
              if (L &lt; g.bestLen) {
                g.bestLen = L;
                g.best = t.slice();
              }
              return L;
            });
          }
          evalPop();

          function pick() {
            var bi = -1,
              bf = Infinity;
            for (var k = 0; k &lt; TOUR; k++) {
              var c = Math.floor(g.rng() * POP);
              if (fit[c] &lt; bf) {
                bf = fit[c];
                bi = c;
              }
            }
            return pop[bi];
          }

          function ox(p1, p2) {
            var n = p1.length,
              i = Math.floor(g.rng() * n),
              j = Math.floor(g.rng() * n);
            if (i &gt; j) {
              var t = i;
              i = j;
              j = t;
            }
            var child = new Array(n),
              used = {};
            for (var k = i; k &lt;= j; k++) {
              child[k] = p1[k];
              used[p1[k]] = true;
            }
            var pos = (j + 1) % n;
            for (var m = 0; m &lt; n; m++) {
              var gene = p2[(j + 1 + m) % n];
              if (!used[gene]) {
                child[pos] = gene;
                pos = (pos + 1) % n;
              }
            }
            return child;
          }
          g.step = function(budget) {
            this.pending += budget;
            while (this.pending &gt;= POP &amp;&amp; this.evals &lt; MAX_EVALS) {
              var next = [];
              // elitism: carry the best tours forward untouched
              var order = fit.map(function(f, idx) {
                return [f, idx];
              }).sort(function(a, b) {
                return a[0] - b[0];
              });
              for (var e = 0; e &lt; ELITE; e++) next.push(pop[order[e][1]].slice());
              while (next.length &lt; POP) {
                var child = ox(pick(), pick());
                if (g.rng() &lt; mutRate) {
                  var a = Math.floor(g.rng() * child.length),
                    b = Math.floor(g.rng() * child.length);
                  var tmp = child[a];
                  child[a] = child[b];
                  child[b] = tmp;
                }
                next.push(child);
              }
              pop = next;
              evalPop();
              this.pending -= POP;
            }
          };
          return g;
        })();
        var rs = {
          evals: 0,
          best: start.slice(),
          bestLen: startLen,
          hist: [],
          rng: r4,
          step: function(budget) {
            for (var i = 0; i &lt; budget &amp;&amp; this.evals &lt; MAX_EVALS; i++) {
              var cand = randTour(this.rng);
              var len = tourLen(cand);
              this.evals++;
              if (len &lt; this.bestLen) {
                this.best = cand;
                this.bestLen = len;
              }
            }
          }
        };
        [sa, hc, ga, rs].forEach(record);
        return [sa, hc, ga, rs];
      }

      function newCities(n) {
        rng = mulberry32(seed);
        cities = [];
        for (var i = 0; i &lt; n; i++) cities.push([0.06 + rng() * 0.88, 0.08 + rng() * 0.84]);
      }

      function resetRace(keepCities) {
        running = false;
        finished = false;
        cancelAnimationFrame(rafId);
        runBtn.textContent = 'Start race';
        if (!keepCities) newCities(parseInt(pgCities.value, 10));
        algs = makeAlgs();
        statusEl.textContent = 'Press start, or click any map to add a city.';
        drawAll();
      }

      function drawTour(idx) {
        var c = vizColors(wrap);
        var ctx = tourCtx[idx],
          cv = tourCanvases[idx];
        var W = cv.clientWidth,
          H = cv.clientHeight;
        ctx.clearRect(0, 0, W, H);
        ctx.strokeStyle = c.grid;
        ctx.strokeRect(0.5, 0.5, W - 1, H - 1);
        var a = algs[idx];
        // tour path
        ctx.beginPath();
        for (var i = 0; i &lt;= a.best.length; i++) {
          var p = cities[a.best[i % a.best.length]];
          var X = p[0] * W,
            Y = p[1] * H;
          if (i === 0) ctx.moveTo(X, Y);
          else ctx.lineTo(X, Y);
        }
        ctx.strokeStyle = c.series[idx];
        ctx.lineWidth = 2;
        ctx.stroke();
        // cities
        for (var j = 0; j &lt; cities.length; j++) {
          ctx.beginPath();
          ctx.arc(cities[j][0] * W, cities[j][1] * H, 3, 0, Math.PI * 2);
          ctx.fillStyle = c.ink;
          ctx.fill();
        }
        scoreEls[idx].textContent = a.bestLen.toFixed(2);
      }

      function drawChart() {
        var c = vizColors(wrap);
        var W = chartCv.clientWidth,
          H = chartCv.clientHeight;
        var ctx = chartCtx;
        var padL = 44,
          padR = 70,
          padT = 12,
          padB = 26;
        ctx.clearRect(0, 0, W, H);
        var maxLen = 0,
          minLen = Infinity;
        algs.forEach(function(a) {
          a.hist.forEach(function(h) {
            if (h.len &gt; maxLen) maxLen = h.len;
            if (h.len &lt; minLen) minLen = h.len;
          });
        });
        if (!isFinite(minLen)) return;
        var lo = minLen * 0.95,
          hi = maxLen * 1.02;

        function X(e) {
          return padL + (e / MAX_EVALS) * (W - padL - padR);
        }

        function Y(v) {
          return padT + (1 - (v - lo) / (hi - lo)) * (H - padT - padB);
        }
        // grid + y ticks
        ctx.font = '11px Poppins, sans-serif';
        for (var g = 0; g &lt;= 4; g++) {
          var v = lo + (g / 4) * (hi - lo),
            y = Y(v);
          ctx.strokeStyle = c.grid;
          ctx.lineWidth = 1;
          ctx.beginPath();
          ctx.moveTo(padL, y);
          ctx.lineTo(W - padR, y);
          ctx.stroke();
          ctx.fillStyle = c.muted;
          ctx.textAlign = 'right';
          ctx.fillText(v.toFixed(1), padL - 6, y + 4);
        }
        // x ticks ("evals" suffix on the last tick names the axis)
        ctx.textAlign = 'center';
        for (var t = 0; t &lt;= 4; t++) {
          var e = (t / 4) * MAX_EVALS;
          ctx.fillStyle = c.muted;
          ctx.fillText((e / 1000) + 'k' + (t === 4 ? ' evals' : ''), X(e), H - 8);
        }
        ctx.textAlign = 'left';
        // series (best-so-far is a step function; draw as steps)
        algs.forEach(function(a, i) {
          ctx.beginPath();
          var prevY = null;
          a.hist.forEach(function(h, k) {
            var x = X(h.e),
              y = Y(h.len);
            if (k === 0) ctx.moveTo(x, y);
            else {
              ctx.lineTo(x, prevY);
              ctx.lineTo(x, y);
            }
            prevY = y;
          });
          // extend to current eval count
          ctx.lineTo(X(a.evals), prevY);
          ctx.strokeStyle = c.series[i];
          ctx.lineWidth = 2;
          ctx.stroke();
        });
        // direct labels at line ends (ink text + colored dot), collision-nudged
        var ends = algs.map(function(a, i) {
            return {
              i: i,
              y: Y(a.bestLen),
              len: a.bestLen
            };
          })
          .sort(function(p, q) {
            return p.y - q.y;
          });
        var lastY = -Infinity;
        ends.forEach(function(e) {
          lastY = e.y = Math.max(e.y, lastY + 13);
        });
        var overflow = lastY - (H - padB - 4);
        if (overflow &gt; 0) ends.forEach(function(e) {
          e.y -= overflow;
        });
        ends.forEach(function(e) {
          var y = e.y;
          var x = W - padR + 6;
          ctx.beginPath();
          ctx.arc(x + 4, y, 3.5, 0, Math.PI * 2);
          ctx.fillStyle = c.series[e.i];
          ctx.fill();
          ctx.fillStyle = c.ink;
          ctx.fillText(SHORT[e.i], x + 11, y + 4);
        });
      }

      // the annealer's telemetry, sharing the race chart's x-axis: planned
      // cooling schedule (faint amber), temperature spent so far (solid amber)
      // and observed share of uphill moves accepted (blue), all on 0-100%
      function drawStrip() {
        var c = vizColors(wrap);
        var W = stripCv.clientWidth,
          H = stripCv.clientHeight;
        var ctx = stripCtx;
        var padL = 44,
          padR = 70,
          padT = 10,
          padB = 10;
        ctx.clearRect(0, 0, W, H);
        var sa = algs[0];

        function X(e) {
          return padL + (e / MAX_EVALS) * (W - padL - padR);
        }

        function Y(v) {
          return padT + (1 - v) * (H - padT - padB);
        }
        ctx.font = '11px Poppins, sans-serif';
        [0, 0.5, 1].forEach(function(v) {
          ctx.strokeStyle = c.grid;
          ctx.lineWidth = 1;
          ctx.beginPath();
          ctx.moveTo(padL, Y(v));
          ctx.lineTo(W - padR, Y(v));
          ctx.stroke();
          ctx.fillStyle = c.muted;
          ctx.textAlign = 'right';
          ctx.fillText(Math.round(v * 100) + '%', padL - 6, Y(v) + 4);
        });
        ctx.textAlign = 'left';
        // planned cooling schedule, faint, across the whole budget
        ctx.beginPath();
        for (var i = 0; i &lt;= 120; i++) {
          var e = (i / 120) * MAX_EVALS;
          if (i === 0) ctx.moveTo(X(e), Y(sa.tempAt(e)));
          else ctx.lineTo(X(e), Y(sa.tempAt(e)));
        }
        ctx.strokeStyle = c.series[2];
        ctx.globalAlpha = 0.3;
        ctx.lineWidth = 2;
        ctx.stroke();
        ctx.globalAlpha = 1;
        // temperature actually spent, solid, up to the current evaluation
        if (sa.evals &gt; 0) {
          ctx.beginPath();
          var segs = Math.max(2, Math.round(120 * sa.evals / MAX_EVALS));
          for (var k = 0; k &lt;= segs; k++) {
            var e2 = (k / segs) * sa.evals;
            if (k === 0) ctx.moveTo(X(e2), Y(sa.tempAt(e2)));
            else ctx.lineTo(X(e2), Y(sa.tempAt(e2)));
          }
          ctx.strokeStyle = c.series[2];
          ctx.lineWidth = 2;
          ctx.stroke();
        }
        // observed share of uphill moves accepted
        if (sa.pstrip.length) {
          ctx.beginPath();
          sa.pstrip.forEach(function(h, k2) {
            if (k2 === 0) ctx.moveTo(X(h.e), Y(h.a));
            else ctx.lineTo(X(h.e), Y(h.a));
          });
          ctx.strokeStyle = c.series[0];
          ctx.lineWidth = 2;
          ctx.stroke();
        }
        // live end labels, matching the race chart's style
        var labels = [{
          y: Y(sa.tempAt(sa.evals)),
          color: c.series[2],
          text: 'T ' + Math.round(sa.tempAt(sa.evals) * 100) + '%'
        }];
        if (sa.pstrip.length) {
          var lastA = sa.pstrip[sa.pstrip.length - 1].a;
          labels.push({
            y: Y(lastA),
            color: c.series[0],
            text: 'uphill ' + Math.round(lastA * 100) + '%'
          });
        }
        labels.sort(function(p, q) {
          return p.y - q.y;
        });
        labels.forEach(function(l) {
          l.y = Math.max(padT + 5, Math.min(H - padB - 3, l.y));
        });
        if (labels.length === 2 &amp;&amp; labels[1].y - labels[0].y &lt; 13) labels[0].y = labels[1].y - 13;
        labels.forEach(function(l) {
          var x = W - padR + 6;
          ctx.beginPath();
          ctx.arc(x + 4, l.y, 3.5, 0, Math.PI * 2);
          ctx.fillStyle = l.color;
          ctx.fill();
          ctx.fillStyle = c.ink;
          ctx.fillText(l.text, x + 11, l.y + 4);
        });
      }

      function drawTable() {
        var c = vizColors(wrap);
        var leader = Math.min.apply(null, algs.map(function(a) {
          return a.bestLen;
        }));
        var rows = algs.map(function(a, i) {
            return {
              i: i,
              len: a.bestLen
            };
          })
          .sort(function(p, q) {
            return p.len - q.len;
          });
        tableBody.innerHTML = rows.map(function(r) {
          var gap = ((r.len / leader - 1) * 100);
          return '&lt;div class="sa-stat"&gt;&lt;span class="sa-stat-lbl"&gt;' +
            '&lt;span class="sa-swatch" style="background:' + c.series[r.i] + ';"&gt;&lt;/span&gt;' +
            NAMES[r.i] + '&lt;/span&gt;&lt;span class="sa-stat-val"&gt;' + r.len.toFixed(2) +
            ' &lt;span style="color:var(--viz-muted); font-weight:400;"&gt;' +
            (gap &lt; 0.005 ? 'leader' : '+' + gap.toFixed(1) + '%') + '&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;';
        }).join('');
      }

      function drawAll() {
        algs.forEach(function(_, i) {
          drawTour(i);
        });
        drawChart();
        drawStrip();
        drawTable();
      }

      var recordEvery = MAX_EVALS / 400;

      function loop() {
        if (!running) return;
        var budget = parseInt(speedSlider.value, 10);
        algs.forEach(function(a) {
          var before = a.evals;
          a.step(budget);
          if (Math.floor(a.evals / recordEvery) &gt; Math.floor(before / recordEvery)) {
            a.hist.push({
              e: a.evals,
              len: a.bestLen
            });
            if (a.pstrip) {
              a.pstrip.push({
                e: a.evals,
                a: a.upP ? a.upA / a.upP : 0
              });
              a.upP = 0;
              a.upA = 0;
            }
          }
        });
        drawAll();
        var done = algs.every(function(a) {
          return a.evals &gt;= MAX_EVALS;
        });
        if (done) {
          running = false;
          finished = true;
          runBtn.textContent = 'Start race';
          var leader = algs.slice().sort(function(p, q) {
            return p.bestLen - q.bestLen;
          })[0];
          statusEl.textContent = 'Race finished. ' + NAMES[algs.indexOf(leader)] + ' wins. Press "New cities" to go again.';
          algs.forEach(function(a) {
            a.hist.push({
              e: a.evals,
              len: a.bestLen
            });
            if (a.pstrip) {
              a.pstrip.push({
                e: a.evals,
                a: a.upP ? a.upA / a.upP : 0
              });
              a.upP = 0;
              a.upA = 0;
            }
          });
          drawAll();
          return;
        }
        statusEl.textContent = Math.round(algs[0].evals / 1000) + 'k / ' + (MAX_EVALS / 1000) + 'k evaluations';
        rafId = requestAnimationFrame(loop);
      }

      runBtn.addEventListener('click', function() {
        if (finished) resetRace(true);
        running = !running;
        runBtn.textContent = running ? 'Pause' : 'Resume';
        if (running) loop();
        else {
          cancelAnimationFrame(rafId);
          statusEl.textContent = 'Paused.';
        }
      });
      resetBtn.addEventListener('click', function() {
        seed += 7;
        resetRace(false);
      });

      tourCanvases.forEach(function(cv) {
        cv.addEventListener('click', function(ev) {
          if (cities.length &gt;= 80) return;
          var rect = cv.getBoundingClientRect();
          cities.push([(ev.clientX - rect.left) / rect.width, (ev.clientY - rect.top) / rect.height]);
          resetRace(true);
          statusEl.textContent = cities.length + ' cities, race reset. Press start.';
        });
      });

      pgCities.addEventListener('input', function() {
        pgCitiesVal.textContent = pgCities.value;
      });
      pgCities.addEventListener('change', function() {
        resetRace(false);
      });

      function updateCool() {
        pgCoolVal.textContent = (parseInt(pgCool.value, 10) / 10).toFixed(1) + '×';
      }

      function updateMut() {
        pgMutVal.textContent = pgMut.value + '%';
      }
      pgCool.addEventListener('input', updateCool);
      pgCool.addEventListener('change', function() {
        resetRace(true);
      });
      pgMut.addEventListener('input', updateMut);
      pgMut.addEventListener('change', function() {
        resetRace(true);
      });
      updateCool();
      updateMut();

      // chart hover tooltip
      chartCv.addEventListener('mousemove', function(ev) {
        var rect = chartCv.getBoundingClientRect();
        var padL = 44,
          padR = 62;
        var frac = (ev.clientX - rect.left - padL) / (rect.width - padL - padR);
        if (frac &lt; 0 || frac &gt; 1) {
          tooltip.style.display = 'none';
          return;
        }
        var e = frac * MAX_EVALS;
        var c = vizColors(wrap);
        var lines = algs.map(function(a, i) {
          var best = null;
          for (var k = 0; k &lt; a.hist.length; k++) {
            if (a.hist[k].e &lt;= e) best = a.hist[k].len;
            else break;
          }
          return '&lt;span class="sa-swatch" style="background:' + c.series[i] + '; margin-right:6px;"&gt;&lt;/span&gt;' +
            SHORT[i] + ': ' + (best === null ? ', ' : best.toFixed(2));
        });
        var accAt = null;
        for (var k3 = 0; k3 &lt; algs[0].pstrip.length; k3++) {
          if (algs[0].pstrip[k3].e &lt;= e) accAt = algs[0].pstrip[k3].a;
          else break;
        }
        tooltip.innerHTML = '&lt;strong&gt;' + Math.round(e / 1000) + 'k evals&lt;/strong&gt;&lt;br&gt;' + lines.join('&lt;br&gt;') +
          '&lt;br&gt;&lt;span style="color:var(--viz-muted);"&gt;SA telemetry: T ' + Math.round(algs[0].tempAt(e) * 100) + '%' +
          (accAt === null ? '' : ', uphill ' + Math.round(accAt * 100) + '%') + '&lt;/span&gt;';
        tooltip.style.display = 'block';
        var tx = ev.clientX - rect.left + 14;
        if (tx + 130 &gt; rect.width) tx = ev.clientX - rect.left - 144;
        tooltip.style.left = tx + 'px';
        tooltip.style.top = (ev.clientY - rect.top + 10) + 'px';
      });
      chartCv.addEventListener('mouseleave', function() {
        tooltip.style.display = 'none';
      });

      window.addEventListener('resize', function() {
        tourCtx = tourCanvases.map(fitCanvas);
        chartCtx = fitCanvas(chartCv);
        stripCtx = fitCanvas(stripCv);
        drawAll();
      });
      new MutationObserver(function() {
          drawAll();
        })
        .observe(document.documentElement, {
          attributes: true,
          attributeFilter: ['data-theme']
        });

      newCities(parseInt(pgCities.value, 10));
      resetRace(true);
    })();
  })();
 </code></pre>
 </details>

 <div class="faq-section">
 <h2>Common questions</h2>

 <details class="faq-item">
 <summary>Is the annealer guaranteed to find the best tour?</summary>
 <p>In theory, with an infinitely slow logarithmic cooling schedule, yes, there's a lovely proof. In practice nobody has infinite time, so real schedules trade the guarantee for speed and settle for "excellent, quickly." On these maps the annealer routinely gets within a few percent of optimal; certifying <em>the</em> optimum is a different (and much more expensive) sport, played with exact solvers like Concorde.</p>
 </details>

 <details class="faq-item">
 <summary>Why does the GA lose here? Your other posts defend GAs!</summary>
 <p>Because this problem is small and 2-opt local moves are unusually powerful on Euclidean TSP, the landscape suits move-based searchers. Change the terrain (add time windows, vehicle capacities, multiple objectives, a simulator in the loop) and population methods perform better, generally. No Free Lunch theorem: the winner is a property of the landscape, not the algorithm. Never trust a benchmark with one problem on it, including mine.</p>
 </details>

 <details class="faq-item">
 <summary>What cooling schedule does the demo use?</summary>
 <p>Exponential decay: temperature starts near the average edge length of a random tour and decays smoothly so it's effectively frozen by the end of the 200,000-evaluation budget. The playground's "cooling speed" scales that decay exponent. </p>
 </details>
 </div>

 <h2 class="section-heading" id="references">References</h2>

 <ol class="references">
          <li id="ref-1">Kirkpatrick, S., Gelatt, C. D., &amp; Vecchi, M. P. (1983). Optimization by simulated annealing. <em>Science, 220</em>(4598), 671&ndash;680. <a href="https://doi.org/10.1126/science.220.4598.671" target="_blank" rel="noopener">https://doi.org/10.1126/science.220.4598.671</a></li>
          <li id="ref-2">&#268;ern&yacute;, V. (1985). Thermodynamical approach to the travelling salesman problem: An efficient simulation algorithm. <em>Journal of Optimization Theory and Applications, 45</em>, 41&ndash;51. <a href="https://doi.org/10.1007/BF00940812" target="_blank" rel="noopener">https://doi.org/10.1007/BF00940812</a></li>
          <li id="ref-3">Metropolis, N., Rosenbluth, A. W., Rosenbluth, M. N., Teller, A. H., &amp; Teller, E. (1953). Equation of state calculations by fast computing machines. <em>The Journal of Chemical Physics, 21</em>(6), 1087&ndash;1092. <a href="https://doi.org/10.1063/1.1699114" target="_blank" rel="noopener">https://doi.org/10.1063/1.1699114</a></li>
          <li id="ref-4">Croes, G. A. (1958). A method for solving traveling-salesman problems. <em>Operations Research, 6</em>(6), 791&ndash;812. <a href="https://doi.org/10.1287/opre.6.6.791" target="_blank" rel="noopener">https://doi.org/10.1287/opre.6.6.791</a></li>
        </ol>

        

 ]]></content:encoded>
    </item>
    <item>
      <title>Evolution, Live: Genetic Algorithms</title>
      <link>https://www.kenreid.co.uk/blog/evolution-live.html</link>
      <guid>https://www.kenreid.co.uk/blog/evolution-live.html</guid>
      <pubDate>Tue, 04 Aug 2026 00:00:00 +0000</pubDate>
      <description>A genetic algorithm evolving a solution live in your browser: populations, crossover, mutation, and selection as sliders you can break. Fifty translucent triangles evolve the alphabet, and you supply the selection pressure.</description>
      <category>ai</category>
      <content:encoded><![CDATA[<p><em>This post includes an interactive demo that runs live in the browser. <a href="https://www.kenreid.co.uk/blog/evolution-live.html">View it on the site</a> to play with it.</em></p>
 <h1>Evolution, Live: Genetic Algorithms</h1>
 <div class="blog-meta">
 4 August 2026 &middot;
 <span class="blog-tag">ai</span>
 </div>

 <p>A while ago I set <a href="https://www.kenreid.co.uk/blog/ant-colony-live.html">an ant colony foraging in your browser</a>. Watching an optimiser work beats reading about one, by a mile, so here is the sequel, for the algorithm that is even more watchable, because it is even more alive. This post evolves a solution in front of you, out of a population that breeds, mutates, and dies, generation by generation, on your CPU.</p>

 

 <div class="plain-english-box">
 <h2>Quick jargon guide</h2>
 <ul>
 <li><strong>Genetic algorithm (GA):</strong> an optimiser that keeps a population of candidate solutions and improves it by imitating natural selection: the fittest breed, the rest die.</li>
 <li><strong>Individual / genome:</strong> one candidate solution, encoded as a string of "genes" the algorithm can cut and mutate.</li>
 <li><strong>Fitness function:</strong> the score that decides who breeds.</li>
 <li><strong>Selection:</strong> picking parents, biased toward the fit. Turn the bias up and evolution speeds toward whatever's currently best, at the cost of variety.</li>
 <li><strong>Crossover:</strong> combining two parents' genomes into a child, the "sexual" recombination step.</li>
 <li><strong>Mutation:</strong> random tweaks to a genome. The source of new material; too little and you stagnate, too much and you get a lot of noise.</li>
 <li><strong>Elitism:</strong> carrying the best individuals into the next generation untouched, so the best score never gets worse.</li>
 <li><strong>Premature convergence:</strong> the population collapsing to near-copies of one decent solution too early, killing the diversity needed to find a great one. </li>
 </ul>
 </div>

 <h2>The whole algorithm on a napkin</h2>

 <p>The entire loop:</p>

 <ol>
 <li>Make a population of random candidate solutions.</li>
 <li>Score each one with the fitness function.</li>
 <li>Select parents, biased toward the fitter ones.</li>
 <li>Cross them over to make children, mutate the children a little.</li>
 <li>Replace the old population with the new one (keeping a few elites).</li>
 <li>Go to step 2. Repeat until bored or converged.</li>
 </ol>

 <h2>Watch it evolve</h2>

 <p>Below, a population is evolving toward a target. Each little picture is one individual; the big one is the current champion. Press play and watch the champion sharpen out of noise, generation by generation. Play with the parameters in the sliders to see how they effect the outcome.</p>

 

 <h2>Break it on purpose</h2>

 <p>Three experiments to try:</p>

 <p><strong>Crank selection pressure to maximum.</strong> The population will rocket toward the best early solution and then <em>stop improving</em>, sometimes far from a good answer. You've just caused premature convergence: everyone became a copy of one lucky ancestor, and with no diversity left, crossover has nothing to recombine and mutation alone can't dig out. </p>

 <p><strong>Set mutation to zero.</strong> Evolution grinds to a halt the moment the population homogenises, because crossover can only ever remix genes that already exist. No new material, no progress. Then push mutation to maximum and watch the opposite disaster: the champion thrashes randomly and never settles, because you've turned evolution into a random search.</p>

 <p><strong>Shrink the population to a handful.</strong> Small populations converge fast and badly; they're a tiny gene pool, prone to the same fragility as any small, inbred group. This happens in real life often when a natural disaster strikes, or a small number of a species is separated (e.g. a pond splits in two, or high winds migrate some animals across distance).</p>

 <h2>The thesis</h2>

 <p>Gradient descent, the engine under modern AI, needs a smooth, differentiable landscape to roll down. The genetic algorithm in your browser needs no such thing. It will happily optimise a target you can only <em>score</em>, never differentiate: a timetable, a circuit layout, a wing shape evaluated by a physics simulator. Whole classes of real problems have no gradient to descend, and on those, evolution isn't a quaint biological metaphor, nor a glorified random number generator, it's the only tool that works.</p>

 <p>And the fitness function, the thing you were choosing every time you moved a slider, is where all the meaning lives. Change what you reward and you change what evolves, completely. I've watched evolutionary systems find gleeful, alien loopholes in a fitness function, technically-correct solutions no human would ever propose, and every one of them was a small lesson in being careful what you optimise for. It's the same lesson the paperclip thought-experiment provides.</p>

 <p>So play with it. Melt it, starve it, inbreed it, and get a better feel for the importance of parameter selection and genetic algorithms.</p>

 <div class="downloads-block">
   <p class="downloads-title">Download the standalone demo</p>
   <div class="download-links">
     <a class="download-link" href="https://www.kenreid.co.uk/blog/downloads/evolution-live-ga.html" download>evolution-live-ga.html</a>
   </div>
 </div>

 <details class="code-example"><summary>The algorithm (JavaScript, 406 lines)</summary>
<pre><code class="language-javascript"> /* ============================================================
    Evolution, Live — the interactive demo for this post.
    No dependencies. A genetic algorithm evolves paintings of a target letter
    (K by default, any of A-Z from the toolbar select):
    each individual is 50 translucent triangles (10 genes apiece: six vertex
    coordinates, rgb, alpha), fitness is one minus the mean per-pixel
    difference from a 48x48 target render. Tournament selection, per-triangle
    crossover, per-gene mutation, optional two-slot elitism. Chart colours
    come from the CSS custom properties on .evo-viz, so the site's light/dark
    toggle restyles the widget live; the paintings themselves keep fixed
    colours because the target never changes.
    ============================================================ */
 (function () {
   'use strict';
   var wrap = document.getElementById('evo-demo');
   if (!wrap) return;
   var cv = document.getElementById('evoCanvas');
   var chart = document.getElementById('evoChart');

   function mulberry32(seed) {
     var a = seed &gt;&gt;&gt; 0;
     return function () {
       a |= 0; a = (a + 0x6D2B79F5) | 0;
       var t = Math.imul(a ^ (a &gt;&gt;&gt; 15), 1 | a);
       t = (t + Math.imul(t ^ (t &gt;&gt;&gt; 7), 61 | t)) ^ t;
       return ((t ^ (t &gt;&gt;&gt; 14)) &gt;&gt;&gt; 0) / 4294967296;
     };
   }
   function vizColors(el) {
     var cs = getComputedStyle(el);
     function v(n) { return cs.getPropertyValue(n).trim(); }
     return { s1: v('--viz-s1'), s2: v('--viz-s2'), s3: v('--viz-s3'), s4: v('--viz-s4'),
              ink: v('--viz-ink'), muted: v('--viz-muted'), grid: v('--viz-grid'), surface: v('--viz-surface') };
   }
   var W = 0, H = 0, ctx = null, WC = 0, HC = 0, ctxC = null;
   function fitOne(canvas, h) {
     var dpr = window.devicePixelRatio || 1;
     var w = canvas.clientWidth || 600;
     canvas.style.height = h + 'px';
     canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr);
     var g = canvas.getContext('2d'); g.setTransform(dpr, 0, 0, dpr, 0, 0);
     return { g: g, w: w, h: h };
   }
   function fitCanvas() {
     // height tracks width: champion beside the grid on desktop, champion
     // above the grid (and a taller canvas) on phones
     var w = cv.clientWidth || 600;
     var mainH = w &lt; 480 ? Math.round(Math.min(600, w * 1.30))
                         : Math.round(Math.max(260, Math.min(340, w * 0.5)));
     var m = fitOne(cv, mainH); ctx = m.g; W = m.w; H = m.h;
     var c = fitOne(chart, w &lt; 480 ? 170 : 120); ctxC = c.g; WC = c.w; HC = c.h;
   }

   // ---- tunables ----
   var TRIS = 50, GENES = TRIS * 10, EVAL = 48;   // genome + fitness render size
   var EVAL_BUDGET = 120, MAX_GENS_FRAME = 2;     // work per animation frame; low
                                                  // enough that the sharpening is
                                                  // watchable rather than instant
   var ELITES = 2;
   var BG = '#1e1e21', TARGET_INK = '#fc6060';    // fixed painting colours

   // ---- state ----
   var seedBase = 20260718, reseeds = 0, rng = mulberry32(seedBase);
   var pop = [];                    // [{genes, fit, cv}], eval canvas per individual
   var gen = 0, evals = 0, bestEver = 0, bestIdx = 0;
   var hist = [], stride = 1, sinceSample = 0;    // chart history, decimated as it grows
   var running = true, raf = null;
   var target = null, targetData = null;

   // control refs
   var runBtn = document.getElementById('evoRun');
   var reseedBtn = document.getElementById('evoReseed');
   var popS = document.getElementById('evoPop'), popV = document.getElementById('evoPopVal');
   var mutS = document.getElementById('evoMut'), mutV = document.getElementById('evoMutVal');
   var pressS = document.getElementById('evoPress'), pressV = document.getElementById('evoPressVal');
   var eliteC = document.getElementById('evoElite');
   var letterSel = document.getElementById('evoLetter');
   var statusEl = document.getElementById('evoStatus');
   var genEl = document.getElementById('evoGen'), bestEl = document.getElementById('evoBest');
   var avgEl = document.getElementById('evoAvg'), divEl = document.getElementById('evoDiv');
   var evalsEl = document.getElementById('evoEvals');

   // ---- the target: one bold letter (K by default), re-rendered on change ----
   function makeTarget(letter) {
     target = document.createElement('canvas');
     target.width = EVAL; target.height = EVAL;
     var g = target.getContext('2d', { willReadFrequently: true });
     g.fillStyle = BG; g.fillRect(0, 0, EVAL, EVAL);
     g.fillStyle = TARGET_INK;
     var size = 46;
     g.font = '900 ' + size + 'px Arial, sans-serif';
     var w = g.measureText(letter).width;   // shrink-to-fit so W and M don't clip
     if (w &gt; EVAL - 6) {
       size = Math.floor(size * (EVAL - 6) / w);
       g.font = '900 ' + size + 'px Arial, sans-serif';
     }
     g.textAlign = 'center'; g.textBaseline = 'middle';
     g.fillText(letter, EVAL / 2, EVAL / 2 + 2);
     targetData = g.getImageData(0, 0, EVAL, EVAL).data;
   }

   // ---- genome plumbing ----
   function renderGenome(genes, g, size) {
     g.fillStyle = BG; g.fillRect(0, 0, size, size);
     for (var t = 0; t &lt; TRIS; t++) {
       var i = t * 10;
       g.fillStyle = 'rgba(' + (genes[i + 6] * 255 | 0) + ',' + (genes[i + 7] * 255 | 0) + ',' +
                     (genes[i + 8] * 255 | 0) + ',' + (genes[i + 9] * 0.85).toFixed(3) + ')';
       g.beginPath();
       g.moveTo(genes[i] * size, genes[i + 1] * size);
       g.lineTo(genes[i + 2] * size, genes[i + 3] * size);
       g.lineTo(genes[i + 4] * size, genes[i + 5] * size);
       g.closePath(); g.fill();
     }
   }
   function evaluate(ind) {
     var g = ind.cv.getContext('2d', { willReadFrequently: true });
     renderGenome(ind.genes, g, EVAL);
     var d = g.getImageData(0, 0, EVAL, EVAL).data, sum = 0;
     for (var p = 0; p &lt; d.length; p += 4) {
       sum += Math.abs(d[p] - targetData[p]) + Math.abs(d[p + 1] - targetData[p + 1]) +
              Math.abs(d[p + 2] - targetData[p + 2]);
     }
     ind.fit = 1 - sum / (EVAL * EVAL * 3 * 255);
     evals++;
   }
   function newIndividual(genes) {
     var c = document.createElement('canvas');
     c.width = EVAL; c.height = EVAL;
     var ind = { genes: genes, fit: 0, cv: c };
     evaluate(ind);
     return ind;
   }
   function randomGenes() {
     var g = new Float32Array(GENES);
     for (var i = 0; i &lt; GENES; i++) g[i] = rng();
     return g;
   }

   // ---- the algorithm ----
   function tournament(T) {
     var bi = (rng() * pop.length) | 0;
     for (var k = 1; k &lt; T; k++) {
       var j = (rng() * pop.length) | 0;
       if (pop[j].fit &gt; pop[bi].fit) bi = j;
     }
     return pop[bi];
   }
   function breed(pa, pb, pMut, dMut) {
     var g = new Float32Array(GENES);
     for (var t = 0; t &lt; TRIS; t++) {      // crossover: whole triangles from either parent
       var src = rng() &lt; 0.5 ? pa.genes : pb.genes;
       for (var k = 0; k &lt; 10; k++) g[t * 10 + k] = src[t * 10 + k];
     }
     for (var i = 0; i &lt; GENES; i++) {     // mutation: per-gene nudge, clamped to [0,1]
       if (rng() &lt; pMut) {
         var v = g[i] + (rng() * 2 - 1) * dMut;
         g[i] = v &lt; 0 ? 0 : v &gt; 1 ? 1 : v;
       }
     }
     return g;
   }
   function stepGeneration() {
     var n = pop.length;
     // slider mappings: pressure 1 = uniform random parents (pure drift),
     // 10 = the whole population in every tournament (winner takes all)
     var s = +pressS.value;
     var T = Math.max(1, Math.round(1 + (n - 1) * Math.pow((s - 1) / 9, 2)));
     var mv = +mutS.value / 100;
     var pMut = Math.pow(mv, 1.5) * 0.35;
     var dMut = 0.05 + 0.45 * mv;
     var elite = eliteC.checked ? Math.min(ELITES, n) : 0;
     pop.sort(function (a, b) { return b.fit - a.fit; });
     var next = pop.slice(0, elite);
     while (next.length &lt; n) next.push(newIndividual(breed(tournament(T), tournament(T), pMut, dMut)));
     pop = next;
     gen++;
     sample();
   }
   function generationCost() {
     return Math.max(1, pop.length - (eliteC.checked ? Math.min(ELITES, pop.length) : 0));
   }
   function runBudget() {
     var spent = 0, gens = 0;
     while (gens &lt; MAX_GENS_FRAME &amp;&amp; spent + generationCost() &lt;= EVAL_BUDGET) {
       stepGeneration();
       spent += generationCost(); gens++;
     }
   }

   // ---- stats + chart history ----
   function popStats() {
     var n = pop.length, sum = 0, best = -1, bi = 0;
     for (var i = 0; i &lt; n; i++) {
       sum += pop[i].fit;
       if (pop[i].fit &gt; best) { best = pop[i].fit; bi = i; }
     }
     bestIdx = bi;
     // diversity: mean per-gene standard deviation, normalised so a fully
     // random population reads ~100% (std of uniform [0,1] is 1/sqrt(12))
     var m, m2, gsum = 0;
     for (var gI = 0; gI &lt; GENES; gI++) {
       m = 0; m2 = 0;
       for (var j = 0; j &lt; n; j++) { var v = pop[j].genes[gI]; m += v; m2 += v * v; }
       m /= n;
       gsum += Math.sqrt(Math.max(0, m2 / n - m * m));
     }
     var div = Math.min(1, (gsum / GENES) / 0.2887);
     return { best: best, avg: sum / n, div: div };
   }
   function sample() {
     if (++sinceSample &lt; stride) return;
     sinceSample = 0;
     var st = popStats();
     if (st.best &gt; bestEver) bestEver = st.best;
     hist.push({ g: gen, best: st.best, avg: st.avg, div: st.div });
     if (hist.length &gt;= 480) {   // decimate so the chart array stays bounded
       hist = hist.filter(function (_, i) { return i % 2 === 0; });
       stride *= 2;
     }
   }

   // ---- drawing ----
   function layout() {
     var pad = 10, lab = 16;
     if (W &lt; 480) {
       var s = Math.round(Math.min(W - 2 * pad, H * 0.55));
       var gy = pad + lab + s + pad + lab;
       return { champ: { x: (W - s) / 2, y: pad + lab, s: s },
                grid: { x: pad, y: gy, w: W - 2 * pad, h: H - gy - pad } };
     }
     var side = H - 2 * pad - lab;
     var gx = pad + side + pad + 8;
     return { champ: { x: pad, y: pad + lab, s: side },
              grid: { x: gx, y: pad + lab, w: W - gx - pad, h: side } };
   }
   function label(text, x, y, c, align) {
     ctx.fillStyle = c;
     ctx.font = '600 10px Poppins, sans-serif';
     ctx.textAlign = align || 'start'; ctx.textBaseline = 'alphabetic';
     ctx.fillText(text, x, y);
     ctx.textAlign = 'start';
   }
   function draw() {
     var c = vizColors(wrap);
     var L = layout();
     var st = popStats();   // refreshes bestIdx so the champion pane never lags
     ctx.clearRect(0, 0, W, H);
     ctx.fillStyle = c.surface; ctx.fillRect(0, 0, W, H);

     // champion pane: the best individual, rendered as vectors so it stays crisp
     label('CHAMPION', L.champ.x, L.champ.y - 5, c.muted);
     ctx.save();
     ctx.translate(L.champ.x, L.champ.y);
     renderGenome(pop[bestIdx].genes, ctx, L.champ.s);
     ctx.restore();
     ctx.strokeStyle = c.grid; ctx.lineWidth = 1;
     ctx.strokeRect(L.champ.x + 0.5, L.champ.y + 0.5, L.champ.s - 1, L.champ.s - 1);

     // target inset, top-right corner of the champion pane
     var ts = Math.max(34, Math.round(L.champ.s * 0.24));
     var tx = L.champ.x + L.champ.s - ts - 6, ty = L.champ.y + 6;
     ctx.drawImage(target, tx, ty, ts, ts);
     ctx.strokeStyle = c.muted; ctx.strokeRect(tx + 0.5, ty + 0.5, ts - 1, ts - 1);
     label('TARGET', L.champ.x + L.champ.s, L.champ.y - 5, c.muted, 'right');

     // population grid: every individual's evaluation render, champion ringed
     label('POPULATION', L.grid.x, L.grid.y - 5, c.muted);
     var n = pop.length;
     var cols = Math.max(1, Math.ceil(Math.sqrt(n * L.grid.w / Math.max(1, L.grid.h))));
     var rows = Math.ceil(n / cols);
     var cell = Math.floor(Math.min(L.grid.w / cols, L.grid.h / rows));
     for (var i = 0; i &lt; n; i++) {
       var x = L.grid.x + (i % cols) * cell, y = L.grid.y + ((i / cols) | 0) * cell;
       ctx.drawImage(pop[i].cv, x + 1, y + 1, cell - 2, cell - 2);
       if (i === bestIdx) {
         ctx.strokeStyle = c.s2; ctx.lineWidth = 2;
         ctx.strokeRect(x + 1.5, y + 1.5, cell - 3, cell - 3);
         ctx.lineWidth = 1;
       }
     }
     drawChart(c);
     updateStats(st);
   }
   function drawChart(c) {
     ctxC.clearRect(0, 0, WC, HC);
     ctxC.fillStyle = c.surface; ctxC.fillRect(0, 0, WC, HC);
     var x0 = 34, x1 = WC - 62, y0 = 8, y1 = HC - 16;
     ctxC.font = '9px Poppins, sans-serif';
     [0, 0.5, 1].forEach(function (p) {
       var y = y1 - (y1 - y0) * p;
       ctxC.strokeStyle = c.grid; ctxC.lineWidth = 1;
       ctxC.beginPath(); ctxC.moveTo(x0, y + 0.5); ctxC.lineTo(x1, y + 0.5); ctxC.stroke();
       ctxC.fillStyle = c.muted; ctxC.textAlign = 'right';
       ctxC.fillText(Math.round(p * 100) + '%', x0 - 5, y + 3);
     });
     ctxC.textAlign = 'center';
     ctxC.fillStyle = c.muted;
     ctxC.fillText('generations', (x0 + x1) / 2, HC - 4);
     ctxC.textAlign = 'start';
     if (hist.length &lt; 2) return;
     var g0 = hist[0].g, g1 = hist[hist.length - 1].g;
     function X(gi) { return x0 + (x1 - x0) * ((gi - g0) / Math.max(1, g1 - g0)); }
     function Y(v) { return y1 - (y1 - y0) * v; }
     var series = [ { k: 'best', col: c.s2, name: 'best' },
                    { k: 'avg', col: c.s1, name: 'average' },
                    { k: 'div', col: c.s3, name: 'diversity' } ];
     // series lines, labelled at their right-hand ends (labels nudged apart)
     var ends = [];
     series.forEach(function (s) {
       ctxC.beginPath();
       for (var i = 0; i &lt; hist.length; i++) {
         var x = X(hist[i].g), y = Y(hist[i][s.k]);
         if (i === 0) ctxC.moveTo(x, y); else ctxC.lineTo(x, y);
       }
       ctxC.strokeStyle = s.col; ctxC.lineWidth = 1.6; ctxC.stroke();
       ends.push({ y: Y(hist[hist.length - 1][s.k]), col: s.col, name: s.name });
     });
     ends.sort(function (a, b) { return a.y - b.y; });
     for (var e = 1; e &lt; ends.length; e++) {
       if (ends[e].y - ends[e - 1].y &lt; 10) ends[e].y = ends[e - 1].y + 10;
     }
     ends.forEach(function (e2) {
       ctxC.fillStyle = e2.col;
       ctxC.fillText(e2.name, x1 + 5, Math.max(y0 + 6, Math.min(y1, e2.y)) + 3);
     });
   }
   function fmtPct(v) { return (v * 100).toFixed(1) + '%'; }
   function updateStats(st) {
     genEl.textContent = gen;
     bestEl.textContent = fmtPct(Math.max(bestEver, st.best));
     avgEl.textContent = fmtPct(st.avg);
     divEl.textContent = Math.round(st.div * 100) + '%';
     evalsEl.textContent = evals &gt;= 10000 ? (evals / 1000).toFixed(0) + 'k' : evals;
   }

   // ---- run control ----
   function loop() {
     if (!running) return;
     runBudget();
     draw();
     raf = requestAnimationFrame(loop);
   }
   function start() { if (running) raf = requestAnimationFrame(loop); }
   function resetSim() {
     rng = mulberry32(seedBase + reseeds * 9973);
     evals = 0; gen = 0; bestEver = 0; bestIdx = 0;
     hist = []; stride = 1; sinceSample = 0;
     pop = [];
     var n = +popS.value;
     for (var i = 0; i &lt; n; i++) pop.push(newIndividual(randomGenes()));
     sample();
   }

   runBtn.addEventListener('click', function () {
     running = !running;
     runBtn.textContent = running ? 'Pause' : 'Play';
     statusEl.textContent = running ? '' : 'Paused.';
     if (running) start(); else if (raf) cancelAnimationFrame(raf);
   });
   reseedBtn.addEventListener('click', function () {
     reseeds++; resetSim();
     if (!running) draw();
   });
   function setPop() {
     popV.textContent = popS.value;
     var n = +popS.value;
     if (n === pop.length) return;
     pop.sort(function (a, b) { return b.fit - a.fit; });
     if (n &lt; pop.length) pop = pop.slice(0, n);
     else while (pop.length &lt; n) {   // grow with mutated copies of survivors
       var p = pop[(rng() * pop.length) | 0];
       pop.push(newIndividual(breed(p, p, 0.1, 0.3)));
     }
     if (!running) draw();
   }
   function setMut() { mutV.textContent = mutS.value + '%'; }
   function setPress() { pressV.textContent = pressS.value; }
   popS.addEventListener('input', setPop);
   mutS.addEventListener('input', setMut);
   pressS.addEventListener('input', setPress);
   letterSel.addEventListener('change', function () {
     // a new target is a new fitness landscape: re-render it and start over
     makeTarget(letterSel.value);
     resetSim();
     if (!running) draw();
   });

   window.addEventListener('resize', function () { fitCanvas(); if (!running) draw(); });
   new MutationObserver(function () { draw(); }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });

   // ---- init ----
   for (var li = 0; li &lt; 26; li++) {
     var opt = document.createElement('option');
     opt.value = opt.textContent = String.fromCharCode(65 + li);
     if (opt.value === 'K') opt.selected = true;
     letterSel.appendChild(opt);
   }
   makeTarget('K');
   fitCanvas();
   setMut(); setPress(); popV.textContent = popS.value;
   resetSim();
   draw();
   start();
 })();
 </code></pre>
 </details>

 <div class="faq-section">
 <h2>Common questions</h2>

 <details class="faq-item">
 <summary>Is this how real biological evolution works?</summary>
 <p>Ish. This is a metaphor. Real evolution has no fitness function written down anywhere (fitness just <em>is</em> reproductive success), no fixed population size, no clean generations, and no goal. The GA borrows the process (variation plus selection) and bolts on an explicit target because we <em>want</em> something specific, while nature just wants a mix of equilibrium of an ecosystem and successful species. </p>
 </details>

 <details class="faq-item">
 <summary>Why would I use this instead of a neural network?</summary>
 <p>When the thing you're optimising isn't differentiable and can only be scored: discrete layouts, schedules, structures evaluated by a simulator, or strategies. Gradient-based methods (which power neural nets) need a smooth slope to follow; evolution doesn't. They're also not rivals: evolutionary methods are increasingly used to tune, prune, and search neural architectures, so the two often ride together.</p>
 </details>

 <details class="faq-item">
 <summary>Doesn't this waste a lot of computation on bad candidates?</summary>
 <p>Yes: the population is insurance against getting stuck, paid for in extra evaluations. On easy, smooth problems it's wasteful and a hill climber wins. On hard, rugged, deceptive ones, the wasted evaluations are what keeps evolution from committing early to a bad answer. No algorithm wins everywhere; that's the No Free Lunch theorem.</p>
 </details>
 </div>

        

 ]]></content:encoded>
    </item>
    <item>
      <title>The Invisible Half of a Blog Post</title>
      <link>https://www.kenreid.co.uk/blog/invisible-half-of-a-blog-post.html</link>
      <guid>https://www.kenreid.co.uk/blog/invisible-half-of-a-blog-post.html</guid>
      <pubDate>Tue, 04 Aug 2026 00:00:00 +0000</pubDate>
      <description>Every post on this blog carries around forty lines of metadata nobody reads: social preview cards, structured data, feeds, citation tags. What each layer does, and why the real problem is discipline, not difficulty.</description>
      <category>technology</category>
      <category>writing</category>
      <content:encoded><![CDATA[
 <h1>The Invisible Half of a Blog Post</h1>
 <div class="blog-meta">
 4 August 2026 &middot;
 <span class="blog-tag">technology</span>
 <span class="blog-tag">writing</span>
 </div>

 <p>When I publish a post here, the words are roughly half the file. The other half is metadata: forty-odd lines in the page's head, plus entries in two site-wide XML files. Skipping it all would change nothing about the reading experience, but it's necessary and is checked by <a href="https://www.kenreid.co.uk/blog/my-website-has-a-test-suite.html">a machine</a>. This post describes it. Part of the series on <a href="https://www.kenreid.co.uk/series-how-this-site-is-built.html">how this site is built</a>.</p>

 <div class="plain-english-box">
 <h2>Quick jargon guide</h2>
 <ul>
 <li><strong>Metadata:</strong> data about the page rather than in it: its title, summary, author, date, and preview image, written in machine-readable form.</li>
 <li><strong>Open Graph:</strong> the tag family (invented by Facebook, used by everyone) that controls the card shown when your link is shared in a chat or feed.</li>
 <li><strong>Canonical URL:</strong> a tag declaring "this address is the one true home of this page", so search engines don't treat variants as duplicates.</li>
 <li><strong>JSON-LD / structured data:</strong> a machine-readable summary of the page (this is an article, by this person, on this date) in a format search engines parse directly.</li>
 <li><strong>RSS feed:</strong> a site-wide XML file listing recent posts, so feed readers can check for new writing without visiting.</li>
 <li><strong>Sitemap:</strong> a plain list of every page you'd like search engines to know about.</li>
 </ul>
 </div>

 <h2>The card in the chat window</h2>

 <p>Open Graph: five tags giving the post's title, description, preview image, address, and type. When someone pastes your link into a group chat or a social feed, the platform reads these tags to build the preview card. Without them, your link renders as bare blue text or, worse, a card assembled from whatever the scraper found first. With them, every share has a title, summary sentence, and one of <a href="https://www.kenreid.co.uk/gallery.html">my photographs</a> at full card width. Twitter's near-identical tag family sits alongside for the platforms that read that instead.</p>

<pre><code class="language-html">&lt;meta property="og:title" content="The Invisible Half of a Blog Post"&gt;
&lt;meta property="og:description" content="Every post on this blog carries around forty lines
      of metadata nobody reads: social preview cards, structured data, feeds, citation tags."&gt;
&lt;meta property="og:image" content="https://github.com/DrKenReid/DrKenReid.github.io/releases/download/photos-v1/78.png"&gt;
&lt;meta property="og:url" content="https://www.kenreid.co.uk/blog/invisible-half-of-a-blog-post.html"&gt;
&lt;meta property="og:type" content="article"&gt;</code></pre>

 <p>The description gets written by a human (me), because it's the sentence that decides whether a stranger clicks. And the image tag points at a full-resolution photo, not a thumbnail, because preview scrapers resize down gracefully and up horribly.</p>

 <h2>The librarian's copy</h2>

 <p>Below the social tags sits a block of JSON-LD: the same facts (headline, author, date, image) restated in schema.org's vocabulary, which is the format Google actually parses for rich results. Where the Open Graph tags talk to chat apps, this block talks to crawlers, and it's how I explain that this page is an article by me.</p>

<pre><code class="language-html">&lt;script type="application/ld+json"&gt;
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "The Invisible Half of a Blog Post",
  "author": { "@type": "Person", "name": "Ken Reid", "url": "https://www.kenreid.co.uk" },
  "datePublished": "2026-08-04",
  "image": "https://github.com/DrKenReid/DrKenReid.github.io/releases/download/photos-v1/78.png"
}
&lt;/script&gt;</code></pre>

 <p>Each post carries citation tags (the format Google Scholar reads) and Dublin Core tags (the library world's metadata standard). I spent years in academia, the tags cost six lines, and if someone ever does want to cite <a href="https://www.kenreid.co.uk/blog/what-50000-scrobbles-say-about-me.html">the scrobbles post</a> in their media-studies dissertation, their reference manager will fill every field correctly. It's courteous and costs me nothing but a couple of seconds, and might give another citation or two to my Google Scholar account!</p>

<pre><code class="language-html">&lt;meta name="citation_title" content="The Invisible Half of a Blog Post"&gt;
&lt;meta name="citation_author" content="Reid, Kenneth N."&gt;
&lt;meta name="citation_publication_date" content="2026/08/04"&gt;
&lt;meta name="DC.title" content="The Invisible Half of a Blog Post"&gt;
&lt;meta name="DC.creator" content="Kenneth N. Reid"&gt;
&lt;meta name="DC.date" content="2026-08-04"&gt;</code></pre>

 <figure>
 <img src="https://www.kenreid.co.uk/img/photography/thumb/78.webp" alt="A four-pane skylight in a dark wooden attic roof, bright sky showing through" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
 <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span> The part of the structure you only notice when the light comes through it.</figcaption>
 </figure>

 <h2>The site-wide ledgers</h2>

 <p>Two XML files round out every publish. The sitemap is a list of every page, so crawlers miss nothing. The RSS feed is how readers with feed readers (a small, excellent demographic) get new posts delivered without visiting, and it's essential infrastructure here. RSS predates the social platforms and will outlive several of them.</p>

<pre><code class="language-xml">&lt;!-- feed.xml --&gt;
&lt;item&gt;
  &lt;title&gt;The Invisible Half of a Blog Post&lt;/title&gt;
  &lt;link&gt;https://www.kenreid.co.uk/blog/invisible-half-of-a-blog-post.html&lt;/link&gt;
  &lt;pubDate&gt;Tue, 04 Aug 2026 00:00:00 +0000&lt;/pubDate&gt;
&lt;/item&gt;

&lt;!-- sitemap.xml --&gt;
&lt;url&gt;&lt;loc&gt;https://www.kenreid.co.uk/blog/invisible-half-of-a-blog-post.html&lt;/loc&gt;
     &lt;changefreq&gt;yearly&lt;/changefreq&gt;&lt;priority&gt;0.8&lt;/priority&gt;&lt;/url&gt;</code></pre>

 <p>Each post also declares a canonical URL: the www and non-www versions of your site, plus any URL with tracking junk appended, all count as <em>different pages</em> to a search engine, splitting your modest search presence into fragments. One tag per page declares the official address and the fragments reunite.</p>

<pre><code class="language-html">&lt;link rel="canonical" href="https://www.kenreid.co.uk/blog/invisible-half-of-a-blog-post.html"&gt;</code></pre>

 <h2>The real problem is discipline</h2>

 <p>Nothing above is hard. The hard part is that it's forty lines of near-identical boilerplate on every post, and near-identical is the operative curse: copy the head from the last post, forget to change the og:image, and next week your post about cats is shared around wearing a photograph of a dog. </p>

 <p>So: automate the checking, not necessarily the writing. My audit script validates every page's metadata (descriptions present and sensibly sized, canonical matching the filename, preview images existing, JSON-LD parsing, feed and sitemap complete) on every push. The forty lines stay correct because a robot reads them. Without that, my genuine recommendation would be to keep <em>less</em> metadata: only what you can maintain, because wrong metadata is worse than none.</p>

 <div class="faq-section">
 <h2>Common questions</h2>

 <details class="faq-item">
 <summary>What's the minimum set worth having?</summary>
 <p>Title, description, canonical, and the Open Graph tags with a real image. That covers search snippets and share cards, which is where the visible benefit lives. Add RSS if you post regularly; it's one generated file and your most loyal readers will use it. Everything beyond that is diminishing returns done for craft.</p>
 </details>

 <details class="faq-item">
 <summary>Does any of this improve search ranking?</summary>
 <p>Mostly no, and be suspicious of anyone selling otherwise. Metadata doesn't make a page rank higher; it makes the page <em>present correctly</em> wherever it appears: the right snippet, the right card, the right attribution. </p>
 </details>

 <details class="faq-item">
 <summary>How do I check what my links look like when shared?</summary>
 <p>Paste the URL into a private chat with yourself and see what unfurls. </p>
 </details>

 <details class="faq-item">
 <summary>Why not generate all this with a static site generator?</summary>
 <p>A generator absolutely would, from a few front-matter fields, and it's a fine reason to use one. This site is hand-built by choice (<a href="https://www.kenreid.co.uk/blog/blog-engine-in-one-json-file.html">a decision explained elsewhere</a>), so the equivalent is a checked template: same output, different division of labour between me and the machines.</p>
 </details>
 </div>

        

 ]]></content:encoded>
    </item>
    <item>
      <title>Ctrl+K for a Static Site</title>
      <link>https://www.kenreid.co.uk/blog/ctrl-k-for-a-static-site.html</link>
      <guid>https://www.kenreid.co.uk/blog/ctrl-k-for-a-static-site.html</guid>
      <pubDate>Mon, 03 Aug 2026 00:00:00 +0000</pubDate>
      <description>Press Ctrl+K anywhere on this site and a command palette appears: every page, every post, every photo tag, searchable from the keyboard. 217 lines of vanilla JavaScript, no library, and the whole thing is in the post to steal.</description>
      <category>technology</category>
      <content:encoded><![CDATA[
 <h1>Ctrl+K for a Static Site</h1>
 <div class="blog-meta">
 3 August 2026 &middot;
 <span class="blog-tag">technology</span>
 </div>

 <p>Try it now, if you're on a keyboard: press <strong>Ctrl+K</strong> (Cmd+K on a Mac), or just <strong>/</strong>. A search box appears over the page. Type "abandoned" and you'll get the blog post about ruins and the gallery filtered to abandoned buildings; type "map" and you're one Enter away from <a href="https://www.kenreid.co.uk/map.html">the photo map</a>. Every page, every post, and every photo tag on this site is reachable from that box, from anywhere. It's the interface programmers know from their editors, transplanted onto a personal website, and this post explains how little it took: 217 lines of vanilla JavaScript, no search service, no library. Part of the series on <a href="https://www.kenreid.co.uk/series-how-this-site-is-built.html">how this site is built</a>.</p>

 <div class="plain-english-box">
 <h2>Quick jargon guide</h2>
 <ul>
 <li><strong>Command palette:</strong> a keyboard-summoned search box that finds and jumps to anything. Popularised by code editors; now in Slack, Notion, GitHub, and (as of some point) here.</li>
 <li><strong>Fuzzy matching:</strong> matching that forgives imprecision: "phto" still finds "photography", because the letters appear in order.</li>
 <li><strong>Focus trap:</strong> keeping keyboard focus inside a dialog while it's open, so Tab doesn't wander off into the page behind it. </li>
 <li><strong>Deep link:</strong> a URL that opens a page in a specific state, like the gallery pre-filtered to one tag.</li>
 </ul>
 </div>

 <h2>What it searches, and what it doesn't</h2>

 <p>The index has three sources, in descending order of hardcoded-ness. First, the site's ten pages, written directly into the script with a subtitle each ("490+ photos", "571 saved passages"); they change a few times a year, so a hardcoded list is simply correct. Second, every blog post, fetched from <a href="https://www.kenreid.co.uk/blog/blog-engine-in-one-json-file.html">the same JSON file</a> that runs the blog listing, so the palette knows each post's title, tags, and excerpt without any separate index to maintain; publish a post, and the palette knows it. Third, the gallery's ten photo tags, each deep-linking to the gallery pre-filtered, which is what makes "wildlife" a destination rather than a word.</p>

 <p>The palette doesn't search <em>inside</em> post text, and it doesn't index the 571 quotes or 490 photos individually. Full-text search of a static site is doable (ship an index file, use a library like Lunr) but the index grows with every word you write, and I certainly wouldn't want paragraph-level results on a personal site. Titles, tags, and excerpts answer "take me to the thing I half-remember". The quote wall also gets one entry, not 571.</p>

 <figure>
 <img src="https://www.kenreid.co.uk/img/photography/thumb/46.webp" alt="A woman walking briskly across a sunlit historic plaza, bag over one shoulder" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
 <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span> Getting somewhere directly.</figcaption>
 </figure>

 <h2>Fuzzy</h2>

 <p>Real fuzzy-search libraries use sophisticated string mathematics. Mine uses a five-tier ladder: an exact match scores 120, a match at the start of the text 100, a match at a word boundary 80, a match anywhere 60, and letters-in-the-right-order (the "phto" case) 25. Each source gets a weighting on top (a title match beats a tag match beats an excerpt match), everything above zero is sorted, and the top ten render. </p>

 <p>Arrow keys move the selection and wrap at the ends; Tab is trapped so focus can't escape the dialog; Escape closes; a screen-reader announcement reports the result count as you type; and the input is focused synchronously on opening, because mobile keyboards only appear if the focus happens inside the user's gesture. </p>

 <h2>Why bother, on a site this small?</h2>

 <p>Part of the answer is who visits: this site's readers skew technical, their muscle memory already knows Ctrl+K. Part is that the palette compounds with the site's growth: at ten pages it's a nicety, at <a href="https://www.kenreid.co.uk/blog.html">46 posts</a> it's the fastest way to find anything, and every post published makes it slightly more useful at zero marginal cost. And part of it, in the spirit of candour that runs through this series: it's the kind of detail that's simply pleasing to have on one's own website. It scratches an itch for me, and it was fun to implement.</p>

 <p>One keydown listener for the shortcut, a dialog built on first open (not before; most visitors never press it), a scoring function, and a list that re-renders per keystroke. Resist the URL that offers to do it for you as a hosted service; for a site whose whole index fits in one JSON fetch, the dependency would outweigh the feature.</p>


 <h2>Take the code</h2>

 <p>The whole thing is below, copy-paste ready. The JavaScript is the complete <code>js/palette.js</code> from this site: swap the <code>PAGES</code> array for your own pages, point the two <code>fetch</code> calls at whatever JSON you have (or delete them, along with the sources they feed), and it will run on any static site with no build step. The CSS is the full set of palette styles, dark theme included; the <code>[data-theme="dark"]</code> selectors assume a theme attribute on <code>&lt;html&gt;</code>, so adapt those to however your site handles dark mode.</p>

 <details class="code-example"><summary>The JavaScript (js/palette.js, 217 lines)</summary>
<pre><code class="language-javascript">/**
 * palette.js — site-wide command palette (Ctrl/Cmd+K, or '/').
 *
 * Fuzzy search across pages, blog posts (data/posts.json), photo tags,
 * and map places (deep links to gallery.html?tag=... and map.html?region=...).
 * Vanilla JS, builds its DOM on first open, loaded with defer on every page.
 */
(() =&gt; {
    const prefix = /\/blog\//.test(window.location.pathname) ? '../' : './';

    const PAGES = [
        { title: 'Home', sub: 'Intro and latest posts', url: 'index.html' },
        { title: 'About', sub: 'Who I am', url: 'about.html' },
        { title: 'Data Science', sub: 'Projects &amp; publications', url: 'data_science.html' },
        { title: 'Photography', sub: '490+ photos', url: 'gallery.html' },
        { title: 'Photo Map', sub: 'Photographs by place', url: 'map.html' },
        { title: 'Music', sub: 'Guitar &amp; listening stats', url: 'music.html' },
        { title: 'Literature', sub: 'Reviews &amp; reading stats', url: 'literature.html' },
        { title: 'Quote Wall', sub: '571 saved passages', url: 'quotes.html' },
        { title: 'Blog', sub: 'All posts', url: 'blog.html' },
        { title: 'Contact', sub: 'Get in touch', url: 'contact.html' }
    ];

    const PHOTO_TAGS = ['wildlife', 'portrait', 'bw', 'architecture', 'abandoned',
        'urban', 'nature', 'silhouette', 'landscape', 'winter'];

    let overlay = null;
    let input = null;
    let list = null;
    let items = [];
    let active = 0;
    let posts = null;
    let places = null;
    let indexRequested = false;

    // Score ladder: exact 120, prefix 100, word boundary 80, anywhere 60,
    // letters-in-the-right-order 25, no match 0.
    function score(query, text) {
        if (!text) return 0;
        const q = query.toLowerCase();
        const t = text.toLowerCase();
        if (t === q) return 120;
        const idx = t.indexOf(q);
        if (idx === 0) return 100;
        if (idx &gt; 0) return t[idx - 1] === ' ' ? 80 : 60;
        let ti = 0;
        for (const ch of q) {
            ti = t.indexOf(ch, ti);
            if (ti === -1) return 0;
            ti++;
        }
        return 25;
    }

    function collectResults(query) {
        const q = query.trim();
        const results = [];
        const add = (s, kind, title, sub, href) =&gt; {
            if (s &gt; 0) results.push({ score: s, kind, title, sub, href });
        };

        // With an empty query the palette shows the pages as a menu.
        for (const p of PAGES) {
            add(q ? Math.max(score(q, p.title), score(q, p.sub) * 0.5) : 10,
                'Page', p.title, p.sub, prefix + p.url);
        }
        if (q) {
            for (const p of posts || []) {
                const tags = (p.tags || []).join(' ');
                add(Math.max(score(q, p.title), score(q, tags) * 0.8, score(q, p.excerpt || '') * 0.4),
                    'Post', p.title, (p.tags || []).join(' · '), prefix + (p.url || ''));
            }
            for (const t of PHOTO_TAGS) {
                add(score(q, t) * 0.9, 'Photos', `${t[0].toUpperCase()}${t.slice(1)} photos`,
                    'Gallery filter', `${prefix}gallery.html?tag=${t}`);
            }
            for (const pl of places || []) {
                add(score(q, pl.name) * 0.9, 'Place', pl.name,
                    `${pl.count} photo${pl.count === 1 ? '' : 's'} on the map`,
                    `${prefix}map.html?region=${encodeURIComponent(pl.name)}`);
            }
        }
        return results.sort((a, b) =&gt; b.score - a.score).slice(0, 10);
    }

    function render(query) {
        items = collectResults(query);
        active = 0;
        overlay.querySelector('.kr-palette-live').textContent =
            items.length ? `${items.length} result${items.length === 1 ? '' : 's'}` : 'No results';
        list.innerHTML = items.length
            ? items.map((r, i) =&gt;
                `&lt;a href="https://www.kenreid.co.uk/blog/${r.href}" class="kr-palette-item${i === 0 ? ' active' : ''}" data-i="${i}"&gt;` +
                `&lt;span class="kr-palette-kind"&gt;${r.kind}&lt;/span&gt;` +
                `&lt;span class="kr-palette-text"&gt;&lt;span class="kr-palette-title"&gt;${r.title}&lt;/span&gt;` +
                (r.sub ? `&lt;span class="kr-palette-sub"&gt;${r.sub}&lt;/span&gt;` : '') +
                '&lt;/span&gt;&lt;/a&gt;').join('')
            : '&lt;div class="kr-palette-empty"&gt;No matches. Try a post title, page, photo tag, or place.&lt;/div&gt;';
    }

    function setActive(i) {
        const els = list.querySelectorAll('.kr-palette-item');
        if (!els.length) return;
        active = (i + els.length) % els.length;
        els.forEach((el, j) =&gt; el.classList.toggle('active', j === active));
        els[active].scrollIntoView({ block: 'nearest' });
    }

    function build() {
        overlay = document.createElement('div');
        overlay.className = 'kr-palette-overlay';
        overlay.innerHTML =
            '&lt;div class="kr-palette" role="dialog" aria-modal="true" aria-label="Site search"&gt;' +
            '&lt;input type="text" class="kr-palette-input" placeholder="Search posts, pages, photos…" aria-label="Search site" role="combobox" aria-expanded="true" aria-autocomplete="list"&gt;' +
            '&lt;div class="kr-palette-list" role="listbox"&gt;&lt;/div&gt;' +
            '&lt;div class="kr-palette-live sr-only" aria-live="polite"&gt;&lt;/div&gt;' +
            '&lt;div class="kr-palette-foot"&gt;&lt;span&gt;&amp;uarr;&amp;darr; navigate&lt;/span&gt;&lt;span&gt;&amp;crarr; open&lt;/span&gt;&lt;span&gt;esc close&lt;/span&gt;&lt;/div&gt;' +
            '&lt;/div&gt;';
        document.body.appendChild(overlay);
        input = overlay.querySelector('.kr-palette-input');
        list = overlay.querySelector('.kr-palette-list');

        overlay.addEventListener('mousedown', (e) =&gt; {
            if (e.target === overlay) close();
        });
        input.addEventListener('input', () =&gt; render(input.value));
        input.addEventListener('keydown', (e) =&gt; {
            if (e.key === 'ArrowDown') { e.preventDefault(); setActive(active + 1); }
            else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(active - 1); }
            else if (e.key === 'Tab') {
                // Focus trap: the input is the palette's single focus stop;
                // Tab moves the selection instead of leaving the dialog.
                e.preventDefault();
                setActive(active + (e.shiftKey ? -1 : 1));
            } else if (e.key === 'Enter') {
                e.preventDefault();
                if (items[active]) window.location.href = items[active].href;
            } else if (e.key === 'Escape') close();
        });
        list.addEventListener('mousemove', (e) =&gt; {
            const el = e.target.closest('.kr-palette-item');
            if (el) setActive(Number(el.dataset.i));
        });
    }

    function loadIndex() {
        if (indexRequested) return;
        indexRequested = true;
        const grab = (url, apply) =&gt; fetch(prefix + url)
            .then((r) =&gt; r.json())
            .then((data) =&gt; { apply(data); render(input.value); })
            .catch(() =&gt; { /* source stays empty; pages still work */ });
        grab('data/posts.json', (data) =&gt; { posts = data; });
        grab('data/photo-locations.json', (data) =&gt; {
            places = (data.regions || []).map((r) =&gt; ({ name: r.name, count: (r.photos || []).length }));
        });
    }

    function open() {
        if (!overlay) build();
        loadIndex();
        overlay.classList.add('is-open');
        document.body.classList.add('kr-palette-open');
        input.value = '';
        render('');
        // Focus synchronously: mobile browsers only raise the soft keyboard
        // when focus happens inside the user gesture.
        input.focus();
        setTimeout(() =&gt; input.focus(), 30);
    }

    function close() {
        overlay.classList.remove('is-open');
        document.body.classList.remove('kr-palette-open');
    }

    const isOpen = () =&gt; Boolean(overlay) &amp;&amp; overlay.classList.contains('is-open');

    document.addEventListener('keydown', (e) =&gt; {
        if ((e.ctrlKey || e.metaKey) &amp;&amp; e.key.toLowerCase() === 'k') {
            e.preventDefault();
            if (isOpen()) close(); else open();
        } else if (e.key === '/' &amp;&amp; !isOpen()) {
            const t = e.target;
            const typing = t &amp;&amp; (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable);
            if (!typing) { e.preventDefault(); open(); }
        } else if (e.key === 'Escape' &amp;&amp; isOpen()) {
            close();
        }
    });

    // Nav hint: the header is injected by shared-components.js, so wait
    // for #nav to exist before appending the Search pill.
    document.addEventListener('DOMContentLoaded', () =&gt; {
        const tryInsert = () =&gt; {
            const nav = document.getElementById('nav');
            if (!nav || document.querySelector('.kr-palette-hint')) return Boolean(nav);
            const li = document.createElement('li');
            li.innerHTML = '&lt;a href="#" class="kr-palette-hint" role="button" aria-label="Search the site (Ctrl+K)"&gt;' +
                '&lt;svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"&gt;&lt;path fill="currentColor" d="M15.5 14h-.79l-.28-.27a6.5 6.5 0 1 0-.7.7l.27.28v.79l5 4.99L20.49 19zm-6 0A4.5 4.5 0 1 1 14 9.5 4.5 4.5 0 0 1 9.5 14z"/&gt;&lt;/svg&gt;' +
                '&lt;span class="kr-palette-word"&gt;Search&lt;/span&gt;' +
                '&lt;span class="kr-palette-kbd"&gt;Ctrl K&lt;/span&gt;&lt;/a&gt;';
            li.querySelector('a').addEventListener('click', (e) =&gt; {
                e.preventDefault();
                open();
            });
            nav.appendChild(li);
            return true;
        };
        if (!tryInsert()) {
            const mo = new MutationObserver(() =&gt; {
                if (tryInsert()) mo.disconnect();
            });
            mo.observe(document.body, { childList: true, subtree: true });
        }
    });
})();</code></pre>
 </details>

 <details class="code-example"><summary>The CSS (147 lines)</summary>
<pre><code class="language-css">/* Command palette (Ctrl/Cmd+K — js/palette.js) */
.kr-palette-overlay {
  position: fixed;
  inset: 0;
  z-index: 6000;
  background: rgba(10, 10, 10, 0.55);
  backdrop-filter: blur(3px);
  display: none;
  align-items: flex-start;
  justify-content: center;
  padding: 12vh 16px 0; }

.kr-palette-overlay.is-open {
  display: flex; }

body.kr-palette-open {
  overflow: hidden; }

.kr-palette {
  width: 100%;
  max-width: 560px;
  background: #ffffff;
  border: 1px solid #e2e2e2;
  border-radius: 14px;
  box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
  overflow: hidden; }

.kr-palette-input {
  width: 100%;
  border: 0;
  outline: none;
  background: transparent;
  padding: 18px 20px;
  font-size: 17px;
  font-family: "Poppins", sans-serif;
  color: #252525;
  border-bottom: 1px solid #ececec;
  box-shadow: none !important; }

.kr-palette-list {
  max-height: 46vh;
  overflow-y: auto;
  padding: 8px; }

.kr-palette-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 12px;
  border-radius: 8px;
  text-decoration: none; }

.kr-palette-item.active {
  background: rgba(252, 96, 96, 0.1); }

.kr-palette-kind {
  flex-shrink: 0;
  width: 52px;
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: #999; }

.kr-palette-text {
  display: flex;
  flex-direction: column;
  min-width: 0; }

.kr-palette-title {
  font-size: 14.5px;
  font-weight: 500;
  color: #252525;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis; }

.kr-palette-item.active .kr-palette-title {
  color: #c53030; }

.kr-palette-sub {
  font-size: 12px;
  color: #8a8a8a;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis; }

.kr-palette-empty {
  padding: 24px 16px;
  text-align: center;
  font-size: 14px;
  color: #8a8a8a; }

.kr-palette-foot {
  display: flex;
  gap: 18px;
  justify-content: center;
  padding: 10px;
  border-top: 1px solid #ececec;
  font-size: 11px;
  color: #9a9a9a; }

[data-theme="dark"] .kr-palette {
  background: #222222;
  border-color: #3a3a3a; }

[data-theme="dark"] .kr-palette-input {
  color: #e2e2e2;
  border-bottom-color: #333; }

[data-theme="dark"] .kr-palette-item.active {
  background: rgba(252, 96, 96, 0.14); }

[data-theme="dark"] .kr-palette-title {
  color: #e2e2e2; }

[data-theme="dark"] .kr-palette-item.active .kr-palette-title {
  color: #fc6060; }

[data-theme="dark"] .kr-palette-foot {
  border-top-color: #333; }

/* Nav hint pill */
.kr-palette-hint {
  display: inline-flex;
  align-items: center;
  gap: 7px; }

.kr-palette-kbd {
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.05em;
  border: 1px solid currentColor;
  border-radius: 5px;
  padding: 1px 6px;
  opacity: 0.75; }

.kr-palette-word {
  display: none; }

/* In the mobile hamburger menu the hint becomes a plain "Search" item
   (keyboard shortcut label makes no sense on touch) */
@media (max-width: 991px) {
  .kr-palette-kbd {
    display: none; }
  .kr-palette-word {
    display: inline; } }</code></pre>
 </details>

 <div class="faq-section">
 <h2>Common questions</h2>

 <details class="faq-item">
 <summary>Why Ctrl+K and not Ctrl+F?</summary>
 <p>Ctrl+F belongs to the browser, and hijacking it is hostile: someone searching within the page they're reading should get exactly that. Ctrl+K is the established palette convention (editors, Slack, GitHub), and the bare <strong>/</strong> covers the other established convention (Wikipedia, YouTube). Never take shortcuts the browser already spent on something people use.</p>
 </details>

 <details class="faq-item">
 <summary>Does it work on phones?</summary>
 <p>Yes, via a search button in the navigation rather than a keyboard shortcut, with the same dialog underneath. Palettes are a keyboard-first idea, so on touch it's simply a decent search box, which is fine: the feature degrades into usefulness rather than absence.</p>
 </details>

 <details class="faq-item">
 <summary>Why not index the full text of posts?</summary>
 <p>Cost-benefit: a full-text index of 46 posts is a few hundred kilobytes shipped to everyone, to serve queries that title-and-excerpt matching already answers. If the blog someday holds hundreds of posts and I'm personally failing to find things, a pre-built Lunr index is the upgrade path. The palette's job today is navigation, not research.</p>
 </details>

 <details class="faq-item">
 <summary>Is 217 lines really the whole thing?</summary>
 <p>The whole behaviour, yes: shortcut, dialog, scoring, keyboard navigation, accessibility announcements. The styling is another 147 lines of CSS, included above. Most of what feels substantial about a palette is borrowed: the pages already exist, the posts file already exists, and the deep links already work. It's a thin, fast layer over structure the site already had, which is the recurring theme of every post in this series.</p>
 </details>
 </div>

        

 </main>

 <hr style="margin: 40px 0;">
 <p style="text-align: center;"><a class="post-cta" href="https://www.kenreid.co.uk/blog.html">Back to all posts</a></p>
 ]]></content:encoded>
    </item>
    <item>
      <title>The Paradox of Tolerance</title>
      <link>https://www.kenreid.co.uk/blog/the-paradox-of-tolerance.html</link>
      <guid>https://www.kenreid.co.uk/blog/the-paradox-of-tolerance.html</guid>
      <pubDate>Sun, 02 Aug 2026 00:00:00 +0000</pubDate>
      <description>Nazi salutes at Glasgow Green, a mile from the statue honouring Glaswegians who fought Franco. Karl Popper saw this coming in 1945: unlimited tolerance destroys itself. What the paradox actually says, what it doesn&#x27;t, and why it matters in Scotland right now.</description>
      <category>philosophy</category>
      <content:encoded><![CDATA[
 <h1>The Paradox of Tolerance</h1>
 <div class="blog-meta">
 2 August 2026 &middot;
 <span class="blog-tag">philosophy</span>
 </div>

 <p>On the banks of the Clyde in Glasgow there is a statue of Dolores Ibárruri, La Pasionaria, arms raised, with an inscription that reads "better to die on your feet than live forever on your knees". It honours the Glaswegians who volunteered to fight Franco's fascists in Spain in the 1930s, many of whom did not come home. In late July 2026, a short walk upriver at Glasgow Green, people at a rally were filmed performing Nazi salutes, and sadly more than one or two.</p>

 <p>The rally was organised by a group calling itself Unite the Clans, and around 1,700 people, by Police Scotland's count, turned up. So did counter-demonstrations organised by Stand Up To Racism and Women Against The Far Right. By the end of the day, footage circulating online showed the salutes, a non-white delivery worker being assaulted, and a crowd chanting "send them home" at a black passerby. Police Scotland made fifteen arrests on the day, on charges including possession of an offensive weapon, assault, and breach of the peace, and said plainly that some attendees had arrived intending "pre-meditated disorder, violence and intimidation". The First Minister called it "a demonstration of far-right hatred". A Chief Superintendent called the scenes "nothing short of disgraceful" and promised those involved that officers would be knocking on their doors. All of this happened while Glasgow was hosting Commonwealth Games delegations from seventy-four nations.</p>

 <figure>
 <img src="https://www.kenreid.co.uk/blog/img/tolerance/glasgow-green-rally.webp" alt="Composite of five photographs from the Glasgow Green rally: men performing Nazi salutes in front of Police Scotland officers, some masked, with Saltire and Union flags visible" loading="lazy" width="1200" height="1200">
 <figcaption class="figure-note">Scenes from the Unite the Clans rally at Glasgow Green, July 2026: salutes performed openly, in daylight, in front of police. Composite of press photographs and video stills circulated after the rally. &copy; the respective photographers and agencies. Used here for commentary and criticism.</figcaption>
 </figure>

 <p>I'm from Scotland. I grew up on the story that Scotland was always the "open arms" country, and the English were the racist, anti-immigrant fannies down south. I remember when I was younger, a teenager, I had a lot of arguments with friends and online on forums about freedom of speech. I was always told that Americans have "true" freedom of speech and can't be arrested for anything they say, while in the UK we are limited in what we can say. But the only examples these people could come up with were slurs, or inciting violence. They argued that "true freedom" should allow anyone to speak and say anything, and it has some merit, at least at first glance. This brings us to the topic of this article: the paradox of tolerance, which I think is widely misunderstood by both the people who invoke it and the people who dismiss it.</p>

 <div class="plain-english-box">
 <h2>Quick jargon guide</h2>
 <ul>
 <li><strong>Paradox of tolerance:</strong> the observation that a society which tolerates absolutely everything, including movements that aim to end tolerance, will eventually be destroyed by them. Total tolerance is therefore self-defeating.</li>
 <li><strong>The Open Society and Its Enemies:</strong> Karl Popper's 1945 defence of liberal democracy, written in exile during the war. The paradox appears in a footnote.</li>
 <li><strong>Militant democracy:</strong> the idea, born from Weimar Germany's collapse, that democracies may restrict the rights of movements that would abolish democracy itself. Modern Germany is built on it.</li>
 <li><strong>Stirring up hatred:</strong> the criminal offence category in Scotland's Hate Crime and Public Order (Scotland) Act 2021, in force since 2024, covering threatening or abusive behaviour intended to stir up hatred against protected groups.</li>
 <li><strong>Overton window:</strong> the range of ideas considered publicly acceptable at a given moment. It moves, in both directions, depending on what goes unchallenged.</li>
 </ul>
 </div>

 <h2>What Popper actually wrote</h2>

 <p>Karl Popper was a Viennese philosopher of Jewish descent who fled Austria ahead of the Anschluss and spent the war years teaching in New Zealand, writing what he called his "war effort": a philosophical defence of open societies against their enemies. <em>The Open Society and Its Enemies</em> came out in 1945. The famous passage is not in the main text, it sits in a footnote to chapter seven, as if Popper considered the point almost too obvious to argue at length:</p>

 <blockquote>
 <p>Unlimited tolerance must lead to the disappearance of tolerance. If we extend unlimited tolerance even to those who are intolerant, if we are not prepared to defend a tolerant society against the onslaught of the intolerant, then the tolerant will be destroyed, and tolerance with them.</p>
 <cite>&mdash; Karl Popper, <em>The Open Society and Its Enemies</em></cite>
 </blockquote>

 <p>It fits on a placard and it appears in my social media feeds every time something like Glasgow Green happens. But Popper kept writing:</p>

 <blockquote>
 <p>In this formulation, I do not imply, for instance, that we should always suppress the utterance of intolerant philosophies; as long as we can counter them by rational argument and keep them in check by public opinion, suppression would certainly be most unwise. But we should claim the right to suppress them if necessary even by force; for it may easily turn out that they are not prepared to meet us on the level of rational argument, but begin by denouncing all argument; they may forbid their followers to listen to rational argument, because it is deceptive, and teach them to answer arguments by the use of their fists or pistols. We should therefore claim, in the name of tolerance, the right not to tolerate the intolerant.</p>
 <cite>&mdash; Karl Popper, <em>The Open Society and Its Enemies</em></cite>
 </blockquote>

 <p>Popper is not saying "silence anyone whose views you find intolerant". He is saying almost the opposite: while a movement is willing to argue, argue back, and suppression is "most unwise". The right to suppress is held in reserve for the moment a movement exits the argument, when it answers speech with fists.</p>

 <h2>The peace treaty framing</h2>

 <p>The cleanest way out of the paradox is to stop treating tolerance as a virtue and start treating it as an agreement, a reframing I owe to Yonatan Zunger's essay <a href="https://medium.com/@yonatanzunger/tolerance-is-not-a-moral-precept-1af7007d6376" rel="noopener" target="_blank">"Tolerance is not a moral precept"</a>, which calls tolerance a peace treaty, and reminds us that a peace treaty is not a suicide pact. A virtue is something you must extend to everyone unconditionally, which is how you end up politely platforming people who want you dead. An agreement, however, is different. Tolerance is a mutual pact: I accept your right to live as you choose, and you accept mine. Like any pact, it protects the people who keep it. Someone who performs a Nazi salute in a public park is not exercising the pact; they are announcing, in the most legible gesture the twentieth century produced, that they have left it. The paradox dissolves once you see it this way. You are not being intolerant when you refuse to tolerate fascism, any more than you are being violent when you defend yourself from a punch. </p>

 <p>I like this framing because it answers the smug gotcha ("so much for the tolerant left!") without any philosophical hand-waving, and because it matches how every other agreement in life works. Nobody thinks a referee is betraying the spirit of football by ever sending players off.</p>

 <h2>Where the threshold sits</h2>

 <p>Where exactly is the line?</p>

 <p>Drawing the line too tightly is of course problematic, where every uncomfortable opinion becomes "intolerance" and suddenly a government can use "hate speech" as a silencing tool. This is the slippery slope the free speech absolutists worry about. Whoever holds the definition of "intolerable" today will not hold it forever, and a rule you would not want your worst enemy to wield is a rule you should hesitate to create. It's why Popper's default is argument and public opinion, with suppression as a reserve power rather than a first resort.</p>

 <p>Drawing the line so loosely that nothing short of actual violence ever crosses it, by which point the argument phase is long over, is also problematic. Weimar Germany had a liberal constitution, a functioning press, and elections, and a movement that used all three while openly promising to abolish them. The people who watched that happen did not conclude that more debate was needed. Post-war Germany clearly learned this lesson, as exhibited by their new laws: the Nazi salute is a criminal offence, parties hostile to the constitutional order can be banned, and this framework (they call it militant democracy) has coexisted with one of the freest societies in Europe for eighty years. The slippery slope has guardrails you can build.</p>

 <p>A salute that means "the Holocaust was good, actually", performed by a crowd that assaulted a delivery worker for his skin colour, is not a contribution to public discourse that we must meet with rational argument. It is the fists-instead-of-arguments moment Popper described. The counter-protesters, the fifteen arrests, and the door-knocking that Police Scotland promised are society using proportionate, lawful force to defend the conditions under which everyone else gets to keep arguing. But I don't think this is the end of it. </p>

 <h2>What Scotland's law actually does</h2>

 <p>People are often surprised to learn that the UK, unlike Germany, does not ban the Nazi salute as a gesture. What we have instead is a patchwork: public order offences covering threatening and abusive behaviour, and in Scotland the Hate Crime and Public Order (Scotland) Act 2021, which since 2024 has criminalised behaviour intended to stir up hatred against protected groups. Whether a given salute is criminal depends on context: who saw it, what accompanied it, what it was intended to do. This context-sensitivity is frustrating to people who want a bright line, but it is also recognisably Popper's structure translated into statute: the gesture alone is speech, the gesture as part of an intimidation campaign is conduct, and the law reserves its force for the latter. A teacher showing a class what the salute is and educating on the fascism that it implies is very different from a crowd doing it at an anti-immigration (or really, anti-black) rally.</p>

 <p>Whether the patchwork is enough is a live political question in Scotland right now. What I am confident about is the direction of the burden. The question is not "can we justify restricting the fascists?" It is "can a society that watched the twentieth century happen justify treating open fascist organising as ordinary politics?" Eighty years of German experience says restriction is compatible with freedom. Ten years of watching the far right grow on unmoderated platforms says indifference is not.</p>

 <h2>The view from the receiving end</h2>

 <p>On the Saturday afternoon of the rally, a 31-year-old delivery driver named Muhammad Jawad Yaqoob picked up an order from the Palm Tree Kitchen on Bridge Street, just south of the river, as dozens of demonstrators went past chanting "get them out". <a href="https://www.bbc.com/news/articles/cm2g6yj0qzlo" rel="noopener" target="_blank">He later told the BBC</a> what happened next: a man shouted "you brownie, you Asian, you Pakistani" at him, and another threw a traffic cone at him while the group cheered. He ran behind a nearby restaurant. Then his phone buzzed: Uber's app, knowing nothing, was asking him to complete the delivery. So he did. He delivered the food, went back to ask the shop for CCTV footage (it had none), and reported the attack to the police anyway.</p>

 <figure>
 <img src="https://www.kenreid.co.uk/blog/img/tolerance/muhammad-jawad-yaqoob.webp" alt="Muhammad Jawad Yaqoob, a man with black hair and stubble in a dark blazer and light blue shirt, photographed in an office, looking at the camera with a neutral expression" loading="lazy" width="1200" height="674">
 <figcaption class="figure-note">Muhammad Jawad Yaqoob, interviewed by BBC Scotland News in the days after the attack. The BBC's report includes the 42-second video of the assault; fair warning, it is unpleasant viewing. &copy; BBC. Used here for commentary and criticism.</figcaption>
 </figure>

 <p>By the time he got home, footage of the attack had spread across social media, as far as Pakistan, where he is from. Days later he had still not returned to work, though he knows he will have to: "I have bills to pay so I can't just sit at home." He told the BBC that until that weekend he had found Glasgow a friendly place. "Everybody gives me tips on my deliveries and says hello, greeting me with smiley faces. But after this incident I feel scared, I don't feel like I know this version of Glasgow. Police have not protected me and there has been no further action." Fifteen arrests on the day is the aggregate response working roughly as designed. The man who threw the cone was, as of the BBC's report, still only "being investigated". Society-level self-defence and individual protection are not the same thing, and the gap between them is where people like Yaqoob actually live.</p>

 <p>Dr Zubir Ahmed, the Labour MP for Glasgow South West, asked the question this whole essay has been circling: "How did we get here? How have we reached a point where people in one of the most diverse communities in Scotland now think twice about stepping outside?" His answer is accurate: "We've been sold a story about ourselves: that racism here in Scotland isn't really as bad." And: "We fix nothing by fooling ourselves that we're better than others." A self-image inherited from your grandparents is not a property you personally possess; it is a standard you either meet or fail. The Justice Secretary, Neil Gray, promised that "people will be brought to justice". Yaqoob, whose attacker cheered and walked away, is still the one attacked in the street, still afraid to go to his job, while we discuss political philosophy. That makes me feel deep shame in the systems in Scotland that fail to protect and help people like Yaqoob thrive. It makes me feel a deep sadness for what I thought I missed: a Scotland that is welcoming, arms open to people who aren't part of its white majority. It makes me wonder if I'd return with my partner, who, unlike me, is not Scottish and is not white. Would Scotland be a welcoming place for her like I thought, or would she be attacked on the street?</p>

 <h2>The part that is ours to do</h2>

 <p>Popper's first line of defence was "rational argument and public opinion", which is to say: us. The Overton window does not move because bad people are strong. It moves because ordinary people recalibrate what they will let pass in a pub, a group chat, a family dinner, a football stand. Every "it's just banter" is a data point in someone's model of what this society permits. The counter-demonstration is the visible version of this, and Glasgow's showed up in numbers.</p>

 <p>La Pasionaria's statue has stood on the Clyde since 1980, paid for by public subscription because the city council of the day would not fund it, which is its own reminder that Glasgow's anti-fascism was always the people's project before it was official. The men it commemorates did not go to Spain because they had read the footnotes of a book that would not be written for another decade. They went because they understood, without needing the philosophy, that some movements do not stay in the argument phase, and that the time to stop them is while stopping them is still cheap. The salutes at Glasgow Green are a test of whether we still understand it. </p>

 <div class="faq-section">
 <h2>Common questions</h2>

 <details class="faq-item">
 <summary>Isn't "not tolerating intolerance" just censorship with better branding?</summary>
 <p>Popper's explicit default is to counter intolerant ideas with argument and public opinion, and he calls suppression of mere utterance "most unwise". The reserve power applies when a movement abandons argument for intimidation and violence, which is a conduct test, not an opinion test. A society that suppresses views it merely dislikes has misread the paradox; a society that cannot defend itself against organised political violence has also misread it.</p>
 </details>

 <details class="faq-item">
 <summary>Doesn't banning symbols just make martyrs and drive movements underground?</summary>
 <p>Sometimes, which is why context matters more than blanket bans. But the strongest counter-evidence is Germany itself: the country with the strictest prohibition on Nazi symbols has spent eighty years as a stable, free democracy, and its far right organises around the ban's edges rather than gaining strength from it. Meanwhile the "sunlight is the best disinfectant" theory has had an extended, well-funded trial on global social media platforms, and the results are not encouraging. Sunlight disinfects arguments; it also feeds propaganda.</p>
 </details>

 <details class="faq-item">
 <summary>Is a Nazi salute actually illegal in Scotland?</summary>
 <p>Not as a gesture in isolation, unlike in Germany or Austria. It can be criminal in context: as threatening or abusive behaviour under public order law, or under the stirring-up-hatred offences in the Hate Crime and Public Order (Scotland) Act 2021, depending on intent and circumstances. That is why the arrests at Glasgow Green were for offences like assault, weapons possession, and breach of the peace rather than "saluting". Whether Scotland should follow the German model is a live debate.</p>
 </details>

 <details class="faq-item">
 <summary>What should I actually do when I see this stuff?</summary>
 <p>Don't share the footage raw (it is often exactly the recruitment material the organisers wanted); report identifiable crimes to Police Scotland; support the organisations that do the unglamorous monitoring work, like HOPE not hate; show up to counter-demonstrations if you safely can, because turnout numbers are the message; and hold the line in the small rooms, the pub and the group chat, where the Overton window actually lives. The culture is handled by us or not at all.</p>
 </details>
 </div>

 <p class="figure-note">Hero image: Arthur Dooley's La Pasionaria memorial to the International Brigades on Custom House Quay, Glasgow. Photograph by Alex Liivet, via <a href="https://commons.wikimedia.org/wiki/File:La_Pasionaria_(50418477533).jpg" rel="noopener" target="_blank">Wikimedia Commons</a> (CC0, public domain).</p>

        

 </main>
 <hr style="margin: 40px 0;">
 <p style="text-align: center;"><a class="post-cta" href="https://www.kenreid.co.uk/blog.html">Back to all posts</a></p>
 ]]></content:encoded>
    </item>
    <item>
      <title>The Fermi Paradox - Why are we alone in space?</title>
      <link>https://www.kenreid.co.uk/blog/the-fermi-paradox-and-the-great-silence.html</link>
      <guid>https://www.kenreid.co.uk/blog/the-fermi-paradox-and-the-great-silence.html</guid>
      <pubDate>Tue, 28 Jul 2026 00:00:00 +0000</pubDate>
      <description>The galaxy is old and vast and should be teeming, and it is silent. A tour of the Fermi paradox&#x27;s unsettling answers through the science fiction that dramatises them, from Liu Cixin&#x27;s dark forest to Banks&#x27;s Culture.</description>
      <category>science</category>
      <category>books</category>
      <content:encoded><![CDATA[
 <h1>The Fermi Paradox - Why are we alone in space?</h1>
 <div class="blog-meta">
 28 July 2026 &middot;
 <span class="blog-tag">science</span>
 <span class="blog-tag">books</span>
 </div>

 <p>One day in the summer of 1950, the physicist Enrico Fermi was walking to lunch at Los Alamos with Edward Teller, Emil Konopinski, and Herbert York. The conversation on the way over touched on a recent flap of flying saucer reports and a New Yorker cartoon blaming the saucers for a spate of missing New York City bins. They sat down, the talk moved on to ordinary things, and then partway through the meal Fermi burst out, apparently from nowhere: "But where is everybody?" And everyone at the table laughed, because everyone at the table knew exactly what he meant. The conversation had never really ended in his head. He had been running the numbers.</p>

 <p>The Milky Way is around thirteen billion years old and contains somewhere between one hundred and four hundred billion stars. We now know, thanks to the exoplanet hunters (what a cool title), that planets are not rare at all; most stars have them, and rocky worlds in temperate orbits number in the billions in our galaxy alone. To be clear about these numbers, think of how many grains of sand you can pick up with your hand. Then think how many are on a beach. Then in all the oceans and seas. Then on all of Earth. Already an impossible number to consider, right? Well, scientists approximate that for every grain of sand on Earth, there are more than 10,000 stars in the sky. Our early estimates of the number of planets are double that.</p>

<figure style="margin: 24px auto; display: flex; flex-direction: column; align-items: center; text-align: center;">
 <div style="position: relative; width: 100%; aspect-ratio: 16/10; min-height: 340px;">
 <iframe src="https://scaleofuniverse.com/en"
         title="The Scale of the Universe: an interactive zoom from the smallest known things to the observable universe"
         loading="lazy"
         referrerpolicy="strict-origin-when-cross-origin"
         allowfullscreen
         style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: 0; border-radius: 4px;"></iframe>
 </div>
 <figcaption style="margin-top: 8px; font-size: 0.8em;">
 If numbers like "four hundred billion" refuse to mean anything (they do for me), scroll through this: <a href="https://scaleofuniverse.com/en" target="_blank" rel="noopener noreferrer">The Scale of the Universe</a> by Cary and Michael Huang, embedded here interactively. Zoom out until the Milky Way is a speck.
 </figcaption>
 </figure>
 
 <p>Even at sub-light speeds, a single patient, expansionist civilisation could settle the entire galaxy in a few million years, which is an eyeblink cosmically. Run those figures with even mildly generous assumptions and the sky should be crowded, humming, obviously inhabited many times over. Instead: silence. Nobody visiting, nobody broadcasting radio signals, light, anything, nobody leaving ruins we can spot. The astronomer David Brin gave that silence its proper name in a 1983 paper: the Great Silence. The problem of "should be teeming" but actually "seems empty" is the Fermi paradox. There are answers to the paradox, but they are unsettling.</p>

 <p>I've spent a large part of my reading life inside science fiction that explores these answers. So this is a tour of the Great Silence with my own bookshelf as the guide, and I'm going to let the books speak in their own words as we go.</p>

 <div class="plain-english-box">
 <h2>Quick jargon guide</h2>
 <ul>
 <li><strong>Fermi paradox:</strong> the tension between the apparent high likelihood of alien civilisations and our total lack of evidence for any.</li>
 <li><strong>Drake equation:</strong> a famous attempt to estimate the number of contactable civilisations by multiplying a chain of probabilities, most of which we can't yet measure.</li>
 <li><strong>Great Filter:</strong> a hypothesised barrier that stops almost all life from reaching a spacefaring stage. It may be behind us, or ahead.</li>
 <li><strong>Dark forest hypothesis:</strong> the idea that the universe is silent because any civilisation that reveals itself risks destruction, so everyone hides.</li>
 <li><strong>Von Neumann probes:</strong> hypothetical self-replicating spacecraft that could explore a whole galaxy from a single origin, which sharpens the paradox.</li>
 <li><strong>SETI and METI:</strong> the search for extraterrestrial intelligence (listening) and messaging extraterrestrial intelligence (deliberately transmitting). One is uncontroversial; the other very much is not.</li>
 </ul>
 </div>

 <h2>The case for a crowded sky</h2>

 <p>In 2003 and 2004, astronomers pointed the Hubble Space Telescope at a patch of sky in the constellation Fornax about as big as a grain of sand held at arm's length. They chose it as there were no bright stars, nothing in the way, a keyhole into the dark. The telescope stared at that keyhole for the better part of a million seconds, and this is what came back:</p>

 <figure>
 <img src="https://www.kenreid.co.uk/blog/img/fermi/hubble-ultra-deep-field.webp" alt="The Hubble Ultra Deep Field: thousands of galaxies of every shape and colour scattered across a black patch of sky" loading="lazy">
 <figcaption>The Hubble Ultra Deep Field. Nearly every point of light here is not a star but an entire galaxy, roughly ten thousand of them, in a patch of sky the apparent size of a sand grain at arm's length. Image: NASA, ESA, S. Beckwith (STScI) and the HUDF Team, 2004 (public domain), via <a href="https://esahubble.org/images/heic0406a/" target="_blank" rel="noopener noreferrer">ESA/Hubble</a>.</figcaption>
 </figure>

 <p>Roughly ten thousand galaxies, each one a hundred billion stars or so. Multiply that keyhole across the whole sky and you get the modern estimate of a couple of trillion galaxies in the observable universe. Arthur C. Clarke, writing in <em>Rendezvous with Rama</em> back in 1973, had this wonderful line on what numbers like that imply:</p>

 <blockquote>
 <p>If such a thing had happened once, it must surely have happened many times in this galaxy of a hundred billion suns.</p>
 <cite>&mdash; Arthur C. Clarke, <em>Rendezvous with Rama</em></cite>
 </blockquote>

 <p>In 1961 the radio astronomer Frank Drake tried to make the argument rigorous, or at least organised, with the equation that now bears his name: take the rate of star formation, multiply by the fraction of stars with planets, the fraction of planets that could host life, the fraction that do, the fraction where life becomes intelligent, the fraction of those that build detectable technology, and the length of time such civilisations remain detectable. The Drake equation is less a calculation than a way of organising our ignorance, since several of those terms are still complete unknowns, but the point stands: unless one of the terms is savagely close to zero, the galaxy should host many civilisations right now. In full, it looks like this:</p>

 <figure style="margin: 24px auto; text-align: center;">
 <div id="drake-equation" style="font-size: 1.25em; overflow-x: auto; padding: 4px 0;">
 <span style="font-style: italic;">N</span> = <span style="font-style: italic;">R</span><sub>*</sub> &middot; <span style="font-style: italic;">f</span><sub>p</sub> &middot; <span style="font-style: italic;">n</span><sub>e</sub> &middot; <span style="font-style: italic;">f</span><sub>l</sub> &middot; <span style="font-style: italic;">f</span><sub>i</sub> &middot; <span style="font-style: italic;">f</span><sub>c</sub> &middot; <span style="font-style: italic;">L</span>
 </div>
 <div style="overflow-x: auto; margin-top: 14px;">
 <table style="margin: 0 auto; border-collapse: collapse; font-size: 0.85em; text-align: left;">
 <thead>
 <tr>
 <th scope="col" style="padding: 6px 12px; border-bottom: 2px solid #999;">Term</th>
 <th scope="col" style="padding: 6px 12px; border-bottom: 2px solid #999;">Meaning</th>
 <th scope="col" style="padding: 6px 12px; border-bottom: 2px solid #999;">Estimate</th>
 </tr>
 </thead>
 <tbody>
 <tr><td style="padding: 6px 12px; border-bottom: 1px solid #99999955;"><em>R</em><sub>*</sub></td><td style="padding: 6px 12px; border-bottom: 1px solid #99999955;">new stars formed in the galaxy per year</td><td style="padding: 6px 12px; border-bottom: 1px solid #99999955;">1.5&ndash;3 (measured)</td></tr>
 <tr><td style="padding: 6px 12px; border-bottom: 1px solid #99999955;"><em>f</em><sub>p</sub></td><td style="padding: 6px 12px; border-bottom: 1px solid #99999955;">fraction of stars with planets</td><td style="padding: 6px 12px; border-bottom: 1px solid #99999955;">&asymp;1 (measured)</td></tr>
 <tr><td style="padding: 6px 12px; border-bottom: 1px solid #99999955;"><em>n</em><sub>e</sub></td><td style="padding: 6px 12px; border-bottom: 1px solid #99999955;">habitable-zone rocky planets per system</td><td style="padding: 6px 12px; border-bottom: 1px solid #99999955;">0.1&ndash;0.4 (estimated)</td></tr>
 <tr><td style="padding: 6px 12px; border-bottom: 1px solid #99999955;"><em>f</em><sub>l</sub></td><td style="padding: 6px 12px; border-bottom: 1px solid #99999955;">fraction of those where life appears</td><td style="padding: 6px 12px; border-bottom: 1px solid #99999955;">unknown; guesses span 10<sup>&minus;8</sup>&ndash;1</td></tr>
 <tr><td style="padding: 6px 12px; border-bottom: 1px solid #99999955;"><em>f</em><sub>i</sub></td><td style="padding: 6px 12px; border-bottom: 1px solid #99999955;">fraction where life becomes intelligent</td><td style="padding: 6px 12px; border-bottom: 1px solid #99999955;">unknown; guesses span 10<sup>&minus;9</sup>&ndash;1</td></tr>
 <tr><td style="padding: 6px 12px; border-bottom: 1px solid #99999955;"><em>f</em><sub>c</sub></td><td style="padding: 6px 12px; border-bottom: 1px solid #99999955;">fraction that build detectable technology</td><td style="padding: 6px 12px; border-bottom: 1px solid #99999955;">a guess; 0.1&ndash;1</td></tr>
 <tr><td style="padding: 6px 12px;"><em>L</em></td><td style="padding: 6px 12px;">years a civilisation stays detectable</td><td style="padding: 6px 12px;">10<sup>2</sup>&ndash;10<sup>8</sup></td></tr>
 </tbody>
 </table>
 </div>
 <figcaption style="margin-top: 10px; font-size: 0.8em;">
 <em>N</em> is the number of civilisations we could hear from right now. Insert optimistic parameters and <em>N</em> comes out in the millions for our galaxy alone; feed the bleak ends through and <em>N</em> falls below one for the entire observable universe. At the original 1961 Green Bank meeting, the group's estimates for the middle terms multiplied out to roughly one, leaving the famous shorthand <em>N</em> &asymp; <em>L</em>: there are as many detectable civilisations as the number of years a typical one manages to stay detectable. Our own <em>L</em> currently stands at about a century.
 </figcaption>
 </figure>

 <p>You don't need <em>many</em> civilisations for the sky to fill up, though, you really only need one. A single civilisation that builds self-replicating von Neumann probes, machines that land on an asteroid, copy themselves, and send the copies onward, could have surveyed every star system in the galaxy in a few million years. The galaxy has had thousands of times that long. One ambitious species, ever, anywhere, at any point in thirteen billion years, and the evidence should already be very obvious and easy to find. As far as we can tell, it's not there. But why?</p>

 <h2>Are we just not listening hard enough?</h2>

 <p>The modern search started in 1960, when Drake pointed a radio telescope at the empty sky and heard nothing. Since then we've had tantalising blips (the famous "Wow!" signal of 1977 arrived, looked exactly like what a beacon might look like, and never repeated) and decades of patchy funding; for most of its history, the search for extraterrestrial intelligence has survived on spare telescope time and philanthropy.</p>

 <p>The great exception is <a href="https://breakthroughinitiatives.org/initiative/1" target="_blank" rel="noopener noreferrer">Breakthrough Listen</a>, the most serious listening effort our species has ever mounted. It launched in 2015, announced at the Royal Society with Stephen Hawking on stage, and backed with $100 million of the investor Yuri Milner's money over ten years, which bought guaranteed, sustained time on world-class instruments rather than scraps of it. The Green Bank Telescope in West Virginia, the Murriyang dish at Parkes in Australia, and later the 64-antenna MeerKAT array in South Africa have been working through a million nearby stars, sweeping the plane of our galaxy, and sampling a hundred nearby galaxies, listening across billions of radio channels at a time, with the petabytes of data made public so that anyone can go hunting through them. That original ten-year observing window is closing about now, and so far we have found: not one confirmed technosignature.</p>

 <p>In 2019, thirty hours of Parkes data aimed at Proxima Centauri, the nearest star to the Sun, turned out to contain a narrowband signal near 982 MHz confined to a single channel, drifting slightly in frequency as a transmitter on a moving world would, present when the dish pointed at the star and gone when it nodded away. It was promising enough to earn a name, BLC1, for Breakthrough Listen Candidate 1. A year of forensic analysis later, the team traced it to radio interference from human-made electronics misbehaving somewhere near the telescope. </p>

 <p>Even Listen, though, is a drop in the pool. The search space is monstrous: billions of stars, billions of frequencies, and every moment you aren't pointed at the right star on the right channel is a moment a signal could slip past. The SETI pioneer Jill Tarter's favourite analogy is that concluding the ocean has no fish after examining one glass of seawater would be premature, and by recent estimates our combined searching so far amounts to roughly a hot tub's worth of the ocean. So "silence" really means "silence in the tiny slice we've checked". But maybe we're lucky not to have found them.</p>

 <h2>Answer one: they don't care</h2>

 <p>Maybe they're out there, and the distances and timescales are simply too vast for overlap. Civilisations may flicker on and off across the galaxy like fireflies on a summer night, each one blazing for ten thousand years and gone, separated from its nearest neighbour by both hundreds of light-years and hundreds of millennia. Nothing needs to be wrong with the universe for this to hold (look at what we're doing to Earth and our own ecosystem, maybe this is how intelligent life goes?). Or maybe, they're out there, but they just don't care about us. Perhaps we're not of interest: we're not smart enough, or we're considered like algae in space. </p>

 <p>This is the ache at the centre of <em>Rendezvous with Rama</em>. A fifty-kilometre cylindrical artefact coasts into the solar system, and humanity scrambles to intercept it, explore it, understand it. Rama is not hostile, not benevolent, not curious. It slingshots around the sun, refuels, and leaves without ever acknowledging that we exist, a stranger passing through the room on the way to somewhere else. Contact as a near-miss with something that was never looking for us at all. Clarke ends the book with:</p>

 <blockquote>
 <p>And on far-off Earth, Dr. Carlisle Perera had as yet told no one how he had wakened from a restless sleep with the message from his subconscious still echoing in his brain: The Ramans do everything in threes.</p>
 <cite>&mdash; Arthur C. Clarke, <em>Rendezvous with Rama</em></cite>
 </blockquote>

 <p>Whatever Rama's builders were doing, humans were not the audience for it, and two more of the things are presumably out there not caring about us either.</p>

 <h2>Answer two: the Great Filter is behind us</h2>

 <p>In 1996 the economist Robin Hanson described this: somewhere along the path from dead chemistry to galaxy-spanning civilisation there must be at least one step so improbable that almost nothing makes it through, because if the whole path were easy, the galaxy would be full. He called it the Great Filter, and the only question that matters is whether it's behind us or ahead of us.</p>

 <p>The comforting version is that it's behind us. There are several candidate steps that might be almost impossibly hard. The origin of life itself, perhaps: we still cannot make it happen in a lab, and we have exactly one confirmed instance. Or the jump from simple cells to complex ones, which on Earth appears to have happened exactly once in four billion years, when one microbe swallowed another and the pair struck the ancient bargain that became the mitochondria powering every animal, plant, and fungus you have ever seen. Life appeared on Earth almost as soon as the planet cooled, then spent ~two billion years, half the planet's history, as single-celled scum before that one lucky deal. If that step is the filter, then the galaxy may be awash with bacteria and empty of anyone to talk to, and we are the rarest thing in a hundred billion star systems. That's not so bad, right?</p>

 <figure>
 <img src="https://www.kenreid.co.uk/blog/img/fermi/pillars-of-creation-jwst.webp" alt="The Pillars of Creation photographed by the James Webb Space Telescope: towering columns of brown and orange interstellar gas and dust studded with newly formed stars" loading="lazy">
 <figcaption>The Pillars of Creation in the Eagle Nebula, imaged by the James Webb Space Telescope. New stars are condensing inside these columns of gas ~6,500 light-years away. Image: NASA, ESA, CSA, STScI; J. DePasquale, A. Koekemoer, A. Pagan (STScI), 2022 (free to use with attribution), via <a href="https://esawebb.org/images/weic2216a/" target="_blank" rel="noopener noreferrer">ESA/Webb</a>.</figcaption>
 </figure>

 <p>It would mean the silence isn't a graveyard or a threat; it's just an empty house that we happen to be the first ones to move in. It would make us precious, and it would make every human failure to look after ourselves and each other a cosmic-scale act of vandalism. There's a well-known corollary, due to the philosopher Nick Bostrom, that follows with uncomfortable force: if we ever find independent microbial life on Mars or Europa, that would be terrible news, because it would mean early steps are easy, which pushes the filter forward, towards us.</p>

 <h2>Answer three: the Great Filter is ahead of us</h2>

 <p>Maybe the hard steps are all behind every civilisation that reaches our stage, and the filter is what comes next. Maybe technological species reliably destroy themselves (nuclear war, climate collapse, pandemics, their own runaway inventions) within a few centuries of becoming detectable. Every silent star could be a civilisation that got exactly as far as we have and no further. On this reading, the most dangerous period in the life of any intelligent species is the one we are living through right now: the window between inventing world-ending tools and developing the wisdom to not use them.</p>

 <p>There's a line in Liu Cixin's <em>The Three-Body Problem</em>, delivered to humanity from a civilisation that has already survived horrors we can't imagine, that could serve as this filter's motto:</p>

 <blockquote>
 <p>Your lack of fear is based on your ignorance.</p>
 <cite>&mdash; Liu Cixin, <em>The Three-Body Problem</em></cite>
 </blockquote>

 <p>What I find clarifying about the filter-ahead hypothesis is that it converts an astronomy question into an ethics question. If it's true, then the Fermi paradox isn't about them at all. It's a mirror held up to us, and our destiny may be in our hands.</p>

 <h2>Answer four: the dark forest</h2>

 <p>Liu's sequel, <em>The Dark Forest</em>, builds an entire cosmology from two bleak axioms: survival is the primary need of any civilisation, and the universe's resources are finite. Add two multipliers, the impossibility of ever verifying another civilisation's intentions across light-years (he calls this the chain of suspicion) and the fact that technology can leap unpredictably, so today's harmless neighbour may be unstoppable in a century, and the game-theoretic conclusion grinds out a clear answer: the only safe response to detecting another civilisation is to destroy it before it can destroy you. The universe is silent because everyone who survived long enough to be asked has already worked this out. Here is the passage where the book finally says it aloud, and it's one of the great chilling paragraphs in modern science fiction:</p>

 <blockquote>
 <p>The universe is a dark forest. Every civilization is an armed hunter stalking through the trees like a ghost, gently pushing aside branches that block the path and trying to tread without sound. Even breathing is done with care. The hunter has to be careful, because everywhere in the forest are stealthy hunters like him. If he finds other life&mdash;another hunter, an angel or a demon, a delicate infant or a tottering old man, a fairy or a demigod&mdash;there's only one thing he can do: open fire and eliminate them. In this forest, hell is other people. An eternal threat that any life that exposes its own existence will be swiftly wiped out. This is the picture of cosmic civilization. It's the explanation for the Fermi Paradox.</p>
 <cite>&mdash; Liu Cixin, <em>The Dark Forest</em></cite>
 </blockquote>

 <p>It's game theory as horror, a prisoner's dilemma with extinction as the payoff matrix, and it's completely logical, which is terrifying.</p>

 <p>Hiding probably doesn't work: Earth's atmosphere has been shouting "biosphere here" in oxygen, detectable by any sufficiently good telescope, for two billion years, long before anyone here could decide to whisper. Interstellar attacks are neither cheap nor risk-free, and a civilisation that attacks every signal it hears reveals itself with every shot. Cooperation, verification, and deterrence all have more room to operate than the novel allows. Most researchers treat the dark forest as a vivid corner case rather than a likely equilibrium. But as a <em>possibility</em>, it is terrifying, because the scariest monsters are the ones made entirely of sound reasoning and logical choices.</p>

 <h2>Answer five: they're everywhere, and we're the ant by the motorway</h2>

 <p>Maybe the galaxy is roaring with civilisation and we simply aren't the kind of thing that can hear it, the way an ant colony beside a motorway has no concept of the traffic. Advanced intelligences may no more use radio than we use flag waving to talk to satellites; they may have migrated to substrates and scales we can't perceive, turned inward into simulated worlds, or be waiting out the present era of the universe entirely (there's a delightfully strange proposal called the aestivation hypothesis on which they're sleeping until the cosmos cools enough to compute efficiently). The zoo hypothesis, where they know about us and deliberately leave us alone like a protected reserve, lives in this family too.</p>

 <p>This is the territory of <a href="https://www.kenreid.co.uk/blog/the-culture-series.html">the Culture novels</a> I love so much. Iain M. Banks's galaxy is the opposite of Liu's: it <em>is</em> teeming, gloriously, with a post-scarcity civilisation run by benevolent AI Minds whose intelligence is to ours roughly what ours is to a beetle's. Banks understood what an encounter between mismatched civilisations means for the smaller one. His novel <em>Excession</em> is named for the problem, and contains this wonderful passage:</p>

 <blockquote>
 <p>An Outside Context Problem was the sort of thing most civilisations encountered just once, and which they tended to encounter rather in the same way a sentence encountered a full stop. The usual example given to illustrate an Outside Context Problem was imagining you were a tribe on a largish, fertile island; you'd tamed the land, invented the wheel or writing or whatever, the neighbours were cooperative or enslaved but at any rate peaceful and you were busy raising temples to yourself with all the excess productive capacity you had, you were in a position of near-absolute power and control which your hallowed ancestors could hardly have dreamed of and the whole situation was just running along nicely like a canoe on wet grass . . . when suddenly this bristling lump of iron appears sailless and trailing steam in the bay and these guys carrying long funny-looking sticks come ashore and announce you've just been discovered, you're all subjects of the Emperor now, he's keen on presents called tax and these bright-eyed holy men would like a word with your priests.</p>
 <cite>&mdash; Iain M. Banks, <em>Excession</em></cite>
 </blockquote>

 <p>The irony of the Culture books is that their galaxy would look silent to us too. The Minds don't broadcast omnidirectional radio beacons; why would they? Our instruments searching for someone like Banks's galaxy are like a toddler trying to read someone's Ph.D. thesis. Clarke got at the same humility half a century earlier in <em>2001: A Space Odyssey</em>, where the alien presence is a featureless black slab that reshapes the destiny of a species, and the astronauts approaching its big brother remind themselves of the only sensible etiquette for meeting something beyond you:</p>

 <blockquote>
 <p>It was the mark of a barbarian to destroy something one could not understand.</p>
 <cite>&mdash; Arthur C. Clarke, <em>2001: A Space Odyssey</em></cite>
 </blockquote>

 <p>Liu's hunters shoot because they cannot understand each other. Clarke's civilisation treats incomprehension as the reason to hold fire. Same premise, opposite ethics.</p>

 <h2>The gentler bets</h2>

 <p>The genre isn't all cosmic dread, and neither are the serious answers. Some resolutions to the paradox are almost cheerful: maybe contact is rare and hard but fine when it happens. My favourite dramatisation of the hopeful case is Andy Weir's <em>Project Hail Mary</em>, in which first contact turns out to be two frightened engineers from different biospheres, stranded in the same star system by the same catastrophe, solving problems together with duct tape and good faith. It's the anti-dark-forest: two civilisations meet in deep space, neither knows anything about the other, and instead of opening fire they build a shared vocabulary and fix each other's ships:</p>

 <blockquote>
 <p>Human beings have a remarkable ability to accept the abnormal and make it normal.</p>
 <cite>&mdash; Andy Weir, <em>Project Hail Mary</em></cite>
 </blockquote>

 <p>And the moment the book is remembered for, the first physical gesture ever exchanged between two intelligent species, is not a weapons lock. It's the alien engineer Rocky pressing a claw to the divider between their incompatible atmospheres:</p>

 <blockquote>
 <p>He puts his claw against the divider. "Fist my bump."</p>
 <cite>&mdash; Andy Weir, <em>Project Hail Mary</em></cite>
 </blockquote>

 <p>Before I bring up the next author, I must state that Orson Scott Card is a notorious homophobe: he can somehow imagine interplanetary peace between species, but not sex between two people of the same sex.</p>

 <p>Card's <em>Speaker for the Dead</em> sits in between the dark and the light. Its catastrophe requires no malice from anyone. Humans and the alien "piggies" harm each other horrifically while both sides believe they are being kind; the tragedy is manufactured entirely out of mistranslation. The book even proposes a taxonomy of strangeness (from the foreigner next door up through the true alien with whom no shared understanding is possible) and then spends its whole length arguing that the boundary between "stranger we can know" and "monster we can't" is not a fact about them:</p>

 <blockquote>
 <p>When you really know somebody you can't hate them. Or maybe it's just that you can't really know them until you stop hating them.</p>
 <cite>&mdash; Orson Scott Card, <em>Speaker for the Dead</em></cite>
 </blockquote>

 <p>Again, how exactly can the guy who wrote that be homophobic? Anyway, as answers to Fermi go, Card's is subtle: perhaps contact has failure modes far short of annihilation, and the silence is partly made of species that met, misunderstood each other, and withdrew.</p>

 <h2>The message we already sent</h2>

 <p>We have already taken a side in the debate, physically. In 1977, NASA launched the two Voyager probes, and bolted to the side of each is a gold-plated copper record carrying greetings in dozens of languages, the sounds of surf and birdsong and a human heartbeat, ninety minutes of music, and over a hundred encoded images of life on Earth. The cover art doubles as an instruction manual, with a pulsar map showing any finder exactly which star the makers orbit. Voyager 1 crossed into interstellar space in 2012 and is now more than twenty-five billion kilometres away, the most distant human-made object there is, and the records are expected to remain playable for a billion years. We wrote our home address on a postcard and mailed it out there for anyone to find.</p>

 <figure>
 <img src="https://www.kenreid.co.uk/blog/img/fermi/voyager-golden-record.webp" alt="The gold-plated cover of the Voyager Golden Record, engraved with playback instructions, a hydrogen atom diagram, and a pulsar map showing the location of the Sun" loading="lazy">
 <figcaption>The cover of the Voyager Golden Record. The engraved diagrams explain how to play it, and the starburst at lower left is a map of fourteen pulsars that triangulates the position of our sun for anyone who finds it. Image: NASA/JPL, 1977 (public domain), via the <a href="https://images.nasa.gov/details/GPN-2000-001978" target="_blank" rel="noopener noreferrer">NASA Image Library</a>.</figcaption>
 </figure>

 <p>I wrote about why the Voyagers move me so much in <a href="https://www.kenreid.co.uk/blog/what-we-leave-behind.html">What We Leave Behind</a>, and everything above complicates that affection. Whether we should keep deliberately announcing ourselves (METI, as opposed to merely listening) is a live argument among serious people. The Arecibo message of 1974 was beamed at a star cluster twenty-five thousand light-years away partly as a stunt; Stephen Hawking spent his last years warning against anything louder; the counterargument runs that our radio and radar have been leaking for a century and our oxygen for two billion years, so discretion is a door we're closing on an empty stable. There is currently no international body with any authority over who may shout into the sky on the species' behalf, which is its own kind of remarkable. We might doom ourselves with our individuality and nationalism.</p>

 <h2>The empty sky is a mirror</h2>

 <p>Step back from the individual answers and a pattern appears: every solution to the Fermi paradox is really a claim about the deepest tendencies of minds. The dark forest says intelligence is inevitably paranoid and pre-emptive. The filter-ahead says technological species are inherently self-destructive. Banks's teeming galaxy says maturity means outgrowing the urge to conquer, and Clarke's says it means learning reverence for what you can't parse. Weir bets that engineers are engineers everywhere (and that curiosity and a will to survive can bond us with anyone). Card warns that the real filter is empathy. None of these are astronomy. They're anthropology projected onto the stars. There's a great line in <em>The Three-Body Problem</em> that summarises our power over any of this:</p>

 <blockquote>
 <p>Every era puts invisible shackles on those who have lived through it, and I can only dance in my chains.</p>
 <cite>&mdash; Liu Cixin, <em>The Three-Body Problem</em></cite>
 </blockquote>

 <p>It's surely no accident that the dark forest crystallised in the imagination of an author shaped by the Cultural Revolution, that the Culture flowed from a Scottish socialist writing through the end of the Cold War, or that the great American first-contact story of the 2020s is about strangers cooperating on a technical problem. Our Fermi answers age as we do. Which makes me wonder which shackles I'm dancing in when I pick my own favourite.</p>

 <figure>
 <img src="https://www.kenreid.co.uk/blog/img/fermi/pale-blue-dot.webp" alt="The Pale Blue Dot photograph: Earth as a tiny pale speck suspended in a band of scattered sunlight against the darkness of space" loading="lazy">
 <figcaption>The Pale Blue Dot. Voyager 1 took this picture of Earth on 14 February 1990 from about six billion kilometres away, at Carl Sagan's urging, before its camera was shut down forever. Earth is the speck halfway down the brightest band, smaller than a single pixel. Image: NASA/JPL-Caltech, 1990 (public domain), via the <a href="https://photojournal.jpl.nasa.gov/catalog/PIA00452" target="_blank" rel="noopener noreferrer">NASA Photojournal</a>.</figcaption>
 </figure>

 <p>The version of the future I want to be true is Banks's, not Liu's: a galaxy where growing up means growing kinder, where the silence is the discretion of the vast rather than the held breath of the frightened. I can't prove it, and nobody can, yet. What I can say is that the paradox stops being paralysing the moment you notice it's a mirror. I just hope we can live up to our own imagined best selves. Clarke, as usual, said it best, describing the first creatures on the African plain who began to be something more:</p>

 <blockquote>
 <p>Unlike the animals, who knew only the present, Man had acquired a past; and he was beginning to grope toward a future.</p>
 <cite>&mdash; Arthur C. Clarke, <em>2001: A Space Odyssey</em></cite>
 </blockquote>

 <div class="faq-section">
 <h2>Common questions</h2>

 <details class="faq-item">
 <summary>Doesn't the sheer size of the universe make alien life basically certain?</summary>
 <p>Life, maybe; <em>contactable civilisations</em>, not necessarily.</p>
 </details>

 <details class="faq-item">
 <summary>Have we really searched enough to call it a "Great Silence"?</summary>
 <p>No. Radio SETI has existed since 1960, but the combined search so far covers a sliver of stars, frequencies, and observing time; one 2018 analysis compared it to sampling a hot tub's worth of water from all of Earth's oceans. </p>
 </details>

 <details class="faq-item">
 <summary>Is the dark forest hypothesis taken seriously by scientists, or is it just great fiction?</summary>
 <p>Both, with emphasis on the fiction. It's a vivid, internally-consistent <em>possibility</em> that draws on real ideas (game theory under uncertainty, the danger of unknown intentions), and it's discussed, but it rests on contestable assumptions: that expansion is always desirable, that pre-emptive destruction is feasible and cheap across interstellar distances, that hiding actually works against a sufficiently advanced observer (Earth's oxygen atmosphere has been detectable for around two billion years). Serious thinkers poke real holes in it. </p>
 </details>

 <details class="faq-item">
 <summary>Should we be sending messages to potential aliens (METI) at all?</summary>
 <p>It's a genuine and unresolved debate. One camp argues that deliberately broadcasting our location to an unknown universe is reckless if anything like the dark forest is true (you don't shout in a forest you can't see into). The other argues that we already leak detectable signals, that fear of contact is unfalsifiable paranoia, and that the potential upside of connection is immense. There's currently no international governance for it, which itself worries people. It's a rare case where a science-fiction premise (should we announce ourselves?) is also a live policy question with no agreed answer.</p>
 </details>
 </div>

        

 </main>
 <hr style="margin: 40px 0;">
 <p style="text-align: center;"><a class="post-cta" href="https://www.kenreid.co.uk/blog.html">Back to all posts</a></p>
 ]]></content:encoded>
    </item>
    <item>
      <title>A Blog Engine in One JSON File</title>
      <link>https://www.kenreid.co.uk/blog/blog-engine-in-one-json-file.html</link>
      <guid>https://www.kenreid.co.uk/blog/blog-engine-in-one-json-file.html</guid>
      <pubDate>Sun, 26 Jul 2026 00:00:00 +0000</pubDate>
      <description>No database, no CMS, no Jekyll: this blog&#x27;s search, tag filters, pagination, read times, and related posts all hang off one JSON file and two Python scripts.</description>
      <category>technology</category>
      <category>writing</category>
      <content:encoded><![CDATA[
 <h1>A Blog Engine in One JSON File</h1>
 <div class="blog-meta">
 26 July 2026 &middot;
 <span class="blog-tag">technology</span>
 <span class="blog-tag">writing</span>
 </div>

 <p>The <a href="https://www.kenreid.co.uk/blog.html">blog listing on this site</a> has live search, tag filters with counts, pagination, read-time estimates, and a "related posts" section under every article. I refused to use a blogging platform (Wordpress, Blogger), and frankly felt too lazy to implement a database. The entire engine is one JSON file, about three hundred lines of plain JavaScript, and two Python scripts I run when publishing. This post explains the arrangement, because I think it's the sweet spot for a personal blog, and it's a chapter in the larger story of <a href="https://www.kenreid.co.uk/series-how-this-site-is-built.html">how this site is built</a>.</p>

 <div class="plain-english-box">
 <h2>Quick jargon guide</h2>
 <ul>
 <li><strong>JSON:</strong> a plain-text format for structured data, readable by both humans and every programming language. </li>
 <li><strong>CMS (content management system):</strong> software like WordPress that stores your posts in a database and assembles pages on demand. </li>
 <li><strong>Static site generator:</strong> a tool (Jekyll, Hugo) that compiles text files into HTML at build time.</li>
 <li><strong>TF-IDF:</strong> a classic text-analysis technique that scores how characteristic a word is for a document: words frequent in this post but rare elsewhere define what it's about.</li>
 <li><strong>Cosine similarity:</strong> a measure of how alike two documents are, computed from their word scores. </li>
 </ul>
 </div>

 <h2>The single source of truth</h2>

 <p>Everything hangs off <code>data/posts.json</code>: a flat list, one entry per post, currently dozens of them. An entry is exactly this:</p>

<pre><code class="language-json">{
  "title": "My Website Has a Test Suite",
  "date": "2026-07-17",
  "tags": ["technology"],
  "category": "Technology",
  "excerpt": "A Python audit script and headless browser smoke tests run on every push. Here's why a personal blog has a test suite.",
  "url": "blog/my-website-has-a-test-suite.html",
  "image": "img/photography/thumb/4.webp",
  "series": { "name": "How This Site Is Built", "part": 8 },
  "readMinutes": 7,
  "words": 1534
}</code></pre>

 <p>Publishing a post means writing the HTML page and adding one entry to this file. The same file then feeds many things: the blog listing, the search index, the tag buttons, the footer's "latest writing" list, the site-wide command palette, the "next/previous post" links, and the related-posts scoring. One file, many consumers, no synchronisation problems, because there is nothing to synchronise. Laziness can force some ingenuity, I like to tell myself.</p>

 <h2>The front end</h2>

 <p>The listing page fetches the JSON, sorts by date, and renders cards nine to a page. Search is an ordinary text input that filters as you type, matching against title, excerpt, and tags. The tag buttons aren't hardcoded anywhere: the script counts how often each tag appears across all posts and renders one button per tag, labelled with its count, sorted by popularity. Click two tags and you get posts matching either; click "All" and you're back to everything.</p>

 <p>Because tag buttons are generated from the data, a typo in the JSON doesn't produce an error, it produces a new button: misspell "photography" once and the blog begins offering a "phtography" filter containing one post. The fix is more careful typing, but it's also <a href="https://www.kenreid.co.uk/blog/my-website-has-a-test-suite.html">a test suite</a>: my audit script holds the list of allowed tags, and anything outside it fails the build. This does mean adding a tag has an extra step, but so far my tags are broad so I've had no issues.</p>

 <figure>
 <img src="https://www.kenreid.co.uk/img/photography/thumb/71.webp" alt="Pine branches with small cones catching low sun, seen through a chain-link fence" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
 <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
 </figure>

 <h2>The publish-time scripts</h2>

 <p><strong>Read times</strong> are computed. A Python script extracts the body text of each post (only the article region, ignoring scripts and navigation), counts the words, and divides by 220 words per minute, rounding up. The result is baked into the JSON as <code>readMinutes</code>. I picked 220 because it's a middle-of-the-road adult reading speed; the exact number is just an estimate so people know what they're getting into, I doubt many people time it!</p>

<pre><code class="language-python">WORDS_PER_MINUTE = 220

post["readMinutes"] = max(1, -(-words // WORDS_PER_MINUTE))  # ceil division
post["words"] = words</code></pre>

 <p><strong>Related posts</strong> are great for keeping visitors interested. A second script reads the full text of every post and builds TF-IDF vectors: each post becomes a list of scored words, where words common in that post but rare across the blog score highest. The title counts double, since it's the strongest signal of what a post is about. Related candidates are ranked by cosine similarity, with a small bonus of 0.06 per shared tag, and the top three get baked into the page as static HTML. The whole ranking is four lines of code:</p>

<pre><code class="language-python"># how alike is this candidate's vocabulary to the current post's?
# 0.0 = nothing in common, 1.0 = identical word profile
sim = cosine(vectors[current_url], vectors[url])

# how many tags do the two posts share? (usually 0 or 1)
shared = len(current_tags.intersection(post.get('tags', [])))

# final score: word similarity plus 0.06 per shared tag.
# the date rides along as a tiebreaker, newer post wins
ranked.append((sim + TAG_BOOST * shared, post.get('date', ''), post))

# ... every candidate scored, then: sort and keep the best three
return [post for _score, _date, post in ranked[:3]]</code></pre>

 <p>The related posts you see under this article were chosen when it was published, by a script that read every post on the site. Doing that in the browser would mean shipping the full text of dozens of posts to every visitor. Doing it at publish time costs the visitor nothing, and the recommendations reflect content, not just matching tags. When any post is published, the script re-bakes every page, so older posts learn about newer ones.</p>

 <h2>Why not Jekyll?</h2>

 <p>GitHub Pages has Jekyll built in, and for most people starting a blog I'd recommend it without hesitation. This site actually predates its blog by a good margin: it began as a simple portfolio back in 2012 or so, then grew into a photography portfolio built from an HTML template, and bolting a static site generator onto an existing hand-crafted site means converting everything to its conventions. And, also importantly, I like doing this. It's a fun project that I learn from, and provides me with more context of the modern web than my 2010 class on portlets, XSLT and DOM.</p>

 <p>But, that means that each post is a full HTML file. I have tricks up my sleeve for making it easier, which I describe throughout this series, and for the few dozen blogs I've written so far it's been worth it. Ask me again at 500.</p>

 <div class="faq-section">
 <h2>Common questions</h2>

 <details class="faq-item">
 <summary>Doesn't editing JSON by hand invite mistakes?</summary>
 <p>Constantly, which is why <a href="https://www.kenreid.co.uk/blog/my-website-has-a-test-suite.html">the audit script</a> validates every field.</p>
 </details>

 <details class="faq-item">
 <summary>Why not compute related posts by tags alone?</summary>
 <p>Tags are coarse: this blog has dozens of posts and 13 allowed tags, so "personal" matches half the site. TF-IDF looks at the actual words, so a post about Scottish hospitals finds other posts about Scotland and photography rather than three random "personal" entries. </p>
 </details>

 <details class="faq-item">
 <summary>What happens when the JSON and the HTML disagree?</summary>
 <p>The JSON wins, because everything visitors see (cards, search, links) comes from it. The <a href="https://www.kenreid.co.uk/blog/my-website-has-a-test-suite.html">audit</a> exists to make disagreement impossible in practice: a post page without a JSON entry is invisible, and a JSON entry without a page fails the build.</p>
 </details>

 <details class="faq-item">
 <summary>Could I copy this approach?</summary>
 <p>Yes, read more of this series to grab snippets and check out my github repo!</p>
 </details>
 </div>

        

 </main>

 <hr style="margin: 40px 0;">
 <p style="text-align: center;"><a class="post-cta" href="https://www.kenreid.co.uk/blog.html">Back to all posts</a></p>
 ]]></content:encoded>
    </item>
    <item>
      <title>Ant Colony, Live</title>
      <link>https://www.kenreid.co.uk/blog/ant-colony-live.html</link>
      <guid>https://www.kenreid.co.uk/blog/ant-colony-live.html</guid>
      <pubDate>Sun, 19 Jul 2026 00:00:00 +0000</pubDate>
      <description>An interactive ant colony optimiser in your browser: watch a shortest path emerge from thousands of tiny, blind, local decisions, and break it with the sliders.</description>
      <category>ai</category>
      <category>data science</category>
      <content:encoded><![CDATA[<p><em>This post includes an interactive demo that runs live in the browser. <a href="https://www.kenreid.co.uk/blog/ant-colony-live.html">View it on the site</a> to play with it.</em></p>
 <h1>Ant Colony, Live</h1>
 <div class="blog-meta">
 19 July 2026 &middot;
 <span class="blog-tag">ai</span>
 </div>

 <p>This search strategy is about something strange and, to me, a fascinating trick we have learned from observing and being inspired by nature: a shortest path found by thousands of blind agents, not one of which has any idea what it's doing.</p>

 

 <p>Real ants find the shortest route between nest and food with no map, no memory to speak of, and no leader. Each ant follows two dumb rules: wander, biased toward the smell of pheromone, and drop pheromone as you walk. And yet the colony reliably solves a problem no individual ant could even represent. The intelligence isn't of the ants, it's <em>emergent</em>.</p>

 <div class="plain-english-box">
 <h2>Quick jargon guide</h2>
 <ul>
 <li><strong>Ant Colony Optimization (ACO):</strong> an optimiser inspired by foraging ants, where many simple agents build solutions and reinforce good ones with virtual "pheromone".</li>
 <li><strong>Stigmergy:</strong> coordination through the environment rather than direct communication. Ants don't talk; they edit a shared chemical map, and the map tells the next ant what to do.</li>
 <li><strong>Pheromone:</strong> a virtual scent deposited on good path segments. It marks routes that worked, biasing future ants toward them.</li>
 <li><strong>Evaporation:</strong> pheromone fading over time. Without it, early mistakes get locked in forever; it's the colony's way of forgetting.</li>
 <li><strong>Exploration vs exploitation:</strong> the balance between following strong trails (exploit) and trying new paths (explore). A tunable weight.</li>
 <li><strong>Emergence:</strong> a behavior exhibited in colonies or groups of organisms that provides solutions without any individual understanding the problem.</li>
 </ul>
 </div>

 <h2>How dumb agents get smart together</h2>

 <p>Many ants set out at random. Shorter routes get walked end-to-end faster, so an ant on a short route completes more trips in the same time, so it lays pheromone more often per unit time. The short route therefore accumulates scent faster than long ones. The next wave of ants, biased toward scent, is more likely to pick it, which lays yet more scent, which biases yet more ants. A positive feedback loop amplifies whatever's working.</p>

 <p>Left unchecked, that loop would be a disaster: the first route to get lucky would snowball and lock in, good or not. Which is why evaporation is essential. Scent fades, so a route has to keep <em>proving itself</em> to stay attractive; a stale trail to a mediocre route decays away and frees the colony to find something better. Reinforcement finds good solutions; forgetting stops the colony being stuck with the first trail. Marco Dorigo formalised all this in the early 1990s<sup><a href="#ref-1" class="cite-ref">[1]</a></sup>, and it turned out to be competitive on routing problems (think Amazon deliveries, Google maps, etc.)

 <h2>Watch the trail form</h2>

 <p>Below, a colony is foraging on a small map. Watch the pheromone (the glow on the paths) start uniform and noisy, then sharpen, as one route out-competes the others and the colony pours itself onto it as an emergent behavior.</p>

 

 <h2>Break it with the sliders</h2>

 <p><strong>Turn evaporation off.</strong> The colony commits, hard and early, to whatever route happened to get the first head start, and then it's stuck there forever even when a shorter path is sitting right next to it. This is premature convergence. A colony that can't forget can't improve.</p>

 <p><strong>Turn evaporation up to maximum.</strong> Now the opposite: trails vanish before they can build, the colony never commits to anything, and the ants wander like it's the first minute forever. Memory that's too short is as useless as memory that's too long. The sweet spot between them is key - interesting thought, right?</p>

 <p><strong>Crank exploration.</strong> Push the ants to ignore the trails and strike out on their own, and watch a fascinating trade: the colony becomes slow to lock onto the best route but much better at noticing when the world changes (drag the food and see). Low exploration is fast and brittle; high exploration is slow and adaptive. </p>

 <h2>Why "no one is in charge"</h2>

 <p>The thing I most want you to take from watching this is the absence at the centre. There is no queen ant in charge. There is no scout ant specialized in picking up distant scents that knows the route. If you interrogated every single ant, none could tell you the solution, because the solution isn't stored in any ant: it's stored in the environment, in the pattern of scent, edited a little by each passing insect and read a little by the next. Intelligence, here, is a property of the <em>system</em>, not any of its parts.</p>

 <p>Starlings do it in the sky, neurons do it in your skull, markets do it (imperfectly), and it's the reason I find swarm methods fascinating. We're used to thinking capability requires a capable agent: a smart ant, a smart boss, a smart model. Stigmergy says no. Get the local rules and the shared medium right, and competence can emerge from a crowd of things too simple to hold it individually. The same process that finds the food can also stampede a colony, or a market, or a comment section, straight off a cliff -- emergence is not the same as wisdom. </p>

 <p>Anyway. Go drag the food around and watch a thing with no mind change its mind. </p>

 <div class="faq-section">
 <h2>Common questions</h2>

 <details class="faq-item">
 <summary>Do real ants actually do this?</summary>
 <p>The core process is real and was studied in actual ants (the classic double-bridge experiments showed colonies converging on the shorter branch via pheromone). The algorithm is still a simplification: real ant navigation also uses visual landmarks, path integration, and multiple pheromone types. As with the genetic algorithm, ACO borrows the idea as a metaphor; it's a faithful teacher of stigmergy and a loose model of entomology.</p>
 </details>

 <details class="faq-item">
 <summary>Is ant colony optimization actually used for anything?</summary>
 <p>Yes, mostly on routing and scheduling: network routing, vehicle routing, and various assignment problems, often hybridised with local search. It's rarely the outright best tool for a given problem (No Free Lunch), but it's competitive on dynamic problems where the environment keeps changing, because the evaporation-and-reinforcement loop naturally re-adapts, which is what you can watch when you drag the food.</p>
 </details>
 </div>

 <h2 class="section-heading" id="references">References</h2>

 <ol class="references">
          <li id="ref-1">
            Dorigo, M., Maniezzo, V., &amp; Colorni, A. (1996). Ant system: Optimization by a colony of cooperating agents. <em>IEEE Transactions on Systems, Man, and Cybernetics, Part B</em>, 26(1), 29&ndash;41. <a href="https://doi.org/10.1109/3477.484436" target="_blank" rel="noopener">https://doi.org/10.1109/3477.484436</a>
          </li>
        </ol>

        

 ]]></content:encoded>
    </item>
    <item>
      <title>My Website Has a Test Suite</title>
      <link>https://www.kenreid.co.uk/blog/my-website-has-a-test-suite.html</link>
      <guid>https://www.kenreid.co.uk/blog/my-website-has-a-test-suite.html</guid>
      <pubDate>Fri, 17 Jul 2026 00:00:00 +0000</pubDate>
      <description>A Python audit script and headless browser smoke tests run on every push. Here&#x27;s why a personal blog has a test suite.</description>
      <category>technology</category>
      <content:encoded><![CDATA[
        <h1>My Website Has a Test Suite</h1>
        <div class="blog-meta">
          17 July 2026 &middot;
          <span class="blog-tag">technology</span>
        </div>

        <p>Deprecations, external dependency shutdowns, infrastructure changes, data changes and shifting standards. Websites rot: each broken piece sits there for months until a reader (or worse, a potential employer) finds it before you do. I got tired of discovering this stuff by accident, so I gave it a test suite.</p>

        <p>This post covers a static audit script that reads every page like a very pedantic proofreader, and a headless browser test that loads the site like a very terminal focused nerd. These both run automatically on every push.</p>

        <div class="plain-english-box">
          <h2>Quick jargon guide</h2>
          <ul>
            <li><strong>CI (continuous integration):</strong> a robot that runs your checks every time you change the code, so a mistake is caught minutes after you make it instead of months later.</li>
            <li><strong>GitHub Actions:</strong> GitHub's built-in CI. You describe jobs in a small text file; GitHub runs them on their machines for free whenever you push.</li>
            <li><strong>Static audit:</strong> checks that read the HTML files as text, without a browser: do the links point at real files, do the pages have descriptions, and so on.</li>
            <li><strong>Headless browser:</strong> a real browser running invisibly, controlled by a script. It executes the JavaScript and renders the page, catching what text-reading can't.</li>
            <li><strong>Alt text:</strong> the written description attached to an image, read aloud by screen readers. Missing alt text is the most common accessibility failure on the web.</li>
            <li><strong>Smoke test:</strong> the minimum useful test: turn it on, see if smoke comes out. For a website: load the page, check the important things actually appeared.</li>
          </ul>
        </div>

        <h2>The proofreader</h2>

        <p>The first half is <code>audit_site.py</code>, a Python script with no dependencies beyond the standard library. It asks git for every tracked HTML file, parses each one, and complains about everything it doesn't like. It requires <strong>zero errors, zero warnings</strong>, on every page, on every commit.</p>

        <p>What it checks, roughly in order of how often it has saved me:</p>

        <ul>
          <li><strong>Broken links and images.</strong> Every internal link and image source is resolved to an actual file on disk. A renamed page or a missing thumbnail fails the audit immediately.</li>
          <li><strong>Metadata.</strong> Every page needs a description of sensible length, a canonical URL that matches its filename, and the social preview tags that make links look respectable when shared. The audit also validates that the preview image actually exists (this caused me so many issues trying to understand how social media sites grab images).</li>
          <li><strong>Structured data.</strong> Each blog post carries a machine-readable summary for search engines. The audit parses every one and checks each URL inside it, because invalid structured data fails silently in the real world.</li>
          <li><strong>Accessibility basics.</strong> Images without alt text, duplicate element ids, missing or multiple h1 headings, and skipped heading levels all get flagged for accessibility requirements I set.</li>
          <li><strong>The data files.</strong> The blog listing is driven by a JSON file: every entry must point at a real page and a real image, every date must be well-formed, and every tag must come from a fixed allowed list. That last one exists because the tag buttons are generated dynamically, so a typo like "phtography" wouldn't error; it would just mint a brand-new filter button and display it.</li>
          <li><strong>The feeds.</strong> The RSS feed and the sitemap are parsed and reconciled against the post list, so publishing a post without wiring it up everywhere gets caught. Not much point in publishing a new blog that isn't shown anywhere!</li>
        </ul>

        <figure>
          <img src="https://www.kenreid.co.uk/img/photography/thumb/4.webp" alt="Two wrecked wooden fishing boats rotting on a tidal mudflat under a heavy grey sky" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
          <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span> </figcaption>
        </figure>

        <h2>Headless smoke tests</h2>

        <p>The audit reads files; it cannot tell you whether the page actually <em>works</em>. This site injects its header, footer, comments, and related posts with JavaScript, and it animates sections into view as you scroll. A one-character mistake in that JavaScript can leave a page technically valid and completely blank, and the audit wouldn't catch it.</p>

        <p>So the second half is a smoke test built on <a href="https://playwright.dev/" target="_blank" rel="noopener noreferrer">Playwright</a>, which drives a real, invisible Chromium. The script serves the repository over a local web server, loads six representative pages, waits a few seconds for the JavaScript and animations to settle, and then asserts on what a visitor would actually see:</p>

<pre><code class="language-python"># A few of the checks, one page each:
# index.html   - hero present, live-stats skeletons cleared, blog cards visible
# blog.html    - at least 3 post cards and 3 tag filter buttons rendered
# gallery.html - at least 12 photos in the grid
# a blog post  - body text visible (opacity != 0), table of contents built
# quotes.html  - at least 500 quote cards
# map.html     - the map initialised with at least 10 markers</code></pre>

        <p>The site reveals sections with a fade-in animation, which means a JavaScript failure doesn't remove content, it leaves content sitting in the page at opacity zero: present in the HTML, invisible to humans. That is the failure a static checker can never catch. The check is a Python tuple holding a line of JavaScript, evaluated inside the live page:</p>

<pre><code class="language-python">("post body visible",
 "(() => { const p = document.querySelector('.blog-post > p');"
 "  return p &amp;&amp; getComputedStyle(p).opacity !== '0'; })()"),</code></pre>

        <p>The test also collects console errors, ignoring the third-party noise (analytics, embeds) and failing on anything from my own code.</p>

        <h2>The robot that runs it all</h2>

        <p>Both halves run in GitHub Actions on every push. The workflow has two jobs: one installs Python and runs the audit plus a check that the minified stylesheet is up to date (the CSS build is <a href="https://www.kenreid.co.uk/blog/hosting-photography-on-github-for-free.html">its own story</a>); the other installs Playwright and runs the smoke tests. If either fails, I get an email and a red X on the commit within a few minutes. A local pre-commit hook runs the fast checks too, so most mistakes never even reach GitHub.</p>

        <p>Is this overkill for a personal site? No: it's <em>because</em> the site is a hobby that I need automation. Nobody is paid to notice when this site breaks (or, you know, paid for writing blogs, creating the site, etc.). There is no QA department, no on-call rota, just me, and I would rather spend my evenings writing posts than manually clicking every link on fifty pages. The test suite is what lets a one-person site behave like it has staff.</p>

        <p>The suite also enforces my rules on me. Zero errors and zero warnings sounds strict, but a clean baseline means any non-zero number is news.</p>

        <h2>What it still misses</h2>

        <p>The audit checks that every referenced image exists <em>on disk</em>. It does not check that the image is committed to git. I published a post whose hero image existed on my machine but had never been committed, every local check passed, and the live site served a grey void where a seascape should have been. A reader (fine: me) spotted it and fixed it later that day, but that's not great when I already had a few dozen readers.</p>

        <p>Each gap you find becomes the next check you write. The fix here is a rule that any local file referenced by a tracked page must itself be tracked, which is one more function in the audit script. Here is that check as it runs today (lightly trimmed):</p>

<pre><code class="language-python">def check_target(url, line, what="target"):
    local = resolve_local(page, url)
    if local is None:
        return  # external URL, checked elsewhere
    if not local.exists():
        add("ERROR", page, line, "broken-link", f"missing {what}: {url}")
    elif page_is_tracked:
        relp = local.resolve().relative_to(ROOT).as_posix()
        if relp not in tracked:
            add("ERROR", page, line, "untracked-ref",
                f"{what} exists locally but is not tracked by git: {url}")</code></pre>

        <p>Fittingly, that check fired while I was preparing this very post: the hero image at the top of this page existed on my machine and had never been committed. Test suites are never finished, only extended.</p>

        <div class="faq-section">
          <h2>Common questions</h2>

          <details class="faq-item">
            <summary>Isn't this what link checkers and Lighthouse already do?</summary>
            <p>Partly, and I'd recommend either over nothing. The difference is specificity: a generic tool doesn't know that my tags come from an allowed list, that my posts share a canonical script set, or that my feed must mirror my posts file. The checks that catch real mistakes are the ones that encode <em>your</em> site's requirements.</p>
          </details>

          <details class="faq-item">
            <summary>How long did this take to build?</summary>
            <p>The first version of the audit was ten minutes: parse the HTML, resolve the links, print complaints. Everything else accreted one check at a time, usually after some error broke the live page. </p>
          </details>

          <details class="faq-item">
            <summary>Does CI like this cost anything?</summary>
            <p>At most a couple of dollars a month for my little site.</p>
          </details>

          <details class="faq-item">
            <summary>Where would you start on an existing site?</summary>
            <p>Broken internal links, missing alt text, and missing page descriptions, in that order. </p>
          </details>
        </div>

        

      </main>

        <hr style="margin: 40px 0;">
        <p style="text-align: center;"><a class="post-cta" href="https://www.kenreid.co.uk/blog.html">Back to all posts</a></p>
      ]]></content:encoded>
    </item>
    <item>
      <title>My Teacher Said I Shouldn&#x27;t Go To University</title>
      <link>https://www.kenreid.co.uk/blog/my-teacher-said-i-shouldnt-go-to-university.html</link>
      <guid>https://www.kenreid.co.uk/blog/my-teacher-said-i-shouldnt-go-to-university.html</guid>
      <pubDate>Thu, 16 Jul 2026 00:00:00 +0000</pubDate>
      <description>My high school music teacher told me not to aim for university: I was &#x27;more suited to manual labour&#x27;.</description>
      <category>personal</category>
      <category>advice</category>
      <content:encoded><![CDATA[
        <h1>My Teacher Said I Shouldn't Go To University</h1>
        <div class="blog-meta">
          16 July 2026 &middot;
          <span class="blog-tag">personal</span>
          <span class="blog-tag">advice</span>
        </div>

        <p>In high school, my music teacher told me I shouldn't aim to be a doctor, because I was "more suited to manual labour". In fact, she said that not only to me, but to my parents, at a student-parent night.</p>

        <p>I want to be fair to her, but I <i>am</i> writing this with a doctorate. Not the kind she meant, admittedly, a PhD makes you the sort of doctor who is useless in a medical emergency but insufferable when anyone brings up a topic vaguely related to your specialty, yet my title is real, my thesis was dissected by a bunch of people with way more experience than I, and somewhere inside, I still remember being told I wasn't good enough, and couldn't be good enough, by a trusted advisor.</p>

        <p>Teachers make thousands of judgement calls, and try to direct their students in the direction best for their displayed aptitudes, skills, abilities and what they appear to enjoy. How did she come to decide I was more suited to working with my hands? A music grade (where, by the way, I scored highly on performance because I loved guitar, and poorly on other parts of the classwork), a snapshot of who I am, and a very narrow idea of what my capabilities looks like, in a very specific field. How can a music teacher say "Well, this student kind of sucks at identifying Baroque music from Rococo, so they probably should be a plumber"? Besides the permanent glare I adopt whenever I think of her, what was the real damage? Well, I was lucky, because teenagers today still experience this kind of snapshot judgement, and the research says it does more than describe futures for kids. It writes them.</p>

        <div class="plain-english-box">
          <h2>Quick jargon guide</h2>
          <ul>
            <li><strong>Teacher expectation effects:</strong> the finding that what a teacher believes about a student can nudge that student's actual outcomes, famously via the "Pygmalion" study. The size of the effect is debated; its existence, much less so.</li>
            <li><strong>Golem effect:</strong> Teacher expectation effect's ugly sibling, <em>low</em> expectations dragging performance down.</li>
            <li><strong>Academic self-efficacy:</strong> a student's belief that they can succeed at academic work, one of the strongest psychological predictors of how they actually do.</li>
            <li><strong>g (general intelligence):</strong> the well-replicated statistical finding that performance across mental tasks correlates. Real, useful, and still only part of the picture any single grade captures.</li>
            <li><strong>Multiple intelligences:</strong> Gardner's popular theory that intelligence comes in eight-ish flavours. Scientifically shaky as psychometrics, but pointing at something real about the plurality of human skill.</li>
          </ul>
        </div>

        <h2>What she was looking at</h2>

        <p>Whatever my teacher saw when she looked at me, I'm guessing it was something to do with grades from when I was forced to play glockenspiel. That thinking is the design of the system she worked in. School distils each pupil into a thin column of numbers generated by one activity: sitting still, absorbing material in a fixed order at a fixed age, and reproducing it silently on paper (or by bashing thin metal plates in a specific order and rhythm). Do that well and you're "academic." Do it badly, or do it <em>later than your teens</em>, because adolescent brains run on wildly different schedules, and the system files you as "not academic". </p>

        <p>Grades are a decent-but-leaky predictor even of the thing they're supposed to predict: meta-analytic work like Richardson, Abraham and Bond's review<sup><a href="#ref-1" class="cite-ref">[1]</a></sup> finds prior grades correlate moderately with university performance, leaving an enormous share of the outcome to everything else: effort regulation, study strategies, tutoring, circumstances, and, notably, <strong>academic self-efficacy</strong>, whether the student <em>believes</em> they can do it. Sounds a bit weak, right? </p>

        <figure>
          <img src="https://www.kenreid.co.uk/img/photography/thumb/451.webp" alt="Close-up of an acoustic guitar in low light, sunlight catching the strings, fretboard, and ornate pickguard" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
          <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span> The guitar my parents gifted me when I turned 16, I still have it at 34, and it came to the USA from Scotland with me.</figcaption>
        </figure>

        <h2>Prophecy is an intervention</h2>

        <p>If a teenager's belief in their own academic capability is one of the strongest psychological predictors of their university performance, then look again at what a sentence like "you're more suited to manual labour" actually is: it's an <em>intervention on the predictor variable</em>. This isn't a guess, it's changing the variables that cause the outcome.</p>

        <p>This is the territory of the famous Pygmalion study,<sup><a href="#ref-2" class="cite-ref">[2]</a></sup> where teachers told (falsely) that certain randomly-chosen pupils were about to bloom saw exactly those pupils gain the most, and of its grim mirror, the golem effect, where low expectations do the opposite work. The literature has spent fifty years arguing about effect sizes, which is valid: expectation effects are usually modest, on average. A stray remark from an authority figure, landing on a fifteen-year-old at the exact age when identity is wet cement, doesn't get experienced as a data point but as a verdict, and some of us can quote it verbatim decades later, which should tell you something the impression such remarks can make on kids. That is to say: it may not always stick, it likely depends on the relationship the kid has with the teacher, the emotional vulnerability and circumstances of the kid, and counterweights, but it did with me, and it does with others.</p>

        <p>I got lucky: enough stubbornness to treat the verdict as a provocation, and enough people elsewhere in my life balancing it out (thanks Mum + Dad). A different kid (same ability, fewer counterweights) hears the same sentence and might be dissuaded entirely from whatever their goals are. </p>

        <figure>
          <img src="https://www.kenreid.co.uk/img/photography/thumb/113.webp" alt="Black and white photograph of an acoustic guitar lying across a striped surface" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
          <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
        </figure>

        <h2>The many shapes of capable</h2>

        <p>I've <a href="https://www.kenreid.co.uk/blog/why-you-arent-a-visual-learner.html">previously taken a flamethrower</a> to pop-psychology taxonomies of the mind, and I intend to stay consistent. Howard Gardner's "multiple intelligences" (the theory that we each carry separate musical, spatial, interpersonal, bodily intelligences) is, as psychometrics, shaky: the abilities correlate, the categories resist clean measurement, and the framework has never out-predicted boring old <em>g</em>. If you came here for "everyone is secretly a genius in their own modality," you are reading the wrong article.</p>

        <p>Even mainstream intelligence research describes ability as a hierarchy: a general factor, yes, but with broad group abilities (verbal, spatial, quantitative, mechanical) and thousands of specific skills layered beneath, all of it interacting with interest, practice, temperament, and time. A school grade samples one thin horizontal slice of that, once, at an age when the whole edifice is still under construction and might be entirely changed because the kid was going through something that morning. The carpenter's spatial reasoning, the care worker's social perception, the mechanic's fault-finding, the musician's ear: these are real, demanding, <em>trainable</em> forms of capability that grades just don't measure at one time, nevermind over time. </p>

        <p>The insult in "suited to manual labour" was never to me, it was to manual labour. The sentence only works as a put-down inside a worldview where the trades are a punishment tier for the insufficiently bookish, or for people who have ADHD or similar difficulties in the neurotypically designed classroom, a worldview that is snobbish about the people who build its houses and wire its schools, and that pushes academically-shaped kids away from skilled work they might have loved. </p>

        <p>There's a gendered layer to this, too. I recently read <em>Boys Don't Try? Rethinking Masculinity in Schools</em> by Matt Pinkett and Mark Roberts,<sup><a href="#ref-3" class="cite-ref">[3]</a></sup> two teachers examining why boys collect verdicts like mine so reliably. A lot of what gets filed under "boys don't try" is self-protection (if you never try, failing can't become evidence about you), and the fix is not lowering the bar to match the stereotype but refusing the stereotype altogether: high expectations, warm relationships, and no prophecies. I'd hand it to any teacher who has ever caught themselves sorting a class into who is and isn't "university material".</p>

        <h2>If you were told similarly:</h2>

        <ul>
          <li>A grade measured how you did one kind of task, in one format, at one age, under whatever was happening at home that year. It contains no information about you at 20, 30 or 40.</li>
          <li>Self-efficacy isn't magic thinking; it's the thing that determines whether you apply, persist, and ask for help.</li>
          <li>Access courses, mature entry, apprenticeships that become degrees, degrees that start at 30. The system's front door has a strange obsession with your teens, it's hardly the end.</li>
          <li>If the trades, or care, or making things is where your capability lives, that's not a consolation prize, that's the goal that society doesn't recognize as success (and considering all the "white collar" job difficulties, the trades are a very lucrative, respectable and desirable goal). </li>
        </ul>

        <h2>To my teacher, if she's reading</h2>

        <p>If she's still alive, I doubt she remembers saying it. That's just it though - the sentence that lodged in me for decades probably cost her four seconds and was immediately forgotten. I hope her years of teaching had better impacts on kids than that one remark, and I'd genuinely rather this post reach some teacher mid-career than reach her. So, to that teacher: you hold a variable that the meta-analyses rank near the top of the stack, and you adjust it every time you tell a teenager what they are. Forecast less, care more. You will be wrong about which ones bloom (the research says so, my doctorate says so, too) so you might as well be wrong in the direction that costs nothing and occasionally builds a doctor from a kid who wants to be a doctor.</p>

        <figure>
          <img src="https://www.kenreid.co.uk/img/photography/thumb/461.webp" alt="Acoustic guitar photographed against a purple wall" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
          <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
        </figure>

        <div class="faq-section">
          <h2>Common questions</h2>

          <details class="faq-item">
            <summary>Do you resent her?</summary>
            <p>Less than the opening of this post suggests. She was one adult having, possibly, one careless afternoon inside a system that invited exactly that kind of sorting. People are rarely improved by contempt, that applies to her too. </p>
          </details>

          <details class="faq-item">
            <summary>Aren't some students genuinely not suited to university?</summary>
            <p>Of course, and frank guidance about fit, options, and trade-offs is a teacher doing their job. <em>Prophecy plus hierarchy</em>: declaring a teenager's ceiling, from thin evidence, in a tone that ranks the alternatives as lesser. "Have you considered an apprenticeship, they'd be lucky to have you" and "you're not university material" contain the same information and opposite interventions.</p>
          </details>

          <details class="faq-item">
            <summary>Isn't "multiple intelligences" debunked?</summary>
            <p>As a psychometric theory, it has serious problems, see the section above, and my <a href="https://www.kenreid.co.uk/blog/why-you-arent-a-visual-learner.html">learning-styles post</a> for the adjacent myth. Ability is demonstrably plural beneath the general factor, grades sample it narrowly, and capability keeps developing long after school stops measuring.</p>
          </details>

          <details class="faq-item">
            <summary>What should a teacher say instead?</summary>
            <p>Describe the path, not the person: "this is what the next grade up looks like, here's the gap, here's how people close it." </p>
          </details>
        </div>

        <h2 class="section-heading" id="references">References</h2>

        <ol class="references">
          <li id="ref-1">
            Richardson, M., Abraham, C., &amp; Bond, R. (2012). Psychological correlates of university students' academic performance: A systematic review and meta-analysis. <em>Psychological Bulletin</em>, 138(2), 353&ndash;387. <a href="https://doi.org/10.1037/a0026838" target="_blank" rel="noopener">https://doi.org/10.1037/a0026838</a>
          </li>
          <li id="ref-2">
            Rosenthal, R., &amp; Jacobson, L. (1968). Pygmalion in the classroom: Teacher expectation and pupils' intellectual development. <em>The Urban Review</em>, 3(1), 16&ndash;20. <a href="https://doi.org/10.1007/BF02322211" target="_blank" rel="noopener">https://doi.org/10.1007/BF02322211</a>
          </li>
          <li id="ref-3">
            Pinkett, M., &amp; Roberts, M. (2019). <em>Boys Don't Try? Rethinking Masculinity in Schools.</em> Routledge. <a href="https://doi.org/10.4324/9781351163729" target="_blank" rel="noopener">https://doi.org/10.4324/9781351163729</a>
          </li>
        </ol>

        

      </main>

        <hr style="margin: 40px 0;">
        <p style="text-align: center;"><a class="post-cta" href="https://www.kenreid.co.uk/blog.html">Back to all posts</a></p>
      ]]></content:encoded>
    </item>
    <item>
      <title>Free Comments via GitHub Discussions</title>
      <link>https://www.kenreid.co.uk/blog/free-comments-via-github-discussions.html</link>
      <guid>https://www.kenreid.co.uk/blog/free-comments-via-github-discussions.html</guid>
      <pubDate>Wed, 15 Jul 2026 00:00:00 +0000</pubDate>
      <description>How giscus gives a static site free comments with no database, no ads, and no tracking.</description>
      <category>technology</category>
      <content:encoded><![CDATA[
        <h1>Free Comments via GitHub Discussions</h1>
        <div class="blog-meta">
          15 July 2026 &middot;
          <span class="blog-tag">technology</span>
        </div>

        <p>Scroll to the bottom of this post and you'll find a comments section. That shouldn't be possible: this is a static site, a folder of HTML files with no server, no database, nowhere to <em>put</em> a comment. The trick is a wonderful little disguise: every comment thread on this blog is a GitHub Discussion, courtesy of a small open-source tool called <a href="https://giscus.app/" target="_blank" rel="noopener noreferrer">giscus</a>. This post details what it is, its setup, and its trade-offs, as part of the series on <a href="https://www.kenreid.co.uk/series-how-this-site-is-built.html">how this site is built</a>.</p>

        <div class="plain-english-box">
          <h2>Quick jargon guide</h2>
          <ul>
            <li><strong>giscus:</strong> a free, open-source widget that displays a GitHub Discussion as a comment section on any web page.</li>
            <li><strong>GitHub Discussions:</strong> forum-style threads attached to a GitHub repository, normally used by software projects for Q&amp;A.</li>
            <li><strong>iframe:</strong> a page embedded inside another page. The comments you see are technically a small giscus page shown in a window within mine.</li>
            <li><strong>Static site:</strong> a website that is only files, with no server-side code, which is why it can't store comments itself.</li>
            <li><strong>Widget/embed:</strong> third-party functionality you add with a script tag. </li>
          </ul>
        </div>

        <h2>How it works</h2>

        <p>My blog's repository has Discussions enabled, with a category set aside for comments. When you visit a post, a script builds the comments section and giscus looks up a discussion whose title matches the page's path: for this post, <code>/blog/free-comments-via-github-discussions.html</code>. If someone has commented before, the thread exists and gets displayed. If nobody has, giscus shows an empty comment box, and the discussion is created automatically the first time someone posts.</p>

        <p>Commenting requires a GitHub account, which is the design's biggest filter and, depending on your audience, either a bug or a feature. For a blog like mine, whose readers skew technical, it's mostly a feature: sign-in deters drive-by spam so effectively that I do no spam moderation at all. For a blog aimed at a general audience it wouldn't be the best solution, and something like a hosted comments service (or no comments, with an email link) might fit better.</p>

        <h2>The setup</h2>

        <p>Enable Discussions on the repository (a checkbox in settings) and create a category for comments; giscus recommends the Announcements type, so that only the widget and maintainers can open new threads, keeping the category from becoming a second inbox. Install the giscus app on the repository from GitHub's app directory. Then the <a href="https://giscus.app/" target="_blank" rel="noopener noreferrer">giscus website</a> generates your embed code: you tell it the repository, category, and how pages should map to discussions (I use the page path), and it produces a script tag with everything encoded as attributes.</p>

        <p>Most sites paste that script tag into their template and are done. Mine goes through the shared components file, which builds it dynamically on every post page. Ez pz.</p>

        <figure>
          <img src="https://www.kenreid.co.uk/img/photography/thumb/409.webp" alt="Green leafy branches in front of sunlight sparkling off out-of-focus water" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
          <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
        </figure>

        <h2>The details</h2>

        <p><strong>Matching the site's theme.</strong> giscus ships with stock light and dark themes, but it also accepts a custom stylesheet URL, so this site serves its own <code>giscus-dark.css</code> and <code>giscus-light.css</code> and the comments render like part of the page rather than a bolted-on box. The fiddly bit is the theme toggle: when you switch the site between dark and light, a message gets posted into the giscus iframe telling it to swap stylesheets too, so the comments change theme in step with everything around them.</p>

        <p><strong>Lazy loading.</strong> The widget loads only when you scroll near it, so readers who never reach the bottom never fetch it. Comments are the last thing on the page both location wise and in load time!</p>

        <p><strong>A refresh button.</strong> The embed is static once loaded: if someone comments while you have the page open, nothing updates. A small refresh control re-mounts the widget on demand.</p>

        <h2>The trade-offs</h2>

        <p>If GitHub discontinued Discussions, or giscus vanished, my comment sections would go blank (the data would survive, in the discussions themselves, and giscus being open source means it could be self-hosted). Comments also don't work offline, and they're invisible to readers with JavaScript off; my <a href="https://www.kenreid.co.uk/blog/reading-this-site-offline.html">offline post</a> covers why the service worker deliberately leaves them alone. This is fine for a personal blog: comments are a nice-to-have on top of the writing, not the point of it.</p>

        <p>What's nice is there are no ads injected next to your readers' words, no tracking scripts profiling them, no database to secure, and moderation through tools (GitHub's) that are already set up and hosted for you. The comparison is with Disqus, the long-time default, is pretty clear: disqus is filled with ads, and filled with tracking scripts etc. I know which one I prefer!</p>

        <div class="faq-section">
          <h2>Common questions</h2>

          <details class="faq-item">
            <summary>Can you delete or moderate comments?</summary>
            <p>Yes, with GitHub's own moderation: I can edit, hide, or delete comments in the underlying discussion, lock threads, and block users, the same tools every open-source project uses. In practice the sign-in requirement means I've needed no moderation whatsoever.</p>
          </details>

          <details class="faq-item">
            <summary>What do commenters give up by signing in with GitHub?</summary>
            <p>Their comment is public on GitHub, attached to their GitHub identity, same as commenting on any open-source project. giscus itself doesn't track visitors or run analytics; readers who never comment send GitHub nothing beyond the iframe request itself.</p>
          </details>

          <details class="faq-item">
            <summary>What about utterances, the issues-based version?</summary>
            <p>utterances is giscus's older sibling that stores comments in GitHub Issues instead of Discussions. It works, but issues are a worse fit (no threading, no reactions-as-votes, and your bug tracker fills with comment threads). giscus is the same idea in a more sensible place.</p>
          </details>
        </div>

        

      </main>

        <hr style="margin: 40px 0;">
        <p style="text-align: center;"><a class="post-cta" href="https://www.kenreid.co.uk/blog.html">Back to all posts</a></p>
      ]]></content:encoded>
    </item>
    <item>
      <title>Putting My Photos on a Map</title>
      <link>https://www.kenreid.co.uk/blog/putting-my-photos-on-a-map.html</link>
      <guid>https://www.kenreid.co.uk/blog/putting-my-photos-on-a-map.html</guid>
      <pubDate>Tue, 14 Jul 2026 00:00:00 +0000</pubDate>
      <description>239 of my photographs plotted on an interactive world map, this is how the photo map works.</description>
      <category>technology</category>
      <category>photography</category>
      <content:encoded><![CDATA[
        <h1>Putting My Photos on a Map</h1>
        <div class="blog-meta">
          14 July 2026 &middot;
          <span class="blog-tag">technology</span>
          <span class="blog-tag">photography</span>
        </div>

        <p>My photography spans two countries I've lived in and a handful I've visited, and a plain grid of thumbnails hides that storytelling, sadly. So, I decided to try my hand with <a href="https://www.kenreid.co.uk/map.html">a photo map</a>: an interactive world map where each marker is a place I've photographed. Click a marker and you get a popup of thumbnails; click a thumbnail and the whole region opens as a full-screen slideshow. This is the how to, as part of the series on <a href="https://www.kenreid.co.uk/series-how-this-site-is-built.html">how this site is built</a>.</p>

        <div class="plain-english-box">
          <h2>Quick jargon guide</h2>
          <ul>
            <li><strong>Leaflet:</strong> the standard open-source JavaScript library for interactive maps. It draws the map, handles panning and zooming, and places markers; it's the engine under a huge share of the web's non-Google maps.</li>
            <li><strong>Tile server:</strong> where the actual map imagery comes from. Maps are served as a grid of small square images ("tiles") drawn on demand as you pan and zoom.</li>
            <li><strong>Geotagging:</strong> attaching a location to a photo, either automatically (phones embed GPS in the file) or by hand (me, squinting at a map, going "that was definitely Falkirk").</li>
            <li><strong>Marker / popup:</strong> the pin on the map and the bubble that opens when you click it.</li>
            <li><strong>Deep link:</strong> a URL that opens the page in a specific state, like the map already zoomed to one region.</li>
          </ul>
        </div>

        <h2>The data</h2>

        <p>The obvious way to geotag photos is to read the GPS coordinates cameras embed in their files. My photos mostly don't have any: they come from a real camera without GPS, not a phone, and even when coordinates exist I strip metadata from published files, usually due to not being super careful about backing up my photos over the years, so some of these are screenshots from websites I put them (GuruShots!). So the map's data is a single hand-maintained JSON file: a list of 29 named regions, each with one latitude/longitude pair and the list of photo numbers taken there, 239 photos placed so far.</p>

<pre><code class="language-js">{
  "name": "Falkirk",
  "lat": 56.0011, "lng": -3.7835,
  "photos": ["135", "141", "197", "..."]
}</code></pre>

        <p>Placing photos by hand sounds like a chore and mildly is but it's not awful either. A region pin says "around Falkirk", never "this street, this house, this ruin", which matters both for <a href="https://www.kenreid.co.uk/blog/abandoned-places-i-photograph.html">the abandoned places</a> (some locations shouldn't be advertised) and for anywhere near where people live. And meaning: "University of Stirling" and "Ochil Hills" are how I think about where photos were taken, not coordinates. </p>

        <h2>The map</h2>

        <p>The mapping library is <a href="https://leafletjs.com/" target="_blank" rel="noopener noreferrer">Leaflet</a>, and in keeping with <a href="https://www.kenreid.co.uk/blog/self-hosting-your-fonts.html">house policy</a> the library itself is served from this site, not a CDN. The tiles (the map imagery) are external however, coming from CARTO's free basemaps. There are two tile styles, a light one and a dark one, and the map swaps between them when you toggle the site's theme.</p>

        <figure>
          <img class="theme-img-light" src="https://www.kenreid.co.uk/blog/img/map/photo-map-light.webp" alt="The photo map: a world map with red circular markers showing photo counts across Scotland, Europe, and the United States" width="1110" height="649" loading="lazy" style="width:100%; border-radius:8px;">
          <img class="theme-img-dark" src="https://www.kenreid.co.uk/blog/img/map/photo-map-dark.webp" alt="The photo map: a world map with red circular markers showing photo counts across Scotland, Europe, and the United States" width="1110" height="649" loading="lazy" style="width:100%; border-radius:8px;">
          <figcaption class="figure-note">The map as it stands: 29 regions, 239 photos placed. Tiles &copy; CARTO / OpenStreetMap contributors.</figcaption>
        </figure>

        <h2>Markers, popups, and the slideshow</h2>

        <p>Each region renders as one circular marker showing its photo count, its size scaled gently with the number (34 to 52 pixels, so Falkirk's 28 photos read bigger than Dublin's one without turning into a bubble chart). I skipped marker-clustering plugins entirely: hand-curated regions <em>are</em> the clustering, done once, with better names than any algorithm would pick.</p>

        <p>Clicking a marker opens a popup with the region's name and up to nine thumbnails; clicking any thumbnail opens the region's <em>entire</em> photo set as a lightbox slideshow, starting from the one you picked. The thumbnails are the same small WebP files the <a href="https://www.kenreid.co.uk/gallery.html">gallery</a> uses, and the slideshow pulls the full-resolution originals from <a href="https://www.kenreid.co.uk/blog/hosting-photography-on-github-for-free.html">the GitHub release where they live</a>. The map also cooperates with the rest of the site: gallery photos with a known place show a location label linking to the map pre-zoomed on that region, via a small <code>?region=</code> parameter in the URL, so the command palette / search function (CTRL + K) can show you places on the map when you search for, say, "East Lansing".</p>

        <h2>If you want one of these</h2>

        <p>The real hurdle isn't the code, it's the data. Leaflet's tutorial gets a map with markers running super quickly; deciding where 239 photos were taken took considerably longer, spread over batches (my git history contains the commit "Geotag batch: Falkirk, Falkland, Lansing added; 63 more photos placed", which tells you how it actually went). Start coarse: a dozen regions, big radii, photos you're sure about. I'll add more when I'm running low on bigger ideas to add to the site!</p>

        <p>If your photos <em>do</em> have GPS data (phone photographers, this is you), your version of the data file can be generated by a script reading the coordinates, snapping them to a sensible grid, and naming clusters after the nearest town. Consider still doing the naming pass by hand; "Grandma's village" beats "cluster_17" forever. And whatever you do, check what location data you're publishing before you publish it: the same GPS metadata that makes the map easy also tells the internet where you sleep.</p>

        <div class="faq-section">
          <h2>Common questions</h2>

          <details class="faq-item">
            <summary>Why not just use the tags in the gallery for places?</summary>
            <p>The gallery's tags describe what's <em>in</em> a photo (wildlife, architecture, silhouette) and are <a href="https://www.kenreid.co.uk/blog/photo-tagging-with-clip.html">generated by a vision model</a>; places are about where I was standing, which no model can see. The two systems complement each other: tags answer "show me the wildlife", the map answers "show me Scotland".</p>
          </details>

          <details class="faq-item">
            <summary>Is relying on free map tiles safe?</summary>
            <p>It's a dependency, like any third party. CARTO's free tier is intended for exactly this scale, and if it ever went away, Leaflet doesn't care where tiles come from: OpenStreetMap's own servers, another provider, or self-hosted tiles all slot into the same line of code. The data file, which is the part with my work in it, is mine and portable.</p>
          </details>

          <details class="faq-item">
            <summary>How precise are the pins?</summary>
            <p>Deliberately imprecise.</p>
          </details>

          <details class="faq-item">
            <summary>Does the map work offline?</summary>
            <p>Partially. The page and its data are cached by the site's <a href="https://www.kenreid.co.uk/blog/reading-this-site-offline.html">service worker</a>, but tiles come from a third party the worker deliberately ignores, so offline you'd get markers floating on grey.</p>
          </details>
        </div>

        

      </main>

        <hr style="margin: 40px 0;">
        <p style="text-align: center;"><a class="post-cta" href="https://www.kenreid.co.uk/blog.html">Back to all posts</a></p>
      ]]></content:encoded>
    </item>
    <item>
      <title>Self-Hosting Your Fonts</title>
      <link>https://www.kenreid.co.uk/blog/self-hosting-your-fonts.html</link>
      <guid>https://www.kenreid.co.uk/blog/self-hosting-your-fonts.html</guid>
      <pubDate>Sun, 12 Jul 2026 00:00:00 +0000</pubDate>
      <description>No Google Fonts, no third-party requests, and icon fonts subset from 75 KB down to 1.5 KB. Here&#x27;s how, and why it&#x27;s worth an afternoon.</description>
      <category>technology</category>
      <content:encoded><![CDATA[
        <h1>Self-Hosting Your Fonts</h1>
        <div class="blog-meta">
          12 July 2026 &middot;
          <span class="blog-tag">technology</span>
        </div>

        <p>The text you're reading is set in Poppins (no, not Mary Poppins), the serifs in the pull quotes are Lora, and until recently both arrived from Google's servers, the way fonts arrive on roughly half the web. Now they're served from this site itself: twelve small font files sitting in the repository next to the photos. This post is just a brief rundown on how and why I did this, and how I shrank three icon fonts by about 97% with a Python script. Part of the series on <a href="https://www.kenreid.co.uk/series-how-this-site-is-built.html">how this site is built</a>.</p>

        <div class="plain-english-box">
          <h2>Quick jargon guide</h2>
          <ul>
            <li><strong>Web font:</strong> a font file the browser downloads with the page, so visitors see your chosen typeface rather than whatever their device has installed.</li>
            <li><strong>WOFF2:</strong> the modern compressed format for web fonts. Universally supported, roughly a third the size of older formats.</li>
            <li><strong>@font-face:</strong> the CSS rule that tells the browser "this family name maps to this font file". Self-hosting is mostly writing these by hand.</li>
            <li><strong>Icon font:</strong> a font whose "letters" are pictures (the GitHub logo, a house, an arrow). A common way to ship icons before SVG took over.</li>
            <li><strong>Subsetting:</strong> deleting every glyph from a font that your site doesn't use. A font with 700 icons of which you use 5 can lose the other 695.</li>
            <li><strong>Unicode range:</strong> the span of characters a font file covers. Serving just the Latin range is itself a subset: no Cyrillic or Vietnamese glyphs for a site written in English.</li>
          </ul>
        </div>

        <h2>The why</h2>

        <p>Well, mainly because I wanted to learn about fonts. I think they're interesting: they communicate tone, a certain visual 'feel' that goes beyond the words on the page, they have interest accessibility benefits and they have some fascinating history. But, the technical reasons are: <strong>Privacy:</strong> every page view against Google Fonts tells Google's servers a visitor's IP address requested your page, and in 2022 a German court found exactly that arrangement violated the GDPR. Serving fonts yourself means your readers talk to nobody but you. <strong>Reliability and speed:</strong> a third-party font server is one more thing that can be slow, blocked (Google is unreachable in some countries), or discontinued. Self-hosted fonts arrive over the same connection as everything else. <strong>Ownership:</strong> the same philosophy as the rest of this site. Fewer moving parts owned by other people.</p>

        <p>The counterargument used to be caching: the theory that everyone has Google's copy of Poppins cached already, so linking to it is free. That theory died in 2020 when browsers partitioned their caches per site for privacy reasons. Every site now downloads its own copy regardless, so the shared-cache benefit is actually negative, since you download it repeatedly.</p>

        <h2>The how</h2>

        <p>Google Fonts provides them, of course. Download the family, keep the WOFF2 versions of the weights you actually use, and write an <code>@font-face</code> block per file:</p>

<pre><code class="language-css">@font-face {
  font-family: "Poppins";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("../fonts/vendor/poppins-400.woff2") format("woff2");
}</code></pre>

        <p>This site carries seven weights of Poppins and five faces of Lora (the italics matter for pull quotes), Latin subset only, for a grand total of about 244 KB, less than a single photograph thumbnail-and-hero pair. The <code>font-display: swap</code> line is the one non-obvious part: it tells the browser to show text immediately in a fallback font and swap when the real one arrives, rather than showing nothing. Neat, right? Invisible text while a font loads is way more irritating than a brief flash of the wrong font that most people won't see.</p>

        <figure style="margin: 30px 0;">
          <img class="theme-img-light" src="https://www.kenreid.co.uk/blog/img/fonts/specimen-poppins-light.webp" alt="Type specimen of Poppins: six weights from Thin 100 to Bold 700, each set as a pangram, with the full alphabet and numerals below" width="1000" height="801" loading="lazy" style="width:100%; border-radius:8px;">
          <img class="theme-img-dark" src="https://www.kenreid.co.uk/blog/img/fonts/specimen-poppins-dark.webp" alt="Type specimen of Poppins: six weights from Thin 100 to Bold 700, each set as a pangram, with the full alphabet and numerals below" width="1000" height="801" loading="lazy" style="width:100%; border-radius:8px;">
          <figcaption class="figure-note">Poppins, rendered from the same woff2 files this page just loaded. Seven weights, 54&nbsp;KB all told.</figcaption>
        </figure>

        <figure style="margin: 30px 0;">
          <img class="theme-img-light" src="https://www.kenreid.co.uk/blog/img/fonts/specimen-lora-light.webp" alt="Type specimen of Lora: regular, medium, and semibold weights with their italics, each set as a pangram, with the full alphabet and numerals below" width="1000" height="598" loading="lazy" style="width:100%; border-radius:8px;">
          <img class="theme-img-dark" src="https://www.kenreid.co.uk/blog/img/fonts/specimen-lora-dark.webp" alt="Type specimen of Lora: regular, medium, and semibold weights with their italics, each set as a pangram, with the full alphabet and numerals below" width="1000" height="598" loading="lazy" style="width:100%; border-radius:8px;">
          <figcaption class="figure-note">Lora's five faces. The italics earn their bytes in the pull quotes.</figcaption>
        </figure>

        <h2>The fun part: subsetting icon fonts</h2>

        <p>The site's template arrived with three icon fonts (FontAwesome, Themify, ElegantIcons), each carrying hundreds of icons. A count of what the site actually uses came to: five FontAwesome icons, sixteen Themify icons, and two ElegantIcons. </p>

        <p>Using the <code>fontTools</code> library, a Python script scans every page and script on the site for icon class names (<code>fa-github</code>, <code>ti-arrow-up</code>, and so on), parses each vendor stylesheet to map those class names to the character codes inside the font, and then writes a new font containing only those characters. The results are satisfying in a way few optimisations are:</p>

<pre><code class="language-python"># Original -> subset (woff/woff2)
# FontAwesome   75.4 KB -> 1.5 KB   (5 icons kept,  ~98% smaller)
# Themify       54.8 KB -> 2.7 KB   (16 icons kept, ~95% smaller)
# ElegantIcons  62.2 KB -> 1.2 KB   (2 icons kept,  ~98% smaller)</code></pre>

        <p>The subset files live alongside the originals, and the CSS build swaps the font sources to the subsets at minification time, so the originals remain on hand for the day a new icon appears. That day is the obvious failure mode (use a new icon, forget to re-subset, icon renders as an empty box), which is why the script also writes a manifest of what's included. I have some checks in place to make sure this goes smoothly, and that will be in another blog on how "my website has a test suite". </p>

        <h2>A note on SVG icons</h2>

        <p>Would SVG icons be more modern than icon fonts? Yes, and a fresh site should use them. But the template came with icon fonts wired into everything, and three kilobytes of subset font is not a problem worth a rewrite. So, why not optimise the thing you have.</p>

        <div class="faq-section">
          <h2>Common questions</h2>

          <details class="faq-item">
            <summary>Is self-hosting Google's fonts legal?</summary>
            <p>Yes. Fonts on Google Fonts are under open licences, mostly the SIL Open Font License, which explicitly permits serving them yourself. </p>
          </details>

          <details class="faq-item">
            <summary>How much faster is it, really?</summary>
            <p>Modest and consistent: you save a DNS lookup and TLS handshake to a third domain, which is tens to a couple of hundred milliseconds on first visit. The bigger wins are the icon subsets (about 190 KB of fonts became 5 KB) and immunity to the third party ever being slow. </p>
          </details>

          <details class="faq-item">
            <summary>Do I need all those weights?</summary>
            <p>Almost certainly not, and this site is mildly guilty: seven weights of Poppins is what the template's CSS references, so seven weights it is. </p>
          </details>

          <details class="faq-item">
            <summary>What about variable fonts?</summary>
            <p>A variable font packs every weight into one file and would replace my seven Poppins files with one. It's the right choice for a new build; I keep the static weights because they're what the download offered and the total is already small. </p>
          </details>
        </div>

        

      </main>

        <hr style="margin: 40px 0;">
        <p style="text-align: center;"><a class="post-cta" href="https://www.kenreid.co.uk/blog.html">Back to all posts</a></p>
      ]]></content:encoded>
    </item>
    <item>
      <title>Reading This Site Offline</title>
      <link>https://www.kenreid.co.uk/blog/reading-this-site-offline.html</link>
      <guid>https://www.kenreid.co.uk/blog/reading-this-site-offline.html</guid>
      <pubDate>Sun, 12 Jul 2026 00:00:00 +0000</pubDate>
      <description>This site now works offline: a ~100-line service worker, no framework, no build step. Here&#x27;s how.</description>
      <category>technology</category>
      <content:encoded><![CDATA[
        <h1>Reading This Site Offline</h1>
        <div class="blog-meta">
          12 July 2026 &middot;
          <span class="blog-tag">technology</span>
        </div>

        <p>This site learned a small trick recently: it works without the internet. I implemented it out of curiosity on how it would be done, but I'm glad I did. If someone tries to read one of my blogs while on a plane, now they can, so long as it was preloaded. Read a few posts over breakfast, open the site again, and the pages you visited are still there, styled, functional. If the connection dies mid-browse, it will still work. Nice, right?</p>

        <p>This is done via a <strong>service worker</strong>, and this post walks through mine: ~100 lines of code, no framework, no build step, in keeping with <a href="https://www.kenreid.co.uk/blog/hosting-photography-on-github-for-free.html">this site's general philosophy</a> of doing things with the smallest possible amount of infrastructure. If you run a static site anywhere (GitHub Pages included), this is a good philosophy, in my opinion.</p>

        <div class="plain-english-box">
          <h2>Quick jargon guide</h2>
          <ul>
            <li><strong>Service worker:</strong> a small script the browser installs alongside your site and runs in the background. It sits between your pages and the network, and can answer requests itself, including when there is no network.</li>
            <li><strong>Cache (the browser kind):</strong> a local store of responses the service worker can save and replay. Lives on the visitor's device, controlled by your code.</li>
            <li><strong>Network-first:</strong> a strategy: try the internet, fall back to the cache. Fresh when online, functional when not.</li>
            <li><strong>Stale-while-revalidate:</strong> the inverted strategy: answer instantly from cache, then fetch a new copy in the background for next time. Fast always, fresh eventually, annoying when you are refreshing a page for an update, though.</li>
            <li><strong>Precache:</strong> the short list of files saved at install time, before you've visited anything, the skeleton the site can't render without.</li>
            <li><strong>Cache invalidation:</strong> famously one of the two hard problems in computer science. A service worker is a machine for having this problem on purpose.</li>
            <li><strong>giscus:</strong> the comment system under my posts. It stores every comment as a GitHub Discussion and loads in an embedded frame from giscus.app, so the conversation lives on GitHub rather than on this site.</li>
          </ul>
        </div>

        <h2>Two strategies</h2>

        <p>Everything the worker does comes down to one decision, made per request: <em>who do you trust more, the network or the cache?</em> </p>

        <p><strong>Pages get network-first.</strong> HTML is where mistakes live: a typo fixed, a broken link repaired, a post updated. When you're online I always want you reading the newest deploy, so the worker tries the network, saves a copy of whatever comes back, and only reaches for that copy when the fetch fails:</p>

<pre><code class="language-js">// HTML: network-first
fetch(req).then(function (res) {
  var copy = res.clone();
  caches.open(PAGES_CACHE).then(function (c) { c.put(req, copy); });
  return res;
}).catch(function () {
  return caches.match(req).then(function (hit) {
    return hit || caches.match('./offline.html');
  });
});</code></pre>

        <p>The chain reads as a 'politeness ranking': fresh page if possible, your cached copy if not, and a dedicated offline page as the final backup. Every page you visit while online becomes a page you own while offline, the cache is your personal reading history.</p>

        <p><strong>Assets get stale-while-revalidate.</strong> CSS, JavaScript, fonts, thumbnails, these change rarely and block rendering while they load, so the priorities invert. The worker answers from cache immediately and refreshes in the background:</p>

<pre><code class="language-js">// Assets: stale-while-revalidate
caches.match(req).then(function (hit) {
  var fetching = fetch(req).then(function (res) {
    if (res && res.status === 200) {
      var copy = res.clone();
      caches.open(ASSETS_CACHE).then(function (c) {
        c.put(req, copy);
        if (req.destination === 'image') trimCache(ASSETS_CACHE, IMG_LIMIT);
      });
    }
    return res;
  }).catch(function () { return hit; });
  return hit || fetching;
});</code></pre>

        <p>Worst case, you see a stylesheet that's one visit out of date. In exchange, repeat visits render instantly, offline or not. That <code>trimCache</code> call is the one piece of housekeeping, meaning that image caches are capped at 200 entries, oldest evicted first, so browsing my whole <a href="https://www.kenreid.co.uk/gallery.html">gallery</a> doesn't fill up your phone!</p>

        <h2>What I deliberately don't cache</h2>

        <p>The worker ignores anything cross-origin; the full-size photographs stay on GitHub's release servers, caching those would mean warehousing megabytes per photo on your device for a click-through you probably won't repeat. Comments are <i>giscus</i>, which lives in an iframe and belongs to GitHub; offline, it simply doesn't appear, which is the correct behaviour. Analytics likewise gets no offline resurrection, if the network can't see you, neither should it.</p>

        <p><strong>Offline mode should preserve the reading, not fake the internet.</strong> The precache reflects the same idea: it's just the offline page, the stylesheet, the core scripts, and <code>posts.json</code> (so the blog index still works), so about eleven files, everything else earns its place in your cache by you actually opening it.</p>

        <figure>
          <img src="https://www.kenreid.co.uk/img/photography/thumb/62.webp" alt="A moody grey sea under heavy clouds, distant fish-farm pens near a rocky headland" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
          <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
        </figure>

        <h2>The footguns</h2>

        <p>I'm not really a website developer or designer, beyond hobbyist endeavors like this website and a couple of landing pages for research labs I worked in, so much of this section is just what I learned while, well, learning this stuff. Service workers have a deserved reputation for one specific misery: <em>the cache that would not die.</em> Ship a worker with a careless cache-first strategy and your visitors can be pinned to an old version of your site for days: including, delightfully, an old version of the service worker itself. The classic developer experience is editing a file, refreshing, seeing no change, and spending an hour debugging to find it's actually working as intended and you just turn off your monitor for a break and see your sad reflection staring back at yourself. Ahem, anyway.</p>

        <p>I'll also admit what this isn't: it isn't a full progressive web app. There's no install banner, no background sync. Those are all possible and all, for a site whose job is being read, beside the point.</p>

        <h2>Try it</h2>

        <p>Open a few posts, then turn on airplane mode and keep clicking. The pages you visited load; the search on the blog index still filters; the pages you didn't visit hand you a courteous offline page instead of a browser error. Turn the network back on and the whole arrangement dissolves back into an ordinary website. Much like an IT worker, if it's doing the job correctly, you won't even notice it's there. </p>

        <div class="faq-section">
          <h2>Common questions</h2>

          <details class="faq-item">
            <summary>Why not just cache the entire site up front?</summary>
            <p>Precaching everything downloads megabytes a first-time visitor never asked for, most of which they'll never read. As much as I'd like all my visitors to read all my website, most just swing by for something that interests them, then off they go.</p>
          </details>

          <details class="faq-item">
            <summary>Does this let the site track me offline?</summary>
            <p>No, rather the opposite. The caches live in your browser, managed by your browser, and I have no visibility into them whatsoever. Offline visits send me nothing: no analytics, no logs, no signal you exist. It's the most private way to read the site.</p>
          </details>

          <details class="faq-item">
            <summary>Why not use Workbox or a PWA framework?</summary>
            <p>Workbox is excellent and I'd reach for it on a complex app. For a hundred-line worker on a static site, the abstraction would outweigh the logic: more configuration than code, and one more dependency to keep updated forever.</p>
          </details>

          <details class="faq-item">
            <summary>How do updates reach me if I'm serving from cache?</summary>
            <p>Pages are network-first, so any online visit gets the latest deploy automatically, the cache only speaks when the network can't. </p>
          </details>
        </div>

        

      </main>

        <hr style="margin: 40px 0;">
        <p style="text-align: center;"><a class="post-cta" href="https://www.kenreid.co.uk/blog.html">Back to all posts</a></p>
      ]]></content:encoded>
    </item>
    <item>
      <title>The Abandoned Places I Photograph</title>
      <link>https://www.kenreid.co.uk/blog/abandoned-places-i-photograph.html</link>
      <guid>https://www.kenreid.co.uk/blog/abandoned-places-i-photograph.html</guid>
      <pubDate>Sun, 12 Jul 2026 00:00:00 +0000</pubDate>
      <description>Why I photograph abandoned places and the ethics of pointing a camera at decay.</description>
      <category>photography</category>
      <category>personal</category>
      <content:encoded><![CDATA[
        <h1>The Abandoned Places I Photograph</h1>
        <div class="blog-meta">
          12 July 2026 &middot;
          <span class="blog-tag">photography</span>
          <span class="blog-tag">personal</span>
        </div>

        <p>There's a filter on <a href="https://www.kenreid.co.uk/gallery.html?tag=abandoned">my gallery</a> labelled "abandoned," and it's one of the used tags for my shots: dozens of photographs of peeling wards, graffitied corridors, staircases climbing into darkness. This is a rundown of what draws me to these places, the story of the category I photographed most, and the ethics of pointing a camera at somewhere truly abandoned, often forgotten, but surviving locations preserving history. Photographers call this "urbex".</p>

        <div class="plain-english-box">
          <h2>Quick jargon guide</h2>
          <ul>
            <li><strong>Urbex:</strong> urban exploration, visiting and documenting abandoned or hidden built places. Ranges from respectful photography to (not endorsed here) breaking and entering.</li>
            <li><strong>"Take nothing but photographs":</strong> the urbex code of ethics, usually finished with "leave nothing but footprints." Nothing gets moved, taken, broken, or tagged.</li>
            <li><strong>Ruin porn:</strong> the critical term for decay photography that aestheticises a place's collapse while ignoring the people it happened to. A charge worth taking seriously, which I try to below.</li>
            <li><strong>Village system:</strong> a late-19th-century model for psychiatric hospitals: instead of one grim block, a self-contained village of villas, workshops, and farmland, meant to give patients ordinary life at a humane scale.</li>
          </ul>
        </div>

        <figure style="margin: 24px auto;">
          <img src="https://www.kenreid.co.uk/img/photography/thumb/141.webp" alt="Dark concrete stairwell rising into shadow at Bangour Village Hospital, black and white" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
          <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
        </figure>

        <h2>Bangour, before the diggers</h2>

        <p>The abandoned place I photographed most is Bangour Village Hospital, in the countryside west of Dechmont in West Lothian, Scotland. It opened in October 1906 as the Edinburgh District Asylum, built on the continental "village system": not a single institution looming over its inmates, but a scatter of villas across parkland, with its own power station, workshops, bakery, kitchen, laundry, and eventually its own church and railway. The idea, radical for its time, was that people in psychiatric care should live somewhere shaped like a life. It's something of a rarity to look at history and feel a bit of pride, but here's a nice example (click below for better quality).</p>

        <figure style="margin: 24px auto;">
          <img src="https://www.kenreid.co.uk/img/photography/thumb/5.webp" alt="Ferns and moss reclaiming a glass-roofed hall at Bangour Village Hospital, West Lothian" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
          <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
        </figure>

        <p>History kept interrupting the little care village. The War Office requisitioned the site in both world wars: during the second, a specialist burns and plastic surgery unit set up there in 1940, seeding what became Bangour General Hospital next door. Psychiatric care returned between and after the wars and wound down slowly for decades; the last ward closed in 2004. Then, for twenty years, the village was empty, a listed Edwardian ghost town, which is when I walked its corridors with my Canon 60D.</p>

        <p>I was not the first to see the beauty in the ruins. The film industry found the empty hospital too: it stood in as the asylum in <em>The Jacket</em> (2005), which put Adrien Brody and Keira Knightley into the same corridors, with better lighting and a catering truck. There's something fitting about a place built to be a village ending its working life playing one on camera, and the location scouts and I clearly agree on what those wards look like: somewhere between memory and horror, depending on the light.</p>

        <figure style="margin: 24px auto; max-width: 300px;">
          <img src="https://www.kenreid.co.uk/blog/img/the-jacket-poster.webp" alt="Poster for The Jacket (2005): Keira Knightley and Adrien Brody against a cold, unfocused treeline, tagline 'Terror has a new name'" loading="lazy" style="width:100%; border-radius:8px;">
          <figcaption class="figure-note">Poster for <em>The Jacket</em> (2005). &copy; Warner Bros</figcaption>
        </figure>

        <p>The window has now closed: the site is becoming housing. West Lothian Council approved nearly a thousand homes in late 2024, and construction started in 2025. I have complicated feelings about this, homes are a better use of land than entropy, and yet - it's a beautiful place I wish I had more time to visit. Every photograph in my Bangour set changed category the day the first digger arrived: they used to be pictures of a place, and now they're records of one. That's the strange dividend of this hobby. You think you're taking moody photos; you turn out to have been doing amateur archival work. This is, I realise, <a href="https://www.kenreid.co.uk/blog/what-we-leave-behind.html">a theme I frequent</a>.</p>

        <figure style="margin: 24px auto;">
          <img src="https://www.kenreid.co.uk/img/photography/thumb/139.webp" alt="Colourful 'Welcome to Hell' graffiti in a dark stairwell corner at Bangour Village Hospital" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
          <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
        </figure>

        <h2>Why ruins photograph so well</h2>

        <p>Partly it's light: broken roofs do things with shafts of sun lighting up rooms in ways that are unnatural, or perhaps weirdly natural in the artificial places. Partly it's texture, forty years of peeling paint and ferns growing from between tiles speaks to us about time, and how in a blink of an eye things become and become undone.</p>

        <p>There's also levels of age, eras shown in rot. Graffiti and ancient beer bottles show that others have explored these ruins when they were perhaps fresher, and the security doors and warning signs about CCTV cameras make it feel forbidden.</p>

        <figure style="margin: 24px auto;">
          <img src="https://www.kenreid.co.uk/img/photography/thumb/140.webp" alt="Black and white photograph of graffiti on a tiled hospital wall at Bangour reading 'watch out... because we're watching for u'" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
          <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
        </figure>

        <h2>The ethics of photographing decay</h2>

        <p>"Ruin porn" is a reasonable charge, and the only defence is conduct. The photographs in this post exist because a place was open and I walked in with a camera. Three rules cover what many of us urbex photographers adhere to.</p>

        <ul>
          <li><strong>Take nothing but photographs.</strong> Nothing gets pocketed, moved for a better composition, or "rescued." The scattered papers in that corridor are still scattered exactly as I found them. This is, in part, to not make it obvious I intruded upon the place, so that security isn't tightened, preventing others from experiencing it. It's also something of respect for the natural collapse of a place, allowing us to capture this descent, to see the "what if humans just disappeared one day?" question with visuals.
            <figure style="margin: 24px auto;">
              <img src="https://www.kenreid.co.uk/img/photography/thumb/285.webp" alt="Dim stone room with faint light through small windows and a dusty floor, abandoned site near Falkirk" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
              <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
            </figure>
          </li>
          <li><strong>Force nothing.</strong> If a place isn't open to walk into, the visit doesn't happen. No cut fences, no pried boards, no "it was already broken so." Beyond the obvious legal line, and the law varies enough by country that you should know yours before you go, forcing entry is what turns documentation into damage.
            <figure style="margin: 24px auto;">
              <img src="https://www.kenreid.co.uk/img/photography/thumb/263.webp" alt="Narrow mossy passage between tall stone walls toward an arched doorway, abandoned site near Falkirk" loading="lazy" style="width:auto; max-width:100%; max-height:507px; display:block; margin:0 auto; border-radius:8px;">
              <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
            </figure>
          </li>
          <li><strong>Don't publish the way in.</strong> I'll name Bangour because it's famous, documented, and now a construction site; I don't share access details for anywhere fragile. Every ruin that goes viral with directions attached gets stripped and burned within the year.</li>
        </ul>

        <figure style="margin: 24px auto;">
          <img src="https://www.kenreid.co.uk/img/photography/thumb/162.webp" alt="Derelict ward corridor at Bangour Village Hospital, doors hanging open and decades of papers scattered across the floor" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
          <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
        </figure>

        <p>I'll add a fourth, more personal one: be safe, and bring a friend. If you're somewhere abandoned, you don't know who you'll find there, and walking around with a four to five figure costing camera may lead to desperate people taking something you'd rather keep.</p>

        <h2>Why bother?</h2>

        <p>Because the maintained world is thoroughly photographed and the unmaintained one is disappearing. Because it answers that previous question about what if humans disappeared. Because there's history in old places, stories and a silence that can somehow capture with a camera.</p>

        <figure style="margin: 24px auto;">
          <img src="https://www.kenreid.co.uk/img/photography/thumb/143.webp" alt="Fire-scorched white-tiled room lit by a shaft of light at Bangour Village Hospital" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
          <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
        </figure>

        <h2>Respect</h2>

        <p>A psychiatric hospital is not a neutral ruin. Thousands of people lived entire lives at Bangour: some helped by it, some failed by it, in an era when society's record on mental illness was grim. Photographing their ward as a spooky backdrop would be a small desecration, at least without recognizing it for what it is. The village system deserves to be remembered as what it was: an attempt, flawed but with good intentions, to build kindness at institutional scale. </p>

        <p>An office in the dark, partitions still standing to attention, ceiling tiles mid-collapse. Whatever the last shift was working on, they left much of it on the shelves and the desks.</p>

        <figure style="margin: 24px auto;">
          <img src="https://www.kenreid.co.uk/img/photography/thumb/120.webp" alt="Collapsed ceiling panels hanging over a pitch-black abandoned hall, Scotland, black and white" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
          <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
        </figure>

        <ul>
          <li><strong>For the people nearby.</strong> Ruins have neighbours: farmers whose fences get climbed, families whose street fills with strangers after a site trends, a security guard paid too little to argue with anyone. Being polite and willing to leave when asked costs a photographer nothing and buys the whole hobby its tolerance.
                  <figure style="margin: 24px auto;">
          <img src="https://www.kenreid.co.uk/img/photography/thumb/155.webp" alt="Old glass reception kiosk beside a peeling, debris-strewn wall in an abandoned building, Scotland" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
          <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
        </figure>
                  </li>
          <li><strong>For the dead and the grieving.</strong> Hospitals, asylums, and churches intersect with the worst days of real families, some of whom are alive and searching the same hashtags you post under. Shoot and write with respect.
                  <figure style="margin: 24px auto;">
          <img src="https://www.kenreid.co.uk/img/photography/thumb/133.webp" alt="Graffiti-covered underpass opening onto steps and a stream, an abandoned bicycle inside, Scotland" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
          <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
        </figure>
                  </li>
          <li><strong>For the building itself.</strong> No tagging, no smashing "for the shot," no souvenirs. 
                  <figure style="margin: 24px auto;">
          <img src="https://www.kenreid.co.uk/img/photography/thumb/145.webp" alt="Spray-painted 'Your Next' graffiti in a dim, damp-stained abandoned room, Scotland" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
          <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
        </figure>
                  </li>
          <li><strong>For other explorers.</strong> Passing on a site's condition, hazards, and history is generosity. </li>
        </ul>

        <figure style="margin: 24px auto;">
          <img src="https://www.kenreid.co.uk/img/photography/thumb/374.webp" alt="Dark wooden shed doorway opening onto a green garden with a blue umbrella, Scotland" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
          <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
        </figure>

        <h2>Safety</h2>

        <p>None of the above matters if the floor collapses under you. A decaying building is indifferent to your intentions, and the most common dangers are not the stranger in the dark, it's the joist that rotted through in 2011. The working checklist, assembled from cautious people and a couple of my own near-misses:</p>

        <ul>
          <li><strong>Floors and stairs lie.</strong> Rot hides under intact-looking boards, and a staircase that held the last visitor has one fewer life left than it did. Stay near walls where the structure is strongest, test before trusting.
            <figure style="margin: 24px auto;">
              <img src="https://www.kenreid.co.uk/img/photography/thumb/283.webp" alt="Ruined stone room with a fireplace, ivy through the window and an overturned bucket, near Falkirk" loading="lazy" style="width:auto; max-width:100%; max-height:507px; display:block; margin:0 auto; border-radius:8px;">
              <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
            </figure>
          </li>
          <li><strong>The air has history.</strong> Buildings of Bangour's era mean asbestos, lead paint, pigeon droppings, and mould in quantities you cannot see. Disturb as little dust as possible, skip the crawl spaces. A decent mask is always a wise decision.
            <figure style="margin: 24px auto;">
              <img src="https://www.kenreid.co.uk/img/photography/thumb/259.webp" alt="Crumbling stone archway and chimney stacks against a cloudy sky, abandoned site near Falkirk" loading="lazy" style="width:auto; max-width:100%; max-height:507px; display:block; margin:0 auto; border-radius:8px;">
              <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
            </figure>
          </li>
          <li><strong>Go in daylight, leave a margin.</strong> Light is the photographer's excuse, but it's also the difference between seeing the missing floorboard and finding it. Arrive with hours to spare and leave while you can still find the exit without a torch. Bring a torch, just in case, anyway.
            <figure style="margin: 24px auto;">
              <img src="https://www.kenreid.co.uk/img/photography/thumb/260.webp" alt="Roofless mansion ruin lined with chimneys and empty windows, overgrown with trees, near Falkirk" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
              <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
            </figure>
          </li>
          <li><strong>Never alone, always announced.</strong> Two people minimum, and someone at home who knows the site and when to expect a check-in. A twisted ankle in an empty building with no signal is a story with company and an emergency without.
            <figure style="margin: 24px auto;">
              <img src="https://www.kenreid.co.uk/img/photography/thumb/262.webp" alt="Dark vaulted stone tunnel entrance with a metal gate and foliage, abandoned site near Falkirk" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
              <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
            </figure>
          </li>
          <li><strong>Dress for the building, not the photos.</strong> Boots with soles that shrug off nails, gloves, long sleeves. Tetanus sucks.
            <figure style="margin: 24px auto;">
              <img src="https://www.kenreid.co.uk/img/photography/thumb/265.webp" alt="Black graffiti reading 'They Hang out the Flag of War' on a stone wall, near Falkirk" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
              <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
            </figure>
          </li>
          <li><strong>Know when to stop.</strong> Fresh collapse, a smell of gas, sounds of people who don't want company, or the plain sense that something is wrong: any of these ends the visit.</li>
        </ul>

        <figure style="margin: 24px auto;">
          <img src="https://www.kenreid.co.uk/img/photography/thumb/197.webp" alt="Barred window casting light across the floor of a pitch-black room, Scotland, black and white" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
          <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
        </figure>

        <p>Twenty photographs, several buildings, one country. Scotland is unusually rich in these places: industrial decline, rural depopulation, and a century of ambitious institutional architecture left it with more empty grandeur per square mile than you would think. </p>

        <figure style="margin: 24px auto;">
          <img src="https://www.kenreid.co.uk/img/photography/thumb/284.webp" alt="Jumble of collapsed wooden roof beams and debris in a ruined building, near Falkirk" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
          <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
        </figure>

        <div class="faq-section">
          <h2>Common questions</h2>

          <details class="faq-item">
            <summary>Is urban exploration legal?</summary>
            <p>It depends entirely on where you are and how you enter: the law differs between Scotland, England, the US, and everywhere else, both on accessing land and on entering buildings, and I'm a photographer, not a solicitor.</p>
          </details>

          <details class="faq-item">
            <summary>Do you break into places?</summary>
            <p>No. If it isn't open enough to walk into, I photograph the outside or I go home. </p>
          </details>

          <details class="faq-item">
            <summary>Why won't you say how to get into sites?</summary>
            <p>Because the record shows what happens next: publicity strips a site of its artefacts, then its copper, then, with unfortunate regularity, someone burns it down. </p>
          </details>

          <details class="faq-item">
            <summary>Isn't this just aestheticising other people's misfortune?</summary>
            <p>It can be, which is why the conduct matters more than the composition. A closed hospital documented with context and respect is memory work. I try to stay on the right side of that line, and writing this post is partly a way of being accountable to it.</p>
          </details>
        </div>

        

      </main>

        <hr style="margin: 40px 0;">
        <p style="text-align: center;"><a class="post-cta" href="https://www.kenreid.co.uk/blog.html">Back to all posts</a></p>
      ]]></content:encoded>
    </item>
    <item>
      <title>Why I Love LaTeX</title>
      <link>https://www.kenreid.co.uk/blog/why-i-love-latex.html</link>
      <guid>https://www.kenreid.co.uk/blog/why-i-love-latex.html</guid>
      <pubDate>Sun, 12 Jul 2026 00:00:00 +0000</pubDate>
      <description>I wrote my thesis, my papers, and my CV in LaTeX, and I&#x27;d have it no other way.</description>
      <category>technology</category>
      <category>writing</category>
      <content:encoded><![CDATA[
        <h1>Why I Love LaTeX</h1>
        <div class="blog-meta">
          12 July 2026 &middot;
          <span class="blog-tag">technology</span>
          <span class="blog-tag">writing</span>
        </div>

        <p>My PhD thesis was written in LaTeX, and so were my papers. So, to the muted horror of every recruiter who has ever asked for "the Word version," is my CV. I have spent a meaningful fraction of my adult life inside a markup language originally released in the 1980s, and I am here to tell you it was not Stockholm syndrome, but respect and, I admit, laziness. LaTeX, once you have it down, is so much easier to use than WYSIWYG editors.</p>

        <div class="plain-english-box">
          <h2>Quick jargon guide</h2>
          <ul>
            <li><strong>LaTeX:</strong> (pronounced "lay-tech", definitely not "lay-tecks") a typesetting system: you write plain text with commands like <code>\section{...}</code>, and a compiler turns it into a polished PDF. The standard tool of mathematics, physics, and computer science publishing.</li>
            <li><strong>Typesetting:</strong> the craft of arranging text on a page: spacing, line breaks, hyphenation, where the figures land. What printers did by hand for five centuries.</li>
            <li><strong>Markup:</strong> instructions written <em>in</em> the text, about the text: "this is a heading," "emphasise this." As opposed to clicking a toolbar and hoping.</li>
            <li><strong>WYSIWYG:</strong> "what you see is what you get". Word, Google Docs. You edit the final appearance directly, which sounds obviously better except (it isn't).</li>
            <li><strong>Compiling:</strong> running the program that converts your marked-up text into the finished document. Comes with error messages best described as "vintage."</li>
          </ul>
        </div>

        <h2>The separation that changes everything</h2>

        <p>LaTeX's founding idea is that <em>what you're saying</em> and <em>how it looks</em> are different jobs, done best by different parties. You handle the words and the structure: this is a chapter, this is a definition, this deserves emphasis. Design is handled through clearly defined rules that generally affect the whole document at once: spacing, hyphenation, figure placement, the numbering of every section, equation, and reference. In Word, this is a standard experience:</p>

        <figure style="margin: 24px auto; max-width: 500px;">
          <img src="https://www.kenreid.co.uk/blog/img/word-one-pixel-meme.jpeg" alt="Anakin and Padme meme: 'Moving an image in MS Word by 1 pixel', 'This won't mess the whole document up, right?', and the meme's own panels are broken and misaligned as the answer." loading="lazy" style="width:100%; border-radius:8px;">
        </figure>

        <p>It's the same principle that makes good data science work, too: separate the content from the presentation and both improve. My thesis had hundreds of numbered things: figures, tables, theorems, references to all of the above. Every single number was generated, every cross-reference resolved automatically, every citation formatted from a bibliography file I never once styled by hand. I've known people to fight their word processors for whole afternoons over master's theses a third the length. I fought mine too, but it's often a fight fought once per type of issue, not every citation, and every plot.</p>

        <h2>It's just text</h2>

        <p>A LaTeX document is a plain text file, which also allows for:</p>

        <ul>
          <li><strong>Version control.</strong> My thesis lived in git, like code, because it <em>was</em> code. Every draft diffable, every deleted paragraph recoverable, every "what did I change since my supervisor read this" answerable.</li>
          <li><strong>Longevity.</strong> The <code>.tex</code> files from decades ago still compile. No format lock-in, no "this document was created in a newer version," no ransom paid to any vendor.</li>
          <li><strong>Tooling.</strong> Search it, script it, generate it. When my experiments produced results, code wrote the tables directly into the document. No transcription, no transcription <em>errors</em>.</li>
          <li><strong>Focus.</strong> A text file cannot distract you with 40 fonts. The absence of formatting options while drafting is not a limitation; it's the whole trick.</li>
          <li><strong>Frugality.</strong> You don't need an Office 365 license to use LaTeX, it's completely free if you run it locally.</li>
        </ul>

        <p>It looks like this:</p>

<pre><code class="language-latex">\documentclass[11pt]{report}
\usepackage{amsmath}
\usepackage[backend=biber]{biblatex}
\addbibresource{thesis.bib}

\begin{document}

\chapter{Introduction}
Scheduling problems are, politely put, everywhere.
As shown in Section~\ref{sec:motivation}, the real
question is not whether to optimise but what to
optimise \emph{for}.

\end{document}</code></pre>

        <h2>The Output</h2>

        <p>LaTeX documents <em>look right</em>, obvious once you've compared them side by side with the alternative: the line spacing, the justified text with proper hyphenation, the kerning, the ligatures, mathematics set the way mathematics should look, without the nightmare-inducing Word equation editor. Knuth built the underlying engine, TeX, because he was (rightly) offended by how his own books were being typeset, and that offence, refined over decades, is now free software! When my thesis came back from the binders it looked like a real book, because by every standard that matters it was typeset like one.</p>

        <h2>Getting started</h2>

        <p>Rather than describe it further, here is a complete LaTeX document. It shows the three things beginners care about: a title block, mathematics, and a table.</p>

<pre><code class="language-latex">% Getting started with LaTeX - the whole document, no hidden parts.
\documentclass[11pt]{article}

\usepackage[a4paper, margin=2.5cm]{geometry}
\usepackage{amsmath}
\usepackage{booktabs}

\title{Getting Started with \LaTeX}
\author{Ken Reid \\ \texttt{kenreid.co.uk}}
\date{July 2026}

\begin{document}
\maketitle

\section{How this works}
You are reading a PDF that was generated from about forty
lines of plain text. Headings are numbered automatically,
and nothing on this page was nudged into place by hand.

\section{Mathematics, the party trick}
Notation that word processors fight you over is native here:
\[
  \hat{\theta} \;=\; \arg\max_{\theta}
    \sum_{i=1}^{n} \log p(x_i \mid \theta)
\]

\section{Structure for free}
\begin{itemize}
  \item Sections, figures, and equations number themselves.
  \item Cross-references update when things move.
  \item Bibliographies format themselves from a reference file.
\end{itemize}

\begin{center}
\begin{tabular}{lcc}
  \toprule
  Tool     & Learning curve & Ceiling \\
  \midrule
  Word     & flat           & low     \\
  Markdown & flat           & medium  \\
  \LaTeX{} & steep          & none    \\
  \bottomrule
\end{tabular}
\end{center}

\end{document}</code></pre>

        <p>And here is exactly what that produces:</p>

        <figure style="margin: 24px auto; max-width: 760px;">
          <object data="latex/getting-started.pdf" type="application/pdf" style="width:100%; height:560px; border-radius:8px;">
            <p>Your browser would rather download PDFs than display them: <a href="https://www.kenreid.co.uk/blog/latex/getting-started.pdf">open the compiled page here</a>.</p>
          </object>
          <figcaption class="figure-note">The compiled result. Also available as <a href="https://www.kenreid.co.uk/blog/latex/getting-started.pdf">the PDF</a> or <a href="https://www.kenreid.co.uk/blog/latex/getting-started.tex">the raw .tex source</a>.</figcaption>
        </figure>

        <p>To try it yourself, no installation required: <a href="https://www.overleaf.com/docs?snip_uri=https%3A%2F%2Fwww.kenreid.co.uk%2Fblog%2Flatex%2Fgetting-started.tex" target="_blank" rel="noopener noreferrer"><strong>open this exact file in Overleaf</strong></a>, which will import the source into a free editor in your browser. You need an account, but it makes the whole process of writing LaTeX much easier. Change a word, press Recompile (CTRL-S or CMD-S), and boom, a beautiful PDF.</p>

        <h2>LaTeX is Used Everywhere</h2>

        <p>The getting-started page above also explains, in miniature, why LaTeX became the default in three particular worlds:</p>

        <ul>
          <li><strong>Research.</strong> Mathematics is native, citations manage themselves from a reference file, and nearly every journal and conference publishes a LaTeX template that formats your paper to their rules automatically. Overleaf added the last missing piece: real-time collaboration, so co-authors edit one live document instead of emailing <code>final_v7_REVISED.docx</code> into the email archives.</li>
          <li><strong>Tech fields.</strong> Plain text means version control, diffs, code review for documents, and automation: results generated by your code can be written into your report by your code. A document pipeline you can script is a document pipeline that scales, which is why it fits engineering culture like a glove.</li>
          <li><strong>Professional documents.</strong> Anything long-lived and repeatedly revised (a CV, a report series, a book) benefits from separating content from presentation. The typography does the rest of the work for you, every single compile, forever. </li>
        </ul>

        <h2>The real costs</h2>

        <p>Love with no complaints is marketing, so: the error messages are archaeological artefacts, a missing brace can produce forty lines of complaint pointing somewhere unrelated, and "Overfull \hbox (badness 10000)" is a sentence I have read several thousand times. And the learning curve is real: the first document takes an evening; the first document you're proud of takes longer.</p>

        <p>Every one of those costs is paid in the first ten percent of a document's life, while the benefits (the automation, the consistency, the diffs, the typography) compound. LaTeX front-loads its pain while word amortises its pain across every day you use it.</p>

        <h2>Who should actually learn it</h2>

        <p>Not everyone, truthfully. If you write short documents with simple structure, modern tools are fine and Markdown is friendlier still. But if you write anything long, structured, numbered, cited, or mathematical (a thesis, a book, a paper, a technical report, or a CV you'll be revising for the next thirty years) the evening it takes to start is one of the best-paying evenings available to you. Overleaf runs it in a browser now, with none of the old installation hazing. </p>

        <p>Mine was simple: the thesis was going to have my name on it forever. It seemed worth typesetting like a real publication should be.</p>

        <div class="faq-section">
          <h2>Common questions</h2>

          <details class="faq-item">
            <summary>Isn't LaTeX overkill for normal documents?</summary>
            <p>For a one-page letter, absolutely. The break-even point is roughly where structure appears: numbered sections, citations, figures that must stay labelled and cross-referenced. </p>
          </details>

          <details class="faq-item">
            <summary>Overleaf or a local installation?</summary>
            <p>Overleaf to learn and collaborate: zero setup, live preview, your co-author can't break your machine. Local (TeX Live plus any good editor) once you want speed, offline work, and git. </p>
          </details>

          <details class="faq-item">
            <summary>Is LaTeX still relevant with modern tools and AI writing assistants?</summary>
            <p>More than ever, oddly: it's plain text, so every modern tool (git, scripts, LLMs) can read and write it natively. </p>
          </details>

          <details class="faq-item">
            <summary>What about your CV claim, seriously, LaTeX for a CV?</summary>
            <p>Seriously: one source file, decades of updates, perfect consistency, and tailored variants generated by commenting sections in and out. </p>
          </details>
        </div>

        

      </main>

        <hr style="margin: 40px 0;">
        <p style="text-align: center;"><a class="post-cta" href="https://www.kenreid.co.uk/blog.html">Back to all posts</a></p>
      ]]></content:encoded>
    </item>
    <item>
      <title>What 50,000 Scrobbles Say About Me</title>
      <link>https://www.kenreid.co.uk/blog/what-50000-scrobbles-say-about-me.html</link>
      <guid>https://www.kenreid.co.uk/blog/what-50000-scrobbles-say-about-me.html</guid>
      <pubDate>Mon, 06 Jul 2026 00:00:00 +0000</pubDate>
      <description>Fifteen years of Last.fm data - what I listen to when I&#x27;m feeling joyful, depressed, doing work, working out or studying.</description>
      <category>personal</category>
      <category>music</category>
      <content:encoded><![CDATA[
        <h1>What 50,000 Scrobbles Say About Me</h1>
        <div class="blog-meta">
          6 July 2026 &middot;
          <span class="blog-tag">personal</span>
          <span class="blog-tag">music</span>
        </div>

        <p>As I write this, my Last.fm profile reads 49,716 scrobbles, which means that sometime in the next couple of weeks, probably while I'm doing the dishes or debugging something, a counter I switched on 15.5 years ago will tick to 50,000.</p>

        <p>The counter has been running since the 10th of March, 2011. While I've always failed to keep a diary throughout my life, it turns out I've been keeping one anyway, fifteen years long, written one song at a time, and I only recently thought to read it.</p>

        <div class="plain-english-box">
          <h2>A quick glossary</h2>
          <ul>
            <li><strong>Scrobble:</strong> a record of one song you listened to, sent automatically to a service called Last.fm. </li>
            <li><strong>Last.fm:</strong> a website that has been logging what its users listen to since 2002.</li>
            <li><strong>Loved track:</strong> a manual button on Last.fm for marking a song you, well, love. </li>
          </ul>
        </div>

        <h2>The top ten makes no sense</h2>

        <p>Here is what fifteen years of listening looks like when you sort it:</p>

        <figure>
          <img class="theme-img-light" src="https://www.kenreid.co.uk/blog/img/scrobbles-top-artists-light.png" alt="Horizontal bar chart of my top ten Last.fm artists: Ludovico Einaudi 1,312 plays; Pogo 1,289; Jeremy Soule 1,160; Alkaline Trio 708; R.E.M. 666; Brad Sucks 605; The Smashing Pumpkins 602; The Strokes 596; Benjamin Monday 523; Andy McKee 506." loading="lazy" style="width:100%; border-radius:8px;">
          <img class="theme-img-dark" src="https://www.kenreid.co.uk/blog/img/scrobbles-top-artists-dark.png" alt="Horizontal bar chart of my top ten Last.fm artists: Ludovico Einaudi 1,312 plays; Pogo 1,289; Jeremy Soule 1,160; Alkaline Trio 708; R.E.M. 666; Brad Sucks 605; The Smashing Pumpkins 602; The Strokes 596; Benjamin Monday 523; Andy McKee 506." loading="lazy" style="width:100%; border-radius:8px;">
          <figcaption class="figure-note">My top ten artists by scrobbles, as of July 2026. Data from Last.fm.</figcaption>
        </figure>

        <p>Read as a list of favourite artists, this is gibberish. An Italian pianist, an Australian producer who makes music out of chopped-up Disney films, the man who composed the orchestral soundtrack to Skyrim, a Chicago punk band (who I entirely disavow now), and R.E.M., all sharing a podium. I guess this might be why the algorithms of music apps really struggle to find music I might like. It may as well just hit shuffle and hope for the best.</p>

        <p>But that's because the chart isn't a list of favourites. It's strata. Each of those artists is a geological layer from a different era of my life, compressed into one picture. So let me read it the order it was written.</p>

        <h2>The Scotland years</h2>

        <p>Alkaline Trio, Yellowcard, Jimmy Eat World, The Smashing Pumpkins, The Strokes, The White Stripes, Manic Street Preachers, R.E.M. This is the music of being young in Scotland: burned CDs that I'd play in my parents car (they were too kind in letting me play Dragonforce and Children of Bodom, ooft), band t-shirts, and lots and sitting in my room with Ultimate Guitar on my computer screen trying to play songs way too complex for my intermediate level guitar skills.</p>

        <p>The evidence survives. Here is me playing "Cherub Rock" by The Smashing Pumpkins, 17 years ago:</p>

        <figure style="margin: 24px auto; display: flex; flex-direction: column; align-items: center; text-align: center;">
          <button type="button" class="kr-embed-facade" data-embed-src="https://www.youtube.com/embed/bqK6W5Yok9g" data-embed-height="315" data-embed-title="Cherub Rock Cover"><span class="kr-embed-facade__play" aria-hidden="true"></span><span class="kr-embed-facade__title">Cherub Rock Cover</span><span class="kr-embed-facade__note">Click to load the YouTube player</span></button>
        </figure>

        <p>A few gigs made it into this layer too: Rammstein (my first, at twelve: being surrounded by that much fire leaves an impression), The White Stripes, Smashing Pumpkins. All were great (though I ended up seeing Smashing Pumpkins 3x in total, with each time presenting less excitement from the band, then Billy Corgan spat into the crowd and onto my face, which I was less than pleased about).</p>

        <p>What strikes me now is that almost none of this layer is still accumulating. R.E.M. sits at 666 plays, and most of the others have barely moved in a decade. The person who needed them gradually changed, the way a diary's handwriting changes without any single entry looking different from the last. Though I do still listen to a lot of Dragonforce and Symphony X.</p>

        <h2>The free internet</h2>

        <p>The next layer is stranger, and I suspect only people of a very specific internet generation and online set of communities will get: Brad Sucks, Josh Woodward, Jonathan Coulton, Lemon Demon, and Pogo, who between them account for thousands of listens. My sixth most-played artist of all time is a one-man band from Canada who called himself Brad Sucks and gave his music away for free on the internet. I actually talked to him once, and he came across as depressed as his songs would indicate he is, which in retrospect I shouldn't have been surprised by. He's a good example of the tortured artist, creating beautiful music out of a tough life.</p>

        <p>This was the era I've <a href="https://www.kenreid.co.uk/blog/life-and-death-of-the-early-internet.html">written about before</a>, when the internet felt like a place made by people rather than companies. Musicians put entire albums online for nothing, because the idea that strangers anywhere in the world could hear your songs was still intoxicating enough to be its own payment. I found Brad Sucks through Last.fm, Jonathan Coulton through WCRadio (warcraft radio) podcasts, and Lemon Demon through a Flash animation about fictional characters having a fight (though this isn't even Neil's best song, FYI, just the most well known).</p>

        <figure style="margin: 24px auto; display: flex; flex-direction: column; align-items: center; text-align: center;">
          <button type="button" class="kr-embed-facade" data-embed-src="https://www.youtube.com/embed/lrzKT-dFUjE" data-embed-height="315" data-embed-title="Lemon Demon - The Ultimate Showdown"><span class="kr-embed-facade__play" aria-hidden="true"></span><span class="kr-embed-facade__title">Lemon Demon - The Ultimate Showdown</span><span class="kr-embed-facade__note">Click to load the YouTube player</span></button>
          <figcaption style="margin-top: 8px; font-size: 0.8em; max-width: 560px;">
            That Flash animation: "The Ultimate Showdown of Ultimate Destiny." 
          </figcaption>
        </figure>

        <h2>The loading screen years</h2>

        <p>Jeremy Soule, who scored Morrowind, Oblivion, and Skyrim, sits at number three with 1,160 plays. Below him: Jason Hayes and Russell Brower, composers for World of Warcraft. Howard Shore's Lord of the Rings scores. Ben Prunty's soundtrack for FTL. C418's Minecraft music. Marcin Przyby&#322;owicz's Witcher 3 score, which I was apparently listening to earlier this week.</p>

        <p>My eleventh most-played artist of all time is <em>Paradox Interactive</em>, which is a Swedish video game publisher. I have played their grand strategy games so much that an entire corporation is functionally one of my favourite bands, sitting comfortably above Rammstein, which is an odd thought.</p>

        <p>It would be easy to read this layer as "Ken played too many video games" (which, well, yeah) but I think it also is the point where music stopped being something I identified with from a teenage angst perspective, worrying about girls or how I was perceived. Game soundtracks are often orchestral, epic, or relaxing, comforting, nostalgic. They accompany you, painting an aural embellishment on top of the stories, the visuals and the characters you play as and with. </p>

        <p>If you want one track that explains this entire layer, it's "Totems of the Grizzlemaw", the Grizzly Hills music from Wrath of the Lich King. I have spent more hours inside this piece of music than many of my favourite albums combined:</p>

        <figure style="margin: 24px auto; display: flex; flex-direction: column; align-items: center; text-align: center;">
          <button type="button" class="kr-embed-facade" data-embed-src="https://www.youtube.com/embed/miqUeM1aSrQ" data-embed-height="315" data-embed-title="Totems of the Grizzlemaw - World of Warcraft: Wrath of the Lich King"><span class="kr-embed-facade__play" aria-hidden="true"></span><span class="kr-embed-facade__title">Totems of the Grizzlemaw - World of Warcraft: Wrath of the Lich King</span><span class="kr-embed-facade__note">Click to load the YouTube player</span></button>
          <figcaption style="margin-top: 8px; font-size: 0.8em; max-width: 560px;">
            "Totems of the Grizzlemaw", from the Wrath of the Lich King soundtrack. 
          </figcaption>
        </figure>

        <p>I loved it enough that it crossed the line from accompaniment back into activity: ten years ago, in the middle of the chaos of renovating my home at the time, I sat down amidst the "stuff" room and recorded an acoustic cover of it. I also <a href="https://tabs.ultimate-guitar.com/tab/misc-computer-games/world-of-warcraft-wrath-of-the-lich-king-totems-of-the-grizzlemaw-tabs-1801728" target="_blank" rel="noopener noreferrer">wrote up the tab</a>, so the Ultimate Guitar habit from the Scotland years clearly never left either:</p>

        <figure style="margin: 24px auto; display: flex; flex-direction: column; align-items: center; text-align: center;">
          <button type="button" class="kr-embed-facade" data-embed-src="https://www.youtube.com/embed/oLqKMQ6H4DM" data-embed-height="315" data-embed-title="WotLK Grizzly Hills Day Music - Acoustic Cover by Ken Reid"><span class="kr-embed-facade__play" aria-hidden="true"></span><span class="kr-embed-facade__title">WotLK Grizzly Hills Day Music - Acoustic Cover by Ken Reid</span><span class="kr-embed-facade__note">Click to load the YouTube player</span></button>
        </figure>

        <p>It wasn't a one-off, either. Here's me playing Dan Romer's Far Cry 5 melody, using a tab by Eddie van der Meer:</p>

        <figure style="margin: 24px auto; display: flex; flex-direction: column; align-items: center; text-align: center;">
          <button type="button" class="kr-embed-facade" data-embed-src="https://www.youtube.com/embed/rL-C8x0VAAs" data-embed-height="315" data-embed-title="Far Cry 5 Melody by Dan Romer, played by Ken Reid"><span class="kr-embed-facade__play" aria-hidden="true"></span><span class="kr-embed-facade__title">Far Cry 5 Melody by Dan Romer, played by Ken Reid</span><span class="kr-embed-facade__note">Click to load the YouTube player</span></button>
        </figure>

        <h2>Music to think to</h2>

        <p>My number one artist of all time, the musician the data says I love more than any other, is Ludovico Einaudi, a minimalist Italian pianist, at 1,312 plays.</p>

        <p>I do genuinely like Einaudi. But he is not my favourite artist. He is my favourite <em>colleague</em>. Einaudi is what plays when I'm writing a paper, or reading about some technical concept. He is a large part of my "Study" playlist. The same is true of the rest of this layer: Andy McKee and Don Ross's fingerstyle guitar, the Oscar Peterson Trio, Mike Oldfield, an ocean of lofi channels with names like oatmello and Lofi Fruits Music, and Bach, who has been study music for three hundred years and remains undefeated. The <a href="https://www.kenreid.co.uk/music.html">study playlist</a> is accessible if you want to hear it.</p>

        <figure style="margin: 24px auto; display: flex; flex-direction: column; align-items: center; text-align: center;">
          <button type="button" class="kr-embed-facade" data-embed-src="https://www.youtube.com/embed/Htk5IuW5JHE" data-embed-height="315" data-embed-title="Ludovico Einaudi - I Giorni (Live at The Royal Albert Hall)"><span class="kr-embed-facade__play" aria-hidden="true"></span><span class="kr-embed-facade__title">Ludovico Einaudi - I Giorni (Live at The Royal Albert Hall)</span><span class="kr-embed-facade__note">Click to load the YouTube player</span></button>
        </figure>

        <p>This is the layer that has been accumulating through my PhD and my postdocs, and it reveals that <strong>the artist at the top of my all-time list got there by being background.</strong> So, I guess scrobbles don't measure love. Einaudi is number one for the same reason my office chair has more hours with me than my closest friends do.</p>

        <h2>Music My Partner Showed Me</h2>

        <p>One of the cool things about being with a musician is they open your mind up to not one but many new worlds of music. Suddenly I'm listening to beautiful folk pieces that speak to my heart:</p>

        <figure style="margin: 24px auto; display: flex; flex-direction: column; align-items: center; text-align: center;">
          <button type="button" class="kr-embed-facade" data-embed-src="https://www.youtube.com/embed/LDIfcBmCgQ8" data-embed-height="315" data-embed-title="Fey Fili - Suzy &amp; Sam"><span class="kr-embed-facade__play" aria-hidden="true"></span><span class="kr-embed-facade__title">Fey Fili - Suzy &amp; Sam</span><span class="kr-embed-facade__note">Click to load the YouTube player</span></button>
        </figure>

        <p>Bollywood songs that I find myself addicted to (and learned the dance to):</p>

        <figure style="margin: 24px auto; display: flex; flex-direction: column; align-items: center; text-align: center;">
          <button type="button" class="kr-embed-facade" data-embed-src="https://www.youtube.com/embed/jCEdTq3j-0U" data-embed-height="315" data-embed-title="Gallan Goodiyaan - Dil Dhadakne Do"><span class="kr-embed-facade__play" aria-hidden="true"></span><span class="kr-embed-facade__title">Gallan Goodiyaan - Dil Dhadakne Do</span><span class="kr-embed-facade__note">Click to load the YouTube player</span></button>
        </figure>

        <p>And somehow ended up being featured in a music video by a musical artist dear to us both (00:48 seconds):</p>

        <figure style="margin: 24px auto; display: flex; flex-direction: column; align-items: center; text-align: center;">
          <button type="button" class="kr-embed-facade" data-embed-src="https://www.youtube.com/embed/zccNFpngv7o?start=48" data-embed-height="315" data-embed-title="Na&iuml;ka - SOLEIL (Official Visualizer)"><span class="kr-embed-facade__play" aria-hidden="true"></span><span class="kr-embed-facade__title">Na&iuml;ka - SOLEIL (Official Visualizer)</span><span class="kr-embed-facade__note">Click to load the YouTube player</span></button>
        </figure>

        <h2>What the diary leaves out</h2>

        <p>Like any diary, the record lies partly by omission. There are no scrobbles for the first chunk of my life, so the bands of my childhood are missing entirely (lots and lots of Mike Oldfield, The White Stripes and Busted), as is anything played on a car radio, at a gig, or in someone else's kitchen, or even on apps where I can't so easily send the listening data to Last.fm. </p>

        <p>And the counting itself is skewed in a way any data scientist will recognise: it measures frequency when what we usually want is intensity. Last.fm actually has a correction for this, the "loved track" button, which requires a deliberate click rather than passive playback. I have 559 loved tracks against 49,716 scrobbles. I've <a href="https://www.kenreid.co.uk/blog/rating-systems.html">written before</a> about how badly rating systems capture what we actually value, and my own listening data turns out to be the same story: the chart knows exactly what I did and almost nothing about what it meant.</p>

        <p>If you want to poke through the full dataset yourself, the live charts below are generated straight from my profile:</p>

        <figure style="margin: 24px auto;">
          <iframe src="https://lastfmstats.com/user/gohex/charts" title="Live Last.fm statistics for gohex" loading="lazy" style="border: none; border-radius: 8px; width: 100%;" height="900"></iframe>
          <figcaption class="figure-note">Live charts from lastfmstats.com. Unlike the rest of this post, these will keep updating after I hit publish.</figcaption>
        </figure>

        <p>And for more of all this, the <a href="https://www.kenreid.co.uk/music.html">music page</a> is the living version of this post: the album wall, the study playlist, and stats that keep counting while I'm not looking.</p>

        <div class="faq-section">
          <h2>Common questions</h2>

          <details class="faq-item">
            <summary>What counts as a scrobble?</summary>
            <p>A play of a track, logged automatically by whatever player or streaming service you've connected to Last.fm. Most scrobblers only count a play once you're at least halfway through the song, so skips don't count.</p>
          </details>

          <details class="faq-item">
            <summary>Isn't letting a website log fifteen years of your listening a bit creepy?</summary>
            <p>I really don't care.</p>
          </details>

          <details class="faq-item">
            <summary>Do you still scrobble everything?</summary>
            <p>Everything that goes through my own devices, yes. After fifteen years it would feel strange to stop. The gaps would bother me more than the surveillance of myself does.</p>
          </details>
        </div>

        

      </main>

        <hr style="margin: 40px 0;">
        <p style="text-align: center;"><a class="post-cta" href="https://www.kenreid.co.uk/blog.html">Back to all posts</a></p>
      ]]></content:encoded>
    </item>
    <item>
      <title>Hosting a Photography Portfolio on GitHub for Free</title>
      <link>https://www.kenreid.co.uk/blog/hosting-photography-on-github-for-free.html</link>
      <guid>https://www.kenreid.co.uk/blog/hosting-photography-on-github-for-free.html</guid>
      <pubDate>Mon, 06 Jul 2026 00:00:00 +0000</pubDate>
      <description>GitHub Pages caps your site at 1 GB, and my photography portfolio is bigger than that on its own. Here&#x27;s the free workaround: thumbnails in the repo, full-size originals in a GitHub release.</description>
      <category>photography</category>
      <category>technology</category>
      <content:encoded><![CDATA[
        <h1>Hosting a Photography Portfolio on GitHub for Free</h1>
        <div class="blog-meta">
          6 July 2026 &middot;
          <span class="blog-tag">photography</span>
        </div>

        <p>My <a href="https://www.kenreid.co.uk/gallery.html">photography gallery</a> currently serves 515 full-resolution photos, about 1.5&nbsp;GB of images, to anyone who clicks on them. My monthly hosting bill for this is zero. </p>

        <p>The catch is that GitHub Pages, which hosts this entire site, has a hard rule: a published site may be no larger than 1&nbsp;GB. My photo collection is bigger than my entire allowance, ignoring all the blog content, and the rest of this website. This post is about GitHub <strong>releases</strong>.</p>

        <div class="plain-english-box">
          <h2>Quick jargon guide</h2>
          <ul>
            <li><strong>GitHub Pages:</strong> GitHub's free static-site hosting, push HTML to a repository and it becomes a website. It's what serves the page you're reading.</li>
            <li><strong>Repository (repo):</strong> a project folder tracked by git, holding every file and the full history of every change ever made to it.</li>
            <li><strong>Release:</strong> a snapshot of a repository at a point in time, to which you can attach downloadable files ("release assets"), normally installers and binaries, in this post something else entirely.</li>
            <li><strong>Git LFS:</strong> Large File Storage, GitHub's official add-on for versioning big files. Solves repo bloat by charging you money instead.</li>
            <li><strong>Egress:</strong> cloud-billing jargon for data leaving a provider's servers, i.e. what it costs when someone downloads your photo. The line item that ends up dominating image hosting bills.</li>
            <li><strong>Manifest:</strong> a plain list of files (here, a JSON file naming every photo) that a page reads so it knows what exists without asking a server to enumerate anything.</li>
            <li><strong>WebP:</strong> a modern image format that compresses far smaller than JPEG or PNG at similar quality, ideal for thumbnails.</li>
            <li><strong>CLIP:</strong> an OpenAI model that matches images against text descriptions, which makes it a free, automatic photo-tagger.</li>
          </ul>
        </div>

        <h2>Hitting the wall</h2>

        <p>When I first added my photography to this site, I exported the photos and committed them to the repository like any other asset. It worked, for a good few years. Then at some random point well past the 1GB limit, GitHub let me know, politely but firmly, that this was no longer going to fly.</p>

        <p>Worse, the damage outlives the mistake. Git never forgets: even after I removed the full-size photos from the site, every committed version of every photo remains in the repository's history. My <code>.git</code> pack sits at 4.91&nbsp;GiB to this day, a monument to the months when I treated a version control system like a hard drive. (You can scrub history with tools like <code>git filter-repo</code>, but every clone, fork, and local copy has to be reconciled with the rewrite, and for a personal site it has so far been easier to live with the scar tissue.)</p>

        <p>So the problem statement became: keep the site on GitHub Pages, keep the photos at full resolution, pay nothing, and stay under 1&nbsp;GB. The numbers to beat, for the curious:</p>

        <ul>
          <li><strong>1 GB</strong>, maximum size of a published GitHub Pages site (and the recommended ceiling for the repository itself).</li>
          <li><strong>100 MB</strong>, maximum size of any single file pushed to a repository.</li>
          <li><strong>100 GB/month</strong>, the soft bandwidth guideline for Pages sites.</li>
          <li><strong>2 GiB</strong>, maximum size of a single <em>release asset</em>, with no documented cap on the total size of a release.</li>
        </ul>

        <h2>So: releases are a free bucket</h2>

        <p>GitHub releases exist so projects can attach compiled binaries to a version tag: you tag <code>v1.0</code>, you upload the installers, users download them. But strip away the intent and a release is just an object store bolted onto your repository. Files up to 2&nbsp;GiB each, no total-size cap in the documentation, served from GitHub's download infrastructure rather than your Pages allowance, and addressable by a clean, predictable URL:</p>

        <p style="overflow-x:auto;"><code>https://github.com/&lt;user&gt;/&lt;repo&gt;/releases/download/&lt;tag&gt;/&lt;filename&gt;</code></p>

        <p>So my full-size photos live in a release called <code>photos-v1</code>, attached to this very website's repository. Uploading is two commands with the <a href="https://cli.github.com/" target="_blank" rel="noopener noreferrer">gh CLI</a>:</p>

        <p style="overflow-x:auto;"><code>gh release create photos-v1 --title "Full-size photography" --notes "Gallery originals"</code><br>
        <code>gh release upload photos-v1 photos/*.png</code></p>

        <p>That release now holds all 515 originals, 1.55&nbsp;GB of them, which is to say: <strong>the "attachment" stapled to my repository is bigger than the entire website is allowed to be.</strong> GitHub is fine with this. Releases are a supported distribution mechanism for a repository's files, and these are, quite literally, the files this repository's site is built around.</p>

        <h2>The full pipeline</h2>

        <p>The release solves storage, but you can't lazily browse a 1.5&nbsp;GB folder from a phone. The other half of the architecture is the oldest idea in web galleries: thumbnails for browsing, originals on demand. Here's the whole pipeline, which is a handful of small Python scripts I run locally when I add photos:</p>

        <ol>
          <li><strong>Normalise filenames.</strong> Every photo becomes a number: <code>417.png</code>. No <code>IMG_20250612_final_FINAL(2).jpg</code>. Numeric names make the manifest trivial and the URLs boring, and boring URLs are the ones that keep working.</li>
          <li><strong>Generate thumbnails.</strong> Each original gets a compressed WebP thumbnail (<code>img/photography/thumb/417.webp</code>). These <em>do</em> live in the repository, 492 of them (a couple dozen originals are heroes and duplicates that never got gallery thumbnails), because collectively they're small enough to sit comfortably inside the Pages budget. This is the only image data the site itself hosts.</li>
          <li><strong>Auto-tag with CLIP.</strong> A local script runs every photo through OpenAI's CLIP model to classify it (landscape, urban, wildlife, winter&hellip;) and writes the results to a JSON file. I wrote about this in <a href="https://www.kenreid.co.uk/blog/photo-tagging-with-clip.html">its own post</a>; those tags drive the gallery's filter buttons, and I never manually categorise anything.</li>
          <li><strong>Write a manifest.</strong> A JSON list of every filename in the release. The gallery page fetches this instead of asking the GitHub API, so the site works even when the API is rate-limiting.</li>
          <li><strong>Upload the originals</strong> to the release with <code>gh release upload</code>, and commit the thumbnails plus the two JSON files.</li>
        </ol>

        <p>Since step 2 is where the actual "optimisation" happens, here it is in full, this is the entire thumbnail pipeline, and it hasn't needed to change in years:</p>

<pre><code class="language-python">from pathlib import Path

from PIL import Image
from tqdm import tqdm

PHOTOS = Path("img/photography")
THUMBS = PHOTOS / "thumb"
WIDTH = 400
QUALITY = 80

THUMBS.mkdir(exist_ok=True)
photos = sorted(PHOTOS.glob("*.png"))

before = after = 0
for photo in tqdm(photos):
    img = Image.open(photo).convert("RGB")
    height = round(img.height * WIDTH / img.width)
    thumb = img.resize((WIDTH, height), Image.LANCZOS)

    out = THUMBS / (photo.stem + ".webp")
    thumb.save(out, "WEBP", quality=QUALITY)

    before += photo.stat().st_size
    after += out.stat().st_size

print(f"{len(photos)} photos, {before/1e6:.0f} MB -> {after/1e6:.1f} MB "
      f"({1 - after/before:.0%} saved)")</code></pre>

        <p>Three choices doing the work. <code>LANCZOS</code> is the slowest and best-looking of Pillow's resampling filters, at thumbnail sizes the difference from the default is visible, and the extra seconds are irrelevant for a batch job. <code>convert("RGB")</code> drops the alpha channel the PNGs carry but the thumbnails don't need. And WebP at quality 80 is the sweet spot where artefacts stop being visible at 400px before the bytes stop shrinking. Net effect: a ~3&nbsp;MB PNG becomes a ~17&nbsp;KB thumbnail, and the whole gallery's browsing layer, all 492 thumbnails, weighs 8&nbsp;MB, less than three originals.</p>

        <p>At view time, the gallery JavaScript loads the manifest, renders thumbnails in batches of 24 as you scroll, and each lightbox link points at the release URL for the original. The visitor's browser talks to GitHub's release servers directly; my Pages bandwidth only ever carries the thumbnails.</p>

        <figure>
          <img src="https://www.kenreid.co.uk/img/photography/thumb/387.webp" alt="Ferris wheel lit up at night in Edinburgh, Scotland" loading="lazy" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
          <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span> This thumbnail weighs a few dozen kilobytes and comes from my repository. Click-through to the original in the gallery, and GitHub's release servers hand you the full-size file at no cost to my hosting budget.</figcaption>
        </figure>

        <h2>Why not the "proper" solutions?</h2>

        <p><strong>Git LFS</strong> is GitHub's own answer to large files, and it would have been the natural choice, except the free tier is a single gigabyte of storage and a similarly small monthly bandwidth allowance, after which you're buying data packs. It solves the repository-bloat problem while replacing "free" with "subscription".</p>

        <p><strong>Object storage</strong> (S3, Cloudflare R2, Backblaze B2) is a good answer and I'd use it for anything commercial. But it means an account, a bucket policy, a billing alarm, and a second system to maintain for a personal site whose entire ethos is "one repository contains everything". My gallery's originals sit next to the code that serves them, in the same repo, behind the same login, and I'm lazy in odd ways.</p>

        <p><strong>Photo platforms</strong> solve a different problem, community and discovery, and charge accordingly. For raw hosting, here's the rough landscape as of mid-2026 (prices drift; check before you commit):</p>

        <div style="overflow-x:auto;">
          <table style="width:100%; border-collapse:collapse; margin:16px 0;">
            <thead>
              <tr style="border-bottom:2px solid #999;">
                <th style="text-align:left; padding:8px;">Option</th>
                <th style="text-align:left; padding:8px;">Rough cost for ~1.5 GB + modest traffic</th>
                <th style="text-align:left; padding:8px;">Notes</th>
              </tr>
            </thead>
            <tbody>
              <tr style="border-bottom:1px solid #99999955;">
                <td style="padding:8px;">Flickr Pro</td>
                <td style="padding:8px;">~$70&ndash;90/year</td>
                <td style="padding:8px;">Unlimited storage, but your portfolio lives on their site, in their design.</td>
              </tr>
              <tr style="border-bottom:1px solid #99999955;">
                <td style="padding:8px;">SmugMug</td>
                <td style="padding:8px;">~$100+/year</td>
                <td style="padding:8px;">Polished portfolio hosting; genuinely good, genuinely not free.</td>
              </tr>
              <tr style="border-bottom:1px solid #99999955;">
                <td style="padding:8px;">Amazon S3 + egress</td>
                <td style="padding:8px;">Pennies for storage, ~$0.09/GB served</td>
                <td style="padding:8px;">The bandwidth line item is the one that surprises people.</td>
              </tr>
              <tr style="border-bottom:1px solid #99999955;">
                <td style="padding:8px;">Cloudflare R2</td>
                <td style="padding:8px;">~Free at this scale</td>
                <td style="padding:8px;">Free egress and a generous free tier; the strongest alternative. Still a second account and billing surface.</td>
              </tr>
              <tr>
                <td style="padding:8px;">GitHub release</td>
                <td style="padding:8px;">$0</td>
                <td style="padding:8px;">Everything in one repo. You're reading the case study.</td>
              </tr>
            </tbody>
          </table>
        </div>

        <h2>The caveats</h2>

        <ul>
          <li><strong>No contract.</strong> GitHub documents release assets as a distribution feature; they don't promise it as a CDN. If your traffic looked like abuse, they'd be within their rights to object. A personal portfolio's click-through traffic is nowhere near that territory, but a startup's image backend would be.</li>
          <li><strong>Public means public.</strong> Release assets on a public repo are accessible to anyone with the URL. Fine for a portfolio, since a portfolio's job is to be seen; wrong for anything private.</li>
          <li><strong>URLs are tied to the repo.</strong> Rename your account or repository and every deep link changes. Choose boring, stable names.</li>
          <li><strong>No image processing.</strong> S3-with-a-CDN setups can resize on the fly. Here, every size you serve is a size you generated yourself. I need exactly two: thumbnail and original.</li>
          <li><strong>Don't commit the originals, ever.</strong> The release replaces the repo for full-size files, it doesn't supplement it. Learn from my 4.91&nbsp;GiB of permanently embarrassing git history.</li>
        </ul>

        <h2>Constraints make better sites</h2>

        <p>The 1&nbsp;GB limit made the gallery <em>better</em>. Being forced to split hot data (thumbnails, manifest, tags) from cold data (originals) is just good architecture, the same shape as any cache-and-archive system. The gallery loads fast on bad connections because it physically cannot ship the heavy files up front.</p>

        <p>Total infrastructure: one repository, one release, three JSON files, and a few local Python scripts. Total cost: nothing. The photos are backed up, versioned adjacent to the site that shows them, and served by one of the most reliable download infrastructures on the internet.</p>

        <div class="faq-section">
          <h2>Common questions</h2>

          <details class="faq-item">
            <summary>Is this against GitHub's terms of service?</summary>
            <p>Releases are for distributing a repository's files, and these are the files this repository's website is built from, so I'm comfortable it's within both the letter and spirit. What the terms do prohibit is using releases as a general-purpose CDN for content unrelated to the repo, or serving traffic at a scale that disrupts the service. Host your portfolio, not your startup's user uploads.</p>
          </details>

          <details class="faq-item">
            <summary>What happens if GitHub kills the loophole?</summary>
            <p>Then I run one script to re-upload 515 files somewhere else (probably Cloudflare R2) and change one URL prefix in one JavaScript file. </p>
          </details>

          <details class="faq-item">
            <summary>Why PNG originals instead of JPEG or WebP?</summary>
            <p>PNG is a lossless container I trust for archival, and storage is (see above) free. </p>
          </details>

          <details class="faq-item">
            <summary>Could I do this without the command line?</summary>
            <p>Yes, releases can be created and files uploaded entirely through GitHub's web interface (Releases &rarr; Draft a new release &rarr; drag files in). The CLI just stops being optional somewhere around your fiftieth photo.</p>
          </details>
        </div>

        

      </main>

        <hr style="margin: 40px 0;">
        <p style="text-align: center;"><a class="post-cta" href="https://www.kenreid.co.uk/blog.html">Back to all posts</a></p>
      ]]></content:encoded>
    </item>
    <item>
      <title>Why You Aren&#x27;t a &quot;Visual Learner&quot;</title>
      <link>https://www.kenreid.co.uk/blog/why-you-arent-a-visual-learner.html</link>
      <guid>https://www.kenreid.co.uk/blog/why-you-arent-a-visual-learner.html</guid>
      <pubDate>Sat, 04 Jul 2026 00:00:00 +0000</pubDate>
      <description>Everyone took the quiz and got a label. Decades of cognitive psychology say the label does nothing: matching teaching to &#x27;learning styles&#x27; doesn&#x27;t improve learning, and believing it limits you. What works instead is effortful, unglamorous, and the same for everyone.</description>
      <category>philosophy</category>
      <category>science</category>
      <content:encoded><![CDATA[
        <h1>Why You Aren't a "Visual Learner"</h1>
        <div class="blog-meta">
          4 July 2026 &middot;
          <span class="blog-tag">philosophy</span>
          <span class="blog-tag">science</span>
        </div>

        <p>At some point, probably in a classroom with the lights half-off and an overhead projector humming, somebody handed you a quiz that sorted you into one of four boxes: Visual, Aural, Read/Write, or Kinesthetic. You got your label assigned to you and you may still remember it. People bring up their learning style the way they bring up their Myers-Briggs type or their Hogwarts house (ew, J.K. Rowling reference), except this one comes with institutional backing: schools teach it, teacher training repeats it, and somewhere between sixty and ninety percent of educators across multiple countries believe it, depending on which survey you read. Sigh.</p>

        <p>There is no good scientific evidence that teaching to a student's preferred learning style improves their learning. Not weak evidence. Not mixed evidence. When the claim has been tested properly, it fails.<sup><a href="#ref-1" class="cite-ref">[1]</a></sup></p>

        <p>I believed this one too, when I was younger. It is a collective illusion that nearly all of us bought into, because it flatters us, because it sounds like science, and because it contains just enough truth to be convincing. The myth does real damage and the way it survives tells you something about how bad ideas persist generally in society.</p>

        <div class="plain-english-box">
          <h2>Quick jargon guide</h2>
          <ul>
            <li><strong>Learning styles:</strong> the claim that each person has a fixed way their brain best absorbs information, and that teaching should be matched to it. This is the idea under examination, and it is distinct from merely having preferences.</li>
            <li><strong>VARK:</strong> the most popular learning styles framework, sorting people into Visual, Aural, Read/Write, and Kinesthetic (movement and touch). Usually administered as a short self-report quiz.</li>
            <li><strong>Modality:</strong> the channel information arrives through: pictures, speech, text, or physical activity.</li>
            <li><strong>Meshing hypothesis:</strong> the testable core of the learning styles claim: that instruction works better when its modality is matched ("meshed") with the learner's style. This is the part the evidence fails to support.</li>
            <li><strong>Dual coding:</strong> combining words with relevant visuals. Helps essentially everyone, not just "visual learners."</li>
            <li><strong>Active recall:</strong> deliberately retrieving information from memory (self-testing, explaining from a closed book) rather than re-reading or re-watching it. Also called retrieval practice.</li>
            <li><strong>Spaced repetition:</strong> revisiting material over widening intervals instead of cramming it in one sitting.</li>
          </ul>
        </div>

        <h2>The intuitive trap</h2>

        <p>Humans love a categorisation instrument. We are the species that invented the personality quiz, the enneagram, and the sorting hat: a short test that returns a diagnosis of who you really are. "Kinesthetic learner" feels like a personalised readout of your brain's wiring, delivered with the authority of an acronym, that will unlock your magical learning capabilities like an episode of Limitless. It explains your struggles (no wonder algebra was hard, it was taught wrong <em>for you</em>) and it costs nothing to adopt.</p>

        <p>And, crucially, there is a grain of truth there. People genuinely do have preferences about how they take in information. I would rather read documentation than watch a tutorial video at a fixed pace, and if you have opinions about that sentence, you have preferences too. Preferences are real, measurable, and worth accommodating where it is easy to do so, if only because comfortable students are less miserable.<sup><a href="#ref-2" class="cite-ref">[2]</a></sup></p>

        <p>The myth is what happens when a preference gets promoted into a claim about cognitive architecture: not "I like diagrams" but "my brain learns through diagrams, and text mostly bounces off." Those are radically different claims. The first is a fact about your tastes while the second is a testable hypothesis about memory and cognition, and it is the one that turns out to be false. What learners prefer and what actually helps them learn are, inconveniently, not the same thing, and learners are surprisingly bad judges of the difference.<sup><a href="#ref-3" class="cite-ref">[3]</a></sup></p>

        <figure>
          <img src="https://www.kenreid.co.uk/img/photography/thumb/93.webp" alt="A double-exposure portrait of a face blended with a landscape of trees and light" loading="lazy" width="400" height="266">
          <figcaption class="figure-note">&copy; Ken Reid. A head full of images. </figcaption>
        </figure>

        <h2>The evidence deficit</h2>

        <p>The learning styles claim has a proper name in the literature: the meshing hypothesis. It says that instruction is more effective when its modality is matched, or meshed, with the learner's style; the visual learner taught with diagrams should outperform the same visual learner taught with text. This is a perfectly testable claim, and to test it you need a specific kind of experiment: classify learners by style, randomly assign them to different instructional formats, then give everyone the <em>same</em> test. If the hypothesis is right, you get a crossover: visual learners do best with visual instruction, verbal learners do best with verbal instruction. Doesn't that sound neat?</p>

        <p>In 2008, the Association for Psychological Science commissioned four cognitive psychologists, Pashler, McDaniel, Rohrer, and Bjork, to review the entire literature. Their conclusion is remarkable for how little it hedges: virtually no studies exist with a design capable of testing the claim, and among the ones that do, several found results that flatly contradict it. Their words, not mine:</p>

        <blockquote>
          <p>At present, there is no adequate evidence base to justify incorporating learning-styles assessments into general educational practice.</p>
          <cite>Pashler, McDaniel, Rohrer, &amp; Bjork, <em>Psychological Science in the Public Interest</em><sup><a href="#ref-1" class="cite-ref">[1]</a></sup></cite>
        </blockquote>

        <p>For a multi-billion-pound industry of assessments, workshops, and curriculum redesign, the empirical foundation simply is not there.</p>

        <p>Once you see why, it feels obvious. Most of what we learn is not stored as sights or sounds but as meaning. When you learn what the French Revolution was, you are not filing away a picture or a soundtrack; you are building a structure of causes, actors, and consequences that you can express in any modality. The best way to teach something is therefore dictated by the <em>content</em>, not by the student. Geometry wants spatial representation. A new language has to be met in use, whether spoken, written, or signed. You cannot learn to drive from a book, however much you like reading, and no amount of interpretive movement will get a kinesthetic learner through a Shakespearean sonnet. Notice that none of this pins a subject to one sense: blind mathematicians work through geometry by touch and description, and signed languages are acquired as completely as spoken ones. The knowledge is the structure, not the channel it happened to arrive through. Good teachers already know this, which is why they were teaching maps with maps and music with sound long before anyone sold them an inventory for it.<sup><a href="#ref-2" class="cite-ref">[2]</a></sup></p>

        <h2>What the myth costs</h2>

        <p>If learning styles were merely wrong, they would be a harmless piece of astrology for the staff room. The problem is that the belief has teeth, and I have watched them bite from both sides of the classroom.</p>

        <figure>
          <img src="https://www.kenreid.co.uk/img/photography/thumb/473.webp" alt="Black-and-white photograph of adult students in a classroom, one deep in thought and another taking notes on a tablet" loading="lazy" width="400" height="264">
          <figcaption class="figure-note">&copy; Ken Reid.</figcaption>
        </figure>

        <p>The damage on the student side is the self-limiting belief. A student who has decided they are a kinesthetic learner now owns a ready-made explanation for every struggle: the algebra is not hard, the delivery is wrong. The label converts "this is difficult and I need to work at it" into "this was not made for people like me," which is a much more comfortable thought and a much more corrosive one. It writes off entire subjects, and entire formats, as other people's territory. The wider version of this worry shows up in the literature too: when education is guided by what learners prefer rather than what works, the learners lose, because preference and benefit frequently point in different directions.<sup><a href="#ref-3" class="cite-ref">[3]</a></sup> I wrote about a cousin of this fallacy in <a href="https://www.kenreid.co.uk/blog/in-defense-of-audiobooks.html">my defence of audiobooks</a>: the assumption that the format something arrives in determines how well a brain can absorb it.</p>

        <p>During my years teaching at universities, learning styles came up in teaching development the way fire safety comes up in an office induction, as settled procedure rather than open question. Colleagues and I were encouraged to consider the mix of learning styles in the room, and dutiful educators everywhere spend real hours building four versions of material because a framework told them their students needed it. Those hours are far from free, when most teachers were also full time researchers, part time mentors and supervisors, and expected to review theses and papers on top. Every hour spent adapting a lesson into modalities comes out of the budget for things with actual evidence behind them: better examples, better feedback, better practice problems. Multiply that across every school and university that takes the framework seriously and the bill for a false idea becomes enormous.</p>

        <h2>What actually works</h2>

        <p>The good news is that dismantling the myth does not leave a crater; cognitive science has replacements with actual evidence behind them, and they share a theme: they are for everyone.</p>

        <p><strong>Dual coding.</strong> Combining words with relevant visuals improves learning across the board, because verbal and visual channels reinforce each other. Note what this does to the myth: diagrams are not a service for the visual-learner minority, they help essentially everybody, just as clear verbal explanation helps essentially everybody. The lesson is "use both," not "sort your students."</p>

        <p><strong>Active recall.</strong> Retrieving information from memory, by testing yourself, closing the book and explaining the idea aloud, or doing problems without the worked example in view, strengthens memory far more than re-reading or re-watching ever will. </p>

        <p><strong>Spaced repetition.</strong> Returning to material over widening intervals beats massed cramming so reliably that it is one of the oldest and most replicated results in the field. Just ask musicians learning a piece.</p>

        <p>The techniques that work are effortful, mildly unpleasant, and identical for all four VARK letters. The learning styles myth offered the opposite: a personalised shortcut, a reason why learning felt hard that was not your fault and could be fixed by someone else changing their slides. I understand why that sold. But real learning is often frustrating precisely when it is working, and no personality quiz can negotiate you out of that. </p>

        <h2 class="section-heading" id="references">References</h2>

        <ol class="references">
          <li id="ref-1">Pashler, H., McDaniel, M., Rohrer, D., &amp; Bjork, R. (2008). Learning styles: Concepts and evidence. <em>Psychological Science in the Public Interest, 9</em>(3), 105&ndash;119. <a href="https://doi.org/10.1111/j.1539-6053.2009.01038.x" target="_blank" rel="noopener">https://doi.org/10.1111/j.1539-6053.2009.01038.x</a></li>
          <li id="ref-2">Riener, C., &amp; Willingham, D. T. (2010). The myth of learning styles. <em>Change: The Magazine of Higher Learning, 42</em>(5), 32&ndash;35. <a href="https://doi.org/10.1080/00091383.2010.503139" target="_blank" rel="noopener">https://doi.org/10.1080/00091383.2010.503139</a></li>
          <li id="ref-3">Kirschner, P. A., &amp; van Merri&euml;nboer, J. J. G. (2013). Do learners really know best? Urban legends in education. <em>Educational Psychologist, 48</em>(3), 169&ndash;183. <a href="https://doi.org/10.1080/00461520.2013.804395" target="_blank" rel="noopener">https://doi.org/10.1080/00461520.2013.804395</a></li>
        </ol>

        

      ]]></content:encoded>
    </item>
    <item>
      <title>Frodo, Sam, and love.</title>
      <link>https://www.kenreid.co.uk/blog/frodo-sam-and-love.html</link>
      <guid>https://www.kenreid.co.uk/blog/frodo-sam-and-love.html</guid>
      <pubDate>Thu, 02 Jul 2026 00:00:00 +0000</pubDate>
      <description>A queer-friendly reading of Frodo and Sam: not a hidden romance, but male friendship deep enough for tears, touch, and total trust. Insisting that tenderness must be romance rebuilds the exact wall this story tears down. (Gimli and Legolas, on the other hand...)</description>
      <category>books</category>
      <category>philosophy</category>
      <category>personal</category>
    </item>
    <item>
      <title>Optimizing Your Schedule II (or: When Hours Aren&#x27;t the Thing You Run Out Of)</title>
      <link>https://www.kenreid.co.uk/blog/optimizing-your-schedule-ii.html</link>
      <guid>https://www.kenreid.co.uk/blog/optimizing-your-schedule-ii.html</guid>
      <pubDate>Tue, 30 Jun 2026 00:00:00 +0000</pubDate>
      <description>A reply to Part I: time is not the only budget. Energy is a second, renewable resource that some activities spend and others restore. A working model of a depleted week, and why rest is production, not indulgence.</description>
      <category>personal</category>
      <category>data science</category>
      <category>advice</category>
    </item>
    <item>
      <title>What We Leave Behind</title>
      <link>https://www.kenreid.co.uk/blog/what-we-leave-behind.html</link>
      <guid>https://www.kenreid.co.uk/blog/what-we-leave-behind.html</guid>
      <pubDate>Sat, 27 Jun 2026 00:00:00 +0000</pubDate>
      <description>From the plutonium in the planet&#x27;s crust to the Golden Record in interstellar space to the memory of a Tuesday coffee, a look at everything we leave behind, and why none of us ever truly vanishes.</description>
      <category>personal</category>
      <category>philosophy</category>
      <category>science</category>
    </item>
    <item>
      <title>Optimizing Your Schedule (or: Treating Your Week Like a Solvable Problem)</title>
      <link>https://www.kenreid.co.uk/blog/optimizing-your-schedule.html</link>
      <guid>https://www.kenreid.co.uk/blog/optimizing-your-schedule.html</guid>
      <pubDate>Mon, 15 Jun 2026 00:00:00 +0000</pubDate>
      <description>What an operations researcher actually means by optimizing a schedule: objectives, constraints and weightings, a values clarification grid, and a working integer-programming model of your week. Nerd mode optional.</description>
      <category>personal</category>
      <category>data science</category>
      <category>advice</category>
    </item>
    <item>
      <title>What Was I Made For?</title>
      <link>https://www.kenreid.co.uk/blog/what-was-i-made-for.html</link>
      <guid>https://www.kenreid.co.uk/blog/what-was-i-made-for.html</guid>
      <pubDate>Fri, 12 Jun 2026 00:00:00 +0000</pubDate>
      <description>On finite time, micro-ambition, Gandalf, Tim Minchin, and why every &#x27;one day&#x27; dream needs a plan or it disappears.</description>
      <category>personal</category>
      <category>advice</category>
    </item>
    <item>
      <title>M*A*S*H in the Modern Era: Comedy, Trauma, and the 4077th</title>
      <link>https://www.kenreid.co.uk/blog/mash-modern-perspective.html</link>
      <guid>https://www.kenreid.co.uk/blog/mash-modern-perspective.html</guid>
      <pubDate>Mon, 01 Jun 2026 00:00:00 +0000</pubDate>
      <description>A modern look at M*A*S*H: how a Korean War sitcom from the 1970s remains one of the sharpest critiques of bureaucracy, war, and trauma in television history.</description>
      <category>television</category>
    </item>
    <item>
      <title>Iain M. Banks and The Culture</title>
      <link>https://www.kenreid.co.uk/blog/the-culture-series.html</link>
      <guid>https://www.kenreid.co.uk/blog/the-culture-series.html</guid>
      <pubDate>Mon, 01 Jun 2026 00:00:00 +0000</pubDate>
      <description>Smashing the world requires only a hammer; building a better one requires imagination. A reflection on Iain M. Banks&#x27;s Culture series and the contrarian act of imagining a future that works.</description>
      <category>books</category>
    </item>
    <item>
      <title>The Individualization of Responsibility</title>
      <link>https://www.kenreid.co.uk/blog/individualization-of-responsibility.html</link>
      <guid>https://www.kenreid.co.uk/blog/individualization-of-responsibility.html</guid>
      <pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate>
      <description>Corporations reframe systemic crises as consumer morality plays. From plastic bags to five-minute showers, how the public ends up feeling guilty while industrial actors avoid regulation.</description>
      <category>philosophy</category>
    </item>
    <item>
      <title>Advice to My Younger Self</title>
      <link>https://www.kenreid.co.uk/blog/advice-to-my-younger-self.html</link>
      <guid>https://www.kenreid.co.uk/blog/advice-to-my-younger-self.html</guid>
      <pubDate>Mon, 25 May 2026 00:00:00 +0000</pubDate>
      <description>Fifty things I would tell my younger self, from pensions and emergency funds to sunscreen, boundaries, and uncomfortable conversations.</description>
      <category>personal</category>
    </item>
    <item>
      <title>The Books I Recommend to Friends</title>
      <link>https://www.kenreid.co.uk/blog/books-i-recommend-to-friends.html</link>
      <guid>https://www.kenreid.co.uk/blog/books-i-recommend-to-friends.html</guid>
      <pubDate>Fri, 22 May 2026 00:00:00 +0000</pubDate>
      <description>The thirteen books I actually push on friends, grouped by the kind of friend I&#x27;d press them into the hands of.</description>
      <category>books</category>
    </item>
    <item>
      <title>Why Nobody Thanks the Person Who Stopped the Disaster</title>
      <link>https://www.kenreid.co.uk/blog/prevention-of-failure-is-unseen.html</link>
      <guid>https://www.kenreid.co.uk/blog/prevention-of-failure-is-unseen.html</guid>
      <pubDate>Thu, 21 May 2026 00:00:00 +0000</pubDate>
      <description>We celebrate the people who fix disasters and take preventive work for granted. That&#x27;s a bug in how we think, and it costs us.</description>
      <category>philosophy</category>
    </item>
    <item>
      <title>When I Ended Up in Italy Because of Mistaken Identity</title>
      <link>https://www.kenreid.co.uk/blog/mistaken-identity-italy.html</link>
      <guid>https://www.kenreid.co.uk/blog/mistaken-identity-italy.html</guid>
      <pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate>
      <description>How a mix-up over an email address sent me to the northern Italian mountains to photograph a tech event I had no business being at.</description>
      <category>photography</category>
      <category>personal</category>
    </item>
    <item>
      <title>How to Write a Blog (or: What I Learned By Doing It)</title>
      <link>https://www.kenreid.co.uk/blog/how-to-write-a-blog.html</link>
      <guid>https://www.kenreid.co.uk/blog/how-to-write-a-blog.html</guid>
      <pubDate>Wed, 13 May 2026 00:00:00 +0000</pubDate>
      <description>What I learned about writing a blog: why the word itself put me off, how I found a process, and what to do when people hate your work.</description>
      <category>personal</category>
      <category>writing</category>
      <category>advice</category>
    </item>
    <item>
      <title>The Hidden Cost of Cobalt</title>
      <link>https://www.kenreid.co.uk/blog/hidden-cost-of-cobalt-congo.html</link>
      <guid>https://www.kenreid.co.uk/blog/hidden-cost-of-cobalt-congo.html</guid>
      <pubDate>Wed, 13 May 2026 00:00:00 +0000</pubDate>
      <description>The batteries in our phones, laptops, and EVs are largely made of cobalt mined in the Democratic Republic of the Congo, often by hand and often by children. A reflection on a presentation I gave, and what we can actually do about it.</description>
      <category>philosophy</category>
      <category>technology</category>
    </item>
    <item>
      <title>ISO 8601 and the Date Cult I Happily Joined</title>
      <link>https://www.kenreid.co.uk/blog/iso-8601-date-cult.html</link>
      <guid>https://www.kenreid.co.uk/blog/iso-8601-date-cult.html</guid>
      <pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate>
      <description>An ode to ISO 8601 (YYYY-MM-DD) as the only sortable, unambiguous, international date format worth using.</description>
      <category>technology</category>
    </item>
    <item>
      <title>Accessibility-First Design in Data Science</title>
      <link>https://www.kenreid.co.uk/blog/accessibility-first-product-design-data-science.html</link>
      <guid>https://www.kenreid.co.uk/blog/accessibility-first-product-design-data-science.html</guid>
      <pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate>
      <description>Data science outputs are often inaccessible by default. Accessibility-first design in plots, dashboards, and tables improves understanding for everyone.</description>
      <category>data science</category>
    </item>
    <item>
      <title>The Life and Death of the Early Internet</title>
      <link>https://www.kenreid.co.uk/blog/life-and-death-of-the-early-internet.html</link>
      <guid>https://www.kenreid.co.uk/blog/life-and-death-of-the-early-internet.html</guid>
      <pubDate>Wed, 06 May 2026 00:00:00 +0000</pubDate>
      <description>From CRT monitors and Comet Cursor to MSN Messenger, Kazaa, Warcraft 3 forums, and Bebo: a personal history of how the early internet shaped who we are.</description>
      <category>personal</category>
    </item>
    <item>
      <title>From Scotland to Michigan</title>
      <link>https://www.kenreid.co.uk/blog/from-scotland-to-michigan.html</link>
      <guid>https://www.kenreid.co.uk/blog/from-scotland-to-michigan.html</guid>
      <pubDate>Mon, 04 May 2026 00:00:00 +0000</pubDate>
      <description>From Scotland to Michigan: a personal account of culture shock, free education, healthcare, xenophobia, weather, and what it means to belong somewhere new.</description>
      <category>personal</category>
    </item>
    <item>
      <title>The Ethics of LLM Use (Not LLMs)</title>
      <link>https://www.kenreid.co.uk/blog/ethics-of-llm-use-not-llms.html</link>
      <guid>https://www.kenreid.co.uk/blog/ethics-of-llm-use-not-llms.html</guid>
      <pubDate>Wed, 29 Apr 2026 00:00:00 +0000</pubDate>
      <description>An AI researcher&#x27;s unvarnished take on LLM ethics: the energy numbers, the cobalt hypocrisy, the Jevons paradox, and why telling people not to use AI on environmental grounds is a privilege position.</description>
      <category>ai</category>
    </item>
    <item>
      <title>Dungeon Crawler Carl and the Strange Dignity of LitRPG</title>
      <link>https://www.kenreid.co.uk/blog/dungeon-crawler-carl-litrpg-dignity.html</link>
      <guid>https://www.kenreid.co.uk/blog/dungeon-crawler-carl-litrpg-dignity.html</guid>
      <pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate>
      <description>A book snob&#x27;s confession: how Dungeon Crawler Carl used absurd LitRPG mechanics to deliver one of the most devastating emotional payloads in modern fantasy.</description>
      <category>books</category>
    </item>
    <item>
      <title>Fifteen Years of Silence: Patrick Rothfuss, The Doors of Stone, and the Architecture of Creative Friction</title>
      <link>https://www.kenreid.co.uk/blog/fifteen-years-of-silence-rothfuss-doors-of-stone.html</link>
      <guid>https://www.kenreid.co.uk/blog/fifteen-years-of-silence-rothfuss-doors-of-stone.html</guid>
      <pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate>
      <description>Fifteen years since The Wise Man&#x27;s Fear and still no Doors of Stone. A personal reflection on the Kingkiller Chronicle, the long wait, the charity debacle, and why writing at this level is genuinely hard.</description>
      <category>books</category>
    </item>
    <item>
      <title>No Idea? No Problem: A Beginner&#x27;s Guide to Building Your Data Science Portfolio</title>
      <link>https://www.kenreid.co.uk/blog/no-idea-no-problem-data-science-portfolio.html</link>
      <guid>https://www.kenreid.co.uk/blog/no-idea-no-problem-data-science-portfolio.html</guid>
      <pubDate>Mon, 20 Apr 2026 00:00:00 +0000</pubDate>
      <description>A practical guide to building a data science portfolio when you have no idea where to start. Covers project types, documentation, novelty, collaboration, and working in the GenAI era.</description>
      <category>data science</category>
    </item>
    <item>
      <title>Evolutionary Computation&#x27;s Identity Crisis in the Age of GenAI</title>
      <link>https://www.kenreid.co.uk/blog/evolutionary-computation-identity-crisis.html</link>
      <guid>https://www.kenreid.co.uk/blog/evolutionary-computation-identity-crisis.html</guid>
      <pubDate>Sat, 18 Apr 2026 00:00:00 +0000</pubDate>
      <description>Evolutionary Computation is having an identity crisis in the GenAI era, despite delivering major real-world wins in engineering, logistics, and constrained optimization.</description>
      <category>ai</category>
    </item>
    <item>
      <title>In Defense of Audiobooks</title>
      <link>https://www.kenreid.co.uk/blog/in-defense-of-audiobooks.html</link>
      <guid>https://www.kenreid.co.uk/blog/in-defense-of-audiobooks.html</guid>
      <pubDate>Fri, 17 Apr 2026 00:00:00 +0000</pubDate>
      <description>The stigma against audiobooks is rooted in bad assumptions about learning, intelligence, and what counts as reading. The research doesn&#x27;t support any of it.</description>
      <category>books</category>
    </item>
    <item>
      <title>Why It Still Matters to Learn to Code in the Age of AI</title>
      <link>https://www.kenreid.co.uk/blog/why-learn-to-code-age-of-ai.html</link>
      <guid>https://www.kenreid.co.uk/blog/why-learn-to-code-age-of-ai.html</guid>
      <pubDate>Thu, 16 Apr 2026 00:00:00 +0000</pubDate>
      <description>Coding still matters because the value is not syntax memorization. It is learning to break down problems, reason across systems, and build reliable solutions.</description>
      <category>ai</category>
    </item>
    <item>
      <title>Your Professional Second Brain for Local LLM Work</title>
      <link>https://www.kenreid.co.uk/blog/second-brain-local-llm-professional.html</link>
      <guid>https://www.kenreid.co.uk/blog/second-brain-local-llm-professional.html</guid>
      <pubDate>Wed, 15 Apr 2026 00:00:00 +0000</pubDate>
      <description>A practical system for project documentation, knowledge management, and local LLM workflows that improves delivery, reviews, and project re-entry.</description>
      <category>ai</category>
    </item>
    <item>
      <title>Snowball vs Avalanche: The Science of Paying Off Debt</title>
      <link>https://www.kenreid.co.uk/blog/snowball-vs-avalanche.html</link>
      <guid>https://www.kenreid.co.uk/blog/snowball-vs-avalanche.html</guid>
      <pubDate>Tue, 14 Apr 2026 00:00:00 +0000</pubDate>
      <description>Snowball and avalanche both work, but for different reasons. A practical guide with simulator-backed charts on interest, timing, and motivation.</description>
      <category>finance</category>
    </item>
    <item>
      <title>Book Ratings Are Broken</title>
      <link>https://www.kenreid.co.uk/blog/rating-systems.html</link>
      <guid>https://www.kenreid.co.uk/blog/rating-systems.html</guid>
      <pubDate>Sun, 12 Apr 2026 00:00:00 +0000</pubDate>
      <description>569 books rated, and the star system fails at all of them. A data-driven look at my own Goodreads ratings, review lengths, genre bias, and the silence problem.</description>
      <category>books</category>
    </item>
    <item>
      <title>Why Most Self-Help Books Are Trash (And Which Ones Aren&#x27;t)</title>
      <link>https://www.kenreid.co.uk/blog/self-help-books.html</link>
      <guid>https://www.kenreid.co.uk/blog/self-help-books.html</guid>
      <pubDate>Sat, 11 Apr 2026 00:00:00 +0000</pubDate>
      <description>The bad ones are blog posts stretched to 300 pages. The good ones cite their sources. Here&#x27;s how to tell the difference.</description>
      <category>books</category>
    </item>
    <item>
      <title>Building a Photo Tagging System with CLIP</title>
      <link>https://www.kenreid.co.uk/blog/photo-tagging-with-clip.html</link>
      <guid>https://www.kenreid.co.uk/blog/photo-tagging-with-clip.html</guid>
      <pubDate>Fri, 10 Apr 2026 00:00:00 +0000</pubDate>
      <description>How I used OpenCLIP to automatically classify 500+ photos into 10 categories in under a minute, zero API cost, running entirely on CPU.</description>
      <category>ai</category>
      <category>photography</category>
    </item>
  </channel>
</rss>
