<?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>Sun, 06 Sep 2026 00:00:00 +0000</lastBuildDate>
    <pubDate>Sun, 06 Sep 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>Leading a Horse to Water</title>
      <link>https://www.kenreid.co.uk/blog/leading-a-horse-to-water.html</link>
      <guid>https://www.kenreid.co.uk/blog/leading-a-horse-to-water.html</guid>
      <pubDate>Sun, 06 Sep 2026 00:00:00 +0000</pubDate>
      <description>You give feedback to a colleague. They thank you for it, and a week later nothing has changed. Three checks before you push harder, one change of approach, a budget, and a date.</description>
      <category>advice</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/leading-a-horse-to-water.html">View it on the site</a> to play with it.</em></p>
        <h1>Leading a Horse to Water</h1>
        <div class="blog-meta">
          6 September 2026 &middot;
          <span class="blog-tag">advice</span>
        </div>

        <p>I'm doing a master's in organizational leadership, and one of the first things I've learned is that leadership is not a status only found in specific roles, it's something we all do every day. Leading projects, leading people, making decisions on next steps, risk assessments, and practicing your social skills. One such example I have been thinking much about recently is providing feedback - generally, but also when you give the same feedback multiple times to someone. You find they nod, say it was fair, thank you for being direct, and then do the thing again the following week.</p>

        <p>It's something perplexing: you are specific in the feedback given, offer help, and mean it. You write the steps down, in order, in an email they could find again. You ask if there was anything in the way, and nothing changes.</p>

        <p>As the saying goes: you can lead a horse to water. But that hardly solves the problem, it just describes it. How many times must you take the horse to water? Should you fill up a bowl and raise it to its mouth? A PowerPoint on "drinking water"? Should you bring in a horse expert to assist?</p>

        <div class="plain-english-box">
          <h2>Quick jargon guide</h2>
          <ul>
            <li><strong>Righting reflex:</strong> the helper's urge to spot a problem and fix it for someone, usually by telling them what to do. Named by the motivational interviewing people.</li>
            <li><strong>Reactance:</strong> the push-back people feel when they sense their freedom to choose being taken away, e.g. tell someone they must, and part of them wants to prove they needn't.</li>
            <li><strong>Precontemplation:</strong> the first of the stages of change: the person does not think there is a problem, so advice is ignored.</li>
            <li><strong>Goal commitment:</strong> whether the person has actually signed up to the goal: goals only predict performance when commitment is there.</li>
            <li><strong>Skill/will:</strong> a coaching shorthand. Can they do it, and do they want to? </li>
            <li><strong>Feedback intervention:</strong> any deliberate act of telling someone how they're doing. </li>
          </ul>
        </div>

        <h2>The Proverb</h2>

        <p>It is the oldest English proverb still in daily use, recorded as early as 1175 in the Old English Homilies:<sup><a href="#ref-1" class="cite-ref">[1]</a></sup></p>

        <blockquote>
          <p>Hwa is thet mei thet hors wettrien the him self nule drinken?</p>
          <cite>&mdash; <em>Old English Homilies</em>, c. 1175 (who can give water to the horse that will not drink of its own accord?)</cite>
        </blockquote>

        <p>John Heywood reshaped it into the version everyone knows by 1546. This is not a new problem among people.</p>

        <p>The drinking is not yours to do, and no amount of leading <i>better</i> or putting in more effort as a leader will make a horse drink. It can be deeply irritating when the horse is now standing in the river up to its knees, you're gesturing at the water, and it just stares at you.</p>

        <p>The version I like adds a third clause. Charlie "Tremendous" Jones, an American insurance-agency-executive-turned-motivational-speaker best known for the bestseller <em>Life Is Tremendous</em>, used to put it this way:</p>

        <blockquote>
          <p>You may not be able to make a horse drink, but you can put salt in his oats and make him thirsty.</p>
          <cite>&mdash; Charlie "Tremendous" Jones</cite>
        </blockquote>

        <p>You cannot make the horse drink, but you CAN make it thirsty, you can check the water is fit to drink, and you can find out whether the animal in front of you is a horse that isn't thirsty or a horse that is ill.</p>

        <figure>
          <a href="https://github.com/DrKenReid/DrKenReid.github.io/releases/download/photos-v1/184.png" class="img-lightbox portfolio-img" style="cursor: zoom-in;">
            <img src="https://www.kenreid.co.uk/img/photography/thumb/184.webp" alt="Two greylag geese walking in single file across a sunlit lawn, the front one glancing back" loading="lazy" width="400" height="266">
          </a>
          <figcaption class="figure-note">
            <span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span>
            These are not horses, but it turns out I haven't taken many photos of horses, so here are some geese. No problems taking them to water.
          </figcaption>
        </figure>

        <h2>First, check the water</h2>

        <p>Before anything else: was the feedback actually constructive? In Kluger and DeNisi's 1996 meta-analysis, which pooled 607 effect sizes from feedback studies, feedback improved performance on average, but in over a third of cases it made performance worse.<sup><a href="#ref-2" class="cite-ref">[2]</a></sup> The feedback that hurt was the kind that pulled attention towards the self (who you are, how you compare) rather than the task (what to do differently, next time, at this step). No one wants to hear what is wrong with their character, but many are happy to hear how they can improve the technique, the strategy or the steps. </p>

        <p>It's always satisfying when a scientific study rings true. I know that I have this problem when I receive feedback: I feel attacked, if it's about my underlying drive or passion, but when I am given feedback on specific actions or tasks, I feel empowered. So, now I try to do the following when giving other people feedback: can they repeat back, in their own words, what they are meant to do differently, by when, and how they will know they've done it? If the answer to any of the three is a shrug, then I did not lead a horse to water, I led a horse to a description of water. "Be more proactive" is a high-level descriptor and nowhere near specific enough.</p>

        <h2>Second, check whether the horse is thirsty</h2>

        <p>The second question is whether the person thinks there is anything to fix. Prochaska and DiClemente's stages of change model, built on decades of watching people quit smoking and fail to, puts a stage before "thinking about it": precontemplation, where the person does not see a problem at all.<sup><a href="#ref-3" class="cite-ref">[3]</a></sup> Advice delivered to someone in precontemplation is like telling someone it will eventually rain in a city fifty miles away, at least to them. The smoking example also rings true for me: I remember begging my parents to stop smoking, even threatening to start myself if they didn't stop. Ultimately, a person needs to want to change a habit, otherwise any well-meaning, well-informed, kind and considered advice is utterly for naught. </p>

        <p>"Yeah, fair, thanks for saying" is what a person says when they agree and intend to act, and it is also what they say to make the meeting end while being polite. This means we can't really settle on that as the finale to the feedback conversation. Klein and colleagues' meta-analysis of goal commitment says: goals predict performance when people are committed to them, and barely at all when they are not, and commitment is not the same as having heard the goal read aloud.<sup><a href="#ref-4" class="cite-ref">[4]</a></sup></p>

        <p>Worse, pushing harder at this stage makes it worse. Miller and Rollnick, the motivational interviewing people, call the helper's urge to fix the "righting reflex," and their central finding, replicated across addiction, health and now management, is that when you argue for change, the other person starts arguing against it, and hearing themselves argue against it makes them believe it.<sup><a href="#ref-5" class="cite-ref">[5]</a></sup> Brehm named the underlying push-back in 1966: reactance, the instinct to reclaim a freedom someone is trying to take.<sup><a href="#ref-6" class="cite-ref">[6]</a></sup> You can't tell a horse it's thirsty and expect it to believe you.</p>

        <h2>Third, check whether the horse can drink</h2>

        <p>Someone agreeing and then not doing is what inspired me to write this, and it is usually read as a will problem when it is often a capability or environment problem. Landsberg's skill/will matrix is a two-by-two from a coaching book.<sup><a href="#ref-7" class="cite-ref">[7]</a></sup> Low skill needs teaching while low will needs reasoning, and low both needs direction. Getting these wrong is like trying to inspire a fish to walk.</p>

        <p>How to know if it's a "will" problem:</p>

        <ul>
          <li><strong>Have I seen them do it once?</strong> If I have never watched the person do the thing, even badly, I do not know whether they can. "Write clearer tickets" assumes they know what a clear ticket looks like. Show them one. Then watch them write one. </li>
          <li><strong>What is paying them to do the opposite?</strong> If they are incentivized or otherwise follow a protocol established by a higher authority or by tradition, they may ignore your feedback.</li>
          <li><strong>Is there room?</strong> Asking someone drowning in tickets to also improve how they write tickets is asking for the improvement to be ignored.</li>
        </ul>

        <figure>
          <a href="https://github.com/DrKenReid/DrKenReid.github.io/releases/download/photos-v1/51.png" class="img-lightbox portfolio-img" style="cursor: zoom-in;">
            <img src="https://www.kenreid.co.uk/img/photography/thumb/51.webp" alt="A mallard drake standing on a mossy bank at the edge of a pond, surrounded by branches" loading="lazy" width="400" height="266">
          </a>
          <figcaption class="figure-note">
            <span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span>
          </figcaption>
        </figure>

        <h2>Salting the oats</h2>

        <p>Given a horse that is capable, not obviously ill, and not thirsty, here are some of my thoughts and learnings compiled together:</p>

        <ol>
          <li>Listen to them, don't assume. Ask them to say what they'll do, and write down what they said, not what you wanted them to say. A plan in someone else's words is often very different from what you think they know. Locke and Latham's goal-setting work has been saying this for forty years: participation in setting the goal raises commitment to it.<sup><a href="#ref-8" class="cite-ref">[8]</a></sup></li>
          <li>One behaviour, one fortnight, one checkpoint. "Be a better communicator" is useless. "Send the summary before the meeting, for the next three meetings, and we'll look at it on the 20th" is tangible, measurable and clear - the outcome is "becoming a better communicator" but the steps are clearly stated.</li>
          <li>If they don't see shortcomings they don't see a problem to fix. If the report is late and you fix it for them at eleven at night, the report is never late from their standpoint. </li>
          <li>Changing a person's behaviour is difficult. Changing a template, a checklist, a pairing session, a definition of done, is straightforward and easy.</li>
          <li>"This is the third time we've had this conversation. What's going on?" and let silence reign until an answer comes. Listening is important.</li>
          <li>"We agreed X by the 20th." A dated, agreed expectation turns the fourth conversation from an argument about memory into a paper trail.</li>
        </ol>

        <h2>How far to go</h2>

        <p>A failure of leadership is waiting too long. When behaviours become patterns that have been that way for years, you are fighting both the problem and established tradition. You can always try one more thing, one more framing, one more month. So the leader who doesn't decide in advance ends up deciding by exhaustion, which is late, and usually unkind to the team, to yourself, and to your colleague.</p>

        <p>Set the budget before the third conversation. What decides it is not how much you like the person (something I know we all struggle with). It's the cost of letting the issue stand:</p>

        <ul>
          <li><strong>You.</strong> The hours, obviously, but also the attention. A stuck person takes up a share of a leader's head that is wildly out of proportion to their share of the team.</li>
          <li><strong>The others.</strong> Every hour spent on the horse that won't is an hour taken from the horses that would.</li>
          <li><strong>The standard.</strong> What you tolerate for long enough becomes the definition of acceptable. And that can also spread: "Well, Jon takes 3 days on these tasks, why should I deprioritize it too?"</li>
          <li><strong>The person.</strong> Every month they spend in a role they are not going to grow into is a month they are not spending somewhere they might. Endless patience inhibits their growth, prevents them from achieving their goals (and possibly stymieing their future income, prospects, benefits, etc.).</li>
        </ul>

        <p>Then, how do you read which kind of stuck you're looking at? </p>

        <ol>
          <li><strong>They try, and fail.</strong> They attempt the thing, it comes out wrong, they attempt it again. This is a skill gap and it is - believe it or not - the good kind of stuck. Keep going, teach more concretely, lengthen the horizon. At least the horse is trying to drink!</li>
          <li><strong>They agree, and don't.</strong> Nod, thanks, nothing. This is will or environment, and the response is a single change of approach, followed, if nothing shifts, by the written expectation with a date on it.</li>
          <li><strong>They dispute the premise.</strong> "I don't think that's a problem." Either they're in precontemplation, or you are wrong. Stop advising and focus on listening. Then go and check with somebody who has no stake in it whether the water you're offering is actually water.</li>
        </ol>

        <h2>When to give up</h2>

        <p>Giving up on an approach is not giving up on a person. Most of what I've described above is giving up: on explanation, on the plan in your words, on absorbing the consequence. Surrendering is often surrendering the current strategy for a better one.</p>

        <p>Giving up on the person is very different. Informal feedback, then explicit written expectation, then a formal process, then parting. The leader's obligation at each rung is the same: the person must never be surprised. If someone learns at a formal meeting that their job is at risk and this is news to them, the leader failed. Additionally, if you're not their leader but are providing feedback, sometimes giving up is giving up your part in the process to someone else - their leader, their mentor, the person themself.</p>

        <p>I hate this part.</p>

        <p>I'd rather run the sixth conversation than the formal one, and I think it's something many people avoid out of feelings of not wanting to be the bad guy. Ultimately, you can only control that which is your own action, not the outcome. Some wise words by an idol of mine, a physically crippled man who escaped slavery and learned much about our control over our personal destinies:</p>

        <blockquote>
          <p>The things in our control are opinion, pursuit, desire, aversion, and, in a word, whatever are our own actions. The things not in our control are body, property, reputation, command, and, in one word, whatever are not our own actions.</p>
          <cite>&mdash; Epictetus, <em>Enchiridion</em></cite>
        </blockquote>

        <p>You can lead the horse to water, you can make sure the water is clean, and you can show it how to drink, but no matter how much you want to, out of compassion, kindness or care, there is a point where you have to accept that you are not the horse and you cannot force it to drink. The servant leadership test is whether the people you lead grow. If they are not growing with you after a fair run, the kind act may be to help them find where they will, perhaps a sister team, or through a friend you know from college. I personally believe leaders should help colleagues beyond their current role and company, and encourage growth, because paths can cross many times, so help them even if not from inside your own team or organization. And remember, this cannot be a surprise, this is only after a long period of trying and goal setting.</p>

        <h2>A Flow Chart</h2>

        

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

          <details class="faq-item">
            <summary>Isn't "salting the oats" just manipulation?</summary>
            <p>The line I use: it's manipulation if they'd object to it on finding out. Everything in the list above can and should be said directly to the person ("I'm going to stop fixing this for you, so you can see when it goes wrong"). It's only manipulative if you don't tell them.</p>
          </details>

          <details class="faq-item">
            <summary>What if I'm the horse?</summary>
            <p>Ask yourself which of the three patterns you're in. If you're nodding to end meetings, you're in precontemplation and the honourable thing is to say so: "I don't think this is a problem, and here's why." Regardless of which part - have clear communication with your leader and follow the steps with them, or by yourself.</p>
          </details>

          <details class="faq-item">
            <summary>Does any of this work when they're senior to you?</summary>
            <p>Less of it. You can check the water (is your point specific, is it attached to something they care about), and you can ask rather than tell. You cannot set a date on a page for your boss, and you cannot stop absorbing their consequences without it costing you. Proceed with caution, but a good leader accepts constructive feedback from their reports.</p>
          </details>

          <details class="faq-item">
            <summary>How long is long enough?</summary>
            <p>Like any habit setting, longer than it takes to see a result: so long that it's the default and feels comfortable to everyone without slippage.</p>
          </details>
        </div>

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

        <ol class="references">
          <li id="ref-1">Old English Homilies, c. 1175. John Heywood, <em>A Dialogue Conteinyng the Nomber in Effect of All the Prouerbes in the Englishe Tongue</em> (1546). Discussed at <a href="https://www.phrases.org.uk/meanings/you-can-lead-a-horse-to-water.html" target="_blank" rel="noopener noreferrer">phrases.org.uk</a></li>
          <li id="ref-2">Kluger, A. N., &amp; DeNisi, A. (1996). The effects of feedback interventions on performance: A historical review, a meta-analysis, and a preliminary feedback intervention theory. <em>Psychological Bulletin</em>, 119(2), 254&ndash;284. 607 effect sizes; mean d = 0.41; over one third of interventions reduced performance.</li>
          <li id="ref-3">Prochaska, J. O., &amp; DiClemente, C. C. (1983). Stages and processes of self-change of smoking: Toward an integrative model of change. <em>Journal of Consulting and Clinical Psychology</em>, 51(3), 390&ndash;395.</li>
          <li id="ref-4">Klein, H. J., Wesson, M. J., Hollenbeck, J. R., &amp; Alge, B. J. (1999). Goal commitment and the goal-setting process: Conceptual clarification and empirical synthesis. <em>Journal of Applied Psychology</em>, 84(6), 885&ndash;896. Meta-analysis of 83 samples. <a href="https://pubmed.ncbi.nlm.nih.gov/10639908/" target="_blank" rel="noopener noreferrer">pubmed.ncbi.nlm.nih.gov</a></li>
          <li id="ref-5">Miller, W. R., &amp; Rollnick, S. (2013). <em>Motivational Interviewing: Helping People Change</em> (3rd ed.). Guilford Press. The righting reflex, ambivalence, and why arguing for change produces arguments against it. Summary at <a href="https://www.ncbi.nlm.nih.gov/books/NBK571068/" target="_blank" rel="noopener noreferrer">ncbi.nlm.nih.gov</a></li>
          <li id="ref-6">Brehm, J. W. (1966). <em>A Theory of Psychological Reactance</em>. Academic Press. Reviewed fifty years on in Steindl et al. (2015), <em>Zeitschrift f&uuml;r Psychologie</em>, 223(4), 205&ndash;214.</li>
          <li id="ref-7">Landsberg, M. (1996). <em>The Tao of Coaching</em>. HarperCollins. Source of the skill/will matrix (direct, guide, excite, delegate).</li>
          <li id="ref-8">Locke, E. A., &amp; Latham, G. P. (2002). Building a practically useful theory of goal setting and task motivation: A 35-year odyssey. <em>American Psychologist</em>, 57(9), 705&ndash;717.</li>
        </ol>

        

      ]]></content:encoded>
    </item>
    <item>
      <title>Making This Website Accessible</title>
      <link>https://www.kenreid.co.uk/blog/making-this-website-accessible.html</link>
      <guid>https://www.kenreid.co.uk/blog/making-this-website-accessible.html</guid>
      <pubDate>Sun, 30 Aug 2026 00:00:00 +0000</pubDate>
      <description>Thirty small habits keep this site accessible: a skip link, screen-reader notes on new-tab links, reduced-motion fallbacks, and an audit that catches the many that I forget along the way.</description>
      <category>technology</category>
      <content:encoded><![CDATA[
 <h1>Making This Website Accessible</h1>
 <div class="blog-meta">
 30 August 2026 &middot;
 <span class="blog-tag">technology</span>
 </div>

 <p>I have <a href="https://www.kenreid.co.uk/blog/accessibility-first-product-design-data-science.html">banged on before</a> about accessibility working best as a starting constraint rather than a retrofit. This site is where I get to practise that habit continually: my core philosophy is that if something is inaccessible to some it may as well be inaccessible to all. I make sure of this with a pile of small habits, and an audit script that catches the bits I inevitably miss. 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>Screen reader:</strong> software that reads a page aloud (or to a braille display) for blind and low-vision users, navigating by headings, links, and landmarks rather than by looking.</li>
 <li><strong>WCAG:</strong> the Web Content Accessibility Guidelines, the standard everyone measures against. Its techniques have catalogue numbers, like items at IKEA.</li>
 <li><strong>ARIA attributes:</strong> HTML attributes (<code>aria-pressed</code>, <code>aria-label</code>, <code>aria-live</code>) that tell assistive technology what a widget is and what state it's in when the visuals alone can't.</li>
 <li><strong>sr-only:</strong> a CSS class that hides text visually but leaves it for screen readers. The inverse of decoration.</li>
 <li><strong>Skip link:</strong> a link at the very top of the page, visible only when focused, that jumps keyboard users past the navigation to the content.</li>
 <li><strong>Reduced motion:</strong> an operating-system setting (<code>prefers-reduced-motion</code>) that asks websites to calm down with the animations. Vestibular disorders make this a health feature, not a taste.</li>
 <li><strong>Focus trap:</strong> keeping keyboard focus inside an open dialog so Tab doesn't wander off into the page behind it.</li>
 </ul>
 </div>

 <h2>The keyboard path</h2>

 <p>The first element injected into every page is what is known as a skip link. Press Tab on any page here and "Skip to main content" appears before anything else. In short, this allows you to press Enter and get past any navigation fluff at the top of the page. It's invisible to mouse users, and is an old trick that is often forgotten in 'modern' websites.</p>

 <figure style="margin: 24px auto;">
  <img src="https://www.kenreid.co.uk/blog/img/a11y/skip-link.webp" alt="The site header with a Skip to main content button overlaid on the navigation, outlined by a red focus ring" loading="lazy" width="1520" height="214" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
  <figcaption class="figure-note">Press Tab anywhere on the site and this is the first stop. Mouse users never see it.</figcaption>
 </figure>

 <p>The Ctrl+K <a href="https://www.kenreid.co.uk/blog/ctrl-k-for-a-static-site.html">command palette</a> got the full dialog treatment: focus moves into the input when it opens, Tab is trapped (it moves the selection instead of escaping the dialog), Escape closes it, and results are given to screen readers through an <code>aria-live</code> region. The filter buttons on the blog and gallery use <code>aria-pressed</code>, so "Photography, toggle button, pressed" comes through instead of a mystery link.</p>

 <figure style="margin: 24px auto;">
  <img src="https://www.kenreid.co.uk/blog/img/a11y/command-palette.webp" alt="The Ctrl+K command palette open over a dimmed homepage, with the query accessib typed, six post results listed, the top one highlighted, and footer hints reading navigate, open, esc close" loading="lazy" width="1520" height="1064" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
  <figcaption class="figure-note">The palette moves focus into the input, traps Tab, closes on Escape, and announces results through an aria-live region.</figcaption>
 </figure>

 <figure style="margin: 24px auto;">
  <img src="https://www.kenreid.co.uk/blog/img/a11y/tag-filters.webp" alt="The blog's tag filter bar with fourteen outlined tag buttons; the photography tag is solid red, showing its pressed state" loading="lazy" width="1520" height="236" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
  <figcaption class="figure-note">The pressed filter reads as "photography, toggle button, pressed": the visual state and the aria-pressed state are the same fact.</figcaption>
 </figure>

 <p>The theme toggle updates <code>aria-pressed</code> and swaps its label between "Switch to light mode" and "Switch to dark mode", so the button always says what it will do, not what it did.</p>

 <h2>The screen reader path</h2>

 <p>Links that open a new tab get a screen-reader-only annotation, WCAG technique G201, applied automatically:</p>

<pre><code class="language-javascript">// Appends screen-reader-only "(opens in new tab)" to target="_blank" links
var span = document.createElement('span');
span.className = 'sr-only';
span.textContent = ' (opens in new tab)';
link.appendChild(span);</code></pre>

 <p>A MutationObserver reapplies it to links injected later (related posts, footers), because on this site half the content arrives after first paint. For a screen reader user, a link that teleports you into a new tab with no warning is awful.</p>

 <p>Images carry real alternative text - I imagine not having it feels almost painful for screen-reader users, they likely feel they're missing out on important content and don't know what it is, like missing pages in a book. I aim for a description, not caption: the photo of my dog is "A picture of my puppy, Brutus", and the chart images in data posts describe the actual bars and numbers, so the information is actually useful, instead of "a photo" or "the results shown as an image". The interactive posts give every canvas <code>role="img"</code> and an <code>aria-label</code> saying what the simulation shows. A label is a thin substitute for "here is a live algorithm race you cannot see", so I'm open to suggestions on how to make it a better experience.</p>

 <h2>The vestibular path</h2>

 <p>Three separate systems check <code>prefers-reduced-motion</code>: the scroll-reveal animations (everything just appears, already visible), the homepage stats (they display their final number instead of counting up), and the CSS transitions (three media blocks calm them down). The site has to be complete with the animation subtracted, and only then is the animation allowed.</p>

 <h2>Checkpoints</h2>

 <p>Habits decay, so the <a href="https://www.kenreid.co.uk/blog/my-website-has-a-test-suite.html">audit script</a> checks every page for the a11y basics (alt text, heading structure, link health, metadata) on every push, and CI fails loudly when a new post shows how bad my memory is at maintaining these standards without a checklist. I love automating stuff, I'd rather put in extra effort early to save effort later.</p>

 <figure style="margin: 24px auto;">
  <img src="https://www.kenreid.co.uk/blog/img/a11y/audit-output.webp" alt="Terminal output of the audit script: four INFO lines about long meta descriptions, then a summary reading 86 pages audited, 0 errors, 0 warnings, 29 info, with the zeroes in green" loading="lazy" width="1520" height="449" style="width:100%; max-width:760px; display:block; margin:0 auto; border-radius:8px;">
  <figcaption class="figure-note">The audit summary on a clean tree. CI runs the same script on every push and fails the build on any error or warning.</figcaption>
 </figure>

 <p>I'd love to hear from an actual screen reader user. If you use assistive tech and something here fights you, the <a href="https://www.kenreid.co.uk/contact.html">contact page</a> works, and I fix reported things fast.</p>

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

 <details class="faq-item">
 <summary>It's just a blog why do you put in so much effort?</summary>
 <p>Reverse the question: what justifies publishing a public page some readers can't use? The effort is also smaller than it looks. Nearly everything above was under an hour of work, done once, and enforced by automation afterwards. The expensive version of accessibility is the retrofit. </p>
 </details>

 <details class="faq-item">
 <summary>Does dark mode count as accessibility?</summary>
 <p>For me, yes. I get headaches and my eyes burn when I look at bright screens. So <a href="https://www.kenreid.co.uk/blog/dark-mode-that-doesnt-flash.html">avoiding the white flash</a> matters most for people like me. For everyone else it's comfort. </p>
 </details>
 </div>

        

 ]]></content:encoded>
    </item>
    <item>
      <title>The Availability Heuristic Runs the News</title>
      <link>https://www.kenreid.co.uk/blog/the-availability-heuristic-runs-the-news.html</link>
      <guid>https://www.kenreid.co.uk/blog/the-availability-heuristic-runs-the-news.html</guid>
      <pubDate>Sun, 30 Aug 2026 00:00:00 +0000</pubDate>
      <description>We judge likelihood by how easily examples come to mind, so we fear the vivid and rare and shrug at the common and deadly. Why the news makes us afraid of the wrong things.</description>
      <category>philosophy</category>
      <category>science</category>
      <content:encoded><![CDATA[
 <h1>The Availability Heuristic Runs the News</h1>
 <div class="blog-meta">
 30 August 2026 &middot;
 <span class="blog-tag">science</span>
 <span class="blog-tag">philosophy</span>
 </div>

 <p>The news is a list of the least representative things that happened yesterday, which you might think - great, that's interesting! The problem is, our minds calibrate to work out how dangerous the world is with this information. Quick: which is more dangerous, flying or driving? Sharks or vending machines? Terrorism or heart disease? If your gut said "flying, sharks, terrorism", your gut is confidently and dramatically wrong.</p>

 <p>You judge how likely a thing is by how <em>easily</em> examples come to mind. The examples that come easily are the vivid ones, the ones the news showed you. That, in a nutshell, is the availability heuristic, and it runs your sense of risk, your politics, and a fair amount of your fear.</p>

 <div class="plain-english-box">
 <h2>Quick jargon guide</h2>
 <ul>
 <li><strong>Availability heuristic:</strong> judging how likely something is by how easily examples spring to mind.</li>
 <li><strong>Base rate:</strong> how often a thing actually happens, as opposed to how often you hear about it.</li>
 <li><strong>Denominator:</strong> the "out of how many" under every scary number; a headline usually reports the numerator and skips this part.</li>
 <li><strong>Newsworthiness filter:</strong> news reports the unusual by definition, so what reaches you is selected against being representative.</li>
 <li><strong>Doomscrolling:</strong> an infinite feed of the world's most vivid catastrophes, which your gut reads as a frequency table.</li>
 </ul>
 </div>

 <h2>The shortcut</h2>

 <p>The availability heuristic, named by Tversky and Kahneman, is the brain's habit of estimating how frequent or likely something is by how readily instances spring to mind<sup><a href="#ref-1" class="cite-ref">[1]</a></sup>. Most of the time it's a decent shortcut: common things usually <em>are</em> easier to recall than rare ones, so "easy to remember" tracks "actually frequent" well enough to get you through a Tuesday.</p>

 <p>It fails when ease of recall is driven by something <em>other</em> than frequency. In the modern world that is constant: vividness, emotional intensity and media coverage all make an event easier to recall without making it any more common.</p>

 <p>So the events that dominate our sense of risk are the rare, dramatic, well-covered ones. Plane crashes get wall-to-wall coverage, and car crashes, which kill vastly more people, are too routine to report, so flying <em>feels</em> more dangerous while being far safer per mile.</p>

 <h2>The risks with no pictures</h2>

 <p>Shark attacks get a summer of headlines out of a handful of incidents, while the far deadlier risks (heart disease, the drive to the beach) produce no images at all. Terrorism reshapes whole societies' fears and budgets while killing a tiny fraction of the number who die of the mundane, unphotogenic causes nobody panics about. That last one makes me angry.</p>

 <p>The mismatch between what we fear and what actually harms us is, in large part, the availability heuristic reading the news as if it were a frequency table<sup><a href="#ref-2" class="cite-ref">[2]</a></sup>.</p>

 <h2>The news is an availability machine</h2>

 <p>The news isn't lying, which is the trap. News reports the <em>un</em>usual by definition: "plane lands safely" is not a story, "plane crashes" is. That newsworthiness filter guarantees that what reaches you is the rare and dramatic rather than the common and deadly.</p>

 <figure style="margin: 24px auto;">
  <img src="https://www.kenreid.co.uk/img/photography/thumb/89.webp" alt="A city stretching to the hills under a lid of dark storm clouds" 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>So the news stocks your memory with the vivid, atypical events that the availability heuristic will then treat as frequent ones. You get a mental sample of the world selected for drama and against representativeness, and your sense of risk is built on that sample.</p>

 <p>This is a cousin of survivorship bias, and of the habit of analysing whatever data happened to be lying around: in all of them, a filtered sample gets mistaken for the whole picture.</p>

 <p>Then there is doomscrolling. An infinite feed that tailors towards what you enjoy - the algorithm literally learns based on how quickly you scroll, or don't, from one item to the next, whether you comment, react, share. So when you show an interest in, well, interesting things that are, say, unusual, such as landslides, violence, politics, terrorism videos, whatever - you begin to see MORE of that because the algorithm learns what you interact with. Therefore, you start to think "this is how things are".</p>

 <p>The same wiring that helped an ancestor remember which mushroom caused a friend to die, or where there were dangerous animals, is now allowing you to identify dangerous patterns in a carefully crafted information feed, fed to you from your phone.</p>

 <h2>What to do about a bias you can't turn off</h2>

 <p>You can't just "stop" this by knowing it exists, but you can install a correction: <strong>when you notice fear, or a judgement about risk, ask where the example came from.</strong> Did this feel likely because you have base-rate data, or because a headline handed you a picture? That one question separates "this is common" from "this is merely memorable".</p>

 <h2>The boring denominators</h2>

 <p>The deeper practice is going after <em>base rates</em>, the boring denominators, instead of anecdotes. How many flights per crash? How many people drive, versus how many die driving? What's the trend, rather than the latest incident?</p>

 <p>Numbers are the antidote because they're unmoved by vividness. A statistic does not care how frightening your mental image is (notice how often journalists say "thousands" without the "out of millions"). So the frightening image is your cue to go and find the statistic.</p>

 <p>Your intuition has been fed a curated diet of the dramatic, so when it hands you a confident sense of how risky the world is, treat the confidence as a symptom rather than a measurement.</p>

 <p>Next time a headline frightens you, go and find the denominator. It's usually two searches away, and nowhere near as interesting as the headline.</p>

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

 <details class="faq-item">
 <summary>Doesn't this just tell people to ignore the news and real dangers?</summary>
 <p>Journalists judge success by number of viewers and interactions. They are incentivised to make news that is clickbait, sensationalised, or blatantly pandering to specific crowds. "The news" is real-world information filtered through groups of people incentivised to misbehave. I recommend at least trying various news outlets, but preferably, get your news from smaller agencies, and be deeply discerning.</p>
 </details>

 <details class="faq-item">
 <summary>If the world is safer than it feels, why does everyone feel it's getting worse?</summary>
 <p>There are a half-dozen upcoming apocalypses that are real, including climate change, antibiotic resistance, and world war. Use your time and anxiety to better the world and our chances of preventing those things, don't fret over inflated figures from pot-stirring articles complaining about immigration or gang violence - it's clickbait and it is seeding hatred against our fellow humans.</p>
 </details>

 <details class="faq-item">
 <summary>Isn't it a bit privileged to tell people their fears are irrational?</summary>
 <p>It would be, if the claim were "your fears are stupid". The heuristic is a universal shortcut with a sensible origin, not a personal failing, and plenty of the fears it produces are justified. The argument is only that ease of recall and true frequency come apart in a media-saturated world, so it pays to check which one a given fear is tracking, especially for the high-stakes calls (what to worry about, how to vote, what you will let reshape a society). Respecting people's fears and helping them aim those fears well are compatible.</p>
 </details>
 </div>

 <h2 class="section-heading" id="references">References</h2>
 <ol class="references">
          <li id="ref-1">
            Tversky, A., &amp; Kahneman, D. (1973). Availability: A heuristic for judging frequency and probability. <em>Cognitive Psychology</em>, 5(2), 207&ndash;232. <a href="https://doi.org/10.1016/0010-0285(73)90033-9" target="_blank" rel="noopener">https://doi.org/10.1016/0010-0285(73)90033-9</a>
          </li>
          <li id="ref-2">
            Slovic, P. (1987). Perception of risk. <em>Science</em>, 236(4799), 280&ndash;285. <a href="https://doi.org/10.1126/science.3563507" target="_blank" rel="noopener">https://doi.org/10.1126/science.3563507</a>
          </li>
        </ol>

        

 <p style="text-align: center;"><a class="post-cta" href="https://www.kenreid.co.uk/blog.html">Back to all posts</a></p>
 <hr style="margin: 40px 0;">
 ]]></content:encoded>
    </item>
    <item>
      <title>Confirmation Bias Is the Mother Bias</title>
      <link>https://www.kenreid.co.uk/blog/confirmation-bias-is-the-mother-bias.html</link>
      <guid>https://www.kenreid.co.uk/blog/confirmation-bias-is-the-mother-bias.html</guid>
      <pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate>
      <description>Confirmation bias is the bias under the biases. Why it&#x27;s the hardest to fix, why smart people are worse, and there might be something that can help us.</description>
      <category>philosophy</category>
      <category>science</category>
      <content:encoded><![CDATA[
 <h1>Confirmation Bias Is the Mother Bias</h1>
 <div class="blog-meta">
 28 August 2026 &middot;
 <span class="blog-tag">science</span>
 <span class="blog-tag">philosophy</span>
 </div>

 <p>Nobody googles "reasons I am wrong about this". We google "reasons I am right about this", and we smugly nod at how wise we are.</p>

 <p>There are dozens of named cognitive biases, and this is the start of a series on them - I find them fascinating and I'm using this series as a means to learn about some psychology 101, and share what I've learned. One such bias sits under the others and is something of a basis for others. Confirmation bias is the tendency to seek, notice, remember and believe evidence that supports what we already think, and to ignore, discount or forget evidence that doesn't. It's how your uncle can keep bringing up the same political arguments at Thanksgiving dinner, forgetting what you've already said that clearly counters his argument - he won't remember, because the information you provide is irrelevant to him.</p>

 <div class="plain-english-box">
 <h2>Quick jargon guide</h2>
 <ul>
 <li><strong>Confirmation bias:</strong> seeking out, noticing, remembering and believing whatever supports what you already think, while the rest slides past.</li>
 <li><strong>Motivated reasoning:</strong> reasoning that works backwards from the conclusion you want, while feeling from the inside like fair weighing.</li>
 <li><strong>Steelmanning:</strong> stating the strongest version of the case against you, so well that the people who hold it would sign off on your summary.</li>
 <li><strong>Motivated numeracy:</strong> the finding that better reasoning skills can <em>increase</em> polarisation on charged questions, because the skill goes to work for the side you already picked.</li>
 </ul>
 </div>

 <h2>Evidence</h2>

 <p>The comforting model of the mind is a courtroom: you gather evidence, weigh it impartially, and reach a verdict. The actual mind runs the process backwards most of the time. You start with the verdict (the belief you already hold, or want to hold) and then work as its defence lawyer, scouring for supporting evidence and cross-examining anything inconsistent into silence. </p>

 <p>You search for confirming information. You read ambiguous evidence as supporting your side. You remember the hits and forget the misses. You let confirming evidence in over a doorstep and make disconfirming evidence climb a wall<sup><a href="#ref-1" class="cite-ref">[1]</a></sup>. </p>

 <h2>Setting the Stage</h2>

 <p>I want to put two scenes down here, because I am going to keep returning to them throughout the series (or try to, if I can bend the examples enough each time!). Every bias in this run is easier to see when it happens to the same people in the same rooms, so: meet the dinner table and the dashboard. They will be back!</p>

 <aside class="kr-scene">
  <p class="kr-scene-label">The dinner table</p>
  <p>My uncle has decided the earth is flat. He arrives at dinner with a video of a coastline where you can supposedly see a lighthouse the curve should have hidden, and a man saying "they never show you the curve." He found it in seconds, because he searched for "proof flat earth real". I countered it, again. You can lie down, watch the sun set, then stand up and watch it set a second time, which wouldn't work on a flat earth. All the other planets can be observed to be spherical. Shadows over time show the Earth is round. None of it sticks: the counter-evidence is immediately dismissed. I run the same search he does, I consider it and work through from his perspective back to mine, rather than searching "proof earth is not flat", and that makes me understand his approach more, and reinforces my position as a rigorous argument, instead of parroting what I previously read.</p>
  <figure style="margin: 18px 0 4px;">
   <img src="https://www.kenreid.co.uk/img/photography/thumb/211.webp" alt="An older man in glasses and a tie at a busy restaurant dinner table, mid-conversation, wine glasses and flowers in front of him" loading="lazy" style="width:100%; display:block; border-radius:8px;">
   <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
  </figure>
  <p class="kr-scene-note">The uncle searched for evidence to prove his position, assuming he was already correct, not showing curiosity and willingness to consider other opinions. Classic confirmation bias.</p>
 </aside>

 <aside class="kr-scene">
  <p class="kr-scene-label">The neighbour</p>
  <p>I knew before even moving into my new place that my neighbours wouldn't like me. And, in my first week here, that man across the road clearly didn't like me. He doesn't wave, and then, when he does wave, it's begrudging, only after I do. He put his bins out later than me, but then after he saw I did it earlier, he's almost doing it the night before to beat me. I send him a Christmas card but he didn't send one to me. He did sign for a parcel for me when I wasn't home, but it just feels like he's doing it to have one over me, seem to be the nicer neighbour - so fake. I knew it before I even met my neighbour: I knew he wouldn't like me, and every interaction just proves it. </p>
  <figure style="margin: 18px 0 4px;">
   <img src="https://www.kenreid.co.uk/img/photography/thumb/306.webp" alt="A concrete tower block shot from below against a clear blue sky, rows of windows receding upward" loading="lazy" style="width:100%; display:block; border-radius:8px;">
   <figcaption class="figure-note"><span class="figure-copyright">&copy; Ken Reid. All rights reserved.</span></figcaption>
  </figure>
  <p class="kr-scene-note">Here, the person new to the neighbourhood already decided how the relationships with their neighbours would be, and took any evidence (even the neighour being nice) as evidence for the neighbour hating them.</p>
 </aside>

 <h2>Why intelligence hinders you</h2>

 <p>Being clever, educated or numerate does not protect you (despite what most of us tell ourselves), and can make things <em>worse</em>. A smarter person is a better lawyer: quicker at generating persuasive arguments for what they already believe, better at spotting the flaw in inconvenient evidence, more fluent at explaining away the anomaly. It's funny how we assume intelligent people are wiser. There's a reason wisdom and intelligence are separate ability scores in pathfinder and dungeons a dragons: intelligence is knowing that a tomato is a fruit, wisdom is not adding it to your cereal.</p>

 <p>Studies find that greater numeracy and reasoning ability can <em>increase</em> polarisation on politically charged questions, because people put their cognitive horsepower to work for their existing side rather than for the truth<sup><a href="#ref-2" class="cite-ref">[2]</a></sup>. The ignorant are overconfident, fine, we all knew that. The capable are better at rationalising, which is not a great thing for society.</p>

 <h2>The one habit that helps (apparently)</h2>

 <p>Willpower does nothing here. "Be less biased" is an instruction on the level of "be taller". Confirmation bias runs below awareness, so telling yourself to be fair-minded mostly produces a more confident lawyer. The thing that helps is structural: <strong>go and find the strongest version of the opposing case.</strong></p>

 <p>Before settling a belief that matters, hunt down the best argument <em>against</em> it, from the cleverest people who disagree, and try to state their case so well they would sign off on your summary. That is steelmanning, and it is the discipline behind <a href="https://www.kenreid.co.uk/blog/how-to-write-a-blog.html">how I try to write</a> (and mostly fail at).</p>

 <p>Ask "what would change my mind?" and get worried if the answer is "nothing". Watch for the flinch, too. That little urge to wave a piece of evidence away is strong and itself deeply bias - you're doing that with your "tribe", your "side" and your "argument" in mind, not actually fairly comparing evidence. Your defence lawyer has found something and would like it kept out of the record.</p>

 <h2>But, even with that...</h2>

 <p>It won't make you unbiased. Nothing does. But - it will insert a step where you point your intelligence towards something more than building walls.</p>

 <p>You cannot trust your sense of having weighed the evidence fairly. It is a horrible fact to know about yourself and we're kind of stuck with that fact. What's worse, knowing this doesn't solve the problem. It's like recognizing you having a proclivity for a certain flavor of ice cream doesn't make you like all of them equally suddenly. But maybe it can help you try new flavors, if you take that next step. This metaphor is a bit stretched: what I mean is, try thinking of other perspectives thoroughly. Intellectual empathy; emotional, too. </p>

 <figure style="margin: 24px auto;">
  <img src="https://www.kenreid.co.uk/img/photography/thumb/66.webp" alt="Stainless steel trays of gelato in a shop counter, chocolate chip, caramel swirl and nut flavours side by side" 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>Go and read the <a href="https://doi.org/10.1037/1089-2680.2.2.175" target="_blank" rel="noopener">Nickerson review</a>. It came out in 1998.</p>

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

 <details class="faq-item">
 <summary>If everyone has confirmation bias, isn't objective truth hopeless?</summary>
 <p>No. Individuals are badly biased, but well-designed <em>systems</em> can be much better than any individual in them. Science works, imperfectly, because it institutionalises disconfirmation: peer review, replication, adversarial scrutiny, predictions registered. The fix for a bias you cannot remove from a single head is to build processes where other people's confirmation biases point in opposite directions and cancel it out, somewhat.</p>
 </details>

 <details class="faq-item">
 <summary>How is confirmation bias different from just having strong opinions?</summary>
 <p>Having a strong opinion is fine and often justified. Confirmation bias is about how you <em>maintain</em> it: protecting it by filtering evidence instead of testing it. What gives it away is not the strength of the belief but how the belief behaves when challenged. A well-held strong opinion can name what would change it and engages with the best counterarguments. A bias-protected one waves disconfirming evidence off without looking at it and never seems to update about anything, ever. </p>
 </details>

 <details class="faq-item">
 <summary>Doesn't steelmanning the other side just make me vulnerable to bad ideas?</summary>
 <p>I think that a belief challenged is a belief trained, like lifting weights. It hurts, and is tiring, but it is the only way to improve. If you argument is so weak that it can't stand up to alternative view points, why are you so keen to hold onto it? </p>
 </details>
 </div>

 <h2 class="section-heading" id="references">References</h2>
 <ol class="references">
          <li id="ref-1">
            Nickerson, R. S. (1998). Confirmation bias: A ubiquitous phenomenon in many guises. <em>Review of General Psychology</em>, 2(2), 175&ndash;220. <a href="https://doi.org/10.1037/1089-2680.2.2.175" target="_blank" rel="noopener">https://doi.org/10.1037/1089-2680.2.2.175</a>
          </li>
          <li id="ref-2">
            Kahan, D. M., et al. (2017). Motivated numeracy and enlightened self-government. <em>Behavioural Public Policy</em>, 1(1), 54&ndash;86. <a href="https://doi.org/10.1017/bpp.2016.2" target="_blank" rel="noopener">https://doi.org/10.1017/bpp.2016.2</a>
          </li>
        </ol>

        

 <p style="text-align: center;"><a class="post-cta" href="https://www.kenreid.co.uk/blog.html">Back to all posts</a></p>
 <hr style="margin: 40px 0;">
 ]]></content:encoded>
    </item>
    <item>
      <title>Tabu Search, Live</title>
      <link>https://www.kenreid.co.uk/blog/tabu-search-live.html</link>
      <guid>https://www.kenreid.co.uk/blog/tabu-search-live.html</guid>
      <pubDate>Wed, 26 Aug 2026 00:00:00 +0000</pubDate>
      <description>An interactive tabu search in your browser: watch a search with a memory solve N-Queens live, ban its own best moves, and escape the traps that stop every other method in this series.</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/tabu-search-live.html">View it on the site</a> to play with it.</em></p>
 <h1>Tabu Search, Live</h1>
 <div class="blog-meta">
 26 August 2026 &middot;
 <span class="blog-tag">ai</span>
 </div>

 <p>Eight posts in, every escape from a trap in this series has been some flavour of randomness:</p>
 <ul>
 <li><a href="https://www.kenreid.co.uk/blog/simulated-annealing-live.html">simulated annealing</a> gambles: while it's hot it accepts bad moves at random and hopes for betterment</li>
 <li><a href="https://www.kenreid.co.uk/blog/ant-colony-live.html">the ant colony</a> wanders its way out</li>
 <li><a href="https://www.kenreid.co.uk/blog/evolution-live.html">the genetic algorithm</a> and its <a href="https://www.kenreid.co.uk/blog/genetic-programming-live.html">program-breeding cousin</a> mutate their way out</li>
 <li><a href="https://www.kenreid.co.uk/blog/particle-swarm-live.html">the swarm</a> jitters</li>
 <li><a href="https://www.kenreid.co.uk/blog/cma-es-live.html">CMA-ES</a> and <a href="https://www.kenreid.co.uk/blog/differential-evolution-live.html">differential evolution</a> sample their way out of trouble</li>
 <li>even <a href="https://www.kenreid.co.uk/blog/variable-neighbourhood-search-live.html">variable neighbourhood search</a>, the most systematic escape artist so far, opens every escape with a random shake</li>
 </ul>

 <p>Tabu search is the odd one out, escaping traps by <em>remembering</em> them: it keeps a short list of places it has just been and refuses to go back, even when going back looks like the best move on the board. </p>

 

 <p>Fred Glover sketched the idea in 1986<sup><a href="#ref-1" class="cite-ref">[1]</a></sup> and built it out properly in 1989<sup><a href="#ref-2" class="cite-ref">[2]</a></sup>. The core of it is simple: run a greedy search, but make the recent past forbidden. That one addition turns the dumbest search strategy going into something that can walk out of a local optimum, cross a plateau, and keep improving. It needs no temperature and no coin to flip, which makes it something of an oddity in this series.</p>

 <div class="plain-english-box">
 <h2>Quick jargon guide</h2>
 <ul>
 <li><strong>Tabu search:</strong> a greedy search that keeps a short-term memory of recent moves and forbids undoing them, so it can climb out of traps instead of circling back in.</li>
 <li><strong>Tabu list:</strong> the ban list itself: the attributes of recent moves (in our demo, squares a queen just vacated) that the search is not allowed to revisit yet.</li>
 <li><strong>Tenure:</strong> how many iterations an entry stays on the tabu list before it expires.</li>
 <li><strong>Aspiration criterion:</strong> the escape clause: a tabu move is allowed anyway if it would produce the best solution ever seen. Bans are for moves that merely look good.</li>
 <li><strong>Cycling:</strong> the failure tabu search exists to prevent: a memoryless search bouncing between the same few solutions forever because each looks like the best neighbour of the other.</li>
 <li><strong>Steepest descent:</strong> evaluate every neighbour of the current solution and take the best one. Fast, greedy, and helpless the moment no neighbour improves.</li>
 <li><strong>Intensification vs diversification:</strong> Glover's tug-of-war: search hard near the good solutions you have found (intensify), versus force yourself somewhere you have never been (diversify). Recency memory the key here.</li>
 </ul>
 </div>

 <h2>Greed with amnesia</h2>

 <p>Steepest descent is the greediest search there is: look at every neighbour of your current solution, take the best one, repeat. It's fast, it's simple, and it's doomed: sooner or later it reaches a solution where no neighbour is better, and there it sits. That was covered that failure in the annealing post, where the cure was randomness on a cooling schedule.</p>

 <p>Allow sideways moves (neighbours that are no worse, a common tweak for crossing flat regions) and the search steps from board A to board B, surveys the neighbourhood, and concludes that the best move from B is... back to A.</p>

 <p>So it goes back, and then the best move from A is back to B. It will do this until all the coal mines and oil refineries run dry.</p>

 <p>This is <em>cycling</em>, the signature failure of memoryless search, where every step the algorithm wakes up fresh with no idea it has been here before. Whether three times or over nine thousand times.</p>

 <h2>A ban list and a countdown</h2>

 <p>In tabu search, after each move, an attribute of the position you just left (in our demo, the square a queen just vacated) goes on a ban list for a handful of iterations, called its tenure.</p>

 <p>While it's on the list, the search may not move a queen back there, however tempting the move looks, and with the recent past fenced off the only direction available is somewhere new. So the ban list turns "best move" into "best move I haven't just made", a small change that punches through plateaus which hold a hill climber forever.</p>

 <p>There is one safety valve: if a banned move would produce the best solution ever seen, the ban is waived. This is the <em>aspiration criterion</em>, and it exists because tabu attributes are deliberately crude: banning a square bans every future board that would use it, including, occasionally, a record-breaking one. When the forbidden move beats anything in the run so far, take it.</p>

 <h2>Glover's tug-of-war</h2>

 <p>Glover framed the wider method as a balance between <em>intensification</em> (search hard near the good solutions you already have) and <em>diversification</em> (force yourself into regions you have never visited). Serious implementations layer on frequency memory (penalise moves you make too often), long-term restarts, and cleverer aspiration rules. It all grows from the same seed: the search takes notes on itself, and then obeys them. The demo here is a simple version.</p>

 <h2>Eight queens, no peace</h2>

 <p>The playground this time is the N-Queens problem: place N queens on an N&times;N chessboard so that none attacks another, meaning no two share a row, column, or diagonal. Our queens start one per column, so columns are handled for free, and a move relocates one queen within her own column. The score is the number of attacking pairs, and zero means solved.</p>

 <p>It's a lovely testbed for tabu search because the landscape is nothing but plateaus. Near the end you are almost always sitting at one or two conflicts, surrounded by equally mediocre neighbours. That's the terrain where greedy search paces in circles, like me trying to find my car in the multistory car park outside my work (I have the memory of a pigeon with some things).</p>

 <p>Below, a tabu search is solving the board live, and red lines join queens that currently attack each other.</p>

 <p>The frost is the memory: when a queen leaves a square that square ices over, and the frost fades as its tenure counts down, so you're watching the algorithm's short-term memory dissolve in real time. The readout underneath lists the banned squares with their countdowns. When the search overrides a ban because the move would break a record, an "aspiration!" badge lights up next to the toolbar.</p>

 

 <h2>Tenure, at zero and at twenty</h2>

 <p><strong>Switch the memory off.</strong> The search becomes plain steepest descent with sideways moves, and on a lucky board it solves anyway. Most of the time it drops to one or two conflicts and then starts pacing, and the "revisited states" counter begins to climb as it bounces between the same handful of boards, forever, at whatever speed you set. The chart flatlines with a tiny shimmer, which is the cycle itself.</p>

 <p>Flip the memory back on and the search usually punches out within a few dozen steps. One counter, and it makes the case for tabu search better than I can.</p>

 <p><strong>Set tenure to 0.</strong> A ban that expires instantly is not a ban, so this is hill climbing again, cycle and all, even with the memory checkbox ticked. Tenure is the memory. The checkbox just decides whether anyone writes to it.</p>

 <p><strong>Set tenure to 20.</strong> Now the search forbids so much of its own neighbourhood that it can barely exploit anything. It reaches a promising region, is immediately banned from refining it, and lurches away, sometimes abandoning a one-conflict board it could have finished in a step or two.</p>

 <p><strong>Grow the board.</strong> Bigger boards mean bigger neighbourhoods and longer plateaus, and the sweet-spot tenure grows with them. Around 7 suits the 8&times;8 board, and at 16&times;16 you'll want noticeably more. The literature's rule of thumb is that tenure should scale with problem size.</p>

 <h2>Remembering the good and remembering the bad</h2>

 <p>There's some interesting debates I have in my mind about this concept, because it's somewhat philosophical. Our human memories are odd, we often think of periods of time that are difficult and we dislike fondly later in life, like times in the military - we remember the fun moments and forget the 5am wake up time, endless PT and drills. But we also remember specific jarring, horrible moments with great clarity - PTSD is exactly a big warning marker left with the intent to protect us, and let us remember what was bad.</p>

 <p>Maybe there's something to take from this: it's actually efficient to remember the bad things, but at some point to let them go, and perhaps similar can be said about remembering the good things, when trying to move towards a better future.</p>

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

 <details class="faq-item">
 <summary>Why is it spelled "tabu" and not "taboo"?</summary>
 <p>Because Glover spelled it that way, and the field kept his spelling. "Tabu" is closer to the Tongan word that English borrowed "taboo" from, and both forms show up in older literature. So it's a citation rather than a typo. It also makes the algorithm much easier to search for, which is a nice side effect.</p>
 </details>

 <details class="faq-item">
 <summary>Tabu search vs simulated annealing?</summary>
 <p>They treat the same disease with opposite medicine. Annealing escapes local optima stochastically: it accepts worse moves with a probability that cools over time, so its behaviour is a distribution and every run differs. Tabu search is deterministic. Same starting board, same tenure, same run, every time (the only randomness in this demo is the scramble that deals the board). Annealing carries no memory and trusts the temperature. Tabu search carries memory and trusts the list. In practice they're complementary, and plenty of hybrid methods anneal with a tabu list bolted on.</p>
 </details>

 <details class="faq-item">
 <summary>Is tabu search used for anything real?</summary>
 <p>Heavily. It has one of the strongest production records of any metaheuristic: job-shop and staff scheduling, vehicle routing, university timetabling, telecoms network design. For years, many of the best-known solutions on standard vehicle routing benchmarks belonged to tabu search variants. Like everything in this series, it asks almost nothing of the problem, just a neighbourhood and a score, so it travels well.</p>
 </details>
 </div>

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

 <ol class="references">
          <li id="ref-1">
            Glover, F. (1986). Future paths for integer programming and links to artificial intelligence. <em>Computers &amp; Operations Research</em>, 13(5), 533&ndash;549. <a href="https://doi.org/10.1016/0305-0548(86)90048-1" target="_blank" rel="noopener">https://doi.org/10.1016/0305-0548(86)90048-1</a>
          </li>
          <li id="ref-2">
            Glover, F. (1989). Tabu Search - Part I. <em>ORSA Journal on Computing</em>, 1(3), 190&ndash;206. <a href="https://doi.org/10.1287/ijoc.1.3.190" target="_blank" rel="noopener">https://doi.org/10.1287/ijoc.1.3.190</a>
          </li>
        </ol>

 ]]></content:encoded>
    </item>
    <item>
      <title>Genetic Programming, Live</title>
      <link>https://www.kenreid.co.uk/blog/genetic-programming-live.html</link>
      <guid>https://www.kenreid.co.uk/blog/genetic-programming-live.html</guid>
      <pubDate>Sun, 23 Aug 2026 00:00:00 +0000</pubDate>
      <description>Genetic programming in your browser: watch a population of expression trees evolve to fit a hidden curve, read the best program as it grows, and give it bloat with the sliders.</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/genetic-programming-live.html">View it on the site</a> to play with it.</em></p>
 <h1>Genetic Programming, Live</h1>
 <div class="blog-meta">
 23 August 2026 &middot;
 <span class="blog-tag">ai</span>
 </div>

 <p>Genetic programming evolves programs. What gets bred is the program itself rather than the settings of a program: a working formula nobody wrote, assembled out of worse ones. I've published with this one: the belt-laying search in <a href="https://www.kenreid.co.uk/blog/factorio-live.html">the Factorio post</a> is a genetic programming system, and it came out ahead of both other methods in the paper.</p>

 <p>This is part eight of the series. So far:</p>
 <ul>
 <li><a href="https://www.kenreid.co.uk/blog/ant-colony-live.html">ants have foraged</a></li>
 <li><a href="https://www.kenreid.co.uk/blog/evolution-live.html">triangles have evolved</a></li>
 <li><a href="https://www.kenreid.co.uk/blog/simulated-annealing-live.html">a search has cooled its way out of bad valleys</a></li>
 <li><a href="https://www.kenreid.co.uk/blog/particle-swarm-live.html">a swarm has chased its own best guess</a></li>
 <li><a href="https://www.kenreid.co.uk/blog/variable-neighbourhood-search-live.html">a stuck search has widened its neighbourhood until it came unstuck</a></li>
 <li><a href="https://www.kenreid.co.uk/blog/cma-es-live.html">a cloud of samples has learned the shape of a valley</a></li>
 <li><a href="https://www.kenreid.co.uk/blog/differential-evolution-live.html">a population has built its moves out of its own spread</a></li>
 </ul>

 

 <p>In part two every individual was a fixed-length list of numbers: fifty triangles, ten genes each, five hundred dials for evolution to twiddle. The <em>shape</em> of that genome was decided by me, in advance, forever. Genetic programming throws that away. The genome is a tree of code, and the size and shape of the tree are up for grabs too. Evolution gets the structure as well as the settings.</p>

 <div class="plain-english-box">
 <h2>Quick jargon guide</h2>
 <ul>
 <li><strong>Genetic programming (GP):</strong> an evolutionary algorithm whose individuals are programs (here, mathematical expressions) rather than fixed-length strings of parameters. Selection, crossover, and mutation operate on the program's tree.</li>
 <li><strong>Expression tree:</strong> a program written as a tree, with operators at the branches and variables or constants at the leaves. sin(x)*(x+1.7) is a seven-node tree.</li>
 <li><strong>Symbolic regression:</strong> searching for a formula that fits data, where the formula itself is the unknown, not just the coefficients of a formula you chose in advance.</li>
 <li><strong>Crossover:</strong> in GP, snipping a random subtree out of one parent and grafting in a random subtree from another. Children can be bigger than both parents or smaller than either.</li>
 <li><strong>Bloat:</strong> trees growing larger generation after generation without getting any fitter - famous disease of GP.</li>
 <li><strong>Parsimony pressure:</strong> a fitness penalty per node, taxing big trees. The standard treatment for bloat.</li>
 <li><strong>Intron:</strong> code that has no effect on a program's output, like a subtree multiplied by zero. Bloat is mostly introns.</li>
 </ul>
 </div>

 <h2>An infinite haystack</h2>

 <p>The demo below does symbolic regression. I sample forty points from a hidden target curve, and the population's job is to find an expression that fits them. Individuals are trees built from plus, minus, times, a protected divide (dividing by nearly zero returns 1 instead of exploding), sine, cosine, the variable x, and random constants.</p>

 <p>Fitness is mean squared error over the sample points with the lower the better. A formula that outputs infinity or NaN anywhere gets a score so bad it amounts to a death sentence.</p>

 <p>Part two's genome was a box with five hundred dials: finite and fixed. The space of all expressions is infinite, and almost all of it is garbage. A random tree comes out like cos(x/x)*0.3-x. There's no gradient to follow and no obvious notion of "nearby".</p>

 <p>And yet a population of these things, bred and culled, finds working programs in seconds - which is kinda crazy.</p>

 <h2>Trading subtrees</h2>

 <p>The engine is the one you already know from part two: tournament selection, crossover, mutation, and a couple of elites carried into each new generation untouched. What changes is what crossover <em>means</em>. With trees, crossover picks a random subtree in each parent and swaps them. It works because subtrees are meaningful units in a way that random substrings of characters never are: sin(2*x) is a working component. Evolution here is trading parts, not shuffling letters.</p>

 <p>The starting population is grown "ramped half-and-half", a mix of bushy full trees and scraggly random ones at several depths (GP-speak for seeding the pool with variety). I also cap tree depth at eight, partly for your device's sake and partly because, as you'll see, GP will take every inch you give it.</p>

 <h2>Watch a program grow</h2>

 <p>Below, a population of formulas is evolving against the data. The dots are the samples from the hidden curve, and the green line is the best formula so far, redrawn each generation so you can watch it snap toward the data.</p>

 <p>The node diagram is that formula's actual code: blue nodes are operators, amber leaves are x and constants. The readout underneath prints it as maths you can read. Generations tick by a few per second so the motion stays watchable.</p>

 

 <h2>The parsimony tax</h2>

 <p><strong>Set parsimony to zero.</strong> Keep one eye on the average size chart. Error improves, then plateaus, while average size climbs and keeps climbing until it slams into the depth cap.</p>

 <p>The population is now hauling around programs of a hundred-plus nodes that fit the data no better than a fifteen-node formula from fifty generations ago. This is bloat, the most famous disease of genetic programming and the reason the parsimony slider exists. Why a population would <em>choose</em> to bloat gets its own section below.</p>

 <p><strong>Now slam parsimony to maximum.</strong> The opposite catastrophe --- every node now costs more fitness than accuracy is worth, so the population collapses to tiny, dumb expressions: a bare constant, a lonely x. They fit the data terribly, but cheaply. Parsimony is a tax, and like any tax, set it too high and nobody builds anything.</p>

 <p><strong>Shrink the population to twenty.</strong> The run crawls or stalls. With so few trees, the pool of subtrees for crossover to trade is tiny, and once the population homogenises, mutation has to invent every new part from scratch.</p>

 <p><strong>And drop mutation to zero</strong> while you're at it. Same lesson as part two, sharper here: crossover can only recombine subtrees that already exist somewhere in the population. Once the interesting ones are gone, they are gone.</p>

 <h2>Bloat is evolved defensive code</h2>

 <p>Subtree crossover is destructive: graft a random subtree into a random point of a working program and you'll usually break it.</p>

 <p>Imagine two parents of equal fitness, one lean, one padded with introns: code that computes something and then multiplies it by zero, or adds a quantity and subtracts it again. When crossover hits the padded parent, odds are it lands in the padding, and the child behaves just like its parent. When it hits the lean one, it hits something that mattered.</p>

 <p>Padded parents therefore produce more viable children. Padding spreads. Average size marches upward while fitness stands perfectly still.</p>

 <p>The population is evolving <em>resistance to its own genetic operators</em>. No line of my code rewards defensive padding: it falls out of selection pressure, the same way real genomes are stuffed with non-coding DNA and real codebases accrete dead branches nobody dares delete (you don't want to see some of the folders I don't sync to GitHub here locally...).</p>

 <p>The trees are, in effect, growing bubble wrap to survive crossover.</p>

 <h2>Where GP turns up in practice</h2>

 <p>Symbolic regression is the flagship application and it earns its keep in science. Fit a neural network to experimental data and you get a black box that interpolates. Fit an expression tree and you get an equation a human can read and argue with.</p>

 <p>GP-based systems have rediscovered conservation laws from raw motion-capture data, and they get used to propose compact models in physics, materials science and ecology. When you want insight rather than raw predictive power, a ten-node formula beats a million-weight network, contrary to what many machine learning specialists would say.</p>

 <p>NASA's ST5 mission needed a small satellite antenna with an awkward radiation pattern, and an evolutionary algorithm designed one<sup><a href="#ref-3" class="cite-ref">[3]</a></sup>. The result looks like a bent paperclip. No engineer would have drawn it, and it outperformed the conventionally designed alternative.</p>

 <p>It flew in 2006. There is evolved hardware in space!</p>

 <p>John Koza launched the field with his 1992 book<sup><a href="#ref-1" class="cite-ref">[1]</a></sup> and then spent years compiling "human-competitive" results: GP runs that reinvented patented circuits and produced patentable designs of their own. </p>

 <p>Every one of them lived in a domain with a fast, automatic, well-behaved fitness function. GP never became a general way to write software, and the field guide I recommend below<sup><a href="#ref-2" class="cite-ref">[2]</a></sup> is refreshingly frank about why. Where a score is cheap to compute and means what you want, GP is a live option. Everywhere else, the fitness function is the really hard part.</p>

 <h2>Structure is a freedom</h2>

 <p>Part two's lesson was that the fitness function is where the meaning lives: change what you reward and you change what evolves. Give evolution control over structure as well and it will use that control in every way that pays.</p>

 <p>It builds the subtree that fits your curve. It also builds padding to shield that subtree from your own crossover operator, and for the same reason: both help its children survive.</p>

 <p>Zero the parsimony and watch the trees fatten in real time, then tax them back down to stumps. Sit through thirty flat generations of the error chart until it falls off a cliff, because two mediocre parents happened to be holding complementary halves of the answer.</p>

 <p>An infinite search space, almost all of it garbage, explored by trading parts and taxing waste. It should not work anything like as well as it does.</p>

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

 <details class="faq-item">
 <summary>Is this how AI coding assistants work?</summary>
 <p>No. A modern code assistant is a large language model: it learned from an enormous corpus of human-written code and generates likely text, so it turns up to every problem with a vast prior about what programs should look like. GP turns up knowing nothing. It has never seen a maths textbook. It has a score, and it searches blind. That makes it wasteful (it rediscovers wheels constantly) and also unprejudiced: it can find designs no human would write, like that antenna. Researchers have begun combining the two, with a language model proposing programs and an evolutionary loop scoring and selecting them.</p>
 </details>


 </div>

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

 <ol class="references">
          <li id="ref-1">
            Koza, J. R. (1992). <em>Genetic Programming: On the Programming of Computers by Means of Natural Selection</em>. MIT Press. ISBN 978-0262111706.
          </li>
          <li id="ref-2">
            Poli, R., Langdon, W. B., &amp; McPhee, N. F. (2008). <em>A Field Guide to Genetic Programming</em>. Freely available at <a href="http://www.gp-field-guide.org.uk" target="_blank" rel="noopener">http://www.gp-field-guide.org.uk</a>
          </li>
          <li id="ref-3">
            Lohn, J. D., Hornby, G. S., &amp; Linden, D. S. (2005). An evolved antenna for deployment on NASA's Space Technology 5 mission. In <em>Genetic Programming Theory and Practice II</em> (pp. 301&ndash;315). Springer.
          </li>
        </ol>

 ]]></content:encoded>
    </item>
    <item>
      <title>Differential Evolution, Live</title>
      <link>https://www.kenreid.co.uk/blog/differential-evolution-live.html</link>
      <guid>https://www.kenreid.co.uk/blog/differential-evolution-live.html</guid>
      <pubDate>Sat, 22 Aug 2026 00:00:00 +0000</pubDate>
      <description>Differential evolution running live in your browser: a population whose own spread sets its step size hunts minima on four landscapes.</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/differential-evolution-live.html">View it on the site</a> to play with it.</em></p>
 <h1>Differential Evolution, Live</h1>
 <div class="blog-meta">
 22 August 2026 &middot;
 <span class="blog-tag">ai</span>
 </div>

 <p>This is part seven of the Algorithms, Live series. So far:</p>
 <ul>
 <li><a href="https://www.kenreid.co.uk/blog/ant-colony-live.html">ants have foraged</a></li>
 <li><a href="https://www.kenreid.co.uk/blog/evolution-live.html">triangles have evolved</a></li>
 <li><a href="https://www.kenreid.co.uk/blog/simulated-annealing-live.html">a search has cooled its way out of bad valleys</a></li>
 <li><a href="https://www.kenreid.co.uk/blog/particle-swarm-live.html">a swarm has chased its own best guess</a></li>
 <li><a href="https://www.kenreid.co.uk/blog/variable-neighbourhood-search-live.html">a stuck search has widened its neighbourhood until it came unstuck</a></li>
 <li><a href="https://www.kenreid.co.uk/blog/cma-es-live.html">a cloud of samples has learned the shape of a valley</a></li>
 </ul>

 <p>Today's algorithm is for hard continuous problems: differential evolution. It's one of the simpler of the evolutionary family, a few lines of arithmetic wrapped in a loop. I was first properly introduced to it by Prof. Cedric Gondro, at Michigan State University, as it's one of his favorite algorithms, so we were considering its use for genomic prediction problems.</p>

 

 <div class="plain-english-box">
 <h2>Quick jargon guide</h2>
 <ul>
 <li><strong>Differential evolution (DE):</strong> an optimiser that improves a population of candidate points by adding scaled differences between population members to other members, keeping each change only if it scores better.</li>
 <li><strong>Difference vector:</strong> the arrow from one population member to another (\(b - c\)). It encodes a direction and a distance the population currently spans, and it is the raw material every DE move is built from.</li>
 <li><strong>Mutant / donor vector:</strong> the new candidate built by adding a scaled difference vector to a third member: \(a + F(b - c).\) Also called the donor, because it donates coordinates during crossover.</li>
 <li><strong>Crossover rate (CR):</strong> the probability that each coordinate of the trial point is taken from the mutant rather than from the parent. High \(\mathit{CR}\) means big coordinated moves. Low \(\mathit{CR}\) means one-coordinate-at-a-time nudges.</li>
 <li><strong>Scale factor (F):</strong> the multiplier on the difference vector. It scales the population's own geometry up or down but never replaces it.</li>
 <li><strong>Premature convergence:</strong> the population huddling into one small region before it has found the best basin. In DE this is doubly fatal, because a huddled population can only generate tiny difference vectors, so it loses the ability to leave.</li>
 </ul>
 </div>

 <h2>Subtraction as a search strategy</h2>

 <p>Here is DE/rand/1/bin<sup><a href="#ref-1" class="cite-ref">[1]</a></sup>: keep a population of points scattered over the landscape. To improve any one of them:</p>

 <ol>
 <li>Pick three other members at random: \(a\), \(b\), and \(c\).</li>
 <li>Build a mutant: take the difference vector from \(c\) to \(b\), scale it by \(F\), and add it to \(a\). In symbols, \(a + F(b - c).\)</li>
 <li>Crossover: build a trial point by taking each coordinate from the mutant with probability \(\mathit{CR}\), otherwise keeping the parent's coordinate. (One coordinate is always taken from the mutant, so the trial is never a clone of the parent.)</li>
 <li>Greedy selection: score the trial. If it beats the parent, it takes the parent's seat in the population. If not, it is thrown away and the parent stays.</li>
 </ol>

 <p>Do that once for every member to create a generation. Repeat!</p>

 <p>Every other optimiser in this series had to decide how big a move to make: simulated annealing had a cooling schedule, the genetic algorithm had a mutation rate, gradient descent has a learning rate. DE samples a difference between two of its own members and uses <em>that</em> as the move.</p>

 <p>Early in a run the population is sprayed across the whole landscape, so the differences are long and the moves are large. As the population drains into a valley, its members bunch together, the differences between them shrink, and the move size shrinks too. </p>

 <p>The difference vectors shrink, and they also point somewhere useful. If the population has settled along a curved valley, the differences between members mostly run along that valley, so mutants get launched along it too.</p>

 <p>The population becomes a rough map of the terrain it's sitting on. The state you already have to keep doubles as the search geometry.</p>

 <h2>Watch the geometry</h2>

 <p>You probably get this now if you're reading through the series, but here goes: the demo below runs DE on a choice of four classic test landscapes. The coloured ground is the fitness surface (deeper blue is worse, the ground fades toward the background where it is low, and a small cross marks the global minimum). The blue dots are the population, and the green ring is the best point found so far.</p>

 <p>Each generation, one member of the population gets its donor construction drawn in full: the three amber dots are \(a\), \(b\), and \(c\), the dashed amber arrow is the difference vector from \(c\) to \(b\), and the red arrow is that same vector scaled by \(F\) and grafted onto \(a\), ending at the mutant (the hollow red circle). The red diamond is the trial point after crossover, ringed if it won its duel with the parent.</p>

 <p>Then compare the arrows at generation two with the arrows at generation sixty. Same code, same \(F\), and the moves have shrunk by orders of magnitude because the population did.</p>

 

 <h2>F, CR, and a population of eight</h2>

 <p><strong>Drop \(\boldsymbol{F}\) to 0.1 on the Egg carton.</strong> Every move is now a tenth of a population difference, so members can only creep to the nearest dimple. The spread collapses early, usually before anyone has found the central basin.</p>

 <p>Then the trap springs. A huddled population can only make tiny difference vectors, so the step size follows the spread down and the search is welded shut. Self-scaling, run in reverse. The best-fitness line goes flat while the spread line dives, and that is premature convergence.</p>

 <p><strong>Now push \(\boldsymbol{F}\) to 1.8.</strong> Every mutant overshoots the region the population spans, so the population becomes a spray. The spread line stops falling, the mean step stays huge, and best fitness improves only by luck. DE needs the population to contract for its thermometer to read cooler. At \(F\) this high, contraction never begins.</p>

 <p><strong>Set \(\boldsymbol{\mathit{CR}}\) near zero on the Banana valley.</strong> Each trial now differs from its parent in a single coordinate, so the search moves like a rook: horizontally or vertically, never diagonally. The banana valley is curved, and progress along it needs both coordinates to change together.</p>

 <p>Watch the population inch along in tiny axis-aligned stair steps. Then push \(\mathit{CR}\) back up to 0.9 and watch it slide along the valley in coordinated diagonal moves. \(\mathit{CR}\) is the knob that decides whether DE moves one coordinate at a time or as whole vectors.</p>

 <p><strong>Shrink the population to 8.</strong> The difference vectors are now drawn from a pool of a few dozen possible pairs, so the same handful of directions gets recycled over and over.</p>

 <p>Diversity is essential and a tiny population runs out of it almost immediately: fast convergence, frequently to the wrong dimple. The genetic algorithm in part two had the same failure for the same reason, a gene pool too small to explore with.</p>

 <h2>The missing knob</h2>

 <p>Put this next to the genetic algorithm from part two and the family resemblance is obvious: a population, mutation, crossover, selection. The GA shuffled bits and letters. DE does arithmetic on real vectors. The GA made mutation from coin flips and a fixed rate you had to tune. DE makes mutation out of the population itself, so the operator adapts as the population does.</p>

 <p>And the GA ran a selection lottery where fit parents got more tickets. DE holds one duel per seat, child against its own parent, winner sits. The best member can only be replaced by something better.</p>

 <p>Its sophisticated cousin <a href="https://www.kenreid.co.uk/blog/cma-es-live.html">CMA-ES</a> learns an explicit statistical model of where good moves live and updates it with some fairly serious mathematics. DE gets a decent share of the same benefit, steps that stretch along the good directions and shrink as the search closes in, using subtraction.</p>

 <p>It is dumber than CMA-ES generally, but very much stands on its own, regardless!</p>

 <h2>Where DE turns up in practice</h2>

 <p>Storn and Price introduced it in 1997<sup><a href="#ref-1" class="cite-ref">[1]</a></sup>, it won early optimisation competitions, and it is still a default first try in engineering optimisation: antenna design, power systems, chemical process tuning, anywhere someone has a black-box simulator with a dozen real-valued dials. The survey literature on its variants runs to hundreds of papers<sup><a href="#ref-2" class="cite-ref">[2]</a></sup>.</p>

 <p>You can buy adaptivity: learned models, schedules, meta-parameters. Or you can arrange the algorithm so the state it already keeps <em>is</em> the adaptation. DE gets self-scaling search out of a subtraction.</p>

 <p>Go and set the Egg carton running, click somewhere daft to strand the population in a corner, and watch the arrows do the rest.</p>

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

 <details class="faq-item">
 <summary>Why is it called "differential" evolution?</summary>
 <p>For the difference vectors: every mutation is built from the difference between two population members. It has nothing to do with differential equations or with derivatives.</p>
 </details>

 <details class="faq-item">
 <summary>How do I pick F and CR in practice?</summary>
 <p>The folklore defaults hold up surprisingly well: \(F\) between 0.5 and 0.9, \(\mathit{CR}\) around 0.9. This demo starts at \(F = 0.8\), \(\mathit{CR} = 0.9\). Low \(\mathit{CR}\) only helps when your variables are independent of each other, which you rarely know in advance, so high \(\mathit{CR}\) is the safer bet. If you'd rather not choose at all, self-adaptive variants such as jDE and SaDE evolve \(F\) and \(\mathit{CR}\) alongside the solutions, and the Das and Suganthan survey covers them at length<sup><a href="#ref-2" class="cite-ref">[2]</a></sup>.</p>
 </details>

 <details class="faq-item">
 <summary>Doesn't greedy selection cause the premature convergence you just demonstrated?</summary>
 <p>Less than you'd think. The duel is greedy about <em>who survives</em>, but the moves themselves come from the whole population's geometry, and that keeps exploration alive as long as the population is spread out. What kills the search in the \(F = 0.1\) experiment is the spread collapsing before the good basin is found. Greedy replacement plus population-shaped mutation is a surprisingly stable pairing, and it's a big part of why DE needs so little babysitting.</p>
 </details>
 </div>

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

 <ol class="references">
          <li id="ref-1">
            Storn, R., &amp; Price, K. (1997). Differential Evolution - A Simple and Efficient Heuristic for global Optimization over Continuous Spaces. <em>Journal of Global Optimization</em>, 11(4), 341&ndash;359. <a href="https://doi.org/10.1023/A:1008202821328" target="_blank" rel="noopener">https://doi.org/10.1023/A:1008202821328</a>
          </li>
          <li id="ref-2">
            Das, S., &amp; Suganthan, P. N. (2011). Differential Evolution: A Survey of the State-of-the-Art. <em>IEEE Transactions on Evolutionary Computation</em>, 15(1), 4&ndash;31. <a href="https://doi.org/10.1109/TEVC.2010.2059031" target="_blank" rel="noopener">https://doi.org/10.1109/TEVC.2010.2059031</a>
          </li>
        </ol>

 ]]></content:encoded>
    </item>
    <item>
      <title>CMA-ES, Live</title>
      <link>https://www.kenreid.co.uk/blog/cma-es-live.html</link>
      <guid>https://www.kenreid.co.uk/blog/cma-es-live.html</guid>
      <pubDate>Wed, 19 Aug 2026 00:00:00 +0000</pubDate>
      <description>CMA-ES live in your browser: watch an optimiser learn the shape of a valley by stretching and rotating its search cloud until the narrow floor costs it nothing to walk along.</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/cma-es-live.html">View it on the site</a> to play with it.</em></p>
 <h1>CMA-ES, Live</h1>
 <div class="blog-meta">
 19 August 2026 &middot;
 <span class="blog-tag">ai</span>
 </div>

 <p>There is a shape of problem that many optimisers struggle with: a long, thin, tilted valley. </p>

 <p>Every algorithm in this series so far has moved in steps whose size and shape were settled in advance:</p>
 <ul>
 <li><a href="https://www.kenreid.co.uk/blog/ant-colony-live.html">the ants of part 1</a> took steps along edges and left scent behind</li>
 <li><a href="https://www.kenreid.co.uk/blog/evolution-live.html">the genetic algorithm of part 2</a> took steps in genome space, one mutation at a time</li>
 <li><a href="https://www.kenreid.co.uk/blog/simulated-annealing-live.html">simulated annealing in part 3</a> had a step size you could watch shrink as the temperature fell</li>
 </ul>

 <p>This one learns the <em>shape</em> of the neighbourhood worth searching, and restretches itself to match: CMA-ES</p>

 

 <div class="plain-english-box">
 <h2>Quick jargon guide</h2>
 <ul>
 <li><strong>CMA-ES:</strong> the Covariance Matrix Adaptation Evolution Strategy. An optimiser that samples a cloud of candidate points, keeps the best ones, and keeps adjusting the cloud's position, size, and shape to follow whatever has been working.</li>
 <li><strong>Evolution strategy:</strong> the family CMA-ES belongs to: evolutionary optimisers for continuous variables, where mutation means adding random numbers rather than flipping genes.</li>
 <li><strong>Mean:</strong> the centre of the search cloud, the algorithm's current best guess at where the answer lives.</li>
 <li><strong>Step size (sigma):</strong> the overall scale of the cloud. Big sigma means bold, scattered samples; small sigma means cautious, local ones.</li>
 <li><strong>Covariance matrix:</strong> the shape of the cloud: which directions it stretches along and how far. This is the star of the show, and adapting it is what the "CMA" stands for.</li>
 <li><strong>Condition number:</strong> how stretched the cloud is: the ratio of its longest axis to its shortest. 1 is a circle; 400 is a needle.</li>
 <li><strong>Black-box optimisation:</strong> optimising a function you can only evaluate, not differentiate or inspect. You put numbers in, a score comes out.</li>
 </ul>
 </div>

 <h2>Steps versus shapes</h2>

 <p>Valleys like that are everywhere in real problems (the technical word is ill-conditioned). An optimiser proposing moves in random directions wastes almost all of them climbing walls while gradient descent zigzags across the floor of possible solutions.</p>

 <p>This requires an optimiser that notices "movement along <em>this</em> axis keeps paying off, movement along <em>that</em> one never does".</p>

 <p>CMA-ES keeps a sampling distribution, a fuzzy ellipse of probability. Each generation it draws a cloud of candidate points from the ellipse, scores them, and shifts the centre toward the better half.</p>

 <p>It also updates the ellipse's <em>shape</em>. Directions the winners recently moved in get widened. Directions that never produced a winner get squeezed. It saves the historic data of wins in the covariance matrix.</p>

 <p>Instead of a fixed cooling schedule, CMA-ES watches its own recent movement. If successive generations keep marching the same way, the steps were too small, so sigma grows. If the path folds back on itself, the steps were too big, so sigma shrinks. Exploration vs exploitation.</p>

 <p>Hansen and Ostermeier formalised all of this in 2001<sup><a href="#ref-1" class="cite-ref">[1]</a></sup>, and the design is careful enough that the standard version has no parameters you are expected to tune. But, in this version, for fun, there are sliders so you can override that.</p>

 <h2>The loop on a napkin</h2>

 <ol>
 <li>Sample a cloud of candidate points from a Gaussian: centre (mean), scale (sigma), shape (covariance).</li>
 <li>Score every point and rank them.</li>
 <li>Move the mean toward a weighted average of the best few, best weighted most.</li>
 <li>Stretch the covariance along the directions those winners came from.</li>
 <li>Grow or shrink sigma depending on whether recent moves kept pointing the same way.</li>
 <li>Go to step 1.</li>
 </ol>

 <h2>Watch it</h2>

 <p>Below, CMA-ES is minimising a landscape drawn as contour shading (darker is worse, the red cross is the global optimum). Blue dots are one generation's samples, the amber rings are the search ellipse at one and two sigma, and the green dot with the fading tail is the mean and the route it took.</p>

 <p>Start with the default narrow valley and watch the ellipse for the first few seconds. It begins as a circle, tips over to match the valley's angle, and stretches. Click or drag anywhere on the map to plant the start point somewhere new.</p>

 

 <h2>The step-size rule</h2>

 <p>On the smooth valleys the run still works, it just crawls. The step-size rule notices the steady progress and inflates sigma back to something sensible, which you can watch happening on the right-hand chart.</p>

 <p>On the egg carton a tiny sigma is fatal. The cloud fits inside a single dimple, every sample agrees the local floor is the best thing going, and the run settles into the nearest dent.</p>

 <p>If the sigma is huge, the first generations are chaos.</p>

 <p>Then watch the sigma chart. The path the mean traces keeps folding back on itself, the adaptation rule reads that as "steps too big", and sigma falls generation after generation until the cloud shrinks onto something worth exploiting. Thrashing and then recovery is the self recovering step size.</p>

 <p>Drop lambda to 6 and each generation's ranking is built from almost nothing. The mean update gets noisy and the covariance estimate gets noisier.</p>

 <p>Back to the narrow valley, default settings, and this time watch only the amber ellipse and the stretch readout. Circle, tilt, stretch, and then a needle sliding along the valley floor with the stretch number climbing into the hundreds.</p>

 <p>Every other algorithm I've covered in this series would be paying the valley's toll on every single move, but CMA-ES redrew its own geometry so there is no toll left to pay!</p>

 <h2>A memory of directions</h2>

 <p>What I like most about CMA-ES is where it keeps what it has learned. The ants kept their knowledge in the world, as scent on edges. The genetic algorithm kept it in a population, spread implicitly across genomes: variety, but no geometry. Simulated annealing had one knob, a step size, and its step size only goes down.</p>

 <p>CMA-ES keeps a small, dense summary: one point for "where", one number for "how far", and one matrix for "which way". The matrix is the difference. It is a running record of the directions that recently succeeded, updated every generation, and it turns a search that happens <em>in</em> the landscape into one that builds a model <em>of</em> the landscape's local shape.</p>

 <h2>Where it gets used</h2>

 <p>For continuous black-box problems, where the objective is a simulator or a physical measurement or a hyperparameter score with no gradient in sight, CMA-ES is the default serious tool up to a few hundred dimensions (yes, you read that correctly!!). </p>

 <p>It has tuned aircraft geometry, robot gaits, controller parameters, and the settings of other learning systems. Hansen's tutorial<sup><a href="#ref-2" class="cite-ref">[2]</a></sup>.</p>

 <p>When progress stalls, the instinct is to push harder in the direction you were already going. CMA-ES keeps an estimate of which directions have been rewarding you lately, and spend your effort along those, even when they aren't the ones you set off in. Which is surprisingly good life advice to get from a pile of linear algebra.</p>

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

 <details class="faq-item">
 <summary>Is this the full CMA-ES?</summary>
 <p>Simplified. The demo does the real core: Gaussian sampling, ranking, log-weighted recombination of the best mu samples, the rank-mu covariance update, and cumulative (path-based) step-size adaptation. It leaves out the rank-one covariance update with its separate evolution path, plus restarts and boundary handling, which the standard implementations all include. </p>
 </details>

 <details class="faq-item">
 <summary>Why does it keep drifting after finding the optimum?</summary>
 <p>Because it never knows it has found it. CMA-ES only ever sees relative rankings inside its own cloud, never "you are done". </p>
 </details>

 <details class="faq-item">
 <summary>When would I not use CMA-ES?</summary>
 <p>When you have gradients, use them. On smooth, differentiable problems, and especially in the millions of dimensions of neural network training, gradient methods win by orders of magnitude. </p>
 </details>
 </div>

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

 <ol class="references">
          <li id="ref-1">
            Hansen, N., &amp; Ostermeier, A. (2001). Completely derandomized self-adaptation in evolution strategies. <em>Evolutionary Computation</em>, 9(2), 159&ndash;195. <a href="https://doi.org/10.1162/106365601750190398" target="_blank" rel="noopener">https://doi.org/10.1162/106365601750190398</a>
          </li>
          <li id="ref-2">
            Hansen, N. (2016). The CMA Evolution Strategy: A Tutorial. <em>arXiv preprint</em> arXiv:1604.00772. <a href="https://doi.org/10.48550/arXiv.1604.00772" target="_blank" rel="noopener">https://doi.org/10.48550/arXiv.1604.00772</a>
          </li>
        </ol>

 ]]></content:encoded>
    </item>
    <item>
      <title>Factorio, Live: a Game as an Optimisation Benchmark</title>
      <link>https://www.kenreid.co.uk/blog/factorio-live.html</link>
      <guid>https://www.kenreid.co.uk/blog/factorio-live.html</guid>
      <pubDate>Sun, 16 Aug 2026 00:00:00 +0000</pubDate>
      <description>The logistic transport belt problem from my GECCO 2021 paper, running live in your browser: watch simulated annealing and genetic programming lay belts across a walled grid until the items get through.</description>
      <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/factorio-live.html">View it on the site</a> to play with it.</em></p>
 <h1>Factorio, Live: a Game as an Optimisation Benchmark</h1>
 <div class="blog-meta">
 13 August 2026 &middot;
 <span class="blog-tag">data science</span>
 </div>

 <p>Factorio is a game I've loved since it was released. It's my most-played game on Steam, and there's something so deeply satisfying about playing a game all about optimization, exploring and solving problems. When I started thinking about the psychology behind this, I realized that video games, puzzles, board games and the like mirror a satisfaction I find when programming, conducting research and solving real-world problems. It scratches an itch in a particular way unfound in other walks of life: defeating challenges beyond a grind in an RPG, lifting a heavier weight at the gym or accomplishing projects based more on time than difficulty. It's the slow accrual of problems solved into a portfolio of solutions working in tandem, so you can zoom out to the map and say "look at my base! it's a bit of a mess, but it's mine, and I love it."</p>

 <p>I decided to bridge the gap between the video game and the research, both of which scratched that itch in my mind. I brought up the idea with one of my supervisors at Michigan State University (MSU) at the time, <a href="https://scholar.google.com/citations?user=u-FjxEUAAAAJ&amp;hl=en" target="_blank" rel="noopener noreferrer">Prof. Wolfgang Banzhaf</a>. He thought it sounded interesting, and liked my point that a video game is a better teaching instrument than a textbook, since this visually shows success and failure, and the mechanics of individual algorithms live in front of you. He said I should go for it, so I enlisted a couple of my colleauges (now co-authors) and we worked on it together. 

 

 <div class="plain-english-box">
 <h2>Quick jargon guide</h2>
 <ul>
 <li><strong>Operational research:</strong> the study of running things well: what to build, where to put it, and in what order. </li>
 <li><strong>Throughput and bottleneck:</strong> how much gets through per second and what component causes a trickle for the rest of a system.</li>
 <li><strong>Transport belt:</strong> the conveyor that moves items in Factorio. Its capacity is fixed, so a longer route costs time, not bandwidth.</li>
 <li><strong>Underground belt:</strong> a pair of entry and exit tiles that carries items beneath obstacles, up to four cells apart. It's kind of like teleporting that takes the same amount of time as a normal belt.</li>
 <li><strong>Metaheuristic:</strong> a search that improves a solution by trial and feedback rather than solving equations. <a href="https://www.kenreid.co.uk/blog/simulated-annealing-live.html">Simulated annealing</a> and <a href="https://www.kenreid.co.uk/blog/evolution-live.html">genetic algorithms</a> are both in this family.</li>
 </ul>
 </div>

 <h2>Watch it lay the belts</h2>

 


  <h2>Two places this demo is not the paper</h2>

 <p>The scoring here is not identical to the published version. The paper scored a candidate by running it in the game, which returns nothing at all until the route lands. That cliff is survivable for a method that lays whole paths in one move, and hopeless for one that edits a single cell at a time: annealing would sit at zero for thousands of evaluations and look broken. So the demo adds partial credit for reaching further east, and a small bonus for using fewer belts once connected. It is a shaped objective, and shaped objectives can be gamed, so this is really just for demo purposes, not a realistic solution (mostly since "east" isn't always the goal).</p>

 <p>The second departure is in how annealing picks what to change. The paper varies its neighbour selection with iteration progress. Here, two thirds of edits are at the frontier, meaning the cell where the current route gives up. The reason is arithmetic: on a twelve by twelve grid, a uniformly chosen cell is almost always somewhere the route never reaches, so most edits are invisible to the score and the search spends its budget rearranging scenery. Both changes exist to make the comparison fair to annealing, which is the direction a bias should run if you are going to have one.</p>

 <p>So: this is a pretty and intentionally non-representative demo of the paper.</p>

 <h2>What the paper actually did</h2>

  <p>The paper behind this is <a href="https://scholar.google.com/citations?view_op=view_citation&amp;hl=en&amp;user=bwl2u00AAAAJ&amp;citation_for_view=bwl2u00AAAAJ:8k81kl-MbHgC" target="_blank" rel="noopener noreferrer"><em>The Factory Must Grow: Automation in Factorio</em></a>, written with my co-authors Iliya Miralavy, Stephen Kelly, Wolfgang Banzhaf and Cedric Gondro, and presented at GECCO in 2021. The starting point was that Factorio is a factory simulator that has already done the expensive part of building a benchmark: it has consistent physics, a fixed belt throughput, and a community of players who have spent years discovering what good layouts look like, so how would our algorithms fair?</p>

 <p>We defined the logistic transport belt problem formally, as an integer program with hard constraints on what can occupy a cell and how underground pairs must line up, and two soft constraints to be maximised: items delivered to the receiver, and items accepted from the chest. Then we built the 'Factorio Optimizer Interface', which uses the game's Lua modding hooks and the RCON protocol to place a candidate layout in a running Factorio server and read back how many items the input inserter took and how many reached the output chest. Because it talks over a socket and reads matrices from disk, the optimiser can be written in anything: ours were in Java, Python and C++. The evaluation function is the game itself.</p>

 <p>Three optimisers were compared on six instances, being three grid sizes at three by three, six by six and twelve by twelve, each with and without obstacles. Parallel simulated annealing, a genetic programming system whose operators place and connect belt runs rather than editing cells, and an evolutionary reinforcement learning approach. The small instances are solvable by anything. The twelve by twelve with obstacles is where things really got interesting.</p>

 <p>Genetic programming did well here because its operators speak the language of the problem: laying a run of belts and connecting two points are single moves, so one step of the search covers ground that costs annealing a long chain of individually pointless edits. </p>

 <figure>
 <img src="https://www.kenreid.co.uk/blog/img/factorio-paper-12x12-ingame.webp" alt="A Factorio screenshot of the twelve by twelve benchmark instance: a square arena walled in two-deep stone, with a steel chest and inserter at the top right and a receiver at the bottom left. Yellow transport belts wind between tree stumps that act as obstacles, and a line of iron ore rides the belts along the route between the two." loading="lazy" style="width:100%; max-width:620px; display:block; margin:0 auto; border-radius:8px;">
 <figcaption class="figure-note">From <a href="https://scholar.google.com/citations?view_op=view_citation&amp;hl=en&amp;user=bwl2u00AAAAJ&amp;citation_for_view=bwl2u00AAAAJ:8k81kl-MbHgC" target="_blank" rel="noopener noreferrer">Reid et al. (2021)</a>. The twelve by twelve instance with obstacles, as the optimiser sees it: walls two cells thick, input top right, receiver bottom left, and the stumps are the cells you cannot build on. The ore on the belts is the fitness function happening.</figcaption>
 </figure>

 <h2>The model</h2>

 <p>Section 4 of the paper states the problem formally. Two hard constraints, two soft ones, and a pair of binary decision variables. Throughput, routing and flow is absent, for a reason I will come back to.</p>

 <p>A position \(p\) is a coordinate pair in the grid. Directions are \(d \in \{0,1,2,3\}\), meaning north, east, south and west. Belts and underground belts are drawn from \(L_b\) and \(L_u\), and \(\phi_p\) is 1 when position \(p\) holds an obstacle and 0 otherwise. The decisions are whether to place a given belt type, at a given position, facing a given way:</p>

 <div class="math-block">
 \[
 \begin{aligned}
 X_{bcpd} &= 0 \text{ or } 1, && \forall b \in L_b,\; \forall p_x \in G_x,\; \forall p_y \in G_y,\; \forall d \in \{0,1,2,3\} \\[4pt]
 X_{ucpd} &= 0 \text{ or } 1, && \forall u \in L_u,\; \forall p_x \in G_x,\; \forall p_y \in G_y,\; \forall d \in \{0,1,2,3\}
 \end{aligned}
 \]
 </div>

 <figure>
 <img src="https://www.kenreid.co.uk/blog/img/factorio-paper-encodings.webp" alt="A table of integer encodings. Negative numbers describe the problem: minus ten to minus seven are output directions, minus six a steel chest, minus five to minus two input directions, minus one a stone wall, and zero is empty. Positive numbers are the solution: one to four are belts facing north, east, south and west, and five to eight are underground belts in the same four directions." loading="lazy" style="width:100%; max-width:420px; display:block; margin:0 auto; border-radius:8px;">
 <figcaption class="figure-note">From <a href="https://scholar.google.com/citations?view_op=view_citation&amp;hl=en&amp;user=bwl2u00AAAAJ&amp;citation_for_view=bwl2u00AAAAJ:8k81kl-MbHgC" target="_blank" rel="noopener noreferrer">Reid et al. (2021)</a>. The encoding an optimiser reads and writes. Everything below zero is the problem and is fixed; everything above zero is the solution and is yours. A direction is not a separate field, it is baked into the number.</figcaption>
 </figure>

 <p>The objective maximises the sum of the two soft constraints, each a parameterised item count scaled by a weight (not as in how heavy it is, but a weighting as in scaling), where \(o\) is the number of items output and \(i\) the number input:</p>

 <div class="math-block">
 \[
 \begin{aligned}
 \max \quad & (SC_1 + SC_2), \qquad SC_1 = o \, w_1, \quad SC_2 = i \, w_2 \\[6pt]
 \text{s.t.} \quad
 & \left[ \phi_p + \Bigl( \sum_{b \in L_b} X_{bp} \Bigr) + \Bigl( \sum_{u \in L_u} X_{up} \Bigr) \right] < 2 && \text{(HC1)} \\[6pt]
 & \left[ \Bigl( \sum_{b \in L_b} X_{b} \Bigr) + \Bigl( \sum_{u \in L_u} X_{u} \Bigr) \right] > 0 && \text{(HC2)} \\[4pt]
 & w_1 + w_2 = 1, \quad 0 \le w_k \le 1
 \end{aligned}
 \]
 </div>
 <p class="math-caption">HC1 allows a position to hold one logistic object or one obstacle, never both and never two. HC2 requires at least one item to be placed at all. A solution failing either scores zero; otherwise fitness lands between 0 and 1.</p>

 <p>HC1 is the constraint that makes this combinatorial. Every cell you spend is a cell you cannot spend on anything else, and the obstacle term means the grid's difficulty is set before the search starts. HC2 exists to rule out the empty solution, which would otherwise be feasible and uninformative.</p>

 <p>There is no flow conservation here, no belt capacity, no constraint saying a route has to connect. The underground pairing rule, the one that requires an entrance and exit in the same row or column, facing the same way, at most four cells apart, is described in the paper as a property of the game rather than written as an inequality.</p>

 <p>All of that is missing because the model does not evaluate solutions. The game does. A candidate is built in Factorio through the interface, run, and scored on what the receiving chest actually contains, so the physics stays where it already worked instead of being restated as constraints that could disagree with it. That is the paper's real trick, and it is why the formal model can afford to be four lines long.</p>

 <p>It also explains the weights. With \(w_2 = 0\) the objective would count only what arrives, and every layout that fails to connect would score identically at zero, leaving a search no way to tell a near miss from an empty grid. Because \(SC_2\) pays for items merely taken out of the input chest, a belt that starts correctly and dead-ends still earns something. That is a modelling decision rather than an algorithmic one, and it is the difference between a landscape a local search can climb and a cliff it cannot.</p>

 <h2>The niche</h2>

  <p>Optimisation research runs on benchmark sets, and benchmark sets have a known failure mode: they get old, methods get tuned against them, and after a decade you cannot tell whether a new result is progress or overfitting to a fixed library of instances. Games are an interesting escape from that, because a game that people play is a simulator someone else has already paid to build, validate and keep interesting.</p>

 <p>Factorio is a good one for the specific reason that its difficulty is combinatorial rather than reflexive. Nothing here depends on reaction time or hidden information., like many games: it is placement under constraints, with a throughput number at the end, which is the same shape as facility layout, circuit routing and warehouse design in real life. It also comes with something no synthetic benchmark has: a large population of humans who have independently optimised the same problem for fun, and whose solutions you can compare against.</p>

 <p>This is one problem inside one game, with a single objective and an evaluation function that a commercial studio can change in a patch. A benchmark you do not control is a benchmark that can be revised out from under your published results. And the community solutions that make it appealing are also a contamination risk, since a method tuned by someone who knows the good patterns is not discovering them. Games are a useful source of problems, not a replacement for the discipline of saying what your benchmark can and cannot show.</p>

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

 <details class="faq-item">
 <summary>Is this really operational research, or just a game?</summary>
 <p>Why not both? Strip the graphics and what remains is a constrained routing problem: place components on a grid, respect adjacency and pairing rules, maximise throughput. The same integer program describes it whether the output is rendered as a conveyor or as a line in a warehouse plan. The game is the interface, not the problem.</p>
 </details>

 <details class="faq-item">
 <summary>Why not just solve it outright?</summary>
 <p>You can, up to a point. The small grids are well within reach of an exact solver, and where an exact answer is available it is the right thing to use, because it comes with a proof. The difficulty is that the search space grows with both the grid area and the number of things each cell could be, and the underground pairing constraint links cells that are not adjacent. Past a certain size you are choosing between a guaranteed answer you will not receive today and a good answer you can have in seconds, which is the situation metaheuristics exist for.</p>
 </details>

 <details class="faq-item">
 <summary>Does the demo run the real game?</summary>
 <p>The demo in this site does not. The paper drove the actual game through an interface and measured real throughput. The demo reimplements the belt mechanics in JavaScript, because running thousands of evaluations in front of you requires an evaluation that takes microseconds rather than seconds. The mechanics modelled here are the ones the problem turns on: belt direction, underground pairing and reach, and items moving at a fixed rate. Everything else about Factorio is absent.</p>
 </details>

 <details class="faq-item">
 <summary>Why is genetic programming so much better here?</summary>
 <p>Because its moves are the right size. Connect lays a whole run of belts in one operation, where annealing changes a cell and asks whether that helped, and most single-cell changes to a broken route help by nothing at all. Given the same budget of four thousand evaluations on the twelve by twelve grid, the population method connects on five runs out of five and reaches a fitness around 0.82, while annealing connects on three and sits near 0.66. The gap in tidiness is starker than the gap in score: annealing arrives with roughly 88 belts on the board against 28, because it has no move that removes a redundant detour once the route works. That's not to say annealing is a weak algorithm. It says the operator matters more than the acceptance rule, which is the same conclusion the paper reached and the reason the comparison is worth running at all.</p>
 </details>

 </div>

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

 <ol class="references">
 <li id="ref-1">
 Reid, K. N., Miralavy, I., Kelly, S., Banzhaf, W., &amp; Gondro, C. (2021). <a href="https://scholar.google.com/citations?view_op=view_citation&amp;hl=en&amp;user=bwl2u00AAAAJ&amp;citation_for_view=bwl2u00AAAAJ:8k81kl-MbHgC" target="_blank" rel="noopener noreferrer">The Factory Must Grow: Automation in Factorio</a>. <em>Proceedings of the Genetic and Evolutionary Computation Conference Companion</em>. <a href="https://arxiv.org/abs/2102.04871" target="_blank" rel="noopener">https://arxiv.org/abs/2102.04871</a>
 </li>
 </ol>

        

 ]]></content:encoded>
    </item>
    <item>
      <title>How the Interactive Posts Work</title>
      <link>https://www.kenreid.co.uk/blog/how-the-interactive-posts-work.html</link>
      <guid>https://www.kenreid.co.uk/blog/how-the-interactive-posts-work.html</guid>
      <pubDate>Sun, 16 Aug 2026 00:00:00 +0000</pubDate>
      <description>Several blogs I&#x27;ve written run simulations in your browser. The chassis underneath them: theme-reactive canvases, seeded randomness, phone-width rules, and a smoke test.</description>
      <category>technology</category>
      <content:encoded><![CDATA[
 <h1>How the Interactive Posts Work</h1>
 <div class="blog-meta">
 16 August 2026 &middot;
 <span class="blog-tag">technology</span>
 </div>

 <p>Someone on Cyberspace (new social media website that has such a cool vibe to it) recently asked me how I set up the simulators on my website, if I used a specific library. I responded saying how it's raw javascript, but that got me thinking: I intend to keep making these kind of educational and fun blogs, so why not make a little framework to make it easier on myself?</p>

 <p>The algorithms themselves need to be separate, I wasn't planning on making a new JMetal, DEAP or similar. 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>Canvas:</strong> an HTML element that gives JavaScript a rectangle of pixels to draw on, every frame, with no memory of what was there before.</li>
 <li><strong>devicePixelRatio:</strong> how many physical screen pixels sit behind one CSS pixel. Ignoring it can cause canvas drawings to come out blurry on phones and retina displays.</li>
 <li><strong>CSS custom properties:</strong> variables that live in stylesheets (<code>--viz-ink: #2b2b2b</code>). Scripts can read them at runtime, which is the hinge this whole system turns on.</li>
 <li><strong>MutationObserver:</strong> a browser API that fires a callback when part of the page changes, like an attribute being flipped by a theme toggle.</li>
 <li><strong>Seeded RNG:</strong> a random number generator that produces the same "random" sequence every time you start it from the same seed.</li>
 <li><strong>requestAnimationFrame:</strong> the browser's "call me before the next repaint" scheduler, the correct heartbeat for animation loops.</li>
 </ul>
 </div>

 <h2>The CSS owns the colours</h2>

 <p>I always enjoyed color theory in computer science. The RGB values, pixels (did you know it stands for "picture element"?), bitmaps vs JPEGs vs other formats, it's super interesting to me. On this website, the theme toggle cannot restyle it the way it restyles text, and the first version of the ant post hardcoded its colours in JavaScript and looked wrong in one theme or the other. Every widget's colours live on its wrapper element as CSS custom properties, with a dark-theme override block, and the drawing code reads them fresh at draw time:</p>

<pre><code class="language-javascript">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')
  };
}

new MutationObserver(function () { draw(); })
  .observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });</code></pre>

 <p>The observer redraws the moment <code>data-theme</code> gets toggled, so a running simulation changes palette mid-run. This has no practical value whatsoever. </p>

 <h2>Randomness must be reproducible</h2>

 <p><code>Math.random()</code> is banned from the demos. Every widget gets its randomness from mulberry32, a seeded generator:</p>

