vim-slime

<!DOCTYPE html> <html> <head> <title>Tarn Barford</title> <meta charset="utf-8"/> <link rel="icon" type="image/x-icon" href="/favicon.ico"> <link href="/style.css" media="screen" rel="stylesheet" type="text/css" /> <link rel="alternate" type="application/atom+xml" title="Journals of Tarn Barford" href="/atom" /> <link href="/highlight.css" media="screen" rel="stylesheet" type="text/css" /> <link href="/highlight-console.css" media="screen" rel="stylesheet" type="text/css" /> </head> <body> <div id="container"> <div id="header"> <div id="header"> <p>From the <a href="/journal">Journals</a> of <a href="/">Tarn Barford</a></p> <h1> vim-slime </h1> <p> Mar 26, 2012 </p> </div> </div> <div id="post_content"> <html><body><p>Today I found the awesomeness that is <a href="https://github.com/jpalardy/vim-slime">vim-slime</a>, it's been an exciting day for me. <a href="http://common-lisp.net/project/slime/">Slime</a> is the "The Superior Lisp Interaction Mode for Emacs", I can almost hear the emacs crowd laughing.</p> <p>For those that use vim and haven't used Slime, vim-slime or <a href="https://github.com/vim-scripts/VimClojure">something similar</a>, this is why it's awesome:</p> <p><strong>Text can be sent from any process to the stdin of a <a href="http://www.gnu.org/software/screen/">gnu screen</a> or <a href="http://tmux.sourceforge.net/">tmux</a> session. The process in this case is vim and the screen/tmux session is a terminal</strong>.</p> <p>Screen is a <a href="/journal/oh-screen-where-have-you-been">really neat</a> terminal multiplexer (you can run multiple terminals in a terminal window). The multiplexed shell processes are children of the screen process, which itself is not a child of the terminal window process. This means a screen process and its child processes keep running if you close the terminal window. Later you can re-connect to it, this is what makes vim-slime possible.</p> <p>Here is an screen shot, on the left is me in gVim writing some awful Clojure <a href="#footnote-1">[1]</a>. On the right is a screen buffer in which I started a Clojure REPL. When I want to try run some code I can send any vim text selection to the REPL in a keystroke (or two).</p> <p><img alt="vim slime screenshot" src="screenshot.jpg"/></p> <p>It doesn't have to be a Clojure REPL either, we can send anything to a screen shell. We could run git commands, find, grep, sed, etc. Like with the Clojure REPL we can even interact with any terminal programs that use STDIN.</p> <p>This concept can be taken even further, You can even connect to a tmux session over SSH and share a terminal or a <a href="http://remotepairprogramming.com/remote-pair-programming-with-tmux-and-vim-the">terminal program like vim to do remote pairing</a>!</p> <p>Hopefully remote pairing is the topic of my next post as there are a couple geographically distant people I know who are keen to do some pair hacking. I stand to learn a lot!</p> <p><a name="footnote-1">[1]</a> I learnt almost everything I know about Lisp from <a href="http://www.ccs.neu.edu/home/matthias/BTLS/">The Little Schemer</a>. Great book.</p></body></html> </div> <div id="comments"> </div> </div> <div id="footer"> <p>&nbsp;</p> <p>Questions, comments, suggestions? Email me, <a href="mailto:tarn@tarnbarford.net">tarn@tarnbarford.net</a> (<a href="/pgp.txt">public key</a>)</p> <p>&nbsp;</p> </div> </body> </html>

Permalink

Swipe Keyboard

<!DOCTYPE html> <html> <head> <title>Tarn Barford</title> <meta charset="utf-8"/> <link rel="icon" type="image/x-icon" href="/favicon.ico"> <link href="/style.css" media="screen" rel="stylesheet" type="text/css" /> <link rel="alternate" type="application/atom+xml" title="Journals of Tarn Barford" href="/atom" /> <link href="/highlight.css" media="screen" rel="stylesheet" type="text/css" /> <link href="/highlight-console.css" media="screen" rel="stylesheet" type="text/css" /> <style> #swipe-canvas { position: relative; width: 900px; height: 300px; } #swipe-results { font-size: 30px; padding-left: 50px; padding-left: 50px; } #swipe-results ul { margin: 0px; padding: 0px; } #swipe-results li { float: left; background-color: #DDDDDD; list-style-type: none; padding: 10px; margin: 5px; border-radius: 5px; } #swipe { position: relative; } #swipe-loading { position: absolute; height: 50px; width: 300px; top: 85px; left: 300px; background-color: darkgray; border-radius: 10px; text-align: center; padding-top: 20px; border: black; border-width: 5px; } </style> </head> <body> <div id="container"> <div id="header"> <div id="header"> <p>From the <a href="/journal">Journals</a> of <a href="/">Tarn Barford</a></p> <h1> Swipe Keyboard </h1> <p> Apr 06, 2014 </p> </div> </div> <div id="post_content"> <html><body><p>When I first tried a <a href="http://www.swype.com/">Swype</a> keyboard I was impressed how effective it was. Even though I don't use the feature on my phone I was interested in how it could be built, so I <a href="https://github.com/tarnacious/swipe-keyboard">implemented this otherwise useless swipe-able keyboard</a> below. It probably doesn't work on mobile devices, but works on modern browsers with mouse pointers (although I've only really tried Chrome and Firefox).</p> <div id="swipe"> <canvas height="300px" id="swipe-canvas" width="900px"></canvas> <div id="swipe-results"></div> <div style="clear: both"></div> <h2 id="swipe-loading">Loading<noscript>Javascript is Required</noscript></h2> </div> <p>I initially tried to solve this using the technique Peter Norvig famously uses in his <a href="http://norvig.com/spell-correct.html]">spell checker</a>. He takes a sequence of characters and generates a set of word candidates by adding, removing and swapping characters in the original sequence, the generated candidates are removed if they are not found a dictionary. This can work but to be effective too many combinations need to be generated.</p> <p>If the dictionary is indexed into a <a href="http://en.wikipedia.org/wiki/Trie">trie</a> the number of combinations generated can be reduced significantly by traversing the trie and only generating valid letter combinations. This is a pretty bare implementation of that, it requires: </p> <ul> <li>The first and last characters of the initial sequence are used </li> <li>Intermediate characters in the initial sequence can be repeated or discarded </li> <li>No characters are added or swapped</li> </ul> <p>Basically, if you swipe through all the characters in a word in order, then the word will be found if it is in the index regardless how many characters are swiped in between. It is surprisingly quick and effective.</p> <p>This implementation uses <a href="https://raw.github.com/first20hours/google-10000-english/master/google-10000-english.txt">these 10000 words</a>, I intended to use digital books but never got around to it as these words demonstrate the concept well enough.</p> <p>This is the first thing I've written in <a href="https://github.com/clojure/clojurescript">ClojureScript</a> or <a href="https://github.com/clojure/clojurescript">Clojure</a>, so my code my vary from non-idiomatic to shamblolic. I initially used a <a href="http://clojuredocs.org/clojure_core/clojure.zip/zipper">zipper</a> to build the trie with immutable data structures, but found the indexing took to long with my zipper implementation so I <a href="https://github.com/tarnacious/swipe-keyboard/commit/6edd7b26e78121fbe8586b3f0ef54ca8277d9e32">switched to using native Javascript maps</a>.</p> <p>I found that <a href="https://github.com/clojure/core.async">core.async</a> library is really awesome, the <a href="http://docs.closure-library.googlecode.com/git/index.html">Google closure library</a> and <a href="https://developers.google.com/closure/compiler/">compiler</a> integration with <a href="http://leiningen.org/">Leiningen</a> the <a href="https://github.com/emezeske/lein-cljsbuild">cljsbuild plug-in</a> to be impressive. My main pains were the slow JVM start-up time, the advanced closure compiler build of the web worker script fails silently when run (but the main script works fine when compiled with the advanced compiler), and at times I felt some compile time type checking would be nice.</p> <p>I would like to extend this experiment to index the word occurrence counts and proceeding word counts in original text and rank the found words as most likely. Support casing, umlauts, special characters, spelling correction and compound words in the indexing and lookup. I think a live lookup while swiping would also be possible.</p> <p>Overall this was fun, turned out OK I think, and was a great learning experience.</p></body></html> </div> <div id="comments"> </div> </div> <div id="footer"> <p>&nbsp;</p> <p>Questions, comments, suggestions? Email me, <a href="mailto:tarn@tarnbarford.net">tarn@tarnbarford.net</a> (<a href="/pgp.txt">public key</a>)</p> <p>&nbsp;</p> </div> <script src="swipe.js" type="text/javascript"></script> </body> </html>

Permalink

Enjoying Software Engineering in the Age of AI

This essay was originally written for and submitted to the Pragmatic Engineer essay contest in October 2026.

At some point earlier this year I realized I was not enjoying work the way I used to. I&aposm the kind of person who likes solving problems for a living. On a good day I might even go so far as to say I enjoy the work of getting to the bottom of an issue and fixing it from first principles. At some other point also earlier this year, AI tools crossed an invisible threshold and became genuinely useful to me at work. I will admit that I was one of those software people who was very unimpressed by ChatGPT in the early days, and I found the hype and excitement around LLMs really insufferable back then. I still find the hype, and now doomerism, really overblown and annoying, but have since come around to the reality that these tools are in fact very useful and have changed the nature of our work forever.

Once it became obvious that these tools were becoming essential to how I work and not going anywhere, I decided I had to either accept my AI-centric fate and learn how to use them well, or leave the industry. I&aposve come to believe that being a software engineer but resisting the increasing spread of AI tooling and applications is futile, and a recipe for misery.

But I refuse to accept that work has to be miserable. It&aposs where I spend most of my waking hours, and I want it to be a source of enjoyment and fulfillment in my life. So I&aposve spent a lot of time this year reflecting on the changes in our industry and the impact they&aposve had on my wellbeing and job satisfaction. This forced me to really think about what specifically bothers me about AI and the way we use it, and to figure out how to articulate these challenges diplomatically at work so I could do something about it. This is a story about my journey accepting and adopting AI tools in my work as a software engineer and championing their responsible usage at a normal tech company.

I use coding agents in almost every part of my workflow now. I build internal tools to make work easier, and I host our monthly AI guild, which started as an internal AI dev tools meetup and, frankly, somewhere to vent about what wasn&apost working. Getting to a place where I felt more productive instead of just more frustrated required investing a lot of effort into learning how to work with these new tools. In my unskilled hands I found they mostly just caused chaos and more work.

It also required some difficult conversations about how we work with each other. My day to day work is completely unrecognizable compared to even a few months ago, but I still find the hardest problems are the same as they ever were: unclear strategy and direction, slow feedback, tightly coupled systems, and poor communication. The 2025 DORA report described AI as an amplifier of an organization&aposs existing strengths and weaknesses. That fits my experience, and it makes the problems and potential benefits harder to ignore. Fortunately, the solutions are broadly the same practices that industry leaders have been championing for decades. Doubling down on them is what made the difference for me between begrudgingly putting up with the onslaught of AI and accepting and even appreciating what it makes possible.

I am a staff software engineer at a company called Seeq. It is probably not very familiar to many of you. We are a normal tech company in many ways. We have an established product, a huge legacy codebase, a few hundred employees, about a quarter of whom are software engineers, and are not based in San Francisco (or anywhere, the company is truly fully remote). We are also unique in many ways. We operate a suite of enterprise SaaS products used every day by the industrial megacorps that make the world go round. We have many paying customers, most of which are advanced processing and manufacturing companies across oil and gas, chemicals, mining and metals, food and beverage, pharma, utilities, and now semiconductors. Modern factories have tens of thousands of sensors collecting data all hours of the day and night, and distilling all of that data into useful information is where Seeq comes in. These companies depend on our software to make operational decisions that have massively consequential and sometimes extremely expensive outcomes in the physical world. I emphasize this to say that we cannot be cavalier about what we ship to production. My team is responsible for two of the core products the company is built on, and the ones by far most used by actual paying customers.

AI made experimentation cheap and fast but engineering is still slow

Being responsible for these kinds of systems was the first source of friction I ran into with the enthusiastic adoption of AI early on. Seeq has been very open to developers using AI tools. We&aposve had early and easy access to new models and tools with relatively few restrictions from the start, and the approach has been mostly to just let people explore on their own and share what they learn. I think this kind of bottom-up approach to AI adoption is one of the things we&aposve handled well. People have different strengths, preferences, and jobs to do, so it makes sense to give individuals space and permission to explore what works best for them.

But this level of extreme flexibility and freedom leaves a lot of questions unanswered. What existing systems and processes are experiments allowed to interact with? When does an experiment become something we have to support? Who is responsible for getting it to that point, and who is responsible for the implied ongoing operation and maintenance? I realized that a lot of the friction I was experiencing was caused by leaving these questions unanswered among people with different problems to solve but working in overlapping areas.

I understand the value of prototypes. They can help determine whether an idea is worth pursuing and facilitate technical decisions. It is useful to be able to show something to potential users, let them interact with it, and discover whether you&aposve properly understood the problem before investing significant resources into developing a solution. I&aposve spent enough time in planning meetings and building software nobody uses to appreciate the value of this kind of work.

The problem is that developing software for production implicitly comes with requirements beyond what a prototype can deliver. It needs to handle errors and edge cases, enforce permissions, work correctly under concurrent load, and integrate with the existing product offerings and business strategy. It also needs to be deployed, observed, supported, and maintained. A prototype can be genuinely useful but still leave all of these responsibilities unhandled. Production software that has paying customers cannot.

AI made the early stages of the software development lifecycle much faster and cheaper. The later stages, though, once these prototypes have helped settle on a direction, are still substantial work. The change in the pace of development was dramatic. In my organization the number of PRs merging has increased by about 70% year over year and the volume of code merging has more than doubled. Some of this is just the natural growth of a scaleup, but over the same time period the number of engineers merging to prod only increased by about 25%. Most of the surplus throughput not accounted for by increased headcount can be attributed to our existing engineers producing more with the help of AI.

Of course volume of code shipped is not a useful measure of developer productivity, but it is a relevant metric for evaluating the load we are putting on all of the systems that surround writing code in the process of producing working software. Our review, testing, continuous integration, deployment, observability, and human attention chokepoints all suddenly had twice as much traffic flowing through them. We have a monorepo with a single shared CI pipeline, which of course started deteriorating in both speed and quality. Defects reached customers when they shouldn&apost have. Poorly architected changes that were too tightly coupled to existing code got merged, and the responsibility for making all of this new code reliable and for handling the fallout of this decreased stability fell implicitly onto people who often had no part in causing it.

All of this happened very fast and created a lot of downstream consequences. Shared build failures hold up every engineer in the organization and delay unrelated work. Exposing buggy functionality erodes customer trust that is harder to earn back than most people realize. Coupling experiments to existing functionality increases the effort required to change either one later on. Leaving ownership implicit means people only discover that problems are theirs once things are already on fire and the arsonists are nowhere to be found.

The accountability gap

I am the firefighter called in to fix prod more often than I would like. When I am investigating an issue having an impact on real customers, I try to trace it back and figure out which point in our development process broke down or failed such that the error was able to reach them in the first place. It&aposs not uncommon to find a gap where work was delegated to an agent and accepted without sufficient understanding of what it had done. I suspect this gap in understanding is behind a lot of the reliability problems teams across the industry are reporting. It also makes working out what happened and how to fix it harder.

The problem with relying too much on agents to handle the entire software development lifecycle is that at the end of the day, somebody has to actually understand how the damn thing works. No matter how sophisticated your harnesses and bots, there will come a time when you are called in to investigate an issue in production that they cannot simply fix. If this work of understanding the system is skipped and things start breaking but nobody really knows why, it falls to whoever is responsible for restoring the service to unmangle the history of decisions that led to the failure, work out which changes are important vs incidental, and separate interacting behaviours so that they can confidently fix the problem. By the time these issues arise, the person behind them has usually moved on to something else.

This accountability gap was a big part of what was behind my growing resentment and misery at work. Understanding systems is work I enjoy doing. But having that kind of work handed to me as an emergency, after someone else signed off on the implementation as finished, is pretty radicalizing. It feels deeply unfair that the person who shipped the changes got all of the benefit of any time saved by relying on AI earlier in the process, and all of the resulting obligations fell to other people downstream. Often me.

Being on the receiving end of too much of this kind of work is incredibly frustrating. It took me a while to be able to separate my frustration with this process from my reaction to AI in general. The problems with this kind of arrangement are exacerbated by AI, but not caused by it. Businesses need exploration, but also reliable delivery and maintenance of the experiments that turn out to be useful. Both kinds of work are valuable and need to get done, but AI makes the first kind much easier than the second, and transitioning between them needs explicit decisions and plans for who will handle the fallout.

The engineering practices that help fix these issues are familiar. Code needs to be more modular, concerns isolated, with clear ownership, and alignment on what is considered ready for customers. AI forced us to be more explicit about these requirements and expectations because it made the costs of leaving them implicit much more painful. We&aposre still working as a team on resolving some of our process issues that cause this kind of tension, but we&aposve made a lot of progress.

Human solutions to human problems

One turning point was when I figured out how to articulate specifically what was bothering me about the way we worked with AI. In the early days of widespread AI use at my company, the biggest source of friction for me was being consistently asked to polish and distill AI-generated output on behalf of the people who had sent it to me. It&aposs really easy to generate very verbose and plausible sounding prose with AI that is totally unclear. In order to make meaningful progress, somebody has to actually figure out what work is important, what has been checked, and what decision is being made. Part of the purpose of writing is to clarify thinking. If the author hasn&apost taken the time to clarify their thoughts, that work falls to the recipient. For most problems, generating text quickly doesn&apost really make the thinking part any faster.

I put up with this for way too long before I realized we could do better. Tom Bedor&aposs post, If You are Asking for Human Attention, Demonstrate Human Effort, made the rounds internally and got a lot of positive reactions, and really resonated with me as an ideal and succinct heuristic for how to work with AI. People kept bringing it up and eventually that phrase became shorthand for what we considered appropriate use of AI across the org.

There wasn&apost a single meeting where this rule was declared. Like most changes at Seeq, it started with a few people championing the idea and it spread organically. Having shared language to describe the problem made it easier to talk about and resolve tension the next time the issue came up. The principle of taking responsibility for what you send to other people is what fundamentally underlies responsible and respectful AI usage, in my opinion. Whoever is on the receiving end of your work should have enough context to respond and shouldn&apost have to do the work of unmangling walls of AI slop first before they can understand what you&aposre asking or trying to do. That is your job as the sender.

With code, as opposed to prose, this includes explaining the change well enough to make it clear what the intended effect is and how it has been verified. The point is that collaboration takes two. Asking someone for input or a review shouldn&apost require them to reconstruct and finish all of the thinking you skipped first. Instituting a personal policy of refusing to review unclear requests and asking the author to do a self review first improved my satisfaction at work more than almost any other single change I&aposve made this year. I started by just asking clarifying questions when a problem statement was unclear, or when someone&aposs description of a solution didn&apost make sense. Instead of framing it as a judgment about their usage of AI, I made it about the work itself. The net result was that I didn&apost have to do the cleanup work myself.

It sounds like a small thing, but it had a huge impact. Politely returning slop to sender changes the experience of being asked to review something. You are still being helpful by encouraging the sender to clarify their thinking and do a pass for obvious mistakes, and it removes the pain of absorbing all of the unfinished parts of the request.

I am fortunate that my coworkers at Seeq are reasonable people who will engage with these kinds of concerns in good faith. It&aposs the kind of place where I could explain what was causing difficulty and have a productive and mature conversation about improving the situation. Our AI guild gave us a place to share what we were learning, including frustrations, alongside tools and techniques that others were finding useful to mitigate those frustrations.

My own perspective has changed a lot too. I have come to appreciate the urgency behind some of the experiments we are running. There are real market pressures to innovate and adapt quickly, and there are many people trying to respond to this legitimate business need. If I&aposm honest I used to think of them as reckless slop slingers coming into my codebase with a wrecking ball and leaving me to pick up the pieces. Now I&aposve come to appreciate the value of that work more, and realized that if I wanted to protect our existing product and customers, I also needed to help enable that kind of work.

My goal shifted from trying to prevent slop cannons from being fired in the first place to figuring out how I could build safe places for them to land.

I still have strong opinions about what belongs in our production codebase. But I&aposve been working on ways to help other people find appropriate environments for their experiments, whether it&aposs a test tenant or experimental branch or an entirely new service with its own codebase and infrastructure. There is a real need for these kinds of experiments and I wanted to help find appropriate places to run them without holding them to all of the requirements of our supported products. I&aposm still working on ways to run parallel production instances for experiments, and advocating for more investment in our feature flag infrastructure that would allow us to reliably control exposure to new functionality and run real experiments safely in production.

These efforts are at different stages. We haven&apost resolved all of the open questions about experimentation and support, but my role in these conversations has become much less adversarial and much more productive. It always goes more smoothly when I can help someone find an outlet for their creativity so they can try out their idea, while also diplomatically explaining the process of what it takes to ship these kinds of experiments to real customers.

The outcome I&aposm after is making requests easier to act on and the responsibility of running production software easier to discuss. AI power users could still use the tools as extensively as they wanted, but the people on the receiving end could also ask for more clarity. By agreeing on some baseline rules of engagement we were able to go back to discussing our work at a technical level without turning every conversation into a referendum on AI.

I find this kind of relational work difficult, like many technical people do. It is hard to diplomatically articulate what is wrong with the way someone is doing something while also leaving room for their perspective. It’s much easier to stay frustrated and hope they change their ways unprompted. In my experience it’s always worth having these kinds of awkward or hard conversations, though. Worst case things stay awkward and hard, but more often than not calmly explaining my perspective and the consequences as I see them is much more productive.

The gains in this area were incremental but impactful. We’ve gotten to a much better place as a team where less of the work of unmangling AI-generated messes falls unfairly on unsuspecting victims now, and there is enough psychological safety on the team that people feel comfortable speaking up about when it occasionally does still become problematic. Overall the result is better cooperation across the board. It also made me much more open minded about what these tools might be able to do for me once I stopped resenting their output so much.

Outsource toil, not understanding

One way I describe what we fundamentally do as software engineers is that we try to make the connection between cause and effect as clear and understandable as possible. Virtually anyone can write code that accomplishes a narrow task now. But it still takes engineering skill to make a system changeable in the long run. Software engineering means being able to understand what behaviour we are trying to change, what is causing the current behaviour, and the mechanism behind how a given change will produce the desired outcome. Answering these questions requires understanding the system, the problem, and the intended outcome. A working prototype can be part of the answer, but we also need an explanation we can test and enough evidence to convince ourselves whether we&aposve actually solved the problem or not. Coding agents are really good at prototyping answers but less good at delivering convincing evidence that they are the correct ones.

It&aposs easy to end up with what appears to be a working app these days and realize you don&apost actually know how it works. Coding agents make it dangerously easy to conflate outsourcing toil vs outsourcing understanding. I mean "toil" here in the Google SRE handbook sense of manual, repetitive, automatable operational work without enduring value, as opposed to engineering work that delivers lasting, if incremental, improvement to the system. The distinction is useful when thinking about the purpose and appropriate use of automation.

My heuristic for adding AI to any system or process is that it needs to remove friction, not add it. I want agents to take work off of my plate. I use them to understand unfamiliar systems and code, but if I become responsible for part of a system, I need to be able to explain what it does and why, so that I can explain what effects my changes will have on the system. Reading generated walls of text doesn&apost inherently lead to understanding.

How we handle code reviews

Another thing that has changed dramatically in my day to day work and how we work at Seeq in general is the way I review code. I used to have quite strong opinions about code organization and style, and I spent a lot of time interrogating the code itself for most PRs. I have given up on even mentioning nitpicks that I would have pointed out before, and have become much more flexible in the range of code styles and approaches I approve now. I am much less flexible about letting incomplete changes through now, though, and do much more in depth exploratory testing for all changes. Rather than focusing on the code itself, my bar is that I need to be convinced a change both works itself and does not break other parts of the system unintentionally before approving. I pay much closer attention than I used to assessing whether the system as a whole is structurally sound and what the blast radius of the change is.

Now I insist that each pull request encodes a single decision. AI has made me less opinionated about code but much more demanding about engineering. I used to invest time discussing naming, organization, abstractions, and idioms. I think these matter a lot less now, but it&aposs still crucial to understand what decision a diff encodes, what it affects, how we can know it works, and how we can undo it. The onus of explaining and understanding a proposed change to a shared system falls to the author of the change. They need to understand the change so they can take responsibility for it. When implementation is cheap, the old economic justification for giant, tangled diffs is much weaker. My expectations are much higher now, and I expect engineers to spend that saved implementation time making changes smaller, better tested, and easier to understand. Our codebase reflects some of this shift in our approach to review. Comparing the same periods in 2025 and 2026, the volume of changes to test code increased about 3x, while the volume of changes to non-test code roughly doubled. Cheap implementation hasn&apost made verification any less important. If anything, we&aposve been spending proportionally more of our newly available capacity proving that changes work as intended.

All of this is why I still care about code, even though I don&apost scrutinize it in the same way as I used to. I no longer think code needs to be optimized for human readability. Increasingly code is mostly read and changed by agents, not humans, so now my focus is primarily on whether the system enforces clear boundaries that allow both to reason locally.

When I review PRs, I&aposm looking for clear boundaries between components, potentially leaky side effects, unhandled failure modes, isolated and modular changes, and evidence that these changes produce the desired effects. I&aposm not really considering how it is written anymore. Some of these things are easier in certain languages, but the principles are the same across any system. Agents struggle with broad context in complex systems. They often make locally reasonable changes without recognizing the broader impacts on seemingly unrelated parts of the codebase, or even the business. In an ideal world we would offload all of this implicit context onto our agents, and I suspect eventually we will, but for now a tremendous amount of tribal knowledge still lives in the brains of humans, which makes us difficult to remove from the review process for changes to large legacy systems.

When I review code, I&aposm implicitly running through everything I know about this subsystem, how it fits into our broader architecture, how this feature or product area fits into the overall product and business strategy and endless other small things that are relevant to a given change but not documented anywhere. My brain is what is making the connections between all of these things.

Everyone knows that in theory you should write modular code with clear boundaries and isolated responsibilities. In practice I have hardly ever seen a codebase that does this reliably and well. It is just so much easier to make the thing work and worry about making it nice later, but later never comes. In theory, if a component had a clear contract between itself and all of its consumers, no shared mutable state, and explicitly injected dependencies, there would be very few potentially unintended consequences to changing it. In practice, though, almost no code is written this way, and most languages make it unnecessarily difficult to write clean code in this sense. In real codebases, everything is coupled to everything else, which makes local reasoning and correctness much less relevant. In codebases like this every change becomes an exercise in discovering new and interesting couplings in the system and, if you&aposre ambitious, trying to untangle them.