<pre><code class="language-javascript">function mulberry32(seed) {
  var a = seed >>> 0;
  return function () {
    a |= 0; a = (a + 0x6D2B79F5) | 0;
    var t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}</code></pre>

 <p>When a reader reports that the race chart does something odd around 90,000 evaluations, I can watch the same 90,000 evaluations they did. "New cities" buttons just bump the seed. </p>

 <h2>Phones are the real test</h2>

 <p>No canvas ever gets a fixed rendered height now: height is computed from measured width, so narrow screens get a taller aspect instead of a squashed ribbon. Side-by-side panes stack vertically below ~480px, and sliders become full-width rows on small screens.</p>

 <p>A canvas only gets <code>touch-action: none</code> if it has drag interaction, so chart canvases never trap a reader mid-scroll.</p>

 <p>And because rules I merely remember are rules I eventually break, the <a href="https://www.kenreid.co.uk/blog/my-website-has-a-test-suite.html">test suite</a> enforces them. The smoke test auto-discovers every blog page containing a <code>&lt;canvas&gt;</code> tag (no registration list to forget to update) and loads each at 360px wide in a headless browser. It fails the build on horizontal overflow, a canvas wider than the viewport, a canvas squashed under 100px tall, or any console error.</p>

 <h2>The chassis checklist</h2>

 <p>What every new interactive post starts from:</p>

 <ul>
 <li>A wrapper div carrying the <code>--viz-*</code> palette, light values plus a dark override block.</li>
 <li><code>vizColors()</code> read at draw time, plus a MutationObserver for theme flips.</li>
 <li><code>fitCanvas()</code> handling devicePixelRatio and re-running on resize.</li>
 <li>mulberry32 for every random draw, seeds visible near the top of the file.</li>
 <li>A requestAnimationFrame loop with a speed slider controlling work per frame, not frame rate.</li>
 <li><code>role="img"</code> and a real <code>aria-label</code> on every canvas.</li>
 <li>A standalone single-file version of the demos in <code>blog/downloads/</code>.</li>
 </ul>

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

 <details class="faq-item">
 <summary>Why raw canvas instead of a chart library?</summary>
 <p>The demos ARE the content, and libraries make the common case easy at the price of making the odd case a bit more awkward. A four-way race chart with collision-nudged end labels, step-function lines, and a temperature strip sharing its x-axis is the odd case. </p>
 </details>

 <details class="faq-item">
 <summary>Don't the simulations drain phone batteries?</summary>
 <p>They only run while you press Run, and they do their work inside a requestAnimationFrame budget, so the browser throttles them in background tabs and off-screen. </p>
 </details>

 <details class="faq-item">
 <summary>Why not SVG, which the theme could style directly?</summary>
 <p>SVG restyles beautifully but costs per element, and these simulations push thousands of points and segments per frame.</p>
 </details>
 </div>

        

 ]]></content:encoded>
    </item>
    <item>
      <title>Letting Robots Update Your Homepage</title>
      <link>https://www.kenreid.co.uk/blog/letting-robots-update-your-homepage.html</link>
      <guid>https://www.kenreid.co.uk/blog/letting-robots-update-your-homepage.html</guid>
      <pubDate>Fri, 14 Aug 2026 00:00:00 +0000</pubDate>
      <description>My homepage shows what I&#x27;m listening to and reading, updated every Monday by a GitHub Action that commits to its own repository. Live data on a static site, no server required.</description>
      <category>technology</category>
      <content:encoded><![CDATA[
 <h1>Letting Robots Update Your Homepage</h1>
 <div class="blog-meta">
 14 August 2026 &middot;
 <span class="blog-tag">technology</span>
 </div>

 <p><a href="https://www.kenreid.co.uk/index.html">My homepage</a> shows my scrobble count, the book I'm working reading through, the last song I played, and whatever I last posted on Bluesky. This is a static site, meaning it can't just run dynamic scripts on a whim. I set up a system to dynamically update my HTML weekly, however, and it works for free. 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>GitHub Actions:</strong> GitHub's free automation. You describe a job in a text file, and GitHub runs it on their machines, on a schedule if you like.</li>
 <li><strong>Cron:</strong> the venerable syntax for "run this at these times". Mine says <code>0 6 * * 1</code>: every Monday at 06:00 UTC.</li>
 <li><strong>API:</strong> a website's machine-readable front door. Last.fm's API answers "what did this user play?" with data instead of a web page.</li>
 <li><strong>API key / secret:</strong> the password for that front door, stored in GitHub's encrypted secrets so it never appears in the code.</li>
 <li><strong>Scrobble:</strong> one logged song play. My <a href="https://www.kenreid.co.uk/blog/what-50000-scrobbles-say-about-me.html">Last.fm account</a> has been counting since 2011.</li>
 <li><strong>Bot commit:</strong> a change to the repository made by an automation rather than a person.</li>
 </ul>
 </div>

 <h2>The idea: if the site can't fetch, fetch into the site</h2>

 <p>A normal website with live data has a server that queries things when visitors arrive. A static site can't do that, but it has a loophole: the site is a git repository, and <em>anything that can commit to the repository can change the site</em>. So instead of fetching data per visit, a scheduled job fetches it once a week and commits the results as plain JSON files. The site stays static. Visitors' browsers read those JSON files with an ordinary fetch: same origin, no keys, no rate limits, nothing that can fail separately from the site itself.</p>

 <p>The whole pipeline is one workflow file and one Python script. The workflow is short: check out the repository, run the script, and commit if anything changed:</p>

<pre><code class="language-yaml">name: Refresh live data

on:
  schedule:
    - cron: "0 6 * * 1"   # weekly, Mondays 06:00 UTC
  workflow_dispatch:

permissions:
  contents: write

jobs:
  refresh:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Fetch live data
        env:
          LASTFM_API_KEY: ${{ secrets.LASTFM_API_KEY }}
        run: python .github/scripts/refresh_now.py

      - name: Commit if changed
        run: |
          FILES="data/lastfm.json data/now.json data/lastfm-history.json data/topalbums.json"
          if git diff --quiet $FILES; then
            echo "No change."
            exit 0
          fi
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add $FILES
          git commit -m "Refresh live data (Last.fm, Goodreads)"
          git push
</code></pre>

 <p>The <code>workflow_dispatch</code> line adds a manual "run now" button in GitHub's interface (which you will want the first dozen times), while the diff check means runs where nothing changed produce no commit at all.</p>

 <h2>What the script gathers</h2>

 <p>The Python script makes a handful of HTTP requests. From the Last.fm API: my total play count, the most recent track, and my top albums of the past three months, which feed the homepage stat, the "now playing" strip, and the album wall on <a href="https://www.kenreid.co.uk/music.html">the music page</a> respectively. From Goodreads, which shut its API years ago but still publishes RSS feeds: my currently-reading shelf, parsed straight out of the XML. Everything goes into a small JSON files under <code>data/</code>, the biggest a few kilobytes.</p>

 <p>Each run appends that week's play count to a rolling log, capped at ninety entries, which at one measurement a week is close to two years of history. </p>

 <figure>
 <img src="https://www.kenreid.co.uk/img/photography/thumb/12.webp" alt="A brindle Staffordshire terrier standing alert in woodland, looking at the camera" 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> A good fetcher.</figcaption>
 </figure>

 <h2>What the homepage shows</h2>

 <p>The strip under the hero is a single row of five things, and only three of them come from the robot. The book and the track are read straight out of <code>now.json</code>. The latest blog post and the latest short story come from <code>posts.json</code> and <code>stories.json</code>, which are written by build scripts when I publish rather than by the Action on a schedule. </p>

 <p>Last.fm distinguishes a track that is playing from one that was played, so the label switches between "Now playing" and "Last played" depending on whether I happen to be listening when you open the page, and the first version gets a little animated equaliser beside it. It is not accurate, because it is updated so infrequently, but it gives some flavor to the site and over time it's likely representative of me. Maybe.</p>

 <p>The fifth item, my most recent Bluesky post, is in no JSON file at all. Your browser fetches it from Bluesky's public API while the page loads. I've been tempted to remove it, as recently I've only posted new blogs to social media, but I hope I will return to posting random thoughts there again soon, making this a bit more useful than another place to see a new blog post (which is already on the index page!)</p>

 <h2>Design decisions </h2>

 <p>The homepage HTML contains a hardcoded scrobble count, and the JavaScript replaces it with the fresh value from the JSON after load. If the fetch fails, or JavaScript is off, the visitor sees a slightly stale number instead of a blank. The robot improves the site but it fails elegantly.</p>

 <p>Goodreads' RSS goes down more often than Last.fm's API. When a source fails, the script keeps whatever the previous run wrote rather than blanking it: better yesterday's truth than today's error. A failure in one source never stops the others from updating, of course.</p>

 <p>The Last.fm key lives in the repository's encrypted secrets and reaches the script as an environment variable. It appears in no file and no log for security reasons (not that I'd be massively put-out if someone broke into my goodreads or last.fm).</p>

 <p>The bot's commits are labelled as bot commits, touch only the data files, and say what they did. My git history has a weekly heartbeat in it now: a tidy row of "Refresh live data" commits, one every Monday, each one the robot clocking in so I don't have to manually update stuff.</p>

 <h2>What else this pattern is good for</h2>

 <p>Anything that changes slowly and comes from somewhere with an API or a feed: your latest posts elsewhere, sports scores, weather, stars on a project, prices you're tracking, a "days since" counter for whatever you are currently ashamed of. The recipe is always the same three steps: a script that fetches and writes JSON, a workflow that runs it on a schedule and commits, and a page that reads the JSON with a fallback. If the data changes faster than a schedule can keep up with (live chat, comment counts), a static site is the wrong tool and no robot can easily fix that. Everything slower works just fine though.</p>

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

 <details class="faq-item">
 <summary>Doesn't committing data on a schedule bloat the repository?</summary>
 <p>Slowly, and acceptably. Each commit stores a few kilobytes of changed JSON, so a year of weekly commits comes to a couple of hundred kilobytes. </p>
 </details>

 <details class="faq-item">
 <summary>Why weekly instead of hourly, or on every visit?</summary>
 <p>I could but it's not exactly vitally important and worth moving out of the free level of GitHub actions.</p>
 </details>

 <details class="faq-item">
 <summary>What happens when the robot itself breaks?</summary>
 <p>GitHub emails me when a scheduled workflow fails.</p>
 </details>

 <details class="faq-item">
 <summary>Could this update the page instantly when something changes?</summary>
 <p>Not this pattern. A schedule can only poll. Instant would need the source to push (webhooks) into something always listening, which drags a server back into the picture. </p>
 </details>
 </div>

        


 <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>Variable Neighbourhood Search, Live</title>
      <link>https://www.kenreid.co.uk/blog/variable-neighbourhood-search-live.html</link>
      <guid>https://www.kenreid.co.uk/blog/variable-neighbourhood-search-live.html</guid>
      <pubDate>Wed, 12 Aug 2026 00:00:00 +0000</pubDate>
      <description>An interactive variable neighbourhood search solving the Travelling Salesman Problem in your browser: watch a tour untangle itself, then watch the search escape a local optimum by changing what counts as a nearby move.</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/variable-neighbourhood-search-live.html">View it on the site</a> to play with it.</em></p>
 <h1>Variable Neighbourhood Search, Live</h1>
 <div class="blog-meta">
 12 August 2026 &middot;
 <span class="blog-tag">ai</span>
 </div>

 <p>Every algorithm in this series so far has had its own way of not getting stuck:</p>
 <ul>
 <li><a href="https://www.kenreid.co.uk/blog/evolution-live.html">the genetic algorithm</a> kept a whole population, so one individual getting stuck didn't matter</li>
 <li><a href="https://www.kenreid.co.uk/blog/simulated-annealing-live.html">simulated annealing</a> tolerated bad moves while it was hot</li>
 <li><a href="https://www.kenreid.co.uk/blog/ant-colony-live.html">the ant colony</a> let old trails evaporate</li>
 <li><a href="https://www.kenreid.co.uk/blog/particle-swarm-live.html">the particle swarm</a> carried enough momentum to sail over a dip before it noticed</li>
 </ul>

 <p>This fifth one runs without a temperature schedule, a population, a pheromone trail, or a velocity. When it gets stuck, it changes what "nearby" means. This one hits home because, like many of the algorithms I am blogging about, I am making use of code I wrote years ago - but this one is special, because I used VNS for my <a href="https://scholar.google.com/citations?view_op=view_citation&hl=en&user=bwl2u00AAAAJ&sortby=pubdate&citation_for_view=bwl2u00AAAAJ:u5HHmVD_uO8C">first ever published research paper</a>.</p>

 

 <div class="plain-english-box">
 <h2>Quick jargon guide</h2>
 <ul>
 <li><strong>Variable neighbourhood search (VNS):</strong> an optimiser that escapes traps by switching, in a fixed order, between different definitions of "nearby".</li>
 <li><strong>Neighbourhood structure:</strong> the set of solutions you can reach from the current one with a single type of move. Different move types give the same solution completely different neighbours.</li>
 <li><strong>Local optimum:</strong> a solution none of whose neighbours are better. Since "neighbours" depends on the move type, a solution can be a local optimum under one move type and easy to improve under another.</li>
 <li><strong>Shaking:</strong> a random jump within the current neighbourhood, applied to a good solution to knock the search into a different basin before descending again.</li>
 <li><strong>2-opt:</strong> the smallest useful tour edit: remove two edges of the tour and reconnect the other way round, which reverses the segment in between.</li>
 <li><strong>Or-opt:</strong> a tour edit that relocates a short chain of consecutive cities (one to three of them here) to a different place in the tour.</li>
 </ul>
 </div>

 <h2>What does 'stuck' mean?</h2>

 <p>The playing field is the same Travelling Salesman Problem as the annealing post: a scatter of cities, and we want the shortest round trip that visits each city once.</p>

 <p>Local search on the TSP is very simple: take any tour, try a small edit, keep the edit if the tour got shorter, repeat. The classic small edit is 2-opt: snip two edges, reconnect them the other way, which reverses a stretch of the tour and uncrosses one of those ugly X shapes. Do that greedily and a random tangle collapses into something pretty good, pretty quickly.</p>

 <p>But, inevitably, it stops. You reach a tour where no single 2-opt move helps, a local optimum, and are unable to move. In the annealing post I treated that as a fact about the tour: it's stuck. What is stuck is the tour plus <em>this particular definition of nearby</em>.</p>

 <p>A 2-opt local optimum only means that none of the roughly n&sup2; tours reachable by one segment reversal is shorter. Offer a different move, say lifting three consecutive cities out and reinserting them elsewhere (Or-opt), and the same tour suddenly has a completely different set of neighbours, some of which may well be better.</p>

 <p>Mladenović and Hansen built this idea in 1997<sup><a href="#ref-1" class="cite-ref">[1]</a></sup>, called it variable neighbourhood search, and it has been a fixture of the routing and scheduling literature ever since.</p>

 <p>A local optimum is a property of the neighbourhood, not of the solution.</p>

 <h2>I'm gonna need a bigger hammer.</h2>

 <p>Basic VNS keeps a list of neighbourhood structures, ordered from smallest to largest, and climbs that list on an "as needed" basis. Here is one VNS setup:</p>

 <ul>
 <li><strong>N1, 2-opt kick:</strong> reverse one random segment. The lightest nudge.</li>
 <li><strong>N2, Or-opt:</strong> pull out a chain of one to three cities and reinsert it somewhere else.</li>
 <li><strong>N3, segment exchange:</strong> swap two short segments of the tour with each other.</li>
 <li><strong>N4, double bridge:</strong> cut the tour in four places and reassemble the quarters in a different order. A big kick. It changes four edges at once.</li>
 </ul>

 <p>That is to say: descend with plain first-improvement 2-opt until you hit a local optimum. That's your incumbent, so set k to 1. Now: <em>shake</em> the incumbent with a random move from neighbourhood N<sub>k</sub>, descend again with 2-opt from wherever that landed, and compare. If the re-descended tour beats the incumbent, move there and reset k to 1. If it doesn't, throw it away and increment k, up to k<sub>max</sub>, then wrap back round.</p>

 <p>This runs without a temperature, an acceptance probability, or a crossover operator. Hansen and Mladenović later distilled the design principles in a survey which is a great starting point <sup><a href="#ref-2" class="cite-ref">[2]</a></sup>.</p>

 <p>This means the algorithm, by its very nature, will reach for a bigger hammer, as needed. The moment <em>anything</em> works, it drops straight back to the smallest hammer.</p>

 <h2>Watch it climb the ladder</h2>

 <p>Below, VNS is solving a seeded random TSP instance live, and the lit badge under the map is the neighbourhood currently in use. Watch it during a rough patch. N1 lights, fails. N2 lights, fails. N3 lights, and then an escape lands, and it snaps straight back to N1. The right-hand chart records that as a sawtooth: every tooth is the search getting stuck, escalating, and being rescued. Amber flashes on the map are the edges the latest shake or improvement just changed.</p>

 

 <h2>The Sliders</h2>

 <p><strong>Tick "plain local search".</strong> This switches the shaking off entirely: descend with 2-opt, then stop, and the tour untangles, hits its first local optimum, and freezes there forever, while the k chart flatlines like in that medical drama my partner watches.</p>

 <p><strong>Set k max to 1.</strong> Now the search still shakes, but only ever with the smallest kick, a single random segment reversal. Watch the escapes counter crawl: a small kick barely leaves the current basin, so the 2-opt descent usually slides straight back into the tour it just left.</p>

 <p>The gap stat twitches and recovers, twitches and recovers, going nowhere. It is all motion and no progress.</p>

 <p><strong>Now give it the full ladder, k max at 4, and watch the cost side.</strong> Every time N4 fires, the gap stat spikes hard, because a double bridge tears four edges out of a good tour, and the descent then spends a long run of moves stitching the damage back together.</p>

 <p>That work is only worth it when nothing smaller can move the search, so VNS keeps the big hammer as a last resort.</p>

 <h2>Change what nearby means</h2>

 <p>I like the contrast with the earlier posts in this series: simulated annealing keeps one move type and varies <em>when it will accept a worse solution</em>. The genetic algorithm keeps a population, so the question of any one solution being stuck barely arises.</p>

 <p>VNS, in its basic form, never accepts a worse solution at all, runs a single solution, and the only thing it varies is the move set. Three completely different answers to the same trap.</p>

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

 <details class="faq-item">
 <summary>How is this different from simulated annealing?</summary>
 <p>Annealing keeps one neighbourhood and loosens the <em>acceptance rule</em>: worse solutions get through with a probability that decays as the temperature falls, which means picking a cooling schedule. Basic VNS keeps a strict acceptance rule (only ever move to something better) and varies the <em>neighbourhood</em> instead.</p>
 </details>

 <details class="faq-item">
 <summary>Is VNS used in the real world?</summary>
 <p>Very much so. It has a strong record on vehicle routing, facility location (the p-median problem was one of its earliest wins), job-shop and nurse scheduling, timetabling, and graph problems, and Hansen and Mladenović's survey<sup><a href="#ref-2" class="cite-ref">[2]</a></sup> catalogues dozens of applications. </p>
 </details>

 <details class="faq-item">
 <summary>Why not just always use the biggest shake?</summary>
 <p>It destroys structure a good tour spent many moves earning, and the descent afterwards must spend many more rebuilding it. </p>
 </details>
 </div>

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

 <ol class="references">
          <li id="ref-1">
            Mladenović, N., &amp; Hansen, P. (1997). Variable neighborhood search. <em>Computers &amp; Operations Research</em>, 24(11), 1097&ndash;1100. <a href="https://doi.org/10.1016/S0305-0548(97)00031-2" target="_blank" rel="noopener">https://doi.org/10.1016/S0305-0548(97)00031-2</a>
          </li>
          <li id="ref-2">
            Hansen, P., &amp; Mladenović, N. (2001). Variable neighborhood search: Principles and applications. <em>European Journal of Operational Research</em>, 130(3), 449&ndash;467. <a href="https://doi.org/10.1016/S0377-2217(00)00100-4" target="_blank" rel="noopener">https://doi.org/10.1016/S0377-2217(00)00100-4</a>
          </li>
        </ol>

        

 ]]></content:encoded>
    </item>
    <item>
      <title>Particle Swarm, Live</title>
      <link>https://www.kenreid.co.uk/blog/particle-swarm-live.html</link>
      <guid>https://www.kenreid.co.uk/blog/particle-swarm-live.html</guid>
      <pubDate>Tue, 11 Aug 2026 00:00:00 +0000</pubDate>
      <description>An interactive particle swarm optimiser in your browser: a flock of dots hunts a landscape none of them can see, steered by private memory and a single piece of gossip, and the sliders let you collapse the swarm on purpose.</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/particle-swarm-live.html">View it on the site</a> to play with it.</em></p>
 <h1>Particle Swarm, Live</h1>
 <div class="blog-meta">
 11 August 2026 &middot;
 <span class="blog-tag">ai</span>
 </div>

 <p>In 1995, a social psychologist and an electrical engineer sat down to animate a flock of birds and accidentally invented one of the most popular optimisers of the next two decades, which is a story I love. I really believe that error is one of the most fruitful forms of novelty creation. Below, you can fly the result yourself. It's the sibling of <a href="https://www.kenreid.co.uk/blog/ant-colony-live.html">the ant colony post</a>: another swarm, another crowd of simple agents solving a problem none of them can see individually, with the communication channel turned inside out.</p>

 

 <p>In Ant Colony Optimisation, the ants coordinate through the environment, and no ant knows anything whatsoever. The knowledge lived in the pheromone map, written and read by thousands of insects who are basically automatons. Particles do the opposite, and each one privately remembers the best spot it has ever stood on, while the whole swarm shares a single piece of gossip: the best spot <em>any</em> member has ever found. That's the entire communication system, and it runs without a trail, a map, or a leader, just a telepathic link of where to find the dropped ice cream or sugar granules.</p>

 <div class="plain-english-box">
 <h2>Quick jargon guide</h2>
 <ul>
 <li><strong>Particle swarm optimisation (PSO):</strong> an optimiser inspired by bird flocking, where a population of candidate solutions ("particles") fly through the search space, each pulled toward its own best-known point and the swarm's best-known point.</li>
 <li><strong>Personal best:</strong> the best position a single particle has ever visited. Its private memory, and one of the two forces steering it.</li>
 <li><strong>Global best:</strong> the best position any particle in the swarm has ever visited. Shared knowledge: every particle feels its pull.</li>
 <li><strong>Inertia:</strong> how much of its current velocity a particle keeps each step. High inertia means momentum and wide exploration; low inertia means the social pulls dominate.</li>
 <li><strong>Velocity clamp:</strong> a cap on how fast a particle may move, stopping the update rule from flinging particles out of the search space entirely.</li>
 <li><strong>Premature convergence:</strong> the whole swarm collapsing onto one point before the space has been properly searched, then being unable to leave. The characteristic failure of anything that shares information too enthusiastically.</li>
 </ul>
 </div>

 <h2>The flock that forgot how to fly</h2>

 <p>James Kennedy was a social psychologist and Russell Eberhart was an electrical engineer. Their 1995 paper<sup><a href="#ref-1" class="cite-ref">[1]</a></sup> opens with birds rather than mathematics. They wanted to simulate the choreography of a flock, in the tradition of the boids models, where each simulated bird matches its neighbours' speed, avoids collisions, and stays near the roost. They started deleting rules to see which ones mattered.</p>

 <p>Collision avoidance went, velocity matching went, nearest-neighbour tracking went. What survived would not fool a birdwatcher, or a pigeon, or a cat, just dots drifting toward places that had scored well. But the dots were doing something more interesting than flocking: they were finding the optimum of whatever function defined "good". The choreography fell away and the optimiser remained, and Kennedy and Eberhart named the agents <em>particles</em>, "birds" probably no longer felt right.</p>

 <p>The version below includes one refinement from 1998, when Shi and Eberhart added the inertia weight<sup><a href="#ref-2" class="cite-ref">[2]</a></sup>: a knob controlling how much of its current velocity each particle carries into the next step. </p>

 <h2>Three kinds of pull</h2>

 <p>Every step, each particle updates its velocity as a weighted sum of three forces, which, to me at least, feel kind of like personality traits:</p>

 <ul>
 <li><strong>Habit (w):</strong> keep doing what you were doing. The inertia weight scales the particle's current velocity.</li>
 <li><strong>Own memory (c1):</strong> a pull back toward the best place <em>you</em> have ever found. Call it self-trust, or nostalgia.</li>
 <li><strong>Peer pressure (c2):</strong> a pull toward the best place <em>anyone</em> has ever found. Conformism, sheep-ery.</li>
 </ul>

 <p>The two social pulls are each multiplied by a fresh random number every step, so no two particles are likely to respond the same. That randomness is what keeps the swarm from collapsing into a single deterministic trajectory.</p>

 <p>The result is clamped to a maximum speed, the particle moves, the function gets evaluated at the new spot, memories get updated, and the loop repeats. That's it! </p>

 <h2>Watch the swarm hunt</h2>

 <p>Below, a swarm is minimising a two-dimensional landscape, where warm colours are low ground (better) and the dashed ring marks the true optimum, which the swarm does not know about. Watch the characteristic dance: particles overshoot the best-known point, get hauled back, overshoot the other way, and the whole cloud contracts around the answer like a slowing pendulum.</p>

 

 <h2>Turning the swarm into a stampede</h2>

 <p><strong>Crank inertia past 1.</strong> Above w = 1 a particle's velocity grows every step instead of decaying, so the only thing stopping the swarm from leaving the map is the speed clamp. The swarm boils, with particles orbiting the best-known point at full speed, permanently overshooting, while the spread chart flatlines high and the fitness chart stalls on momentum.</p>

 <p><strong>Now do the opposite: inertia near 0, peer pressure at 3.</strong> This is the stampede: with no momentum to resist, every particle dives straight at the first decent global best, the spread chart crashes within a few dozen iterations, and the swarm becomes one throbbing dot.</p>

 <p>On the bowl this looks like efficiency, but switch to the egg carton and watch the same setting get eaten: the swarm piles into whichever dimple it saw first, the true optimum sits two ridges away, and nobody has enough independence left to go and look. Premature convergence.</p>

 <p><strong>Set own memory (c1) to zero.</strong> Pure conformism: particles have no attachment to their own discoveries, and the global best is the only guidance. </p>

 <p><strong>Set peer pressure (c2) to zero.</strong> Now nobody shares anything, and you get thirty hermits running thirty independent local searches, each rattling around its own personal best and improving it a little. </p>

 <h2>Now tune it back</h2>

 <p><strong>Then go back to the classic setting</strong> (w = 0.72, c1 = c2 = 1.5, numbers the field converged on after years of tuning) and watch the dance it buys you: overshoot, return, contract, with just enough disagreement to keep a few scouts out wide while the core closes in. On the egg carton it will still fail sometimes. The demo isn't broken, that just shows that it's not perfect, and there are still papers being published solving more complex landscapes with PSO.</p>

 <h2>Personality knobs</h2>

 <p>Kennedy never stopped pointing out that this is a model of social learning first and an optimiser second: individuals keep private experience, the group broadcasts its best result, and behaviour is a compromise between habit, self-trust and conformism.</p>

 <p>Every failure mode you just created is a recognisable social failure. The high-conformism swarm is an echo chamber stampeding to its first confident answer. The zero-gossip swarm is a room full of talented people who never talk. The overheated swarm is a committee that keeps relitigating and never makes an actual decision (sounds like many meetings I've been in).</p>

 <h2>Annealing, evolution, ants, particles</h2>

 <p>The through-line of the series:</p>
 <ul>
 <li><a href="https://www.kenreid.co.uk/blog/simulated-annealing-live.html">simulated annealing</a> was one searcher learning when to accept a worse answer</li>
 <li><a href="https://www.kenreid.co.uk/blog/evolution-live.html">genetic algorithms</a> were a population improving by dying</li>
 <li><a href="https://www.kenreid.co.uk/blog/ant-colony-live.html">the ants</a> stored the answer in the world</li>
 <li>and the particles store it in each other</li>
 </ul>

 <p>Four strategies, one shared problem: explore enough to find the right valley, exploit enough to reach the bottom of it. See some interesting overlaps in strategy, parameters, landscape advantages and disadvantages?</p>

 <p>Anyway. Go scoop up the swarm, drop it in a corner of the egg carton, and watch thirty dots argue their way out. Or fail to. </p>

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

 <details class="faq-item">
 <summary>Is this actually how birds flock?</summary>
 <p>No. Real flocking is much closer to the boids rules Kennedy and Eberhart started from (local alignment, cohesion, separation among a handful of nearest neighbours), and modern starling studies suggest birds track a fixed number of neighbours rather than everyone in range. </p>
 </details>

 <details class="faq-item">
 <summary>How is this different from a genetic algorithm?</summary>
 <p>Both are population-based and gradient-free, but the mechanics are almost disjoint. In a genetic algorithm, individuals are born, breed and die: information moves between solutions by crossover, and progress comes from selection pressure. In PSO nobody dies and nobody breeds. The same thirty particles persist the whole run, and information moves through shared memory instead of inheritance. In practice PSO tends to converge faster on smooth continuous problems, while evolutionary methods are easier to adapt to weird, structured or combinatorial ones.</p>
 </details>

 <details class="faq-item">
 <summary>Does anyone still use PSO?</summary>
 <p>Yes, heavily, especially in engineering: antenna design, power systems, controller tuning, and any setting where evaluations are expensive, gradients are unavailable, and a decent answer soon beats a perfect answer never.</p>
 </details>
 </div>

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

 <ol class="references">
          <li id="ref-1">
            Kennedy, J., &amp; Eberhart, R. (1995). Particle swarm optimization. <em>Proceedings of ICNN'95 - International Conference on Neural Networks</em>, 4, 1942&ndash;1948. <a href="https://doi.org/10.1109/ICNN.1995.488968" target="_blank" rel="noopener">https://doi.org/10.1109/ICNN.1995.488968</a>
          </li>
          <li id="ref-2">
            Shi, Y., &amp; Eberhart, R. (1998). A modified particle swarm optimizer. <em>1998 IEEE International Conference on Evolutionary Computation Proceedings, IEEE World Congress on Computational Intelligence</em>, 69&ndash;73. <a href="https://doi.org/10.1109/ICEC.1998.699146" target="_blank" rel="noopener">https://doi.org/10.1109/ICEC.1998.699146</a>
          </li>
        </ol>

        

 ]]></content:encoded>
    </item>
    <item>
      <title>50 Blogs: An Infographic</title>
      <link>https://www.kenreid.co.uk/blog/50-blogs-an-infographic.html</link>
      <guid>https://www.kenreid.co.uk/blog/50-blogs-an-infographic.html</guid>
      <pubDate>Sat, 08 Aug 2026 00:00:00 +0000</pubDate>
      <description>Fifty posts in: an infographic the site drew about itself, and what it taught me. Words written, what gets read versus what I love writing, and the 135 drafts waiting their turn.</description>
      <category>personal</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/50-blogs-an-infographic.html">View it on the site</a> to play with it.</em></p>
 <h1>50 Blogs: An Infographic</h1>
 <div class="blog-meta">
 8 August 2026 &middot;
 <span class="blog-tag">personal</span>
 </div>

   <div class="fifty-party fifty-viz" style="background:none; border:none; padding:0; margin:0 0 20px;">
     <span class="fifty-bunting" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></span>
     <button type="button" class="fifty-again" id="fifty-again">Throw the confetti again</button>
   </div>

 <p>This is post number fifty. That felt like the kind of milestone that deserved a party, and since this is a data science blog at heart, the party includes confetti, a banner and a bunch of unnecessary plots.</p>

 <p>Everything below is drawn live in your browser from the same JSON file that runs the blog itself, so the infographic *should* stay up to date. If you are reading this long after publication and the numbers have crept past fifty, you can consider this post "blog emeritus".</p>

 <p>Fifty is also the first point where the archive is big enough to start exhibiting some shape, behaviors and such. So between the charts I have tried to be straight with myself about what this blog turned into: what I enjoy writing about most, what people read, and the discovery that, uh, they're not always teh same thing.</p>

 

 <div class="fifty-tiles-wrap">
 <div class="fifty-tiles" id="fiftyTiles" role="group" aria-label="Headline statistics">
   <div class="fifty-tile"><span class="num" id="tilePosts">50</span><span class="lbl">Posts published</span></div>
   <div class="fifty-tile"><span class="num" id="tileWords">132,421</span><span class="lbl">Words written</span></div>
   <div class="fifty-tile"><span class="num" id="tileHours">10.4</span><span class="lbl">Hours of reading</span></div>
   <div class="fifty-tile"><span class="num">135</span><span class="lbl">Drafts waiting</span></div>
 </div>
 </div>

 <p>One hundred and thirty-two thousand words. That's kind of crazy. That's a decent-sized novel and a half, mostly about whatever my brain wouldn't let go of and I didn't want to bore my wife or colleagues with (or at least not more than I already had). The fourth tile is the one that I have mixed feelings about: I like having so many draft ideas, some just bullet points or a few scattered paragraphs, it makes it easier to choose a topic I feel like when I have 15 minutes to write. But it also means my drafts just grow and grow, and I already know there are about 5 blogs I wrote out, went to revise, decided I didn't like them and deleted them. So I sometimes wonder how many drafts won't make it to the end, but I suppose that just comes with the process.</p>

 <h2>The publishing rhythm</h2>

 <p>I started in April: an opening sprint, a spring lull while I travelled to Colorado, and then July happened. Admittedly, a lot of my posts are shorter format, or reuse code I wrote years ago for demonstration purposes, making fifteen blogs in one month less crazy than it sounds. August is only a week old, but I don't think it'll grow much looking at my calendar.</p>

 

 <p>Zoom in: this is every day since the first post, one cell per day. GitHub feel.</p>

 <p>Mondays are the most common publishing day, which makes sense, because I like to write at the weekend mostly then try to publish when I think people are more likely to click while bored on a commute or during a lunch break. 12 July, when four posts went out in one day, was not intentional. I had planned to publish 1 and draft the others but I accidentally had them in the queue for publishing, thankfully they were mostly done.</p>

 

 <h2>Tags and what they say about me</h2>

 <p>Tags are categories of a sort, and the totals appear to be something of a personality test. Technology and personal are tied at the top, which fits thematically about what this site is: half machine and half diary. Books outnumbering television which appears once doesn't surprise me: I read a lot more than I watch TV or movies.</p>

 <p>I started this blog assuming it would be somewhere I could talk vaguely about whatever I feel like, but would probably end up just being a professional portfolio of some kind. I've since learned that the posts I am most eager to write, and proudest of afterwards, are the discursive ones: the philosophy and the social questions. They're the most fun, encourage the most discussion with friends and people who read them.</p>

 <p>The clearest evidence is that <a href="https://www.kenreid.co.uk/series-everyday-ethics.html">Everyday Ethics</a> became a series retroactively. I wrote four essays about responsibility, prevention, tolerance, and supply chains over three months without once noticing they were the same project, and only recognized that once I hit blog 50. I've added other blogs into series, and made pages dedicated to series now, too. I think this is healthy: some topics deserve returning to, or expanding upon, or link nicely together so readers might enjoy them in a specific order. </p>

 

 <p>The same tags get more interesting when you spread them over time. April was books and AI, May and June drifted personal, and July was the month the technology tag ate the blog, because that was the month I rebuilt half the site and wrote about what I did (and more are in drafts!). This wasn't planned at all: topics tend to come and go on whims.</p>

 

 <h2>Tags by hours</h2>

 <p>There is one more way to slice the tags: by reading time instead of post count. Counted this way the leaderboard flips: technology has the most posts but they are quick reads. The personal essays are where the hours actually live, with books close behind.</p>

 

 <h2>Fifty dots</h2>

 <p>Every post on this site is a dot. Time on X, word count on Y, and its main tag for the colour. The band between one and three thousand words is my default length it seems. The dot at the top is the accessibility post at 9,015 words. The one scraping the floor is an 802-word story about ending up in Italy through mistaken identity. </p>

 <p>The first twenty-five posts have a median of 2,621 words. The second twenty-five have 1,818. I am expressing brevity over time, unintentionally. </p>

 

 <h2>The pile of words</h2>

  

 <h2>What gets read</h2>

 <p>These are all-time page views from analytics for the most-read posts, and lets address the Cthaeh in the room: one post about a fantasy novel that has not been published in fifteen years has collected more readers than everything else on this site combined, several times over. Yeah, one post went viral, so my stats are a bit iffy.</p>

 <p>I wrote it in one long sitting. The 9,015-word accessibility chapter I laboured over doesn't even crack the top five in views (reminds me of my thesis now that I think of it). There is a lesson in there about audiences, and I am choosing not to learn it, because I will just continue to write whatever I feel like regardless.</p>

 

 <p>Every post with tracked views, positioned by how many words I wrote against how many people read them. The vertical axis is logarithmic (because of the Rothfuss article). If effort predicted readership this would be a rising diagonal line. It is not a rising diagonal line.</p>

 

 <p>A footnote for fellow data people: the Rothfuss number is actually two numbers. I renamed that post once ("fourteen years of silence" became "fifteen years of silence", I somehow failed to count and published before realizing my mistake), and its old address kept collecting readers for months. The figure above merges both.</p>

 <h2>Elsewhere on the internet</h2>

 <p>There is also a readership I can only see from a distance. On Reddit, there is a  discussion thread about the Rothfuss blog, and it has now been viewed 300,000 times.</p>

 <p>For scale, that is roughly eight times every page view this site has ever recorded, across all fifty posts and every other page, combined. The most-read thing connected to this blog is a forum thread about the blog. Shows how often people read a headline and discuss it on social media, and how few actually click the source material.</p>

 <p>A decent share of the twenty-eight thousand almost certainly walked over from there, which makes my biggest referrer a comment section full of people waiting for a book.</p>

 <h2>The small print</h2>

 <p>Across the 118 days between the first post and the fiftieth, the blog averaged 1,122 published words per day, counting the days I wrote nothing. I think this is largely due to the fact I write some every day, or at least edit, and when I write I tend to write a lot, perhaps an entire draft, and create a couple other drafts I have for ideas for future posts at once. </p>

 <p>Titles run from 16 characters ("Ant Colony, Live") to the 105-character Rothfuss monument, and only two of the fifty end in question marks, so apparently I prefer telling to asking.</p>

   <div class="fifty-wrapped" id="fiftyWrapped" role="group" aria-label="Fifty posts in numbers">
     <div class="wrap-card wide" style="--accent:var(--viz-s1)"><span class="wrap-chip"><svg class="wrap-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></span><span class="wrap-num" data-count="236">236</span><span class="wrap-lbl">jargon terms explained</span><span class="wrap-sub">across 44 plain-English boxes</span></div>
     <div class="wrap-card" style="--accent:var(--viz-s2)"><span class="wrap-chip"><svg class="wrap-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg></span><span class="wrap-num" data-count="180">180</span><span class="wrap-lbl">images</span><span class="wrap-sub">3.6 a post, on average</span></div>
     <div class="wrap-card" style="--accent:var(--viz-s3)"><span class="wrap-chip"><svg class="wrap-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg></span><span class="wrap-num" data-count="102">102</span><span class="wrap-lbl">sources cited</span><span class="wrap-sub">linked 139 times in the text</span></div>
     <div class="wrap-card" style="--accent:var(--viz-s4)"><span class="wrap-chip"><svg class="wrap-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg></span><span class="wrap-num" data-count="161">161</span><span class="wrap-lbl">questions answered</span><span class="wrap-sub">in the FAQ block at the foot</span></div>
     <div class="wrap-card" style="--accent:var(--viz-c3)"><span class="wrap-chip"><svg class="wrap-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span><span class="wrap-num" data-count="97">97</span><span class="wrap-lbl">passages quoted</span><span class="wrap-sub">books, papers and songs</span></div>
     <div class="wrap-card" style="--accent:var(--viz-s1)"><span class="wrap-chip"><svg class="wrap-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14L21 3"/></svg></span><span class="wrap-num" data-count="209">209</span><span class="wrap-lbl">outbound links</span><span class="wrap-sub">the audit checks every one</span></div>
     <div class="wrap-card wide" style="--accent:var(--viz-s2)"><span class="wrap-chip"><svg class="wrap-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8M8 17h6"/></svg></span><span class="wrap-num" data-count="2648">2,648</span><span class="wrap-lbl">words in the average post</span><span class="wrap-sub">the shortest ran 802, the longest 9,015</span></div>
     <div class="wrap-card" style="--accent:var(--viz-s3)"><span class="wrap-chip"><svg class="wrap-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg></span><span class="wrap-num" data-count="41">41</span><span class="wrap-lbl">minutes</span><span class="wrap-sub">the longest single read</span></div>
     <div class="wrap-card" style="--accent:var(--viz-s4)"><span class="wrap-chip"><svg class="wrap-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"/></svg></span><span class="wrap-num" data-count="12">12</span><span class="wrap-lbl">days</span><span class="wrap-sub">my longest silence</span></div>
     <div class="wrap-card" style="--accent:var(--viz-c3)"><span class="wrap-chip"><svg class="wrap-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg></span><span class="wrap-num">Mon</span><span class="wrap-lbl">favourite publish day</span><span class="wrap-sub">written at the weekend, posted for the commute</span></div>
     <div class="wrap-card" style="--accent:var(--viz-s1)"><span class="wrap-chip"><svg class="wrap-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></span><span class="wrap-num" data-count="42" data-suffix="%">42%</span><span class="wrap-lbl">belong to a series</span><span class="wrap-sub">21 of the fifty</span></div>
     <div class="wrap-card" style="--accent:var(--viz-s2)"><span class="wrap-chip"><svg class="wrap-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 2L3 14h9l-1 8 10-12h-9z"/></svg></span><span class="wrap-num" data-count="5">5</span><span class="wrap-lbl">days in a row</span><span class="wrap-sub">my longest unbroken run</span></div>
   </div>

 

 <h2>What I've learned doing this</h2>

 <p>What I love writing and what gets read are different. The views chart is ruled by a post about a fantasy novel, and the essays I care most about live in its shadow.</p>

 <p>The mailbag tells the other half of it. The one email about a non-Rothfuss post was about the most discursive thing on this site, from a reader on another continent who found it by googling an idea (also neat that I come up on a Google search!). Search traffic arrives in crowds for the topical stuff, and the philosophical stuff gets found by one person at a time, but they actually write in and discuss the ideas with me. That was so nice.</p>

 <p>Volume was never the goal: I'd rather have a few people engage than thousands who don't.</p>

 <p>So the next fifty will lean harder into the discursive essays, because I enjoy it. The drafts folder is stacked with cognitive biases, ethics, and questions about meaning, far more than gadgets and code. The technology posts will keep coming, because rebuilding this site is apparently a hobby I document compulsively (twelve parts and counting).</p>

 <h2>The queue</h2>

 <p>About those 135 drafts: they range from nearly finished essays to single sentences I clearly believed were profound at the time (even if on second read they're awful!).</p>

 <p>Four of them are planned as entire series, and the queue leans discursive: biases, ethics, meaning, and the occasional myth that needs debunking. At the current pace the queue would last well over a year without a single new idea arriving, and new ideas arrive weekly, so the realistic projection is that the drafts folder will continue to outgrow published posts.</p>

 <h2>The mailbag</h2>

 <p>The best part of the Rothfuss emails is that the two most thoughtful Rothfuss letters take opposite sides. One reader argued, carefully and at length, that a fandom's broken trust should carry consequences, that admiration and trust are different things, and that the charity chapter still stings years later.</p>

 <p>Another wrote that anyone who has wrestled a long, enormous endeavour knows how daunting it must be, and that the entitled anger aimed at the author misses how fortunate we are to have the first two books at all.</p>

 <p>One reader in India googled "the individualization of responsibility", stayed to say the site was organised enough to borrow from someday, and promised to cite the post on WhatsApp to friends and family. Loved waking up to that email. He also has <a href="https://medium.com/@thequippingquill" target="_blank" rel="noopener">his own blog</a>, which is a great read!</p>

 <p>Another, an Austrian living in Germany, checks the web every so often for a Doors of Stone release date (in vain, as they put it, till date), landed on the Rothfuss piece instead, and then read half the M*A*S*H post before remembering they had never seen M*A*S*H but kept reading anyway. How nice is that?</p>

 <h2>Thank you</h2>

 <p style="text-align:center; margin:30px 0;">
   <button type="button" id="fiftyRandom" class="btn alime-btn btn-2">Read a random post</button>
 </p>

 <p>Fifty posts in, people are reading: subscribing by email, arriving from search engines at two in the morning, following the RSS feed, leaving comments, and sending the occasional message that starts "I read your post about..." and makes my whole day.</p>

 <p>Being read turns thinking out loud into a conversation, and I am grateful for every one of you, from the person who has read all fifty to the twenty-eight thousand who came for Rothfuss and left.</p>

 <p>Thank you for reading. Here is to the next fifty!</p>

        

 ]]></content:encoded>
    </item>
    <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)</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 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>

        


 <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 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="kr-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="kr-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 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="kr-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="kr-swatch sa-swatch-2"></span> <strong>Hill climber</strong>, same 2-opt moves, zero tolerance: improvements only.</li>
 <li><span class="kr-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="kr-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 it happening: 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 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 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)</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.kr-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="kr-stat"&gt;&lt;span class="kr-stat-lbl"&gt;' +
            '&lt;span class="kr-swatch" style="background:' + c.series[r.i] + ';"&gt;&lt;/span&gt;' +
            NAMES[r.i] + '&lt;/span&gt;&lt;span class="kr-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="kr-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</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</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>Three ways to stall evolution</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)</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 .kr-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 eighty 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: eighty-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 eighty 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://www.kenreid.co.uk/img/photography/hero/78.webp"
}
&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 eighty 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. Those eighty 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. 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: 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 492 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: a whole-string 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">50 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)</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</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 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 that really the whole thing?</summary>
 <p>The whole behaviour, yes: shortcut, dialog, scoring, keyboard navigation, accessibility announcements. The styling is the 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>

        


 <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 handing a microphone to 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 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>

 <div class="callout">
 <p><strong>Edit, 11 August 2026:</strong> a 29-year-old man has since been arrested and charged with racially aggravated assault over the Bridge Street attack, and the Glasgow Times has published <a href="https://www.glasgowtimes.co.uk/news/26453610.video-shows-police-breaking-racist-attackers-door/" rel="noopener" target="_blank">video of police breaking down his door</a>. The paragraph above stands as written on 2 August.</p>
 </div>

 <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 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>

        

 <p style="text-align: center;"><a class="post-cta" href="https://www.kenreid.co.uk/blog.html">Back to all posts</a></p>
 <hr style="margin: 40px 0;">
 ]]></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>
    </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>
    </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 see what evaporation is really for.</description>
      <category>ai</category>
      <category>data science</category>
    </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>
    </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>
    </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>
    </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>
    </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>
    </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>
    </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>
    </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>
    </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>
    </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>
    </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>
    </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 very 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 brutally 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>