Overall the impact of AI on this category of my engineering opinions has surprised me a lot. I used to really really care about elegant code. I spent countless hours poring over really nice, functional solutions to hard problems and coming up with perfect names for things. Now I rarely read code line by line. When I do read code, I&aposm looking at the structure around it more than the details of the implementation. The type of understanding I care about has effectively shifted up a level, from how each individual component of the system is written to what effect it has on everything around it, and how we can verify that. Clean code to me, now, means very local responsibilities uncoupled from other parts of the system. This was always better, but with agents making most of the changes now, it&aposs essential.

Automation and observability

I mentioned that AI has also increased the amount of time I spend looking for bugs and regressions. This became really tedious really fast and was one of the first things I worked on automating in my new workflow. It started out as a local skill, then evolved into a full-on exploratory-testing bot that engineers can opt in to have test their PRs. It drives a browser to explore functionality, check for regressions, drill into edge cases, and has found tons of bugs. It also leaves behind a trail of state and steps to reproduce any issues it finds on PR tenants, which makes problems much faster and easier to describe and reproduce, which saves work when the author needs to investigate and fix them.

Seeq has been adopting more and more of this kind of agent-based automation in our software development lifecycle over the course of this year, but it has also inspired a general shift toward thinking about how we can be more efficient. We’ve added a lot of automations to our development process that are completely conventional. We have a Slack review-assignment bot that assigns pull request reviewers, triggered by a magic phrase in PR descriptions. We have a GitHub bot that runs and monitors dozens of checks and merges PRs once they all pass. It&aposs the only way to merge anything to trunk in our repo. We have other bots for assigning escalated tickets, managing PTO, and they all coordinate enough so work doesn&apost get assigned to someone who is away. None of these use LLMs.

We also still use code review as a tool for understanding and sharing context. The review assignment bot picks someone at random from the squad, so it&aposs common to get assigned a review for something you had no part in developing. It can be a drain to get up to speed on new areas, but we have found this inefficiency to be a more than worthwhile investment because it forces a baseline level of knowledge sharing across the team. Team members are regularly exposed to changes outside of their immediate work streams this way, so we share at least some understanding of the overall system.

I think this is an important component of shared responsibility, which I increasingly believe is at the heart of team harmony. If we expect someone to be able to investigate a problem or maintain a feature later, they need to have opportunities to learn how it works before that becomes urgent. Reviewing each other&aposs diffs is part of how that happens. We use AI extensively to help explain changes, get up to speed on unfamiliar parts of the system, and find bugs in review, but having multiple people understand why decisions are being made keeps knowledge and a shared mental model circulating through the team.

This same emphasis on outcomes-based verification in review also carried over into production for me and has changed the way I approach monitoring and following up on my work. I got a lot more serious about observability this year. I will admit it started somewhat cynically because I was convinced I could prove that the steady stream of what I considered slop getting merged was causing increased incidents. What I found was mostly that it turns out humans produce a lot of slop, too. What I ended up with, though, is a team health dashboard that shows us errors we should care about and surfaces our top production exceptions. We&aposre working on getting better at monitoring these things proactively instead of only responding to incidents. And the main benefit of having better metrics is that it helps me ground technical discussions. Now I can point to specific errors and have a shared basis for quantifying our problems. It makes it much easier to move from frustration to action.

Now that producing code is so cheap, it&aposs more important to decide what to spend time on. It&aposs dangerously easy to waste days or weeks playing with prototypes before realizing you have no actual goal or purpose. I think this was always true, but making the prototypes used to take so long comparatively that deciding how to measure the outcomes was an afterthought.

Like many teams, we had been underinvesting in observability because we hadn&apost felt enough pain to make it a priority. Faster implementation made our missing feedback loops impossible to ignore. If you want to change a running production system frequently, you need to be able to see what happens when you do. Good intentions and careful reviews cannot provide that visibility on their own.

Modernizing CI to accommodate agentic development

Our CI pipeline also started creaking under the strain of our increased throughput. The merge queue would get backed up, builds started taking longer and longer, tests got flakier, and other unreliable checks added uncertainty to the development process where there shouldn&apost be. As implementation got faster, the time spent waiting on builds and merges became a noticeable pain point.

Our response was to treat CI as an internal product in its own right and worth improving. We upgraded Gradle, added CI capacity and build parallelism, improved caching, removed the ability to bypass any tests, and started treating build breaks, including flaky tests, as urgent work. We also invested in making our linting and other mechanically automated checks faster, often in the time-honoured way developers everywhere make anything faster, by replacing TypeScript-based tools with ones written in Rust. This was a ton of work, but it has been well worth it.

Compared to this time last year, our mandatory merge check for PRs is down to about 10 minutes from around 25, with p90 merge checks at 18 minutes down from about 40. Over the same period the median whole build time fell from over 100 minutes to around 50. Reliability also improved dramatically. During comparable windows this year and last, our trunk branch spent around 145 hours red in 2025 and 55 hours red in 2026. The share of builds encountering at least one flaky test also fell from 25% in late 2025 to around 12% today.

Not everything improved. Our system test suite got slower, mostly because of the many new tests added, and is now by far the longest running PR check. We&aposre working on moving these tests to faster layers of the test stack and getting better at only running tests that need to run based on a given changeset. Faster feedback does not have to mean less verification, so we are determined to find better and faster ways of testing our system without reducing automated coverage.

Improving CI has been one of the most worthwhile investments we&aposve made. Saving ten or twenty minutes on one build sounds like a small improvement, but multiplied across an entire org shipping substantially more changes, the dividends are enormous. Just as importantly, reliable feedback loops let engineers carry on with work without the looming uncertainty of whether some unrelated flaky test will derail the next chunk of work. It&aposs not as quantifiable, but removing that mental overhead is a real benefit.

We obviously still have a lot of work to do in this area and meanwhile the pace of development is still relentlessly accelerating, but these gains have already had a huge positive impact. Not least, they have convinced a critical mass of developers that this work is worth doing. Without AI, I don&apost think we would have felt the pain acutely enough to act, and the changes themselves would have been too cumbersome and tedious to make. I think it&aposs fair to credit increased AI adoption for these improvements to our build pipeline on both counts.

None of these practices are novel. Modularity, automated testing, observability, and reliable integration are all baseline practices that industry leaders have been preaching for years. When implementation was slower, weak engineering practices imposed frictions gradually enough that we learned to live with them. Now with the dramatically increased throughput of software through our development cycles, those weaknesses quickly become acute bottlenecks. Trying to produce code faster without investing in the systems that verify, integrate, deploy, and observe it just leads to chaos and endless firefighting. Sometimes it takes significant investment to fill the gaps and catch up after years of kicking these cans down the road, but I really think they are no longer optional. Teams that don&apost adopt modern software engineering best practices will continue to either miss out on the main benefits of AI or suffer in production incident hell.

Bespoke software to remove friction

Alongside all of these org-level changes, I noticed that my own workflows were becoming more friction-riddled and frustrating. Agents were making good progress doing my work in the background and keeping many plates spinning at once, but I was constantly checking in, effectively polling them all day to see where my attention was needed.

I used to be the kind of engineer who really enjoyed getting stuck in to a problem and staying there until I had figured it out. I love the flow state that comes from cruising through a task that is just hard enough to keep you both interested and stimulated. Working with agents completely broke this for me and at first I really hated that. They introduced this whole new category of work that could proceed without my immediate involvement, which made it possible to keep several things moving forward at once, but it also meant I was constantly checking in on my agents, and polling is exhausting.

I realized that since software was effectively free to produce now, I could solve a lot of my frustrations with better bespoke tooling. Anytime I run into friction in my workflow now, I remove it with some sort of bot or tool or new app.

For example, the increased throughput of my team meant I had a lot more things to review, and I started to feel like I was drowning in notifications and dropping balls. So I built a custom command centre for myself. It&aposs like a virtual executive assistant. It pulls everything in my work world from Confluence, Jira, Slack, GitHub, and email that could possibly need my attention and distills it into one unified, prioritized, utterly non-overwhelming list. Now I don&apost check notifications anywhere, I just work through my list with my AI assistants. At first I had an LLM organizing the list and fell into the trap of manually checking the AI&aposs work, which totally defeats the purpose of automation. That&aposs when I realized an LLM was the wrong tool for this kind of prioritization. After a few rounds of refining a deterministic prioritization algorithm, it&aposs pretty dialled in and I trust the system enough now that I&aposm not manually polling all of the disparate streams of noise that could possibly be delivering me a signal anymore. Once I got to that point, it felt like nirvana. I&aposm missing fewer things and doing a better job promptly unblocking others. That&aposs essential to me because most of the benefit of keeping more work in flight at once is lost if it all gets bottlenecked on one person anyway. I am determined for that person to never be me.

I also have a whole collection of other scripts, dashboards, and TUIs that manage all of the complexity of working in my company&aposs particular environment. For example, our PRs each have a branch tenant attached, some require corresponding documentation updates, reviews from various codeowners outside the team, and checks from various out-of-band systems. No off the shelf tool would ever accommodate the endless idiosyncrasies of our workflow, but with AI it is trivially easy to build custom tools to accommodate it. Many engineers have built tools that suit their own workflows and bring all of the relevant details together in ways generic tools can&apost. Another example is how I manage multiple worktrees. I always have a few on the go now for the sake of keeping workstreams separate across agent sessions, but spinning them up and tearing them down was always a bit cumbersome. I and others have built everything from simple scripts to terminal interfaces for making that easier too. Mine also consolidates agent memory sessions from worktrees, which was otherwise getting lost, into my main agent memory ledger before tear down. Stuff like this is what I mean when I say AI can be used to great benefit for removing friction.

One of my favourite internal tools I built is a better interface for commenting on Confluence docs. A lot of our product development starts in documents, and discussions can run for a long time in the comments. Confluence comments were clearly just not made for this, but the document is a useful place to have the conversation because the context is right there. I wanted a lot of extra features, like drafts, seeing comment threads together in context, and a more clear overview of what had changed since I last reviewed, so I built it. It makes discussions smoother and completely eliminates what was previously one of the most annoying parts of my day.

Building that tool was roughly when I realized my motivation and attitude toward AI were starting to shift in a meaningful way. I had initially been really annoyed by AI adoption and treated learning how to use it well as a necessary evil, or at least something I had no choice but to do if I wanted to keep working in tech. Now I have a whole collection of tools like this that genuinely make my life easier, both at home and at work. Almost none of them would have ever justified the time investment before coding agents got good enough to build them for me. I always wanted the benefits, but the cost was so high it was easier to keep putting up with the inconvenience.

AI dramatically moved the bar for me on what problems are worth solving. This is also a useful corollary to my own argument about prototypes vs production software. Many of the tools I use locally for one specific purpose would be a nightmare to deploy, but that&aposs fine, because I never have to. Not having customers depend on them removes entire categories of requirements, and also makes the burden and responsibility of maintenance acceptable to me. If other people start depending on this software, though, those expectations need to change.

There is nothing inherently irresponsible or bad about building something small or bespoke, but it&aposs important to match the scope and support expectations of software to its intended audience. Not every useful script or tool needs to become a general purpose product.

Finding a new version of my job that I could enjoy again

I find the implications of all of this interesting in the context of endless predictions about when software engineers will become obsolete. In my own life I&aposve noticed my personal demand for software skyrocket. The lower cost of delivery just keeps revealing more and more software I want in my life. Frictions that used to seem too small or personalized to address have become reasonable projects to tackle. Understanding these problems and deciding what would actually fix them is still hard, but it&aposs becoming satisfying work that I am getting better at and increasingly enjoy doing.

I don&apost think every problem introduced by AI has an already-known solution, or that we are anywhere near finished figuring out how to work with it effectively. The details of my workflow and this mountain of custom software I run for myself keep changing, almost literally daily. My org&aposs processes at work are still very much in flux, too. There are still disagreements about experimentation and product commitments, open questions around how to prioritize what gets done, what is worth doing in the first place, and what responsible engineering looks like in the age of AI.

I think eschewing AI as a professional software engineer is no longer practical, or responsible even, but that&aposs a whole other essay. When I realized this earlier this year, it made me seriously wonder whether I even had a future in this industry. Once I figured out that what I hated about my job wasn&apost really the fact that everyone was using AI, but the way they were using it, I started to see a path forward. I don&apost hate AI. I hate reviewing work that nobody understands and inheriting responsibilities that nobody explicitly accepted. I hate waiting on unreliable infrastructure, and spending my precious attention polling machines searching for signals hidden in endless noise. AI made all of these problems so much worse so quickly that I thought I just couldn&apost handle it anymore.

Fortunately addressing them required remarkably little new wisdom, just a lot of patience and grit. At work, we made changes smaller and clearer. We are working on making ownership and accountability explicit. We invested tremendous time and energy improving our tests, builds, observability, and process automation. We came to a reasonable mutual understanding of when it is appropriate to ask for human attention, and set a bar for asking for it. We outsource toil, not understanding.

The tech industry has always been prone to hype cycles, but this is certainly the most dramatic upheaval I&aposve experienced in my decade or so writing software. If you are finding this transition miserable, I think it&aposs worth taking time to reflect on what specifically in your day to day is causing that. Reject the premise that work has to be awful.

I started this process because I refused to accept that hating my job was an inevitable part of being a software engineer now. Getting clear about what I needed from my coworkers, more attentive to what they needed from me, and willing to put in the effort to make the necessary changes made work better for me.

I use AI far more extensively now than I did when I hated what it was doing to my job. I didn&apost stop caring about engineering, though. I just realized that cheap code makes engineering more important. Implementation is something we can delegate now. The days of crafting artisanal functions by hand are over. That makes me sad and I miss those days, but that&aposs okay. Understanding systems, deciding what should change, producing evidence that it worked, and taking responsibility for what happens next are all still interesting, and what the job is now.

Permalink

From the Outskirts: Q3 2026

From the Outskirts is a roundup of my open source work in Q3 2026.

In the last update, I mentioned a DNS library and hoped to get a public preview of Busker out during Q3. Both projects are now public.

There's also been some work on FFI (native interop) in the Clojure ecosystem that I'm particularly excited about.

In this edition of From The Outskirts:

Busker is in public preview

Busker is a Clojure web server built on libh2o (the web server core that powers Fastly's global CDN) serving Ring applications over HTTP/1.1, HTTP/2, and HTTP/3 with automatically provisioned TLS certificates (now Clave makes sense?). I've soft-launched it, no big announcement yet, so others can preview it and provide feedback before APIs solidify. It's available to try, but still experimental and not recommended for production use.

But why another Clojure web server? I'll expand on this in a future Rationale section of the Busker docs, but it boils down to simple deployments.

My clients value simplicity and affordability in the services I build and run for them (much of my work is operational, keeping servers online and healthy). Funding for these projects is grant-based, and between grants we have to run lean and mean. Every component in the deployment system is something that can fail. So I want to minimize all the moving parts.

Busker helps with this because it lets me embed the web server inside the Clojure application. I already embed the database+queue etc in the application also. Deployment is as simple as scping an uberjar (that can generate its own systemd service unit file) to a Linux virtual machine.

An internet-facing HTTP stack is a bad place to indulge in reinventing things. That's why another pillar of Busker is libh2o: we build on the H2O maintainers' work and Fastly's expertise rather than rolling our own HTTP implementations. Busker already gives me enough security-sensitive work without volunteering for another stream of incoming CVEs.

More on Busker in another post. The documentation has setup instructions and examples if you'd like to experiment with it. Send me feedback!

Native libraries from Clojure and Babashka

ol.vips and Busker's native bindings are now using babashka/ffi. borkdude was inspired created babashka/ffi after some cross-pollination between Anders Murphy and myself.

An FFI, or foreign function interface, lets you call functions in native libraries from another language. In this case, that means calling them from Clojure and Babashka, including ordinary bb scripts.

Before babashka/ffi I was using coffi, the library that babashka/ffi is inspired by. And I want to take a moment to give props and thanks on Joshua Suskalo, the coffi author. He pioneered this approach to easy native interop in Clojure, using Java's new Foreign Function and Memory API.

But why switch to babashka/ffi? Compatibility, simply put. It supports both Clojure and Babashka. It's built into Babashka itself, whereas coffi is not Babashka compatible. That opens up native libraries to small Babashka scripts, not just full JVM applications. You can load a library, describe the functions you need, and call them from the script. With the same babashka/ffi API you can also run code in Clojure on the JVM.

Example: libvips is a very well respected library in the image processing world, primarily for security, speed and extremely low memory footprint (compared to alternatives like ImageMagick). ol.vips is my Clojure library for image processing that wraps the native libvips shared library and provides a nice Clojure interface. As of this post it is ported to babashka/ffi but is not 100% compatible yet. I'm waiting on a small issue in babashka to be fixed.

Oh and in sqlite4clj I landed the coffi -> babashka/ffi migration also. Are you using SQLite in Clojure? Are you still using JDBC? Boo! Check out sqlite4clj!

This is what excites me about Project Panama and the JVM's Foreign Function and Memory API! Clojure was designed as a hosted language and taking advantage of the JVM library ecosystem has definitely been a boon. But often the Java libraries we want to consume are bloated and/or expose cumbersome object-oriented APIs that are ungainly in Clojure.

I think Clojure has a great future making use of native libraries instead of reaching for another bloated Java dependency. Like with libh2o and libvips, there are substantial existing implementations to build on, while the application-facing code can remain Clojure. Native libraries still need packaging and the bindings still need to get memory management right, but I'm glad to have this option in both Clojure and Babashka.

Mozilla Fluent tooling

Fluent is Mozilla's localization system for the text an application presents in different languages. It's been around since roughly 2017 and is in my opinion the best system for creating and managing translations of strings for software projects that don't bend the knee to English.

Take a selected-file count, with $count passed in as a number:

English (en.ftl):

selected-files = { $count ->
    [one] One file selected.
   *[other] { $count } files selected.
}

Arabic (ar.ftl):

selected-files = { $count ->
    [zero] لم يتم تحديد أي ملفات.
    [one] تم تحديد ملف واحد.
    [two] تم تحديد ملفين.
    [few] تم تحديد { $count } ملفات.
    [many] تم تحديد { $count } ملفًا.
   *[other] تم تحديد { $count } ملف.
}

English needs only two plural categories, but Arabic has six! With fluent-clj, the application supplies the same message ID and count:

(require '[noahtheduke.fluent :as fluent])

(def en (fluent/build "en" (slurp "en.ftl")))
(def ar (fluent/build "ar" (slurp "ar.ftl")))

(fluent/format en :selected-files {:count 2})
;; => "2 files selected."

(fluent/format ar :selected-files {:count 2})
;; => "تم تحديد ملفين."

However, suppose that the message we wanted to show addresses the reader with the 2nd person pronoun "You":

"You have two files selected."

Arabic distinguishes masculine and feminine forms of singular "you", but English's "you" doesn't.

Assuming we have the gender of the reader then this becomes in fluent:

English (en.ftl):

selected-files = { $count ->
    [one] You have one file selected.
   *[other] You have { $count } files selected.
}

Arabic (ar.ftl, [two] branch only):

...
    [two] { $gender ->
        [masculine] لديكَ ملفان محددان.
        [feminine] لديكِ ملفان محددان.
       *[other] تم تحديد ملفين.
    }
...

Unlike plural categories, masculine and feminine are labels we chose for an application-supplied $gender string. Fluent doesn't infer the person's gender. Passing "unspecified" uses the neutral fallback, "Two files have been selected." Rebuild the bundles from the updated files:

(def en (fluent/build "en" (slurp "en.ftl")))
(def ar (fluent/build "ar" (slurp "ar.ftl")))

(fluent/format en :selected-files {:count 2 :gender "feminine"})
;; => "You have 2 files selected."

(fluent/format ar :selected-files {:count 2 :gender "feminine"})
;; => "لديكِ ملفان محددان."

(fluent/format ar :selected-files {:count 2 :gender "masculine"})
;; => "لديكَ ملفان محددان."

The English translator/writer doesn't even have to know about the $gender for these strings. Arabic uses it to choose the wording.

Fluent is powerful and enables you to produce native sounding applications for all languages. If I'm building software for people, getting their language right is part of the job.

Fluent, however, does have a tooling problem, especially if you don't want to use the npm ecosystem. It can be cumbersome to work with fluent files in your editor as there's no linter or highlighting.

As part of a client project I was able to build some tooling which I can now open source under outskirtslabs/fluent-tooling. It includes :

  • tree-sitter-fluent, an error-tolerant Tree-sitter grammar
  • ftl-lint, a command-line linter for Fluent .ftl files with compiler-style diagnostics
  • and fluent-ts-mode, a Tree-sitter major mode for Emacs with syntax highlighting.

The docs cover the individual tools and how to use them.

Protocol53

ol.protocol53 provides one Clojure interface for managing DNS records across providers. Available provider integrations include Cloudflare, deSEC, GoDaddy, and Porkbun.

It grew out of Clave's need to handle ACME DNS-01 challenges without duplicating the integration work for every DNS provider.

Example usage:

(require '[ol.protocol53 :as protocol53]
         '[ol.protocol53.cloudflare :as cloudflare])

(def dns (cloudflare/provider {:api-token (System/getenv "CLOUDFLARE_API_TOKEN")}))

(protocol53/append-records! dns
 "example.com"
 [{:name "www"
   :type "A"
   :ttl  60
   :data "192.0.2.1"}]
 {:timeout (java.time.Duration/ofSeconds 10)})

See the documentation and API reference.

Further Out: Q4 2026

Stardust DB has landed in public preview. It's a data engine from the creator of Datastar. Think SQLite + Datomic + Redis + Elasticsearch + NATS + Object Storage all in one small binary. It has crazy potential, so I'll be poking and proding at it over the next months to learn more.

Busker's performance is pretty good, but it can be better. My goal is to be within 20% of vanilla h2os numbers for h1, h2, and h3.

That's all from the outskirts. Back to the lab.

‣ Casey

Reply via: Email · Bluesky

Permalink

volley 0.1.0-alpha1

I made a test runner for Clojure! Clojure is still my language of choice and I&aposve been writing most of my personal projects with it (or babashka) lately, and especially in the babashka projects have been wishing for a better test running experience. I use vitest at work, so it inspired a lot of the functionality of volley.

It&aposs a runner for clojure.test tests and currently works on babashka and JVM Clojure. It runs test namespaces in parallel worker processes, shows each namespace as it finishes, and has a watch mode that reruns only the tests that change. It&aposs compatible with kaocha, since that&aposs what I was mostly using before, so switching is just changing one command. With AI generating more and more of my code these days, I find I&aposm running more and more tests, and was wanting a runner that could parallelize large test suites where that results in noticeable speedup.

This is what volley looks like running its own test suite:

volley running its own test suite

Try it out

volley is on Clojars as com.kirahowe/volley. For bb projects, add it to bb.edn with a task:

{:paths ["src" "test"]
 :deps {com.kirahowe/volley {:mvn/version "0.1.0-alpha.1"}}
 :tasks {test {:doc "Run the tests"
               :requires ([volley.main])
               :task (apply volley.main/-main *command-line-args*)}}}

On JVM Clojure projects that already use kaocha, swap lambdaisland/kaocha for com.kirahowe/volley in :extra-deps, and change kaocha.runner to volley.main in :main-opts, and leave the rest alone. A bin/kaocha binstub that runs clojure -M:test "$@" should keep working.

How it works

The parent process plans the run and hands namespaces to workers, which stay alive across runs. By default, it runs one worker per CPU, capped at 8 for bb and 4 on the JVM. --workers N overrides these, and --isolate gives every namespace a fresh worker. Workers run on whichever runtime the parent uses, or you can specify directly with --runtime bb|jvm. Timings are saved in .volley/, so later runs start the slowest namespaces first.

In a terminal, volley re-draws one line per running namespace. When stdout is piped, it prints plain lines instead. Failures show expected and actual values, a structural diff for = assertions, captured output, and the call stack without the bb interpreter&aposs frames. A failed defspec property test shows its seed and shrunk input. The run ends with the slowest tests and prints a command that reruns just the failures.

--watch polls test and source paths, works out from ns forms which test namespaces depend on the file you changed, and reruns only those. Keys let you rerun everything, rerun failures, or filter by namespace or test name.

Most of kaocha&aposs options and config are supported by default. Suite ids, --focus and --skip (and their -meta forms), --fail-fast, the reader tags in tests.edn, :kaocha/bindings, pending tests, the hooks plugin, JUnit XML, custom reporters, the --print-* flags, and kaocha&aposs exit codes should all work as expected.

Performance

So far I&aposve only tested this on my busy 16-core M4 max MacBook Pro, but I have found huge improvements on big test suites. In one bb project with 507 tests runs went from ~150s to ~30s with 8 workers, on another JVM Clojure project I saw improvements from ~215s to 40s. So far small JVM Clojure test suites don&apost see much improvement in my experience. This is on my radar to improve, but for now if your tests run in under about 10s, you might not see any improvement with volley because the JVM startup overhead dominates the timing.

Known limitations

  • Only handles clojure.test for now. There&aposs no ClojureScript and no coverage.
  • Kaocha plugins such as the notifier, gc-profiling and cloverage are accepted but do nothing. volley prints one warning pointing this out.
  • --randomize and --seed are accepted with a warning. This release doesn&apost randomize test order.
  • volley departs from Kaocha on purpose in a few places: load errors, System/exit inside a test, fail-fast inside a test, load order, and the --print-* maps. There are more details in the "Deliberate divergences from Kaocha" section here.

This is the very first alpha release so there are still many rough edges, but I&aposm using it all day every day on my own projects so figured it&aposs ready "enough" to put out there. My goal is to keep improving it to fix all the paper cuts. If you try it and run into any, I would love to know about it. Please feel free to create issues in the repo or message me in any of the Clojure community places I lurk. Thanks for reading!

Permalink

The code was the easy part: Lessons from contributing to the Clojure language

Written by: Arthur Fücher

I always wanted to contribute to Clojure. Not building a new library, but improving the language itself. Working on a programming language felt too hard to me, a senior software engineer.

During a mentorship at Nubank, I told Alex Miller that I hoped to reach that point someday. His response was roughly: “You can do that today.”

Not everyone has the opportunity to have a Clojure core contributor mentor them. Alex’s point was simple: the tickets are public, the contribution process is clear, and there are real problems to solve now.

The mentorship became: pick an open ticket, work on it, learn.  And I worked on two Clojure core tickets. CLJ-2897, about dynamic library loading through prepl, merged and included in Clojure 1.13.0-alpha5. CLJ-1548, about primitive type hints in protocols, still in review when I presented this experience at Clojure/Conj 2026.

I expected the code to be the hardest part, and it was hard. But it wasn’t the hardest part: that turned out to be four questions. Questions that I had to answer before anyone would even look at my code.

1. What is actually wrong?

My first ticket involved Clojure’s prepl, a programmatic REPL. Like a conventional read-eval-print loop, it accepts expressions and evaluates them. Unlike a conventional REPL, it emits structured results. A developer can connect to a running process through a socket and evaluate forms..

Using  add-lib, which add dependencies to a running REPL session, failed in prepl:

That was an odd message to receive while using a REPL. The ticket identified the immediate cause: prepl did not bind *repl* to true. 

My first patch added the binding. It was a two-line change that addressed the ticket’s stated cause. I attached the patch. I felt great.

Then Alex pushed me to consider what the user wanted to do. They didn’t want a particular value for *repl*; they wanted to load a library.

I applied the patch and tested, the original error disappeared. But now another error message: Context classloader is not a DynamicClassLoader.

Same user. Same goal. Another error.

add-lib requires a DynamicClassLoader. Prepl hadn’t set one up.

My revised patch added both the *repl* binding and the classloader setup. Now, the prepl session could then add Cheshire, require cheshire.core, and call it.

The first patch removed the reported error, while the second made the user’s task work.

I had even seen a reference to the related classloader issue, CLJ-2540, in the original ticket. I read it without understanding how central it was. Reading a ticket and absorbing a ticket are different things.

2. Why does the current behavior exist?

Once the patch worked, I needed to understand why prepl lacked those pieces in the first place.

The explanation was historical. Prepl was created in 2018 by copying and modifying clojure.main/repl. But it didn’t keep the REPL’s DynamicClassLoader setup. Later, in 2023, clojure.main/repl gained the *repl* binding, but the change didn’t reach prepl. 

Two implementations with related responsibilities had drifted apart. This was a consequence of how the code had evolved.Understanding this raised a bigger question: should fix the drift? Or the symptom of the drift?

My second ticket exposed a different kind of mismatch.

In Clojure, we have primitive type hints. It tells the compiler to use a primitive-aware invocation rather than boxing a value. With ordinary functions, that can be a useful optimization. But consider this protocol example from CLJ-1548:

Defining the protocol works. Extending it for strings works. Directly calling the function fails.

The reason is subtle. defprotocol accepts the primitive hint but doesn’t generate a corresponding primitive-specialized method. Yet the hint remains in the function’s :arglists metadata. When the compiler reads that metadata and selects a specialized invocation. In this case IFn$OLO, an interface the generated protocol function doesn’t implement.

The JVM cast fails before dispatch to the string implementation can happen. The function’s metadata promises a calling convention that the generated function cannot fulfill.

Clojure 1.12 already included a fix for a similar issue, about primitive return hints, CLJ-2698. But CLJ-1548 concerned primitive parameter hints, and understanding the difference mattered.

“Why does this happen” is not a curiosity question. It’s the only way to decide whether the fix belongs..

3. What has already been considered?

Both tickets contained more accumulated reasoning than I first appreciated.

The prepl ticket had pointed to CLJ-2540 all along. A triage process expanded the protocol ticket with the related return-hint precedent. and possible solutions. I wasn’t arriving at untouched problems. Other contributors had already done important investigative work.

And yet simply reading those notes wasn’t enough. My first prepl patch proved that I could read a clue and still underestimate it.

After the revised prepl patch worked, Alex encouraged me to implement alternatives. Not only think about them. And we ended up comparing three approaches:

  • Copy the missing behavior into prepl. Add *repl* and the DynamicClassLoader setup. The patch was small, five added lines and four removed, but it wouldn’t prevent future drift.
  • Rebuild prepl around clojure.main/repl. Reuse the existing REPL’s extension points, potentially reducing divergence. Experimenting with this revealed complications involving shared state between read and evaluation phases, prompt behavior.. The prototype involved 65 additions and 44 removals.
  • Extract shared classloader setup. Introduce a reusable macro used by both REPL implementations. This provided limited reuse but still didn’t eliminate the drift.

The experiments mattered because they replaced an attractive architectural idea with concrete trade-offs. An approach can look elegant until it has to preserve existing exception handling, output structure, and other behavior that real clients expect.

In total, I wrote 126 lines of experimental code that wouldn’t be part of the accepted change. And I want to be clear: it wasn’t a waste. It was the cost of knowing different dimensions of the solution. You can’t know the simple option is correct until you’ve seen what the complicated ones actually cost.

4. What quietly rules most options out?

The approach that best prevented future prepl/REPL drift wasn’t selected.

Rebuilding prepl on top of clojure.main/repl offered the clearest reuse story. But getting the behavior right will need modifying repl itself. And repl has external users who may depend on details of its existing behavior. Changing it to fix prepl would impose risk on code that hadn’t experienced the original bug.

We chose the smaller, less disruptive patch. It solved dynamic library loading in prepl without claiming to solve future divergence. We documented that limitation rather than hidden it.

The protocol ticket made compatibility even more concrete. One proposed solution was to reject unsupported primitive hints at definition time. 

To me it was obvious:  Why let developers define something that will fail when called? 

But the answer was: Because not every call fails.

Let’s check this example using a protocol function named add (the definition is omitted here):

In a direct call, the compiler sees the primitive-hint metadata and calls .invokePrim.. Passing the function as a value uses plain IFn.invoke, not invokePrim.

That means there may be code with primitive hints on protocol methods that has worked fine for years.Rejecting those definitions would break code that currently runs.

So, the alternatives had different consequences for existing users.

On both tickets I had more than one option that worked. What was decided between them had less to do with the code, and a lot to do with everything around it.

The final challenge: Explaining the reasoning

By this point, I had spent time reconstructing behavior, implementing alternatives.  Ready for the next step: Screening/prescreening process. I thought I had done the difficult work.

Then Michael Fogus screened the protocol ticket and left six comments. None were about the code. They concerned the description: put the problem first, introduce context in the order a new reader needs it, and make the argument for the change easier to follow.

I rewrote the ticket’s explanation, but the patch stayed the same.

That feedback connected with something Peter Naur wrote in Programming as Theory Building (1985): 

“[…]programming properly should be regarded as an activity by which the programmers form or achieve a certain kind of insight, a theory, of the matters at hand.”

And he says one more thing: the theory lives in people. It does not fit entirely in the text. So if the theory is in my head, and the decision is in someone else’s, how do I move it across?

The four questions I had been answering were ways of building a story. A story that moves a theory from one head to another. 

The evolution of CLJ-2897 made the contrast visible. Its initial description had 82 words; the final one had 505. Alongside, were 9 lines of accepted code and 126 lines of experimental work written to be thrown away.

It shows that for a patch to move, the story, the theory had to be in the head of the person deciding.  A ticket isn’t paperwork, it is the transfer of understanding.

What contributing to Clojure core taught me

The prepl patch was ready in August 2025 and reached Clojure 1.13.0-alpha5 in July 2026. The protocol ticket, opened in October 2014, was still in progress when I wrote this..

That pace was frustrating. And it’s slow for the exact reason I am sharing about this experience. Nobody there accepts code before they accept the theory.

I came into this experience prepared to write code. What I wasn’t ready for were the four questions. And the fifth thing: writing it down as a story that builds the theory in someone else’s head.

Those activities were the real contribution, alongside the code.

I still think about Alex telling me that I could work on Clojure today. He wasn’t promising a quick patch or an easy review. He was pointing out that I could start learning from a real problem immediately.

Want to contribute? Explore Clojure’s issue discussions, its ticket tracker, and the official contribution workflow. Choose a problem and investigate what people are actually trying to accomplish. Read the history. Try the alternatives. Explain what you learn.

You don’t need a mentor to ask what the user is actually trying to do. 

You can do that today.

The post The code was the easy part: Lessons from contributing to the Clojure language appeared first on Building Nubank.

Permalink

2026 Nominations and Your Vote

Clojurists Together Foundation is in the process of electing new board members as three of our existing board members are finishing their terms. One member, Lorelai Lyons, has submitted an application for another two year term.

Departing members

Two others, Laurens Van Houtven (lvh) and Maria Geller have decided to step aside to open the door for other Clojurists to participate. Maria has been a board member since Clojurists Together was first formed in 2017, and lvh was elected in the first board election in 2018. lvh has been President of the Clojurists Together Foundation since we formed our own non-profit trade association in 2020 and put in a huge amount of time and energy to leave the Software Freedom Conservancy and form our own non-profit. I want to extend a very special thanks to them for their years of service and leadership in the community, and will be sad to see them leave. They were both instrumental in helping Clojurists Together get to where it is today.

Election

After accepting applications for nominations, we received 5 applications from candidates - who will serve two-year terms. These candidates are all talented Clojurists that we would love to have on the board.

Our bylaws state:

The Board shall nominate no more than 12 candidates seeking board membership in any given election. In nominating candidates for Director positions and in choosing the number of candidates to nominate overall, the Board shall use reasonable efforts to maintain a Board composition consisting of at least: (1) 25% female Directors, (2) 25% non-Caucasian Directors, and (3) 35% from any category(ies) of persons (e.g., race, gender, ability) commonly considered to have suffered from discrimination at some time and then-currently under-represented in the technology industry, in each case as determined by the Board in its reasonable discretion.

The board has nominated the following 5 candidates for the current board election:

Arthur Fücher
Henning Jansen
Lorelai Lyons
Chris Oakman
Mohit Verma

Next Steps

All current Clojurists Together members will be receiving their ballot to vote shortly. If you haven’t received a ballot by Wednesday, 22nd October, please contact hi@clojuriststogether.org.

Voting will close on Monday, 9th November, 2026 at midnight Pacific Time. The top three candidates, as voted on by members will win. We will announce the winners shortly afterwards and you’ll be able to meet them at the Annual members meeting at 10 am Pacific time, Nov. 17th, 2026. The new board will meet shortly after the annual meeting to select officers.

Candidate Profiles

Below we have published the candidates applications (in alpha order):


Arthur Fücher

Company: Nubank
GitHub: https://github.com/afucher
LinkedIn: https://www.linkedin.com/in/arthur-fucher

Why they’d like to join
I love Clojure and its community. I want to help it grow where it is still small.

Advocating for Clojure is already part of what I do. I run Clojure BR, the Brazilian Clojure community. It is a personal project, not tied to any company. We do meetups, livestreams, a newsletter in Portuguese, and more.

I would bring a non-US, Portuguese-speaking view to the board. I live in Brazil, so I can help the board stay close to maintainers and companies in Latin America. I can also support advocacy and content in Portuguese.

I also bring years of paid Clojure work, focused on developer experience and tooling. And I contribute to open source, including patches to Clojure core.

Bio
Arthur Fücher is a Clojure developer from Brazil focused on developer experience and tooling. He organizes Clojure BR, the Brazilian Clojure community, and contributes to open source Clojure projects, including patches to Clojure core. He has spoken at Clojure/conj in 2025 and 2026 and at conferences in Brazil, and is a member of the InnerSource Commons Foundation. Outside of code, he is a Magic: The Gathering player and board game lover.


Henning Jansen

Company: Norled AS
GitHub: https://github.com/Henningzen

Why they’d like to join
I have payed a keen interest in the Clojurists Community for several years, and clearly see the benefit from this organization and the impact it has across initiatives in the continued evolving Clojure echo-system. Given my background from industrial domains, I regard my insight and expertise highly relevant.

Bio
For more than seven years now, I have been part of developing and maintaining a large-scale enterprise application for Maritime Public Transportation, developed on Clojure, ClojureScript with Datomic in the backend. During parts of this time, I have been individual contributor to Clojurists Together. I want to expand my contribution to also being of service to the board, providing industry insights and experience.


Lorelai Lyons

Company: GBS Solutions Corporation (contractor using Clojure)
GitHub: thefakelorlyons
Slack/discord: melody

Why they’d like to join
I value the mission of CT, and I value Clojure and its community. I think open source development is important and I like the idea of supporting all of these causes through CT.

Bio
My name is Lor and I have been a part of Clojurists Together for two years now (as a Board Member). I have been writing Clojure as a hobby/passion for 5-6 years and professionally for 2 years now. I am most well known (most likely) from my work on ‘brainfloj’, but I have worked on several other significant projects in clojure, and I value the Clojure community and open source development in general. In addition to Clojurists Together, I am also relatively active in Clojure Camp, and SciCloj.


Chris Oakman

GitHub: https://github.com/oakmac/
https://chrisoakman.com
https://x.com/oakmac1

Why they’d like to join
I have been involved in the Clojure community for over ten years, and I think Clojurists Together is a positive force for our ecosystem. When I received a CT grant for Standard Clojure Style in 2024, I worked harder on that project than I normally would have because I wanted to honor the community’s vote of confidence. Funding open source pays for essential maintenance and bug fixes, but that signal of recognition and support matters just as much. I would like to help CT keep doing that good work.

At the last Clojure/conj, several people encouraged me to apply for the board, and I think I’d be a good fit. I have worked on ClojureScript and developer tooling since 2012, I know the ecosystem well, and I stay in contact with people across the community. I have also served on nonprofit boards for years outside of software, so I’m comfortable with the day-to-day work of meetings, voting, and follow-through. Mostly, I want to volunteer my time to help run and promote CT, and to contribute to some of the open source projects CT supports where I can be useful.

I also want to be an optimistic voice for how our community approaches AI. Software development is changing fast, and we should embrace this technology while still holding on to the principles that make Clojure great. We should support tooling and documentation that are easy for LLMs to consume, and encourage more content showing how well Clojure works alongside modern AI tools. I understand that many developers have mixed feelings about how AI is changing the work of software, and I think it is important for the Clojure ecosystem to actively engage with the future rather than sit on the sidelines.

Bio
Chris Oakman has been writing Clojure and ClojureScript since 2012. He is the author of the ClojureScript Cheatsheet (cljs.info/cheatsheet) and Standard Clojure Style, designed the ClojureScript logo, and worked on Parinfer. Chris worked as a coding bootcamp instructor for several years, including teaching a course on ClojureScript. He cares about developer education, supporting open source work, and building the documentation and resources that help the Clojure community grow. He lives in The Woodlands, Texas, where he plays ultimate frisbee and has served for many years on the boards of two local ultimate nonprofits.


Mohit Verma

LinkedIn: https://www.linkedin.com/in/mohitverma1981

Why they’d like to join
I am inspired by Clojure Together’s mission to sustain open‑source development and empower a global community of contributors. Having led large‑scale transformation programs in the BFSI sector, I see strong parallels between building resilient financial operations and nurturing sustainable developer ecosystems. Joining the Board would allow me to contribute my governance expertise, strategic oversight, and passion for community building to ensure the initiative continues to thrive with transparency, accountability, and long‑term impact.

With over two decades of senior leadership experience—including as SVP/Director at BNY Mellon—I bring deep knowledge of compliance, operational excellence, and stakeholder engagement. My background in organizational change management and risk frameworks equips me to strengthen board governance and support strategic growth. Beyond corporate leadership, my entrepreneurial and educational pursuits reflect a commitment to collaboration and knowledge‑sharing, aligning closely with Clojure Together’s ethos. I believe this blend of global governance experience and grassroots engagement makes me a strong fit to help guide the organization’s future.

Bio
I have more than two decades in Senior Leadership roles in the BFSI industry. In my most recent experience as Senior Director at BNY Mellon. My career has focused on compliance, operational transformation, and building strong governance frameworks. Along the way, I’ve led large teams, managed complex change programs, and helped organizations stay resilient in fast‑moving environments.

Outside of corporate life, I’m exploring entrepreneurship and education—launching a home‑based food venture and building an English tutoring practice. These projects reflect my passion for community, collaboration, and knowledge‑sharing. I believe this mix of global governance experience and grassroots engagement makes me well‑suited to contribute to Clojure Together’s mission of sustaining open‑source development with transparency and long‑term impact.

Permalink

Probabilistic programming for Clojure programmers: write the model once, choose the inference later

If you have used core.logic, you already know half of probabilistic programming. A logic program states constraints and lets the system search for the values that satisfy them. A probabilistic program does the same, except that a constraint can be soft: a value that fits the data poorly is not ruled out, only made less likely. Two dice show the idea below. A small forecasting model then runs under several inference methods in Foerster, an MIT-licensed probabilistic programming library for Clojure, and every result is checked against an exact answer.

Foerster continues the Anglican line of probabilistic programming in Clojure. Christian Weilbach, who wrote it, did his PhD in Frank Wood’s group at the University of British Columbia, where Anglican was built.

What is a probabilistic program?

Take two dice and the constraint that they sum to 7. Without any library, the plainest way to find the pairs that meet it is to generate pairs at random and keep the ones that pass:

What is this syntax?
defn roll []:
  inc(rand-int(6))
end

defn generate-and-test [n]:
  loop [kept []]:
    if n = count(kept):
      kept
    else:
      a := roll()
      b := roll()
      recur(if 7 = a + b:
              conj(kept, [a b])
            else:
              kept
            end)
    end
  end
end

frequencies(generate-and-test(6000))
;; each of [1 6] [2 5] [3 4] [4 3] [5 2] [6 1] about 1000 times
(defn roll [] (inc (rand-int 6)))

(defn generate-and-test [n]
  (loop [kept []]
    (if (= n (count kept))
      kept
      (let [a (roll) b (roll)]
        (recur (if (= 7 (+ a b)) (conj kept [a b]) kept))))))

(frequencies (generate-and-test 6000))
;; each of [1 6] [2 5] [3 4] [4 3] [5 2] [6 1] about 1000 times

The random choices form a generative model, a small simulator of two dice. The test conditions the simulator on what we know. What survives is the posterior: the pairs that are still possible, and how often each occurs.

A probabilistic program states the same model without fixing how to search it:

What is this syntax?
defn dice []:
  sp/spin:
    a := sample(dist/uniform-discrete(1, 7), :id, :a)
    b := sample(dist/uniform-discrete(1, 7), :id, :b)
    factor(if 7 = a + b:
      0.0
    else:
      ##-Inf
    end)
    [a b]
  end
end
(defn dice []
  (sp/spin
   (let [a (sample (dist/uniform-discrete 1 7) :id :a)
         b (sample (dist/uniform-discrete 1 7) :id :b)]
     (factor (if (= 7 (+ a b)) 0.0 ##-Inf))
     [a b])))

sample introduces an unknown, as a fresh logic variable does in core.logic, but with a probability on each value: here a die whose six faces are equally likely. factor adds to the run’s log weight. Zero leaves the run unchanged, and minus infinity makes it impossible, as a failing goal does. sp/spin turns the body into a program that an inference method can pause, copy and resume at each sample and factor. With {:method :enumerate}, Foerster runs all 36 pairs and returns the six that hold, each with probability 1/6. That is the answer run* gives, with weights.

Now soften the constraint. We do not know the sum, but a sensor that measures it with noise of about one showed 7:

What is this syntax?
observe(dist/normal(a + b, 1.0), 7.0, :id, :reading)
(observe (dist/normal (+ a b) 1.0) 7.0 :id :reading)

observe weights each run by how likely the reading is under it, here a normal density centred on the run’s sum. No pair is ruled out, and the arithmetic can be done by hand. Each of the six pairs that sum to 7 gets weight e⁰ = 1, each of the five pairs that sum to 6 or to 8 gets e^−½ ≈ 0.61, and the weights keep falling with the distance from 7. Normalized, a sum of 7 has probability 0.45, sums of 6 and 8 have 0.23 each, sums of 5 and 9 have 0.04 each, and the rest almost nothing. Foerster’s enumeration gives the same numbers.

core.logicFoerster
a fresh logic variablesample, an unknown with a probability on each value
a goal such as ==, which holds or failsobserve or factor, a weight; failing is weight zero
conde, a choice between branchesa discrete sample, with a probability on each branch
run*, the set of answersthe posterior, the answers with their weights
the search strategythe inference method

Two more ideas carry over. The total weight before normalizing, called the evidence, says how well the model as a whole explains the data. A hard constraint that no run meets leaves nothing, and a soft one leaves a number that can be compared between models. And as core.logic leaves the search to the system, a probabilistic program leaves the inference method open. ProbLog joins the two traditions as probabilistic logic programming.

How does the system find the answer?

Enumeration works for 36 pairs. It fails for many choices, or for continuous ones, because no list of all runs exists. The other methods search the space of runs, and each resembles a familiar strategy:

  • Importance sampling runs the program many times and weights each run instead of discarding it. It is generate and test with soft scores, and it fails when good runs are rare.
  • Sequential Monte Carlo (SMC) runs many copies side by side. At each observation it copies the runs that fit and drops the ones that do not, like a search that prunes partial solutions as soon as a constraint can be checked.
  • Markov chain Monte Carlo (MCMC) changes one choice of a single run at a time and keeps the change by a rule that sometimes accepts a worse run. This local search visits runs in proportion to their weight.

Does the choice of method matter?

A shop sells around a hundred units of a product each week. The weekly figure is noisy, and the underlying demand drifts. How high is demand now, and how certain is that? The model says that demand starts near 100, moves by a random amount each week, and that each week’s figure is demand plus noise:

What is this syntax?
defn demand [weeks drift]:
  sp/spin:
    loop [t 0, level sample(dist/normal(100.0, 20.0), :id, [:level 0])]:
      observe(dist/normal(level, 8.0), nth(weeks, t), :id, [:y t])
      if inc(t) = count(weeks):
        level
      else:
        recur(inc(t), sample(dist/normal(level, drift), :id, [:level inc(t)]))
      end
    end
  end
end
(defn demand [weeks drift]
  (sp/spin
   (loop [t 0
          level (sample (dist/normal 100.0 20.0) :id [:level 0])]
     (observe (dist/normal level 8.0) (nth weeks t) :id [:y t])
     (if (= (inc t) (count weeks))
       level
       (recur (inc t)
              (sample (dist/normal level drift) :id [:level (inc t)]))))))

As with the dice, sample marks the unknowns, here twenty-one weekly demand levels, and observe scores each run against the twenty sales figures. The data are an ordinary argument, weeks. The :id names each choice, so a method can refer to the level in week 7 across runs. The pictures article explains priors and posteriors if those terms are new. Each method is one option of the same call:

What is this syntax?
infer/infer(demand(weeks, 4.0), {:method :smc, :particles 1000})
infer/infer(demand(weeks, 4.0), {:method :importance, :particles 1000})
infer/infer(demand(weeks, 4.0),
  {:method :mh, :iterations 4000, :chains 4, :burn 1000})
infer/infer(demand(weeks, 4.0),
  {:method :pgibbs, :particles 50, :iterations 600})
(infer/infer (demand weeks 4.0) {:method :smc :particles 1000})
(infer/infer (demand weeks 4.0) {:method :importance :particles 1000})
(infer/infer (demand weeks 4.0) {:method :mh :iterations 4000 :chains 4 :burn 1000})
(infer/infer (demand weeks 4.0) {:method :pgibbs :particles 50 :iterations 600})

The model is linear and Gaussian, so a Kalman filter computes the exact answer, which makes it a fair test. The data are twenty simulated weeks, with a true drift of 4 per week and observation noise of 8. Each particle method ran ten times with different seeds, so the table shows how much an answer varies from run to run. All numbers come from Foerster’s main branch on 15 October 2026.

MethodFinal level (mean)Log evidenceCheck
Exact (Kalman filter)102.89−77.49
Sequential Monte Carlo, 1000 particles102.92 ± 0.23−77.44 ± 0.14about 120 histories survive to week 0
Importance sampling, 1000 samples102.9 ± 1.8−77.7 ± 0.52 to 10 effective samples
Metropolis–Hastings, 4 chains103.4R-hat 1.03, 114 effective draws
Particle Gibbs, 4 chains102.93R-hat 1.002, about 1500 effective draws

SMC lands within 0.36 of the exact final level in every run, and its log evidence averages −77.44 against the exact −77.49. It pays for its pruning on the early weeks: of a thousand particles at the end, only 114 to 134 still descend from different starting points. Importance sampling never prunes, so after twenty weeks a handful of runs carry nearly all the weight, and the estimate moves by almost two units between runs.

Metropolis–Hastings (MH), the simplest MCMC, fails here, and its diagnostics say so before any exact answer is consulted. Its four chains disagree: their rank-normalized R-hat is 1.03, above the usual threshold of 1.01, and their 12,000 draws are worth about 114 independent ones. Foerster computes these numbers as Stan and ArviZ do. The cause lies in the model. Neighbouring weeks are strongly tied, so changing one week’s level at a time barely moves the run. Andrew Gelman’s folk theorem of statistical computing, restated in the Bayesian workflow paper, says as much: when the computation struggles, look at the model.

The fix is a method that changes all weeks together. Particle Gibbs runs a whole SMC through the twenty weeks at every step, holding one surviving run fixed, and so redraws all twenty-one levels at once. Four chains of 500 steps after warm-up, with 50 particles each, reached an R-hat of 1.002 and about 1500 effective draws of the final level, counting each step’s weighted average as one draw. Their mean was 102.93 against the exact 102.89. The first week’s level, which SMC sees through only a few surviving histories, reached an R-hat of 1.002 and about 660 effective draws. Particle Gibbs took four minutes against half a minute for MH, but produced about twice as many effective draws per second. Nothing in the model changed, only the search.

Twenty weeks of observed demand with the inferred demand level: the exact filtered mean and the 95% band from sequential Monte Carlo. 90 100 110 120 130 140 1 5 10 15 20 week observed SMC level, 95% band exact (Kalman filter) Each point is one week's observed demand. The shaded band is where sequential Monte Carlo puts the hidden demand level with 95% probability after that week, and the dashed line is the exact answer, which this simple model happens to allow. The two lines nearly coincide.

What if a parameter is unknown too?

So far the drift was given as 4. In practice nobody measures how fast demand moves, so the drift becomes one more unknown with a prior:

What is this syntax?
defn demand-unknown [weeks]:
  sp/spin:
    drift := sample(dist/gamma(2.0, 2.5), :id, :drift)
    loop [t 0, level sample(dist/normal(100.0, 20.0), :id, [:level 0])]:
      observe(dist/normal(level, 8.0), nth(weeks, t), :id, [:y t])
      if inc(t) = count(weeks):
        drift
      else:
        recur(inc(t), sample(dist/normal(level, drift), :id, [:level inc(t)]))
      end
    end
  end
end

infer/infer(demand-unknown(weeks),
  {:method :pmmh, :particles 100, :iterations 2000, :params #{:drift},
   :scale 3.0, :burn 200})
(defn demand-unknown [weeks]
  (sp/spin
   (let [drift (sample (dist/gamma 2.0 2.5) :id :drift)]
     (loop [t 0
            level (sample (dist/normal 100.0 20.0) :id [:level 0])]
       (observe (dist/normal level 8.0) (nth weeks t) :id [:y t])
       (if (= (inc t) (count weeks))
         drift
         (recur (inc t)
                (sample (dist/normal level drift) :id [:level (inc t)])))))))

(infer/infer (demand-unknown weeks)
             {:method :pmmh :particles 100 :iterations 2000
              :params #{:drift} :scale 3.0 :burn 200})

Particle marginal Metropolis–Hastings (PMMH) searches over the drift alone. For each proposed drift it runs SMC over the weekly levels and accepts or rejects the drift by the evidence that SMC reports. That evidence estimate is unbiased, so the chain targets the exact posterior of the drift even though every step uses an estimate.

The posterior distribution of the weekly drift: a histogram of PMMH draws over the exact density computed by quadrature. 0 2 4 6 8 10 12 weekly drift (sd of the level change) simulated with 4 PMMH draws exact posterior Twenty weeks of data narrow the drift only partly: values from below 1 to about 6 remain plausible. The histogram of PMMH draws follows the exact density, which quadrature over the Kalman filter's evidence provides for this model.

The chain’s mean drift is 2.82 against an exact 2.85, and its 90% interval runs from 0.61 to 6.09 against an exact 0.61 to 6.28. The data were simulated with a drift of 4, inside the interval but not at its centre, because twenty noisy weeks cannot pin the drift down. A forecast with one fitted drift would hide that width. The 2000 iterations took five and a half minutes on a laptop. For this linear Gaussian model, Stan or a Kalman filter inside a one-parameter chain would take seconds. PMMH is for models without a gradient to follow, models that branch, count or call a simulator.

How do I run it?

Foerster is on Clojars. With the Clojure CLI:

{:deps {org.replikativ/foerster {:mvn/version "0.1.113"}}}

A model runs inside a Spindel world, an execution context, and inference returns a spin that the REPL dereferences:

What is this syntax?
require('[org.replikativ.foerster.core :as infer :refer [sample observe factor]],
  '[org.replikativ.foerster.dist :as dist],
  '[org.replikativ.spindel.core :as sp])

def world: sp/create-execution-context()

sp/with-context world:
  @infer/infer(demand(weeks, 4.0), {:method :smc, :particles 1000})
end
(require '[org.replikativ.foerster.core :as infer :refer [sample observe factor]]
         '[org.replikativ.foerster.dist :as dist]
         '[org.replikativ.spindel.core :as sp])

(def world (sp/create-execution-context))

(sp/with-context world
  @(infer/infer (demand weeks 4.0) {:method :smc :particles 1000}))

Build the model inside with-context, as here: outside a world, the program has nowhere to run. The scripts that produced every number above are in the Foerster repository.

What can Foerster not do yet?

Foerster is beta software. Beyond the methods shown here, it implements tempered and waste-free SMC, SMC², involutive MCMC with reversible jump, parallel tempering, the No-U-Turn sampler on numerical blocks, MAP estimation, the Laplace approximation, Pathfinder, and variational inference. The methods notebook describes each one with its papers, and the gallery works through classic models against exact answers.

For a static model with continuous parameters, Stan, PyMC and NumPyro remain the mature choice. Foerster’s No-U-Turn sampler works on numerical blocks whose gradient the model supplies or Raster compiles. On one hierarchical model with 89 parameters, it matched PyMC’s warm run with a gradient compiled by Raster, and ran 1.5 times faster with a hand-written one. Its general methods over program traces are slower: single-site MH takes about twelve times as long per move as Gen, because each move replays the rest of the program, and its particle filter is about four and a half times slower than Gen’s Unfold combinator. The comparison has the details, and coming from other systems maps Stan, PyMC, Turing, Gen, Anglican and WebPPL concepts onto Foerster, including what is missing.

What comes next?

Every model so far is a pure function. Useful models often are not: a forecast that reads current stock, a simulation that writes into a scenario database, an agent that calls tools. If a thousand particles write to the same database, inference corrupts the state it reasons about, which is why Gen requires that a model change no external state.

Foerster runs each particle in a Spindel world, whose state can be copied cheaply, as the copy-on-write article describes. When SMC copies a particle, it copies the particle’s world, and when it drops a particle, it discards the world. With :world-policy :fork, every particle starts from a copy of the caller’s world, so a model can read and change the systems registered there, and nothing a particle writes reaches the caller. This costs about three times as much as running in fresh worlds. Systems that cannot be copied safely, such as live network handles, are refused before the model runs, and Markov chains run in fresh worlds only. The same mechanism makes counterfactuals direct: intervene on one choice in the world where the posterior was computed (Pearl’s do operator, as in the causal graph article) and replay the program with everything else held to what was inferred. The worlds guide covers both, and a later article shows them on a model that books orders into a forked database.

The same machinery steers processes that are not statistical models. Foerster’s steer namespace runs SMC over the steps of an agent working on a task, following twisted SMC (Zhao et al., 2024) and the SMC steering of language models (Lew et al., 2023). On short spreadsheet tasks it matched picking the best of four attempts with 17% fewer tokens, but solved nothing more. It should pay on long tasks whose steps can be checked cheaply.

For Simmis, inference over forked worlds is research rather than a product feature. It is how a governed organizational record could later answer a question such as “what happens to our cash if this order slips” with a distribution instead of a single number.

Permalink

Ep 120: Data Vision

Each week, we discuss a different topic about Clojure and functional programming.

If you have a question or topic you'd like us to discuss, tweet @clojuredesign, send an email to feedback@clojuredesign.club, or join the #clojuredesign-podcast channel on the Clojurians Slack.

This week, the topic is: "ingesting, deciding, and merging". We curate data, merge it, and represent choices.

Our discussion includes:

  • Different patterns of pure models
  • Outside data vs inside data
  • Differences and modelling tension between external and internal data
  • Why pure models tend to have patterns of functions
  • Ingested model
  • Decision-making model
  • Merging model
  • Async join
  • How pure models are combined with necessary side effects
  • Using models in procedural flows and data flows
  • Real world integration examples from Kubernetes and e-sports

Selected quotes

A pure data model is data that models something in the real world and pure functions you pass that data through. That's it.

For an ingested model, you want to curate a subset of external data, or normalize it in some way, so it's useful to the rest of your application.

Two main pieces: a representation of the information that is pertinent to your application, and a set of functions that operate on that data. Those functions tend to fall into patterns.

The ingested model is a coherent window to that outside world. It's the lens through which your application sees that external data. It serves as a stabilizing force for the rest of your application.

The Kubernetes data is wide, and it's deep. It's got a lot of information. It's only rivaled by GitHub responses in its size and scope!

The decision, itself, is data. If you want to push side effects to the edges, you need to represent the result of a decision in some way.

You put the side effects on the edges. I/O on the way in. I/O on the way out. The pure models in the middle.

The code that connects the model to a side effect is as simple as taking things out of a map and passing them as arguments into a side effect function. It only does one thing. It's almost one line of code. It's so small that it's easy to understand.

Most of your mind is focused on the ingested model and the decision-making model. You're curating and representing information, and then you're reasoning about that information.

A merging model is a pure model that takes other pure models and creates a composite, or enriched, singular output. It's multiple pieces stitched together into a larger work.

Merging models together is a separate concern from the individual models. A merging model's job is to construct a cohesive view from separate, but related information.

If it's all pure, you can test it. You can make sure that it behaves the correct way in different situations.

A way to look at at different models is by responsibilities. The outside system takes responsibility for representing the data in a way that has utility for its concerns, design constraints, and realities. The ingested model's responsibility is figuring out what matters for the application. The decision-making model's responsibility is representing choices that matter and the consequences of those choices. The merging model's responsibility is about how to connect separate things into a larger whole

So each kind of model is a separate problem space, but they're all related, and it becomes clear how they are connected. By decomposing models into separate problem spaces, you end up with models that are relatively stable because they only need to change when that aspect of the problem changes. They are decoupled and orthogonal to each other because they're in different information domains, problem domains, responsibility domains.

One of the best parts of a decoupled system is that it's easy to understand one model at a time. The theme of this series is "re-entrant coding". I only want to understand the parts of the system that I have to understand to accomplish the current task. I don't want to have to understand everything altogether. By decoupling these things with clean responsibilities and boundaries, we get a system that's easy to start working with again.

Each model is visible because it takes data and produces data. You have something that can be seen, inspected, logged, recorded, and put in a test. You avoid the kind of functional composition where information goes through a bunch of internal transformations, and you can't quite get a hold of it in your head. You have a relatively shallow call stack because each model produces data. The orthogonality of the models helps keep the call stack shallow.

A nice, related set of pure models: ingesting, making choices, and merging things together.

You can make the functional jokes. I'll stick with the dysfunctional jokes.

Permalink

BudgetSWU: my latest pile of code

I made a little web app called BudgetSWU. It recommends decklists for a trading card game called Star Wars: Unlimited. I started playing SWU almost a year and a half ago because I had a dream I was in a mall opening a pack of Yugioh cards--another trading card game that I played as a kid--and it gave me enough nostalgia to do some googlin' and discover SWU.

As an adult I now have lots more money to buy cards than I did as a kid (yay) but much less time to play with them (boo). So I don't spend much time building my own decks; I just find decklists on the internet and buy the cards for those.

BudgetSWU is now one of those places on the internet. It gets decklists from melee.gg which is used in tournaments (large and small), including each decklist's (player's) placement in the tournament. It also gets pricing data for all the cards in those decklists from tcgcsv.com, which in turn aggregates pricing data from TCGPlayer, the ebay of trading cards (it is owned by ebay, in fact).

As a final bit of data, I compute "popularity" by taking each deck's "archetype" (the leader + base, for those in the know--except that I'm actually using just base color) and seeing how many decklists in the entire corpus have that same archetype. It's cooler to play decks that not everyone else is already playing, know what I mean?

Then BudgetSWU filters all the decklists down to a handful of recommendations by computing a Pareto Front. The idea here is that we have multiple factors with which we can rank decks (cost, performance, popularity/nicheness) and we don't necessarily know which factor is most important (if deck A costs 50% more than deck B, how much better/niche does deck A have to be for it to be a better recommendation than deck B?). But we do know that if deck A beats deck B on all three of those factors, then deck A is probably the better recommendation. So we filter out all the decks that have a clearly better alternative, and then we tell the user "here's what's left; you decide." As of writing, that ends up being less than 20 decks, which is a very manageable number for a user to scroll through.

The next cool bit is that if you make an account, you can enter what cards you already own, and then the recommendations will be computed based on the cost of only the cards you don't have yet. Entering in all your cards would be a pain since players old enough to have jobs usually have thousands (of cards, not jobs). So BudgetSWU sorts all the cards by cost, most expensive first. (Right now the most expensive cards go for about $30-ish a piece). You can start from the top and enter quantities until you got to cards that cost, say, a dollar each or whatever you feel like, which doesn't take long.

Finally: whenever a new decklist makes it into your personalized recommendations (tournaments happen every week), you get a notification email. Booyah.

I generated the code in Clojure. It uses my web framework Biff. I've recently been working on some LLM skills that provide guidance for writing Biff code that doesn't suck. And I have to say, I'm fairly pleased with the results. The code still isn't anything I would print out and frame on my wall, but the overall structure is generally inline with how you're supposed to write Biff apps. If this was an app I planned to develop further (it's not), I would be happy with the state the code is in as a starting point.

If you're into specifications, you can read the one for BudgetSWU over here. i.e. that page is the thing I actually typed, mostly.

Permalink

Using AI to analyse legacy systems faster: what our pilots revealed

Futuristic in Industry 4.0 and business virtual diagram with Ai, robot assistant, Cloud, big data and automation. two colleague web designer discussing data and digital tablet docking keyboard.

AI can help organisations analyse legacy systems faster 

One of the biggest barriers to modernising legacy technology is understanding how existing systems work.   

To modernise systems, organisations first need a clear picture of how applications work, connect and depend on one another. That process can take weeks or even months. Documentation is often incomplete or out of date, knowledge may be concentrated in a small number of specialists, and some systems rely on technologies that few engineers still work with regularly. 

Our pilots indicate that AI-assisted approaches could help technical teams analyse some legacy systems more quickly. 

Across participating organisations, activities estimated to take weeks using traditional approaches were completed in hours. 

Working with organisations including the Student Loans Company (SLC), the UK Hydrographic Office (UKHO), the Met Office, and our delivery partner Kainos, we found that AI can help teams analyse unfamiliar systems, identify dependencies and build technical knowledge more quickly than traditional approaches alone. The pilots covered different systems and technical environments, so the results should not be treated as directly applicable to every legacy estate. 

However, for organisations managing large and complex technology estates, this could make some modernisation activities easier to prioritise, plan and deliver.  

What happened at the Student Loans Company 

At the Student Loans Company, teams explored how AI assisted approaches could be used to analyse an estate of 33 applications written in Clojure and support future modernisation planning. 

Using traditional approaches, the work was estimated to take around five weeks. During the pilot, teams completed the analysis in less than a day. 

The analysis gave the team a clearer view of the 33 applications and their dependencies, which it used to inform migration planning and work towards supported versions of Java. 

The most significant finding was not the reduction in effort itself, but the ability to unlock work that had previously been difficult to prioritise.  

What happened at the UK Hydrographic Office 

At the UK Hydrographic Office, teams focused on a 20-year old desktop application that previously had no automated tests. 

AI assisted techniques were used to generate automated tests for the application. Test coverage increased from 0% to 96%, while regression testing that had previously required around 15 days of manual effort was reduced to approximately 30 minutes. 

The benefits extended beyond faster testing. 

Teams reported greater confidence in making changes to the application safely and maintaining it over time. For many legacy systems, the perceived risk of change can be a barrier to improvement. The pilot indicated that AI-assisted testing could give engineers more evidence when assessing proposed changes. 

Why this matters 

The examples from SLC and UKHO illustrate a wider challenge facing organisations across the public sector. 

Legacy systems often support critical services, but analysing them can be complex and expensive. Teams may spend weeks mapping dependencies, reviewing code and building technical knowledge before they can assess risks or plan change. 

The strongest finding from the pilots was that AI can help accelerate this discovery and assessment phase. That may sound modest, but it addresses a critical point where many modernisation efforts slow down or stall. 

By helping teams understand systems more quickly, AI assisted approaches can make it easier to: 

  • assess risks 
  • identify system dependencies 
  • prioritise investment 
  • improve technical documentation 
  • develop modernisation plans 

In short, AI can help organisations spend less time understanding their systems and more time improving them. 

AI is not a substitute for good delivery 

The pilots also reinforced an important lesson. 

AI does not remove the need for good governance, engineering expertise or delivery capability. 

Successful outcomes depended on organisations having the right foundations in place, including clear ownership, access to systems and information, experienced technical teams and established delivery processes. 

In practice, the pilots showed that AI amplified existing strengths and weaknesses rather than replacing them.  

Used well, AI can accelerate analysis, improve visibility of complex systems and help organisations make progress more quickly. Used without appropriate controls, it can amplify existing problems and accelerate poor decision making. 

Where AI-assisted analysis may help 

The pilots suggest that AI can significantly reduce the effort required to assess complex systems, helping organisations move more quickly from discovery to modernisation. 

For organisations managing large and complex technology estates, this can help engineers, architects and delivery teams to identify risks earlier, making modernisation activities easier to plan, justify and deliver.  

What happens next 

We are continuing to work with organisations using AI assisted approaches to analyse, assess and modernise legacy systems, building on the lessons from these pilots. 

Alongside this work, we are developing a playbook of good practice that captures practical approaches, lessons learned and implementation considerations for organisations looking to apply AI to legacy technology challenges. The playbook is currently undergoing user testing, review and approval ahead of publication. 

Get involved 

If your organisation is exploring AI assisted approaches to analysing, documenting or modernising legacy systems, or would like to share experiences and engage with our wider Firmer Foundations programme, we would like to hear from you. Get in touch at firmer.foundations@dsit.gov.uk 

Permalink

Best Clean Code and Refactoring Books, and the Order to Read Them

Clean Code was the first text I handed to my students. Since then I have read the six other books fighting for the "clean code" shelf, and they do not all say the same thing. Sometimes they say the opposite.

I wrote a reading note for each one. Here is the order I would follow if I were starting over, and what you can leave on the shelf.

The craft before the rules

The Pragmatic Programmer by Thomas and Hunt (2019 edition, 8/10) is my way in. What strikes you first is the tone: few commandments, many stories, never a condescending look. And this edition publicly corrects the previous one. The Law of Demeter is demoted, DRY (do not duplicate a piece of knowledge) is clarified because people misread it, and on security the authors write "we were wrong". A book that owns its mistakes twenty years later earns trust for everything else.

The limit is mechanical: 53 topics in 352 pages means skimming. The tooling chapter is thin, and the code samples jump from Ruby to Elixir to Clojure without digging into any of them. You read it for the stories and mental models, not the code. Skip the samples, keep the rest.

The dogma and its counterweight

Clean Code by Robert C. Martin (2008, 6.8/10) taught a generation that code is read ten times more than it is written. The chapters on naming, functions and comments have not aged a day. The rest is verbose Java, and the last three case-study chapters are 90 pages of listings nobody reads anymore, me included. One thing the fans forget: Martin himself admits that no research backs the short-functions rule, and that his own functions start out long and messy before being refined under tests. The "three lines maximum" dogma comes more from his readers than from the book.

To hear the other bell, read A Philosophy of Software Design by John Ousterhout (2021, 8/10). A hundred and ninety-six pages, and in my eyes the best software design book of the last twenty years. His deep module idea changed how I judge a class: I stopped counting lines and started comparing what the interface costs to learn with what it gives back. The examples circle around two or three cases (a text editor, RAMCloud) and smell like a lecture hall. And "make your modules deep" is easier to understand than to apply. Where the two books contradict each other, on function size and on comments, Ousterhout is almost always right.

The first hundred pages of construction

Code Complete by Steve McConnell (2004, 960 pages, 7.4/10) is the book I wish I had read at the start of my career, but for its first hundred pages, not its 960. The thesis on complexity, abstract data types, cohesive routines, defensive barricades: you usually learn all of that by trial and error, after humbling code reviews. The book shortcuts that painful education.

What has aged is visible: samples in C++, Visual Basic 6 and Pascal, "code tuning" chapters about processor pipelines, a tools catalogue straight from a museum. What holds up is everything about humans: the numbers on code inspections, the psychology of debugging, complexity as the universal enemy. You do not need all of it. Parts I, II and VII plus chapters 20 and 21 make roughly 250 pages, and the rest is reference material you open now and then.

Refactoring and legacy, the two moves

Refactoring by Martin Fowler (2018, 8.2/10) holds the best code lesson I have ever read. Chapter 1 is seventy pages of watching someone work, every move explained, the code never broken. Fowler has a rare honesty: his central argument is labeled "unproven hypothesis". The limit is that half the book is a dictionary of transformations nobody reads in one sitting. Read chapters 1 to 4 like a novel and keep the rest within reach during code review. The Refactor menu in your editor exists because of this book.

Then comes Working Effectively with Legacy Code by Michael Feathers (2004, 7/10), which redefines the word: legacy is not old code, it is code without tests. His characterization tests (which snapshot current behavior) answer a question nobody phrases well: how do you start testing when you do not know what "correct" means for this code? The seam model (places where you can change behavior without editing the code) teaches you to read code by looking for joints. Feathers also admits that his techniques often make code uglier in the short term, and that the scar is temporary. That frankness makes the book reliable.

It is dated, and every example is Java, C++ or C. Part II, twenty chapters in FAQ format, often repeats the same four moves under a different class name. Open it at the chapter matching your problem of the day.

Test first, to lose the fear

Test-Driven Development: By Example by Kent Beck (2002, 240 pages, 7.6/10) does not sell a recipe, it shows a state of mind: the calm of knowing, every second, that the code still works. The fear of breaking things turned into quiet boredom. Since then I no longer write tests to "have coverage", I write them to move forward without dread.

Part I, the money example, drags: you repeat the same micro-cycle dozens of times and you got it long before. The code is 2002 Java and Python 2. TDD is still debated, and Beck is the first to say no study settles it. Read it for the mindset, keep the cycle, the TODO list and "fake it" when you are stuck, and adapt the micro-steps to your own pace.

The order I would follow

Book

Pages

Rating

What it is for

1

The Pragmatic Programmer

352

8

The craft's mental models

2

Clean Code

464

6.8

Naming, functions, comments

3

A Philosophy of Software Design

196

8

The counterweight: deep modules

4

Refactoring

448

8.2

Chapters 1 to 4, then the catalog as needed

5

TDD by Example

240

7.6

Moving forward without fear of breaking

6

Working Effectively with Legacy Code

440

7

Testing code that never had tests

as needed

Code Complete

960

7.4

The useful 250 pages, then reference

Conclusion

The book I used to hand my students has the lowest rating on the list, 6.8/10. And it is Fowler and Feathers who hold the moves you actually use on a Monday morning, in code you did not write.

If you want to see these moves applied to a real project, I wrote about the order of a legacy Symfony audit.

I stitched the marrow of dozens of tech books into a single thread, from bits and Big O up to the AI era: that is The Developer's Great Book. Four of these books (Clean Code, A Philosophy of Software Design, The Pragmatic Programmer, Refactoring) are melted into it at the "Writing for humans" level, and three others (TDD by Example, Working Effectively with Legacy Code, Code Complete) at the "Prove it, ship it, hold the load" level.

📚 Other comparisons. Best DevOps and Engineering Team Books, and the Order to Read Them and Best Event Sourcing and CQRS Books, and the Order to Read Them.

Permalink

AI Is a Tool. Using It Well Takes Skill.

In his October 11, 2026 article, "'AI' is Not a Tool," Rich Hickey argues that LLMs are automation rather than tools because they cannot be wielded. I agree that outsourcing work can cost us opportunities to learn. But his conclusion does not follow. Even by his definition, an LLM can be something a human wields. And using it well takes skill, just as using other tools does.

Picking up a saw does not guarantee a straight cut. Holding a chisel does not make you a sculptor. Tools can amplify poor judgment as readily as good judgment. Their usefulness depends on the operator's ability to direct them, interpret feedback, recognize mistakes, and make corrections. Those requirements also apply to LLMs.

One of my colleagues used to say, "AI is a mirror." I find that insightful: it reflects who you are, including the curiosity, judgment, and habits you bring to it. The questions you ask and the standards you apply to its answers matter.

An LLM's unpredictability is a meaningful limitation. But predictability, reliability, simplicity, and bounded function are qualities by which we evaluate tools, not prerequisites for membership in the category. A tool does not stop being a tool because it is difficult to control or easy to misuse.

Hickey's distinction between tools and automation also creates a false division. Automation can be a feature of a tool. A compiler performs substantial work without the programmer continuously directing each operation. A spreadsheet recalculates results without someone manually carrying out every calculation. We still wield these systems by specifying inputs, examining results, and deciding what to do next. Why should generating text automatically disqualify an instrument from being a tool when translating code or calculating numbers automatically does not?

The claim that LLM use lacks intention, feedback, direction, and expertise is especially difficult to defend. A programmer can ask an LLM to explain unfamiliar code, challenge that explanation, inspect the source, test a proposed change, reject it, and refine the request. That is a feedback loop directed by a human pursuing a purpose. Doing it well requires knowing what to ask, what evidence matters, and when the answer is wrong. Expertise can make the instrument more useful.

Hickey's concern about reading is valid for a particular use: accepting a summary in place of reading a paper can sacrifice much of what reading teaches. But someone can also read the paper and use an LLM to question their interpretation, explore an objection, or identify something to examine more closely. The possibility of avoiding intellectual work does not establish that every use avoids it. The relevant question is what the person actually does.

What disappoints me the most is the reliance on the AI industry's anthropomorphic vocabulary: "go rogue," "escape containment," "conspire." Repeating those expressions risks accepting the framing that makes these systems sound like independent beings. Software can produce unexpected outputs, fail to follow instructions, or cause harm when given access to consequential actions. Those are real engineering problems. Calling them "conspiracy" adds a suggestion of human motives without establishing any.

I would not claim that an LLM always does exactly what a human tells it to do. It plainly can fail. But failure to obey perfectly does not make it cease to be a tool, any more than successful use proves that its operator has surrendered their judgment.

The useful distinction is between wielding an instrument with skill and delegating work without adequate understanding or oversight. That distinction applies to AI, and it gives us something more practical than declaring that an entire class of software cannot be a tool.

Permalink

Clojure OSS update

It's been a while since I worked on my Clojure libraries, so I would like to give a little update on that. Two of them got a new release, and one got a debut release on Clojars.

clj-pnm [1]

Apart from the minor bug fixes and Clojure(Script) dependency update, the most exciting thing is that the Clojure dialect support matrix has grown. clj-pnm was built from the start as a cross-dialect library supporting both Clojure and ClojureScript. It is lightweight enough to expand that.

For the Clojure side, the library is compatible with Babashka, as it can be called from Babashka code and can be used as a Babashka dependency.

On the ClojureScript side, clj-pnm can target NodeJS and the browser as before. Switching from figwheel to shadow-cljs for the testing framework was integral in ensuring that. There is a special test runner for testing Squint compatibility, but with no code changes, this lightweight dialect of ClojureScript is also added to the compatibility matrix.

There are tests for each of these in the library repository[2].

cljs-map-pins [3]

cljs-map-pins got a maintenance release, so no new features yet. But this post can serve as a reminder of how fun it is to use this library (at least for me :-D). I love visualizing things on a map, and this library is made just for that. Two examples I like to refer to, albeit they are made with a much older version of the library, are The Warriors path through New York City[4] and Big Wayne tribute[5].

See the repository[6] for more details.

defns [7]

defns is a proof-of-concept library that provides a way for defining functions with type signatures for which :pre and :post conditions are generated. This is an experimental idea and should not be used for anything serious, but I've seen some interest in it and decided to release it to Clojars. The fun part was writing the library clj-kondo configuration and custom hooks for the macros. When using defns as a dependency, make sure to import the clj-kondo configuration for the linting to work.

See the repository[8] for more details.

Enjoy!

[1] clj-pnm

[2] clj-pnm repository

[3] clj-map-pins

[4] The Warriors path through New York City

[5] Big Wayne tribute

[6] clj-map-pins repository

[7] defns

[8] defns repository

Permalink

Week Notes 2026.41

Babashka tasks for deploying Datomic Cloud ions; making AWS API Gateway requests with SigV4 authentication.

Permalink

Copyright © 2009, Planet Clojure. No rights reserved.
Planet Clojure is maintained by Baishamapayan Ghose.
Clojure and the Clojure logo are Copyright © 2008-2009, Rich Hickey.
Theme by Brajeshwar.