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

Statistics made simple

I have a weird relationship with statistics: on one hand, I try not to look at it too often. Maybe once or twice a year. It’s because analytics is not actionable: what difference does it make if a thousand people saw my article or ten thousand?

I mean, sure, you might try to guess people’s tastes and only write about what’s popular, but that will destroy your soul pretty quickly.

On the other hand, I feel nervous when something is not accounted for, recorded, or saved for future reference. I might not need it now, but what if ten years later I change my mind?

Seeing your readers also helps to know you are not writing into the void. So I really don’t need much, something very basic: the number of readers per day/per article, maybe, would be enough.

Final piece of the puzzle: I self-host my web projects, and I use an old-fashioned web server instead of delegating that task to Nginx.

Static sites are popular and for a good reason: they are fast, lightweight, and fulfil their function. I, on the other hand, might have an unfinished gestalt or two: I want to feel the full power of the computer when serving my web pages, to be able to do fun stuff that is beyond static pages. I need that freedom that comes with a full programming language at your disposal. I want to program my own web server (in Clojure, sorry everybody else).

Existing options

All this led me on a quest for a statistics solution that would uniquely fit my needs. Google Analytics was out: bloated, not privacy-friendly, terrible UX, Google is evil, etc.

What is going on?

Some other JS solution might’ve been possible, but still questionable: SaaS? Paid? Will they be around in 10 years? Self-host? Are their cookies GDPR-compliant? How to count RSS feeds?

Nginx has access logs, so I tried server-side statistics that feed off those (namely, Goatcounter). Easy to set up, but then I needed to create domains for them, manage accounts, monitor the process, and it wasn’t even performant enough on my server/request volume!

My solution

So I ended up building my own. You are welcome to join, if your constraints are similar to mine. This is how it looks:

It’s pretty basic, but does a few things that were important to me.

Setup

Extremely easy to set up. And I mean it as a feature.

Just add our middleware to your Ring stack and get everything automatically: collecting and reporting.

(def app
  (-> routes
    ...
    (ring.middleware.params/wrap-params)
    (ring.middleware.cookies/wrap-cookies)
    ...
    (clj-simple-stats.core/wrap-stats))) ;; <-- just add this

It’s zero setup in the best sense: nothing to configure, nothing to monitor, minimal dependency. It starts to work immediately and doesn’t ask anything from you, ever.

See, you already have your web server, why not reuse all the setup you did for it anyway?

Request types

We distinguish between request types. In my case, I am only interested in live people, so I count them separately from RSS feed requests, favicon requests, redirects, wrong URLs, and bots. Bots are particularly active these days. Gotta get that AI training data from somewhere.

RSS feeds are live people in a sense, so extra work was done to count them properly. Same reader requesting feed.xml 100 times in a day will only count as one request.

Hosted RSS readers often report user count in User-Agent, like this:

Feedly/1.0 (+http://www.feedly.com/fetcher.html; 457 subscribers; like FeedFetcher-Google)

Mozilla/5.0 (compatible; BazQux/2.4; +https://bazqux.com/fetcher; 6 subscribers)

Feedbin feed-id:1373711 - 142 subscribers

My personal respect and thank you to everybody on this list. I see you.

Graphs

Visualization is important, and so is choosing the correct graph type. This is wrong:

Continuous line suggests interpolation. It reads like between 1 visit at 5am and 11 visits at 6am there were points with 2, 3, 5, 9 visits in between. Maybe 5.5 visits even! That is not the case.

This is how a semantically correct version of that graph should look:

Some attention was also paid to having reasonable labels on axes. You won’t see something like 117, 234, 10875. We always choose round numbers appropriate to the scale: 100, 200, 500, 1K etc.

Goes without saying that all graphs have the same vertical scale and syncrhonized horizontal scroll.

Insights

We don’t offer much (as I don’t need much), but you can narrow reports down by page, query, referrer, user agent, and any date slice.

Not implemented (yet)

It would be nice to have some insights into “What was this spike caused by?”

Some basic breakdown by country would be nice. I do have IP addresses (for what they are worth), but I need a way to package GeoIP into some reasonable size (under 1 Mb, preferably; some loss of resolution is okay).

Finally, one thing I am really interested in is “Who wrote about me?” I do have referrers, only question is how to separate signal from noise.

Performance. DuckDB is a sport: it compresses data and runs column queries, so storing extra columns per row doesn’t affect query performance. Still, each dashboard hit is a query across the entire database, which at this moment (~3 years of data) sits around 600 MiB. I definitely need to look into building some pre-calculated aggregates.

One day.

How to get

Head to github.com/tonsky/clj-simple-stats and follow the instructions:

Let me know what you think! Is it usable to you? What could be improved?

Permalink

Annually-Funded Developers' Update: July & August 2026

Hello Fellow Clojurists!

This is the fourth of six reports from the developers who are receiving annual funding for 2026. Thanks to everyone for supporting their work and these important contributions to the Clojure community. Their previous reports can be found here:
January/February 2026
March/April 2026
May/June 2026


Bozhidar Batsov: CIDER 2.0, cider-nREPL,Sayid, Orchard, Drawbridge…
Clojure Camp: Supporting and engaging new Clojurians at Conj
Eric Dallo: ECA, clojure-lsp
Jeaye Wilkerson: Jank optimization, runtime excep., Error pgs, C++, Commons
Michiel Borkent: SCI, clj-kondo,Babashka, squint, Buzz, Choq:Cherry, Cljbang.el, and more

Bozhidar Batsov

2026 Annual Funding Report 3. Published Sept. 10, 2026.

The summer turned out to be just as busy as the spring. CIDER 2.0 finally shipped, and once it was out the door I used the momentum to sweep through pretty much every corner of the nREPL/CIDER ecosystem - some long-neglected projects got proper releases, and the nREPL protocol got a couple of brand new implementations in languages I play on the side from time to time. The highlights:

  • CIDER 2.0 (“Terceira”) is out, followed by 2.0.1, and 2.1 is taking shape on master
  • clj-refactor 4.0 is out
  • Sayid went from 0.4 to 0.8 in the span of three weeks
  • Drawbridge, nREPL’s HTTP transport, got its first meaningful release in years
  • nREPL went polyglot: nREPL servers for Erlang and Elixir and an OCaml client
  • clj-suitable 0.7 and 0.8 closed most of the gap between ClojureScript and Clojure completion
  • A lot of work landed on nREPL’s master (TLS hardening, URL-based connections, docs) and a new release is right around the corner.

Below are the details, project by project.

CIDER

CIDER 2.0 (“Terceira”) landed on July 15, right on the schedule I had announced in the preview post. The big themes were covered in the last report (transient menus, inline macro stepping, call-graph browsers, source-based find-references, the tracing and tap buffers, rich content in the REPL), so here’s what changed between the preview and the final release:

  • cider-doctor, which checks your Emacs setup and the active nREPL session for common problems and produces a copy-pasteable report
  • an orchard value for cider-print-fn, selecting cider-nrepl’s much faster orchard.pp pretty-printer
  • SSH tunnels now forward a free local port, so remote REPLs sharing a port no longer collide on localhost
  • C-c C-d at the stdin prompt sends end-of-input, and stdin is routed to the exact connection that asked for it
  • a long tail of nREPL client fixes: a slow memory leak on the eldoc/completion path, nrepl-dict-merge mutating a shared literal, notifications treated as format strings

CIDER 2.0.1 followed a week later with fixes for the problems early adopters ran into: evaluation in a dependency’s source buffer erroring with “No linked CIDER sessions” (in several variants), cider-enlighten-mode never lighting anything up (a 1.22 regression), the macroexpansion commands refusing to expand let/fn/loop, and load-file potentially freezing Emacs on a huge result.

After that master (the future CIDER 2.1) kept moving at a steady pace. A few of the things that landed there:

  • CIDER’s dynamic font-locking (REPL-defined macros, functions, deprecated/instrumented/traced symbols) now works better in clojure-ts-mode buffers via tree-sitter. Previously it worked “officially” only under clojure-mode. The debugging reader tags are highlighted there too.
  • A new cider-preferred-clojure-mode controls which mode CIDER uses to font-lock the code it renders - REPL results, doc examples, overlays and its own display buffers. clojure-ts-mode is finally a first-class citizen in CIDER.
  • Symbol prompts can go through completing-read (so Vertico/Ivy/Helm kick in) and completion annotations render as an aligned type/namespace column in Corfu, Vertico and the built-in *Completions*.
  • Connecting got smarter. Container-published nREPL ports are resolved for /docker: and /podman: buffers, lein trampoline REPLs are detected, .nrepl-port files are no longer discarded on systems without lsof, and there’s a new “How CIDER Finds Ports” section in the manual.
  • Every form command got an “at point” variant (inspect, pprint, macroexpand, format, insert in REPL), there’s a cider-inspect-menu listing every way to start an inspection, and the contents of comment forms are treated as top level by the whole defun command family.
  • Stray output from long-lived background processes (say, a core.async go-loop still printing under a finished eval’s id) is now routed to the REPL instead of being dropped with a warning.

One more thing. I shipped “smarter form targeting” on master - the evaluation commands resolving the form from where the cursor actually is, rather than the form before it - wrote about it, got a lot of feedback, and reverted it a few days later. CIDER 2.1 will keep the classic Emacs semantics. Fifteen years in, the existing behaviour is the contract, not an implementation detail I get to tidy up. The detour wasn’t wasted, though: it surfaced a bug where the text of a line comment was treated as code, and the “at point” family of commands is a direct result of it.

cider-nrepl

Three releases in July, wrapping up the tools.deps migration and driving the CIDER 2.0 launch:

  • 0.62.0 finalized the Leiningen to tools.deps migration, simplified deferred middleware loading, documented the op response keys (with a test verifying the descriptor contract) and shipped the hardened content-type and slurp middleware that made rich content safe to enable by default.
  • 0.62.1 fixed a whole cluster of debugger bugs: record literals no longer get downgraded to plain maps by instrumentation, deftype/defrecord method bodies are skipped (goodbye Unable to resolve symbol: STATE__), and enlightening deftest bodies works again.
  • 0.62.2 pruned trace and tap subscriptions with dead transports (a dead subscriber used to break every traced evaluation), stopped the debugger from shadowing enlighten’s evaluator, brought the docs back in sync with the code and added a section for tool authors.

Orchard

Orchard 0.44.0 shipped on July 4, mostly thanks to Sashko’s inspector work (a replace command, truncated table columns, ARef contents rendered fully). My part was a round of tests for the less covered namespaces and, later on master, a fix for orchard.print ignoring custom print-method implementations for records and collections. Thanks, Sashko!

clj-refactor 4.0

clj-refactor.el 4.0 is the release I had been promising for a few cycles. It requires Emacs 28.1+ and CIDER 2.0+, and it’s a big one:

  • project-wide refactorings (rename symbol, change signature, inline symbol) now show a diff preview before touching disk, and cljr-undo-last-refactoring reverts the last one in a single step
  • the slow refactorings run asynchronously, so Emacs no longer freezes while the middleware analyzes the project
  • cljr-change-function-signature can add and remove parameters and handles multi-arity functions
  • a clj-refactor-menu transient replaces the hydra menus, and the multiple-cursors, hydra and inflections dependencies are gone
  • many commands degrade gracefully without a REPL (cljr-clean-ns, cljr-slash, cljr-add-missing-libspec, cljr-remove-let, cljr-promote-function)
  • cljr-slash can add and hotload a missing library, artifact lists are cached, and the namespaced refactor-nrepl ops are used when available

I still think the long-term home for the most useful bits is CIDER and clojure-mode, but at least the project is in good shape while that’s being figured out. I’d still love to hear from clj-refactor users on this.

clj-suitable

clj-suitable, the ClojureScript completion backend, was another project that had been coasting for years:

  • 0.7.0 adapted to Piggieback 0.7’s delegating repl-env, modernized every dependency, replaced the Leiningen build with tools.build, moved CI to GitHub Actions and added a shadow-cljs integration test over a real Node runtime.
  • 0.8.0 brought the static completion much closer to compliment: fuzzy matching (pr-fn completes print-function), compliment-style ranking, completion of local bindings (destructuring included) and of referred vars inside :refer vectors. It also fixed the REPL’s *1/*2/*3 getting clobbered by completions and a few long-standing shadow-cljs and Node.js issues.

Sayid

The Sayid revival continued at a brisk pace, with five releases between July 1 and July 17:

  • 0.4.0 dropped the com.billpiel namespace prefix, added data-returning variants of the workspace and query ops, and introduced a client-rendered, foldable tree view of the recorded call tree built on CIDER’s cider-tree-view.
  • 0.5.0 made recording bounded: a record limit, per-function limits, sampling, a max trace depth and bounded printing, so tracing a namespace under a test suite can’t eat all your memory anymore.
  • 0.6.0 rebuilt inner tracing on tools.analyzer.jvm, replacing the fragile source-rewriting instrumenter.
  • 0.7.0 added sayid.data (the recorded call tree as plain data, with tap> integration for Portal and friends) and sayid.golden, a golden-trace testing helper.
  • 0.8.0 focused on the experience: a sayid-menu transient, plain-language feedback from the trace commands, getting-started hints in empty views, and a fix for the inspector integration that had been broken for years.

Drawbridge

Drawbridge is nREPL’s HTTP transport, created by Chas Emerick in 2012 and “technically maintained” ever since. I finally gave it the attention it needed:

  • 0.3.1 updated the dependencies (nREPL 1.7, Ring 1.15) and throttled client polling so it stops flooding servers with GET requests.
  • 0.4.0 is the interesting one: drawbridge.bridge, a local nREPL socket server that relays to a remote Drawbridge endpoint, so any socket-based client (CIDER, Calva, rebel-readline) can now talk to Drawbridge; a WebSocket transport with server push instead of long-polling; bearer-token authentication via secure-ring-handler, which refuses to run unauthenticated unless you insist; and a deps.edn, so it’s usable as a git dependency.

nREPL

No release this cycle, but master is shaping up nicely for 1.8:

  • the built-in command-line client can connect using a URL, including the nrepls:// and nrepl+unix: URLs that TLS and filesystem-socket servers advertise, and http(s):// when Drawbridge is on the classpath
  • TLS hardening: descriptive errors for invalid key material, Ed25519 keys, tolerating a swapped certificate order, and a documented security model
  • the built-in client sends input to the server as raw text, so reader typos, auto-resolved keywords and custom tagged literals no longer crash it
  • stdin fixes: EOF arriving behind buffered input is reported properly, and a race between the stdin consumer and producer is gone
  • nrepl.spec finally matches what describe and ls-sessions actually send
  • a pile of documentation debt cleared (lookup return values, the session-closed status, the -f/--repl-fn option, middleware best practices) and a CI check keeping ops.adoc in sync with the descriptors
  • Clojure 1.10 is the new minimum and nrepl.misc/requiring-resolve is gone in favor of the core one

The nrepl.org site also picked up links to several new clients and servers (Nautilos, nREPL.hx for Helix, Janet and Steel Scheme servers).

nREPL on the BEAM

nrepl-beam is a brand new project I started in July, mostly because I wanted to see how well the nREPL spec holds up when implemented from scratch outside the JVM. It’s home to:

  • dialtone, an nREPL server for Erlang (and a server core for the whole BEAM)
  • repartee, the Elixir server built on top of it
  • chaser, a terminal nREPL client that works with any nREPL server

0.1.0 shipped on July 14. Both servers implement the full op set (eval with streamed output, sessions, interrupts, stdin, load-file, completions, lookup) and pass neat’s cross-implementation integration suite alongside Clojure, Babashka and Basilisp. Writing them was a good test of the spec, and it produced a few of the documentation fixes listed above. Turns out the best way to find holes in a spec is to implement it in a language you barely know.

mezcaml

In the same spirit, mezcaml is a minimal nREPL client for OCaml: a small client library plus a command-line REPL, working against any nREPL server regardless of the language on the other end. No release yet, but the core protocol works, it reads whole forms, and it has server-driven completion. Nothing serious - it was a fun way to combine my recent OCaml hacking with nREPL.

clojure-mode, clojure-ts-mode and MrAnderson

Smaller things: the #_ toggle commands in clojure-mode were renamed to clojure-toggle-discard and friends (matching Clojure’s own terminology, old names kept as aliases), both modes got a :to-have-face matcher for font-lock tests, and clojure-ts-mode now checks the indentation of its sources on CI.

MrAnderson 0.7.1 added a command-line interface, so it can be run without Leiningen, and reworked its downstream integration tests against cider-nrepl and refactor-nrepl, which had silently stopped exercising local changes. Oops.

Blog posts

I wrote a lot this summer, mostly a series on the notable changes in CIDER 2.0:

What’s next

CIDER 2.1 is the obvious next milestone, and it’s mostly a matter of letting the clojure-ts-mode integration settle. After that I’d like to cut nREPL 1.8 with the TLS and URL work, and get mezcaml and the BEAM servers to a point where they are genuinely useful to someone other than me.

Thanks to Clojurists Together for the continued support of my Clojure OSS work! You rock!


Clojure Camp

2026 Annual Funding Report 4. Published Sept. 13, 2026.

What happened:

  • Logistics for Conj ‘bursary’ (bringing 5 new Clojurians to Conj)
  • Prep for Conj workshops (code jams, pairing/mobbing)
  • Prep for our booth at Conj (will be trying out an “unconference conversation zone”)
  • Logistics for “micro-Conj” experiment (hosting an AirBnB during Conj, future: sans the Conj)
  • Sponsored a research project re: how beginner questions re: Clojure have changed over time (just starting)
  • Remote mobs, book club

Plans:

  • Finish prep for Conj-related projects (workshops, booth, airbnb) and attend Conj
  • Continue on badges site content
  • Continue on new event management tooling
  • Remote mobs, book club

Eric Dallo

2026 Annual Funding Report 4. Published Sept. 13, 2026.

Lots of work in July and August! ECA got closer to the editor with inline chats and LSP navigation (clojure-lsp integration :heart:), alongside more work on reliability, chat history and the client experience. On clojure-lsp, I continued addressing edge cases after the huge performance improvements from the last report, with a new refactoring and help from contributors making the project easier to debug. Thanks ClojuristsTogether and everyone helping with feedback, issues and contributions! :heart:

ECA

The main highlight this time is being able to talk to ECA directly from the code, without switching to the chat window! This builds on the same server and protocol used by the existing clients, so inline conversations can reuse chat history, call tools and ask for approvals too. As usual the changelog is huge, so here are the highlights since the last report:

image

0.145.0 - 0.157.3

  • Inline chat: New chat/inlinePrompt protocol method, with support in Emacs, VS Code and IntelliJ. Ask a question from the current file or selection, follow up beside the code, and optionally fork an existing conversation to reuse its context without changing the original chat.
  • LSP navigation: New editor_definition and editor_references tools let the agent use the editor’s language intelligence instead of relying only on text searches. A nice connection between ECA and tools like clojure-lsp, with support for other languages and editors too!
  • /btw side questions: Ask a quick question in a separate, forked conversation without cluttering the main chat history, even while the main task is still running.
  • Granular tool approvals: Approve and remember individual shell/git commands and subcommands instead of granting access to the whole tool, with clearer details showing what will be remembered.
  • Chat history performance: Chats now live in separate cache files with a lazy-loaded index, avoiding CPU spikes and slow startup as history grows. History is also shared across git worktrees of the same repository, and concurrent servers merge cache writes instead of overwriting each other.
  • More predictable prompt caching: Existing chats keep their system prompt stable by default, with /sync-system-prompt to explicitly apply updated instructions. Changes that invalidate the prompt cache are now visible in the chat.
  • Better recovery and cancellation: More robust handling of dropped connections, interrupted streams and rate limits, configurable retries, and prompt cancellation across more providers. Failed subagents now return useful errors and partial output instead of appearing to succeed with an empty result.
  • Plugins and agent control: Plugins can declare dependencies that are loaded automatically, while spawnableBy controls which primary agents can spawn a subagent. Agents can also disable groups of MCP tools by server name or regex.
  • MCP and login improvements: Timeouts prevent MCP servers from staying stuck during discovery, and tools become available without waiting for slow prompt/resource listings. Provider login got interactive choices and clearer feedback, including consent before enabling a GitHub Copilot model policy.
  • Models and local providers: Added GPT-5.6 variants and Claude Opus 5 support, automatic discovery of Copilot model APIs and reasoning variants, context-limit detection for llama.cpp/llama-swap, and token usage reporting for Ollama.

Also, there were lots of improvements in eca clients repos related to those changes.

The experimental eca-cli also received community contributions for fuzzy file selection, background-job management, MCP status and diff previews before approving edits. Still early, but really nice to see the terminal client moving forward!

clojure-lsp

Following the memory and startup work covered in the last report, these 2 months were about fixing edge cases, adding a new refactoring and improving the contributor experience, still unreleased:

  • New cycle-namespaced-map code action to switch between ordinary and namespaced maps, for example {:foo/bar 1} and #:foo{:bar 1}. #994
  • Fix API/CLI renaming when a symbol appears multiple times on one line, preventing corrupted replacements. #2450
  • Keep dependency completion working while typing a key in an incomplete map in deps.edn or project.clj. #2384
  • Fix cache writes failing when clj-kondo ignore hints precede Java interop code. #2380
  • Identify clj-kondo snapshots by their Git SHA in --version, and fix native-binary version reporting.
  • Correct missing-classpath handling during stub generation.
  • More detailed initialization timing logs and contributor documentation covering performance tests, code coverage and FlowStorm debugging.

Thanks to blueskyonmars for helping with the debugging and contributor documentation improvements!


Jeaye Wilkerson

2026 Annual Funding Report 4. Published Sept. 11, 2026.

Hello Clojurists Together members! Thank you so much for the sponsorship this year. Here is my update for July and August, which is extracted from my recent blogpost here.

Uncaught runtime exceptions

In today’s modern C++ compilers, there’s no standard, portable way to get a stack trace. Coming from the JVM, this may sound surprising, but it’s par for course in the native world. Even worse, jank is JIT compiling C++ code and we want to get accurate stack traces which include those frames as well. Even worse, we need to map some of that C++ back to actual jank code. So, in order to get beautiful, accurate stack traces for jank’s uncaught runtime exceptions, there was a lot of work to be done. Check out the results!

image

As you can see in the figure above, an exception was thrown from the C++ code backing clojure.core/subs. jank properly reports the error by pointing at the nearest user’s Clojure call, skipping over the one in clojure.core which calls the C++ function. In the stack trace, we can see two Clojure-specific frames, numbered as #1 and #3. First, we see the frame for clojure.core/subs. Then we see the frame for user/foo, which actually does the call to subs. Note that both of these frames include the exact arity that was used, as well as the precise source location in their respective jank files.

What you’re not seeing here is that this stack trace is pulling debug info from three separate places:

The current executable, for all of the non-Clojure frames. An AOT-compiled object file, which was loaded when clojure.core was required. This is equivalent to Clojure JVM’s .class files. A JIT-compiled object file, which was added to the LLVM JIT runtime when the user/foo function was compiled.

After my recent efforts, jank now weaves all of these together seamlessly to provide you a lovely error report. This works reliably on macOS and on Linux.

Error pages

Building on the error output above, you may also notice the URL that’s tucked into the bottom of the code snippet. Since the original error reporting design last year, I have intended for jank to have a dedicated error page for each error. Each page should provide more information about the error, common causes, and suggested fixes. All of these pages have now been created and are part of the jank book. Some of them are more bare-bones than others and I plan to continue filling them in over time. Getting them created sooner will start aiding in SEO, though, which will help ensure that if you search for any jank errors you hit, the right resources will be shown to you.

Here’s an example of what I have in mind:
analyze/invalid-cpp-conversion

C++ candidates

I have saved the best for last, as far as error messages go. We know that Clojure is infamous for its error messages and I hope to have shown how jank addresses that. However, C++ is also infamous for its error messages and jank is just as much C++ as it is Clojure. C++ is a much scarier beast when it comes to all of the possible things that can go wrong, though. So how can we reimagine C++ error messages? Well, I gave it my best shot. Take a look. :)

image

The call is ambiguous because the second argument is an int, which directly matches neither long nor short but can implicitly be converted to either of them. jank’s AST is intertwined with Clang’s AST, so we can extract all of the necessary information to render this neatly. Unlike Clang, or GCC, jank renders these in a table format which I find to be incredibly succinct and appealing.

Also, as a bonus, the signature and source information for these bar functions is correct, even when they’re declared inside of a cpp/raw in a jank file. Let’s take a look at another one.

C+ JEaye 2

When there are many candidates to report, jank optimizes useful output by ranking the candidates based on argument count, required conversions, as well as access levels. By default, jank will only show the top three candidates.

Finally, I’ll show one more image, which is of a special kind of ambiguity with some jank-specific behavior. On top of normal C++ overloading, implicit conversions, etc, jank also supports automatic trait conversions, which use a well-known trait to convert to/from jank objects and native values. If an argument to a native function is a jank object, the compiler will consider whether or not a trait conversion can be used. However, this can result in ambiguities, too, if multiple candidates are viable. Here’s an example.

image

There’s a lot more that jank can already do with these C++ candidate failures, but I can only show so much in a blog post. I’m sure you’ll see more next time you’re writing some jank code!

Why bother with all of this?

You may not be as excited as I am about these images of error reports. That’s understandable. It’s partly a compiler nerd thing, since effective error reporting can be quite tricky. However, it’s also partly a huge usability win over not only Clojure JVM, not only Clang and GCC, but also the status quo in a lot of developer tooling. I am trying to build a language, and tooling ecosystem, that is the best it can be. That’s the language I want to use. I want to entice others to try it by dedicating this time to usability, too.

To me, this is incredibly important.

Native build system

Another large system I’ve been working on is jank’s native build system. This is a Cargo-like build system, for those familiar with Rust. The goal of the build system is to enable easy consumption of native libraries, both from the installed system and from compiled sources. The jank build system stands on top of Clojure’s existing package management, namely through Clojars. When you add a dependency, the jank tooling will automatically pick up if that dependency has a native jank build script and will build the package locally. These scripts are always run in a sandbox which has no access to your personal files. This is an improvement over the default Cargo machinery.

This build system was originally created by Kyle Cesare and I’ve been further improving it these past few months by adding sandboxing support to macOS, improving static linking support, and overall making things more robust and stable. Now that we have a powerful native build system, what we need is a repository of high quality packages. That is precisely why I started the jank commons.

jank commons

The jank commons is an official repository of native jank packages published to Clojars. Each of these packages integrates seamlessly into the jank build system and has an example project which is continuously compiled. Following Rust/Cargo’s naming scheme, the jank commons is currently full of foo-sys packages. The -sys suffix conveys that it’s a package which provides a system library without providing a higher level API. Writing a higher level API is left up to other packages which then depend on the -sys packages. The key benefit here is that the higher level packages don’t need to bother with all of the system details of packaging native libs and can just focus on writing good APIs. Another benefit is that generally only -sys packages will need native build scripts, so isolating those can further help with security. Even better, since jank has seamless C++ interop, idiomatic Clojure APIs are optional.

An even easier way to browse the native packages jank has is through the awesome-jank list. This is mainly populated by the jank commons right now, but please take this to be a call to action to get more native libs packaged for jank! The jank commons README has a guide for exactly how to do this and the whole native build system is thoroughly documented in the jank book.

Everything else

There’s a lot more that’s been going on in the jank repos, but it’s too much to cover in detail here. For example:

What’s next

This is the last post before my talk at Clojure Conj 2026. If you can’t make it, check out the free live stream! In the coming weeks, I will be racing to improve jank’s stability, portability, and usability, leading up to the Conj. After the Conj, and for the remainder of the year, I’ll be focused on much of the same.

It’s my goal to get jank into your hands, dear reader. For many of you, I think that jank is already capable enough for you to begin your tinkering. With the addition of the jank commons, starting a new raylib game in jank is as easy as lein run. Getting a distributable binary is as easy as lein compile. What follows is just polishing up all of the rough edges so that developing your games and applications is a breeze.

If you’ve been waiting to try jank, give it a go! If there’s something you need which jank is missing, let me know! I’ll make sure it’s noted down and prioritized.

I’ll see you all at the Conj.


Michiel Borkent

2026 Annual Funding Report 4. Published Sept. 11, 2026.

In this post I’ll give updates about open source I worked on during July and August 2026.

To see previous OSS updates, go here.

Sponsors

I’d like to thank all the sponsors and contributors who make this work possible. Without you, the projects below would not be as mature or would not exist or be maintained at all! So a sincere thank you to everyone who contributes to the sustainability of these projects.

gratitude

Current top tier sponsors:

Open the details section for more info about sponsoring.

Sponsor info

If you want to ensure that the projects I work on are sustainably maintained, you can sponsor this work in the following ways. If you work for a company that uses my OSS, please ask your employer, that would be even better. Thank you!

Updates

In the past two months it was summertime in Europe. Due to a couple of heatwaves, it was the perfect time to spend inside and enjoy my new air conditioning, while coding ;-).

The first half of July was mostly spent on improving performance and compatibility of SCI on CLJS. SCI now JIT-compiles interpreted function bodies to JavaScript at runtime, which closes a lot of the gap with compiled ClojureScript: a tight numeric loop went from ~175ms to ~7ms, over 20 times faster than the interpreter. Implementing core protocols on custom types now also works. There’s hardly anything you can’t do in SCI that you can do in compiled CLJS. I released new versions of scittle and nbb that take full advantage of this.

Also in the middle of July, clj-kondo got a pretty cool enhancement. It infers types of function arguments from how they are used. E.g. when you write (defn foo [x] (inc x)) we can infer that foo is a function that takes a number. I took this principle as far as I could while preventing false positives. Of course, clj-kondo supports the latest Clojure 1.13 destructuring changes too.

In the second half of July I spent significant time on improving babashka tasks with automatic help and completions, backed by babashka.cli. You can read all about that in this blog post: Babashka tasks with automatic help and completions

In August I had the pleasure of giving a talk about Reagami at Func Prog Sweden. In the talk I gave an interactive demo of how to use Reagami in a Squint project through a REPL. I also went into detail on the algorithm that powers the fast DOM diffing. While preparing for the talk, I added SSR to Reagami too. You can view the talk on YouTube:

The last few weeks of August I created babashka.ffi, a new namespace in babashka to call C libraries. See my previous blog post: Babashka 1.13.220 gets FFI. To validate the design I wrote four libraries with it: babashka.sqlite, babashka.duckdb, babashka.postgres and filewatcher. Each one exercised a different corner of the API, along with some examples based on raylib. PacMan is particularly cool:

pac-man running in babashka through babashka.ffi and raylib

Right now I’m looking forward to giving a babashka workshop at the Clojure/conj together with Rahul Dé. We’re still polishing the workshop material behind the scenes and I’m excited to see how it’s turning out. I’m sure it’ll be a lot of fun and hope to catch many of you there.

In between all of this, I also worked on squint. It now supports the core protocols, so you can plug in your own collections and use them with core functions. E.g. you can use Immutable.js with Squint. I’m thinking about lightweight immutable persistent data structures for squint, but so far I haven’t had much need for them, outside of Advent of Code puzzles.

The above was all about making existing projects better. But I also had a few new creative ideas:

  • Choq: Cherry hosted on QuickJS, nREPL included.
  • Buzz: a cross client-server framework that lets you write web-apps on the JVM or babashka without any JS tooling, while still having full JS expressivity via Squint. I wrote tube-pod and multi-snake with it.
  • Cljbang.el: A Clojure-like language that runs as Emacs Lisp

Here are some highlights per project. See each project’s CHANGELOG.md for the full list.

  • Babashka: native, fast-starting Clojure interpreter for scripting.

    • 1.13.220: Add experimental babashka.ffi: call C functions in shared libraries straight from babashka and JVM Clojure! See the guide
    • 1.13.220: On Linux, the install script installs the dynamic binary by default. It installs the static binary on musl systems and on systems with glibc older than 2.17. The --static and --dynamic options override the automatic selection
    • 1.13.220: :exec-args can sit directly on a task, not only under :cli, the way (exec ...) already reads it. Before, it was ignored on an :exec-fn or :cmd task
    • 1.13.220: A task’s :cli spec adds to the runner-level :tasks {:cli {:spec ...}} instead of replacing it. An option from the runner level keeps its coercion and default, and --help lists it under Inherited options
    • 1.13.220: A task with :exec-fn runs when another task :depends on it. Before, it did nothing
    • 1.13.220: Options declared by an :exec-fn task named in :depends also parse for the CLI task that runs, with their coercion and default. --help lists them under Inherited options
    • 1.13.220: :cmd can be a symbol naming a var that holds the command tree, like :cli. Its namespace loads on demand
    • 1.13.220: Shell completion offers inherited options (via :depends) too
    • 1.13.220: SCI: call site caching for instance and static methods, constructors and fields. Interop calls are up to 5x faster
    • 1.13.219: Tasks get automatic --help and shell completions, through the new :exec-fn and :cmd keys. See the blog post! These task keys should be considered experimental and may change in a future version of babashka, depending on feedback from the community
    • 1.13.219: Clojure 1.13 map destructuring: :keys!, :syms!, :strs!, & inside a directive, :select, :all and :defaults. Adds req! and some-vals to clojure.core
    • #1321: support implementing the clojure.core/Inst protocol on records, types and reify, and with extend-protocol and extend-type
    • #2054: a proxy of java.io.Writer supports the one-argument write and append, so binding *out* to it works
    • #1918: fall back to $HOME when the OS does not supply a home directory, e.g. for LDAP users in the static binary
    • #1994: fix :eval and :print options of clojure.main/repl being ignored in the interactive REPL (@jeroenvandijk)
    • Bump jline to 4.4.0: security hardening, a rewritten signal path for the FFM terminal, Kitty keyboard protocol
    • #2021: bump http-kit to 2.9.0-beta4, which fixes four security advisories
    • Class additions by @weavejester (#1985, #1986, #1987, #1988), @paintparty (#1982) and @christoph-frick (#2003)
    • Full changelog
  • babashka.ffi: call C functions in shared libraries from Clojure. New library, also usable from JVM Clojure. See the guide and the examples. The API is experimental

  • babashka.sqlite: SQLite for babashka through babashka.ffi

    • Uses the SQLite shared library that macOS, Linux and Windows already ship with, so there is nothing to install
    • with-conn, queries, aggregates, transactions, last-insert-rowid, interrupt, and create-function! for defining a Clojure function callable from SQL
    • CI green on three operating systems
  • babashka.duckdb: DuckDB for babashka through babashka.ffi

    • Query CSV files directly with SQL, results as Clojure data
    • HoneySQL support, thread safety, prepared statement cleanup
  • babashka.postgres: PostgreSQL for babashka through babashka.ffi and libpq

    • connect, close!, with-conn, query, execute!, with-transaction, in-transaction?, cancel!, json, jsonb, version, server-version
    • Vectors map to arrays in both directions, maps map to JSON. Bring your own JSON library through :read-json and :write-json
    • clj-kondo export with a with-conn hook, CI on three operating systems
  • filewatcher: watch files and directories from babashka

    • Built on babashka.ffi: FSEvents on macOS, inotify on Linux, ReadDirectoryChangesW on Windows, and polling everywhere
    • The same event types on all three platforms, modeled after chokidar
    • A watcher keeps the process alive until close
  • SCI: Configurable Clojure/Script interpreter suitable for scripting

    • ClojureScript JIT compilation. SCI on CLJS compiles interpreted function bodies to JavaScript at runtime via js/Function. This is enabled by default and needs no configuration. When JIT is enabled, loops and numerical computations become much faster (and, in unrestricted contexts, JS interop too)
    • When eval is unavailable (e.g. under a Content Security Policy) SCI falls back to the interpreter. Results, error messages and error locations should be identical. And of course, it works under :advanced compilation
    • You can turn JIT off at runtime with js/globalThis.SCI_DISABLE_JIT = true before loading SCI, or in your Google Closure compile settings with :closure-defines {sci.core/disable-jit true}
    • More CLJS JIT performance improvements. Up to 20x on arithmetic-dense code for >2 arity. Keyword lookups, instance? and js globals no longer fall back to the interpreter
    • ClojureScript: native protocol support (#639). SCI code can implement CLJS protocols on deftype, defrecord and reify, and host code calling protocol methods on such instances dispatches into the sci implementations. Works under :advanced compilation
    • #1063: CLJS: deftype and defrecord fields are JS accessors on the type’s prototype: (.-field x) works on instances, (set! (.-field x) v) mutates deftype fields
    • New :unrestricted option on init and eval-string: when true, evaluated code may mutate built-in vars and CLJS instance interop skips :classes checks. The option applies only to the context it was passed to
    • BREAKING: enable-unrestricted-access! now throws. Use the :unrestricted option instead. The old function set a process-global flag that leaked into nested contexts
    • Support async functions by adding :async true in the attr map of defn
    • Caches resolved JVM instance methods per call site for performance
    • Fix babashka#2030: aset on a primitive array was reflective and 170x slower than aset-double
    • Errors thrown inside a loop now report a located stack frame for the loop form instead of a frame without location (all platforms, including babashka)
    • Full changelog
  • clj-kondo: static analyzer and linter for Clojure code that sparks joy.

    • Type checker: infer the type of a function param from how it is used in the body. E.g. (defn f [s] (subs s 1)) (f 42) will warn, since the evidence (subs s 1) tells us that s should be a string.
    • Type checker: infer the value type of a destructured map key from how it is used in the body. E.g. (defn f [{:keys [x]}] (inc x)) (f {:x "foo"}) will warn. A key whose use rejects nil and that has no :or default is required.
    • Type checker: a destructured binding gets the value type of its key when the map’s type is known, including through function return maps. E.g. (defn cfg [] {:port "8080"}) (let [{:keys [port]} (cfg)] (inc port)) will warn.
    • Type checker: a key missing from a map literal is provably nil, also through destructuring, keyword access chains and function return maps. E.g. (inc (:y {})) will warn.
    • Type checker: narrow the type of a local in the then-branch of if or the body of when when it is guarded by a known predicate. E.g. (if (string? x) (inc x) ...) will warn.
    • Built-in analysis now uses Clojure 1.13.0-alpha4. Param type inference over the core sources grows the arg type coverage of clojure.core from 23 to 150 vars. E.g. (interleave 1 [2]) and (mod "a" 2) will warn.
    • #721: NEW linter: :constant-condition: warn on a condition whose truthiness is the same on every run. On by default. Replaces :condition-always-true, whose config and ignores still apply to always-true conditions, and takes over the cond catch-all warning from :unreachable-code
    • Clojure 1.13 CLJ-2961: infer required keys from :keys!, :syms! and :strs! and report them at call sites
    • #2874: Clojure 1.13 CLJ-2964: support :select in map destructuring. The bound map’s keys are known to the type checker
    • Clojure 1.13 CLJ-2966: support :defaults in map destructuring, error when used without :or
    • #2943: Type checker: when an :analyze-call hook rewrites a call, clj-kondo checks the arity of the original function but not its parameter types.
    • #2900: :discouraged-var: new per-var :positions option (a set or vector of :call and/or :value) to limit the warning to call position or value position. A var passed to a higher-order function such as map counts as :value.
    • #2851: NEW linter: :seq-rest: suggest using (next x) over (seq (rest x)). Defaults to :off (@tomdl89)
    • #1882: built-in support for clojure.test.check.clojure-test/defspec
    • #2877: warn when #_ before an unmatched reader conditional discards the next form. E.g. [#_#?(:cljs 1) 2] reads as [] in :clj and will warn.
    • Vars defined in comment forms no longer count for :shadowed-var, :unused-private-var and :inline-def.
    • Performance: use a record for var usages: 13.5% less allocation, ~5-10% faster linting. More performance work by @alexander-yakushev
    • The minimum Clojure version to run clj-kondo on the JVM is now 1.11.
    • Full changelog
  • babashka CLI: Turn Clojure functions into CLIs!

    • #197: :positional spec marker: positional args get their own Arguments: help section and may not be passed as options
    • #197: :restrict-args: error on positional args not consumed by :args->opts
    • #219: :cmd-aliases on a table entry or tree node gives a command one or more alternative names.
    • A short option that declares a non-boolean :coerce takes the rest of its token as its value, like getopt: -J-Dfoo=bar binds "-Dfoo=bar", -p80 binds 80. Flag letters may precede the valued option in a cluster: with :b a flag and :a valued, -ba x parses as -b -a x
    • #216: in a cluster of flags, where no letter takes a value, an interior hyphen is an error instead of silently ending option parsing.
    • Help: show the dispatch-level :spec options under Inherited options:. The parser always accepted these options, but help did not show them
    • Help: format-command-help accepts :spec, the dispatch-level spec, so a standalone call shows the same options as dispatch
    • dispatch: the command named on the command line wins over the :exec-args of its ancestors. A value the user typed at an ancestor level still wins over both
    • Add ordered :enum values for validation, help and completion
    • Support :doc and :epilog as a vector of lines, joined with newlines
    • #198: :cmd may be a vector of [name command] pairs, preserving command order without :cmd-order
    • #199: fix hang on variadic arguments that weren’t “collected” (e.g. (repeat :k))
    • #203: parse-opts* resolves :spec so its :coerce/:collect entries steer parsing like in parse-opts
    • Completion: the fish snippet registers with --keep-order, so fish offers options in the order they are emitted, long option before its short alias, rather than sorting short options first
    • zsh completion: offer a command’s options without typing a dash first, by opting the registered program names out of zsh’s prefix-needed style
    • Thanks to @lread for continued documentation review and maintenance
    • Full changelog
  • Squint: CLJS syntax to JS compiler

    • Preparatory release before adding immutable + persistent collections in squint.immutable. Added a lot of protocols and made sure core functions work properly with them
    • Add the ILookup, IAssociative, IMap, ICounted, IKVReduce, ICollection, IEmptyableCollection and IEquiv protocols. get, assoc, contains?, find, dissoc, count, reduce-kv, conj, empty and = dispatch to them on custom types. Plain objects and arrays keep their fast paths
    • Add the IStack, IIndexed, IVector, IWriter and IPrintWithWriter protocols, write-all, and an ITransientVector -pop! slot; nth, peek, pop, pop!, subvec, vec, vector?, sequential?, set?, map?, seq, = and printing dispatch to custom collection types
    • Add equiv, hash, hash-ordered-coll, hash-unordered-coll and the IHash protocol. hash follows equiv: plain mutable objects and arrays hash by reference
    • Add the IMeta and IWithMeta protocols; meta and with-meta dispatch through them and the internal meta symbol property is gone
    • clojure.set dispatches through the collection protocols: results keep the input’s type, membership tests against a protocol set are value-based, and rename-keys/map-invert no longer mutate a record
    • Add defrecord, record? and the IRecord marker protocol. Records store their fields as own string-keyed properties and implement the map-facing protocols, so keyword lookup, keys, seq, assoc, conj and = work through the regular core functions. assoc keeps the record type, dissoc of a basis field gives a plain map, printing gives #TypeName{:a 1}
    • Clojure 1.13 destructuring: :keys!/:syms!/:strs! for required keys, & inside them for keys required but not bound, :select, :all, :defaults, and :or by key
    • Fix #975: & {:keys [...]} now destructures a map instead of the raw rest args, and a seq destructured as a map is read as kwargs
    • Fix #977: recur inside try no longer emits an illegal continue
    • Support :as-alias in ns :require like CLJS: no runtime import, only a compile-time alias so a namespaced keyword such as ::alias/x resolves
    • Add :require-global and :refer-global to ns, binding globals loaded via a script tag to consts without emitting an import
    • Add :squint/compile-time opt-in mechanism for macro/compile-time namespaces. See doc/compile-time.md
    • A defmacro is compile-time only: no longer emitted to the runtime module, and :refering a macro no longer emits a runtime import for it, matching CLJS
    • The CLI reports the file, line and column of a compile error and exits non-zero, instead of dumping the raw exception
    • Fix #957: vite HMR: support ^:dev/after-load + ^:dev/before-load hooks similar to shadow-cljs
    • .indexOf on a lazy seq now uses reference equality like a JS array, not value equality. This diverges from CLJS but keeps = out of any bundle that only builds lazy seqs, shrinking a conj bundle from 3801 to 2215 bytes
    • Use Symbol.for for protocol method dispatch, so pulling in multiple copies of squint.core (e.g. via http://esm.sh/) does not break protocol dispatch
    • Full changelog
  • Cherry: Experimental ClojureScript to ES6 module compiler

    • Add cherry.test with clojure.test-compatible testing API, requirable as cljs.test or clojure.test
    • cherry.test/report is a multimethod dispatching on [*current-reporter* type] like cljs.test, so reporting can be extended with defmethod
    • Add a vite plugin with browser REPL over nREPL and ^:dev/after-load / ^:dev/before-load hot-reload hooks, sharing squint’s implementation: import cherry from 'cherry-cljs/vite.js'
    • Add reify, defmulti/defmethod and the vswap! macro. #'foo emits foo’s value, like squint
    • Dynamic vars compile to squint’s box scheme, so set! and binding work across ESM modules. cljs.core dynamic vars are exported as accessor boxes proxying the real var
    • defprotocol :extend-via-metadata impls resolve under the fully qualified method symbol, so replicant’s mutation-log renderer works: replicant’s own test suite passes under cherry
    • Fix deftype implementing cljs.core protocols such as Inst, IIterable and IAtom: their marker properties were Closure-renamed in the precompiled core and missing from the emitter’s core protocol set. The externs list and the set are now generated from cljs.core’s protocols (bb gen-externs) and the build fails on drift
    • Fix #190: share PROTOCOL_SENTINEL with coexisting CLJS runtimes in the same JS realm
    • Share the macro scan and macro lookup with squint. Namespaces flagged {:squint/compile-time true} load only their compile-time part into the macro environment, like squint
    • CLI: --help/-h, argument validation and error messages via babashka.cli’s dispatch, like squint. Adds watch and nrepl-server commands, shell tab completion, and reads options from cherry.edn instead of squint.edn
    • Fix emitted import specifiers on Windows: backslashes are normalized via the path resolution now shared with squint
    • Full changelog
  • Choq: a ~5 MB binary running the cherry compiler on embedded QuickJS

    • New project. Runs cherry inside quickjs-ng via rquickjs
    • No JIT, so hot code is slower than Node.js, Bun or Deno, but the binary is small, startup is fast and memory use stays low. A Hono app serves around 30k requests per second locally, using less memory than the same app on Node.js or Bun
    • An install script for macOS, Linux and Windows, and dev release binaries
    • Clojure git and Maven deps, a module table covering url and util, @babashka/fs, and a test runner
    • Experimental
  • Buzz: write a web application with the JVM or babashka only

    • New project. Server state is watched and updated from client code. The UI compiles through squint and renders with Reagami, so no ClojureScript toolchain and no Node.js
    • Rendering is asynchronous by default and coalesces at 20ms, and a failing render is contained to its own connection
    • Examples: a whiteboard, a tap viewer, and a Datalevin browser with a CodeMirror query editor
    • Highly experimental, the API will change
  • tube-pod: turn YouTube videos into a private podcast

    • New project, written with Buzz. Add a link in the browser, tube-pod downloads the audio with yt-dlp, writes an RSS feed and serves both
    • Rsyncs the audio and the feed to a remote after each change, since a laptop is asleep when you want to listen
  • multi-snake: snake for as many players as show up

    • New project, written with Buzz. Everyone plays on one board, in one world, held in one atom on the server
    • Runs at multi-snake.michielborkent.nl
  • Reagami: A minimal zero-deps Reagent-like for Squint and CLJS

    • Add reagami.ssr to render hiccup to an HTML string on the JVM, Babashka, Squint and CLJS. See Server-side rendering
    • reagami.core/render (the regular render function) now hydrates a server-rendered page. It adopts the existing DOM instead of clearing the root
    • Add create-reagami-app. Run npm create reagami-app my-app to create a Vite project with hot reload and a browser nREPL
    • Breaking: :on-render now takes a map: (fn [{:keys [node lifecycle state save]}]). Call save with a value to keep it for the next call, and read it back as state. In previous versions, the hook took three arguments and its return value became the state
    • Move reordered nodes with moveBefore where the browser has it, so a moved subtree keeps its iframe state, animations, focus and selection (#54)
    • Set value, checked, selected and disabled on a tag with a hyphen as attributes, not as JS properties. A custom element observes attributes, so a property never had any effect. Native elements still handle them as properties
    • Custom events, e.g. :on-rated, now reach the element through addEventListener, because a browser only wires an on* property for standard events
    • Add web component example. A <todo-list> custom element, used from Squint, from JavaScript with and without Reagami
    • Fix memory leak with :on-render nodes and other :on-render improvements
    • I gave a talk about Reagami at Func Prog Sweden
  • cljbang: a Clojure-like language that runs as Emacs Lisp

    • Compiles Clojure forms to Emacs Lisp forms and evaluates them in the running Emacs. No subprocess and no transpiled text, following the same approach as squint
    • Namespaces with per-namespace aliases, multiple arities in fn and defn, loop/recur with a tail position check, try/throw/ex-info, case, atoms, syntax quote including nesting, &form and &env in macros, regex and set literals, #_, edn/read-string, slurp and spit
    • el! for calling Emacs Lisp names that are not valid Clojure symbols
  • nbb: Scripting in Clojure on Node.js using SCI

    • ClojureScript JIT compilation. Nbb now bundles a SCI that compiles interpreted function bodies to JavaScript at runtime via js/Function. This is enabled by default. This makes loops, numerical computations and JS interop much faster
    • Nbb now ships babashka.fs as a built-in library. The full file system API (glob, copy, move, create-dirs, delete-tree, with-temp-dir, path helpers and more) is available via (require '[babashka.fs :as fs]), matching Babashka
    • Support implementing CLJS protocols (e.g. ILookup, etc) on deftype and defrecord
    • Support editscript: CLJS deftype/defrecord field interop, set! on ^:unsynchronized-mutable fields, add cljs.core type classes like PersistentHashMap, write-all and goog.math.Long
    • #416: Fix problem with prn in nREPL
    • SCI now covers most CLJS capabilities, so nbb should run existing CLJS libraries unless they rely on very specific macros that require the JVM. If you have anything that does not run, please report it in #nbb!
  • Scittle: Execute Clojure(Script) directly from browser script tags via SCI

    • ClojureScript JIT compilation. Scittle now bundles a SCI that compiles interpreted function bodies to JavaScript at runtime via js/Function. This is enabled by default
    • Include helitorus demo to show improved JIT
    • Bump reagent to 1.2.0, re-frame to 1.4.7, replicant to 2026.06.2 and shadow-cljs to 3.4.11
  • squint-inline: write squint functions and inline expressions in a ClojureScript project

    • New project. Squint operates on JavaScript objects and arrays, so assoc, update-in and select-keys work on those without js->clj and clj->js
    • Squint core is tree-shaken through :js-provider :import, and each function’s tree-shaken size is recorded
    • Squint functions can call each other across namespaces, and JS module references work inside squint bodies
  • Edamame: configurable EDN and Clojure parser with location metadata and more

    • Speed up parsing by holding parse context in record fields instead of the extmap: ~10% faster on JVM, ~4% on ClojureScript
    • Respect :refer + rename in :auto-resolve-ns
    • With :auto-resolve-ns, qualify syntax-quoted imported classes (e.g. `Date with (:import [java.util Date])) with the full classname
    • With :auto-resolve-ns, leave method, constructor and dotted syntax-quoted symbols (`.toString, `Bar., `foo.bar) as-is, matching Clojure
    • Do not resolve function literal params in a syntax quote
    • ClojureDart: fix parsing zero literals and make plain readers non-indexing, matching tools.reader (#144)
  • fs: file system utility library for Clojure

    • Released 0.5.34, which ships the Node.js support mentioned in the previous update as the @babashka/fs npm package
  • http-client: HTTP client for Clojure and babashka

    • #80: accept a function of the request URI in :proxy to select a proxy per request (@jeeger)
  • http-server: serve static assets

    • Range requests: inclusive Content-Range last-pos per RFC 9110, suffix ranges (bytes=-N, previously a 500), clamping last-pos beyond EOF, reading the full range, and a test suite (@slagyr)
  • Cream: Clojure + GraalVM Crema native binary

    • Reduced the core.async virtual thread memory corruption I reported upstream to a pure Java repro. GraalVM 25.0.3-ea.04 fixes it, and the pipeline test is back on now that the compile NPE is gone too
    • Enable the Ristretto JIT for runtime-loaded bytecode, and update the benchmarks for it
    • Clojure code runs without a JDK present, with the boot class loader warning suppressed
    • Pick up pom.xml when there is no deps.edn, and recompile Java sources when a dependency changed
  • graaljs-cherry: a native-image cherry REPL on GraalJS

    • New prototype. Compiles cherry expressions on the JVM and evaluates the resulting JS in an embedded GraalJS context
    • Two variants: a default Truffle JIT build, and a 49MB --small build without it
  • clj-kondo-browser: a static Clojure source browser built from clj-kondo analysis

    • New prototype. Renders a codebase as a static HTML page where every symbol links to its definition and usages, scope-aware, so a local is linked only within its scope
    • Runs clj-kondo as a pod and gets the classpath from deps.clj
  • grasp: Grep Clojure code using clojure.spec regexes

    • Babashka compatibility (#34)
  • deps.clj: a faithful port of the Clojure CLI Bash script to Clojure

    • As always, catching up with the most recent Clojure CLI versions
  • lein-clj-kondo and clj-kondo-bb: released alongside each clj-kondo release

Other projects

These are some other projects I’m involved with, but little to no activity happened in the past two months.

Click for more details

Permalink

Multitouch UI, remote microcontroller flashing, LLM task workflow

Hi friends,

I’ve been focused on a few projects I’m not quite ready to discuss yet, so this issue is a bit of a smorgasbord of thoughts and vibe-coded tool releases:

  • Musings on multi-touch: After 20 years, why is it still just zooming/scrolling?
  • Probetron: Turning a Raspberry Pi 4b into a network-accessible microcontroller programmer/debugger
  • LLM task workflow harness

Also, I’ll be in:

  • New York City, Sept 26–Oct 6
  • Milan, Oct 6–8

Let me know if you want to hang or have any favorite food/activity recommendations!

Why not more multitouch?

Apple popularized multitouch input with the iPhone and Mac trackpads in the late ‘00s, and since then it seems like most programs still only take advantage of the multitouch input for passive navigation: vertical scrolling, horizontal panning, and zooming.

Multitouch gestures are certainly well suited to these uses; in comparison, I feel extremely slow and clumsy whenever I have to zip around a PDF document or 2D canvas using only a mouse/trackball while holding down control/shift/command/who-can-even-remember.

However, I’m surprised that so little software seems to take advantage of such high-bandwidth human control input. In researching this topic I came across BetterTouchTool and was floored by the enormous number of gestures and input modalities it supported out of the box:

I’m now using it to map various taps and touches to commands like move/duplicate/rotate in my CAD tool, so that I can keep my hands on the trackpad rather than constantly running them back and forth to press hotkeys on the keyboard.

While it’s certainly fun to map a rotation of my fingers to the “r” key to trigger a “rotate” command in KiCad, it has me thinking of just what might be possible if multitouch were actually designed into an application rather than “bolted-on” like this to existing key shortcuts.

I’m sure iPad apps have a lot more sophisticated touch handling, though the locked-down, consumption-oriented focus of that device has kept me away from it. The two apps I’ve noticed as an outsider to the platform are:

  • Concepts (having only seen it in use via Stuff Made Here’s engineering videos)
  • Shapr3d, a legit Parasolid-backed CAD program on the iPad supporting touch and pencil

I’ve heard there are plenty of “Professional” apps for the iPad — let me know if you’re using any that have sophisticated multitouch or pen input for creative work!

Recently I’ve been designing PCBs using KiCad, and the experience of trying to place and connect hundreds of objects by tediously clicking on them one-by-one and nudging them around with arrow keys or a single cursor has me dreaming of what a multi-touch-forward interface might look like. What sorts of operations might we have for making selections, refining them, and manipulating the underlying entities?

Four years ago I experimented with CADtron, a pen/mouse-gesture-first 2D geometric CAD, and I’m starting to get the itch again. Especially now with LLMs doing the grunt work, it feels possible to build something more than just a research prototype…

Probetron

Speaking of LLMs and designing circuit boards, I recently found myself shuttling a microcontroller between:

  • my desktop computer, where I was having an LLM generate firmware, and
  • my lab bench, where I was connecting it to an apparatus

After a few iterations of this — asking the LLM to generate some firmware, flashing it, walking it over and reconnecting on the bench, noting the error message, relaying that back to the LLM, repeat — I realized I’d become what my friend calls a “reverse centaur”. Where a regular centaur combines the strength and speed of a horse body with the intelligence of a human head, the reverse centaur combines the feeble body of a person with the skittish, doofy mind of a horse — the worst of both worlds.

Upon realizing this, I threw together a tool for flashing a microcontroller and forwarding input/output (SWD, UART, and USB serial) over the network. This would allow me to put “hardware-in-the-loop” as they say, hand everything to an LLM, and take myself out of the inner “is the well-specified feature actually working?” iteration loop.

I built everything around a Raspberry Pi 4b, as I had one lying in a drawer. The device-under-test (DUT) can be flashed and reset using the Pi’s GPIOs, so no additional programmer hardware is needed:

A Raspberry Pi 4b with a mess of wires connecting it to an rp2350 microcontroller

My initial “this’ll take an hour for an LLM to throw together” estimate turned out to be a bit off — the project took the better part of a weekend. Partly scope creep:

  • baking an immutable SD card OS image from within a Linux VM rather than provisioning software on the Pi directly
  • refusing to require any configuration/setup on client machines, which, when combined with SSH really not wanting you to have keyless/passwordless login, means a lot of shenanigans serving an SSH private key over HTTP (lol)

but mostly because lots of lil’ rough edges came up during my initial usage:

  • the probe.rs progress bar doesn’t use newlines, but instead relies on learning your terminal’s width so that your terminal actually wraps the lines (and thus the script needs to forward your terminal width to the Pi)
  • when the Pi wasn’t coming up on the network, since I didn’t have an extra monitor handy to debug it, the LLM recommended adding “usb gadget mode” to the image so that when plugged into another computer via USB-C, the Pi would identify as a network adapter and thus you could SSH in to debug over a USB cable (that was a pretty cool solution, I thought)
  • there were all sorts of race-conditions related to the DUT’s serial-port-over-USB; I exposed the USB serial via a TCP socket so that the programs I’d already developed for my firmware would need only minimal modifications (opening a tcp://... instead of /dev/tty.usbserial123). This was quite glitchy until I realized that my firmware’s “hello, thanks for connecting to me” initial message was getting lost in the Pi’s OS buffers — I had to modify the harness on the Pi so that it only opened the usb serial port when a real client connected to the TCP socket (and likewise closed the serial port when the TCP socket closed).

The code is 100% LLM-generated, but it’s working well enough in my use case flashing rp2350 microcontrollers and forwarding their USB serial output, that it’s likely useful to others as well. I’ve open-sourced the repo here, give it a spin and let me know how it goes!

A single-file LLM task workflow harness

Back in May, I wrote:

No matter how much you plead in markdown:

You MUST run test.sh before committing

there’s a chance they’ll just go ahead and commit anyway (or “fix” the failing test by deleting it, etc.).

If you want LLMs to follow a deterministic process, you must use them via a deterministic harness.

Beyond simply ensuring tests/linters are run, another trick I’ve found that improves LLM code output is running a fresh context with a generic prompt like “review the last commit and tidy up any duplicated code, verbose comments, etc.”. I found it pretty funny that even a frontier model like Fable 5 reliably makes a mess when it’s implementing anything, such that running the same model in a fresh clean up context yields a transcript full of gems like “oh, this last commit added the same block of code in four places, I should make this a reusable function!”

While there are tons of harnesses out there, I wanted one that’s small and human-readable. For fun, I made it a single Babashka file so it’d be totally self-contained and easy to copy, modify, etc.

I’ll first talk about the workflow, then about the implementation details in Clojure and my dissatisfaction with the explicit state-machine architecture.

Task workflow

For the workflow itself, I was inspired by my friend Colin’s pi-task, in particular how it “frontloads” human involvement: One starts by interactively discussing the task scope with LLM first, then factoring that into a plan, which is then implemented autonomously.

I really enjoy the interactive design session, which is substantially more comprehensive than the “plan mode” built into Claude Code and Codex (which only seem to ask me a few clarification questions at best before trying to jump into implementation).

My harness follows Colin’s by starting with separate steps (context + prompt) for:

  • discussing the scope of the task itself (“refinement”),
  • how to implement that scope in terms of individually deliverable/testable subtasks (“planning”),
  • reviewing the plan

The prompts for these steps encourage the LLM to ask a single multiple choice question at a time, but since the responses are free-form text it’s always easy to steer the conversation:

  • none of these are good options, what about direction X?
  • let’s not do this functionality at all
  • this is a prototype, don’t worry about that edge case
  • clone project A and see how they handled this

After the task has been refined, the fresh context of the planning step divides the (now detailed) task specification into explicit subtasks. Each subtask can have:

  • explicit dependencies (on other subtasks)
  • check scripts that must pass before the subtask can be committed

To accomplish task X, the subtasks might be something like:

  • refactor the existing functionality spread across the codebase into a new crate/name
  • add new namespace with additional supporting functionality
  • implement X using these two new namespaces

I much prefer steering not-so-clever implementation agents upfront, rather than having a “frontier long task horizon” agent get, uh, creative with a gazillion tokens.

Finally, the “plan review” step launches a fresh context to review the generated plan. This has the same free-form Q&A format discussed above, and usually finds a handful of places where the subtask implementation or testing details are unclear.

This last step can be repeated as many times as desired. Furthermore, I designed the workflow to emit task.md as a sort of “structured markdown”:

# My task

check: tests-run-for-every-subtask.sh

Some background context that's given to every implementing subtask

## Subtasks

### a

some subtask

### b

dependencies: a

another subtask

### c

check: specific-test-only-for-this-subtask.sh

yet another subtask

so at any point you can decide to “take matters into your own hands” and edit the plan/subtasks directly rather than try to explain it to the LLM.

I tend to spend about 30–60 minutes in these authoring steps, which yields a task.md overview that is much more detailed and comprehensive than anything I’d have come up with on my own in the same period of time. A test-plan.md is also generated, which is intended to help you walk through and test that the task was done properly — it contains stuff like user interface and hardware tests that the LLM can’t do on its own as part of the implementation.

Once you’re happy with the plan, run tasktron.clj approve and the harness will:

  1. create a new branch for the task
  2. start subtask implementations in parallel, using git worktrees

Each subtask implementation agent is prompted “Do just [subtask description] as part of [task description]”. When the implementation agent completes, a review agent is prompted “Review this commit for conformance to this task and subtask” and can decide to:

  • approve the code unchanged
  • approve the code with amendments
  • reject the code and restart the subtask with a fresh implementation context with additional instructions

When approved, the harness handles cherry-picking the commit onto the task branch tip. (If there’s a conflict, an agent is started to handle it.)

While the task is cooking, a status overview is displayed:

Once everything has been completed, I review the work and merge the branch myself. I tend to do non-fast-forward merges so it’s clear in the git history that some commits were done as part of a single conceptual task. As part of the merge, I also check-in the task.md and test-plan.md so that context is stored in the repository.

I’ve been using this workflow for about a month and I’m quite happy with it thus far. The authoring workflow in particular has been awesome, and it has definitely helped me come up with better designs than I would’ve otherwise. I’m also happy with the local-first workflow where everything is built using git branches and worktrees (rather than some remote issue tracking API).

If you want to give it a spin, put tasktron.clj on your path, run it in a git repository, and follow the instructions. It shells out to pi and/or claude, and you can edit the source to select the harness and model for the specific workflow step. (I can’t stand talking to Claude but have free tokens, so I discuss with GPT-5.6-Sol and have Opus 4.8 implement.)

Task harness implementation

One of my goals was to implement the task harness as a single, “obviously correct” file, which could be read from the top down in a sort of “bottom-line up front” fashion, with the overall architecture coming first and the grittier implementation details coming later. (See Grant Slatton’s How to write complex software for more on this approach.)

I wrote it in Clojure, as that’s a concise, data-oriented language I know well.

Finally, I wanted the harness to be robust, with all of the essential state stored on disk, so that after an LLM provider outage, power outage, etc., I could just run tasktron.clj again and it would continue exactly where it left off (ideally resuming the in-flight LLM sessions by their transcript UUID in the same worktrees).

To do this, I implemented the system as an explicit state machine, using Malli to make legible the expected data shapes.

For example, each subtask state is associated with some data and possible transitions to other states:

(def state->definition
  {:initial               {:schema      :map
                           :transitions {:specified :pending}}

   :pending               {:schema      :map
                           :transitions {:implementation-started :implementing}}

   :implementing          {:schema      [:map [:base CommitId]]
                           :transitions {:implementation-finished :checking
                                         :blocked                 :blocked}}

   :checking              {:schema      [:map [:base CommitId] [:commit CommitId]]
                           :transitions {:check-passed :reviewing
                                         :check-failed :revising
                                         :blocked      :blocked}}

   :reviewing             {:schema      [:map [:base CommitId] [:commit CommitId]]
                           :transitions {:feedback :revising
                                         :amended  :checking-amendment
                                         :approved :awaiting-integration
                                         :blocked  :blocked}}

   ...})

The events associated with each subtask are stored in an append-only log on disk, from which the current state is derived. The next state is derived from the current state and some event, usually the result of an agent turn, but which might also come from the harness itself (running tests, reporting an infrastructure failure, etc.).

Effects are reified as data so that side-effects can be isolated to a single function and the vast majority of the code and tests can remain functionally pure (i.e., they don’t have side-effects and they return values determined entirely by the provided arguments). The effects themselves are derived from the state; e.g., if the next state is “attempt-rebase” the associated effect contains the commit ID of the source and the commit ID of the target (that we’re trying to rebase onto), with the effect handler returning an event (either “success, here’s the new commit ID” or “there’s a conflict”).

The core interpreter loop of the harness is then fairly compact:

(loop [state                   initial-state
       subtask                 nil
       event                   initial-event
       subtask->running-effect {}]
  (let [observed-task   (parse-task (slurp (str (fs/path task-dir TASK-FILENAME))))
        state           (reconcile-task state observed-task)
        next-state      (step state subtask event (now))
        effects-desired (effects-for-state next-state)
        effects-pending (remove (fn [{:keys [subtask]}]
                                  (contains? subtask->running-effect subtask))
                                effects-desired)]

    ;; Write state before running effects so re-running recovers from crashes.
    (save! task-dir next-state)
    (report! next-state (now))

    (let [subtask->running-effect (reduce (fn [active {:keys [subtask] :as effect}]
                                            (submit-effect! executor completed worktrees runtime effect)
                                            (assoc active subtask effect))
                                          subtask->running-effect
                                          effects-pending)]
      (if (seq subtask->running-effect)
        (let [{:keys [subtask event]} (await-completion! completed interactive? report! next-state)]
          (recur next-state
                 subtask
                 event
                 (dissoc subtask->running-effect subtask)))
        next-state))))

All-in-all, the harness consists of:

  • 500 lines of prompts and Malli schemas
  • 1500 lines of domain-specific workflow and task interpreter code
  • 500 lines of supporting code for parsing/generating markdown, interacting with Git, and handling CLI arguments
  • 2000 lines of LLM generated tests

While it works well enough, I don’t feel like the code meets my goal of being “obviously correct”.

All the code reifying effects, managing an explicit state machine, and deriving state from an immutable log all obscure the core subtask workflow:

  • create/reset the worktree to the latest branch tip
  • invoke implementation agent on the subtask
  • run the check script(s)
  • invoke review agent, having it either accept, amend, or send back to re-implement from scratch
  • run the check script(s) again (if amended)
  • allow up to 3 retries of this loop (more than that indicates the subtask is ill-specified)
  • cherry-pick onto the branch tip

I’ve been quite happy with this core workflow, and actually want to extend the harness with a “one-shot” entry point that kicks off this workflow for a single prompt (skipping the full refinement and plan steps), for those cases where I’m working on something and notice a minor thing that could be fixed/improved that I can review later when I’m finished with my current work.

Before I add new features to the harness, though, I’d like to refactor away from the explicit state machine design to reduce the amount of code.

I’m thinking the “durable execution” pattern might be a good fit. Essentially, memoize (durably, on disk) every side-effecting function call such that on failure/restart the system automatically “replays” itself back into the same state without re-executing side-effects that’ve already run.

Most of what I’ve found in this space (e.g. Temporal, Armin Ronacher’s Absurd Workflow) rely on some external database service, which is antithetical to my “it’s a single readable script” goal. I’m curious to see how concisely everything could be hand-rolled in Clojure, but of course if you are aware of relevant prior art (in any language) please let me know!

Misc. stuff

Permalink

def Is Not a Function. It’s Not Even a Macro.

Every so often a question comes along that seems too basic to be interesting, and turns out to be exactly the opposite. “Is def a function or a macro?” is one of those questions. The honest answer is: neither. And once you sit with why, you end up staring straight at the floor Clojure is built on.

The trap of the obvious answer

If you’ve been writing Clojure for a while, your instinct probably says “macro.” After all, def looks like it’s doing something at compile time — it takes a raw symbol, x, and doesn’t evaluate it the way a normal function would evaluate its arguments. That’s macro-like behavior, right?

Except it isn’t, and here’s the tell: macros are things you can expand. Try it:

(macroexpand '(def x 1))
;=> (def x 1)

Nothing happens. You get the same form back, unchanged. A real macro — when, ->, defn — rewrites itself into something else when you expand it. def refuses to rewrite into anything, because there’s nowhere lower to go. It’s already at the bottom.

That bottom has a name: special form.

Three categories, not two

It’s tempting to think of Clojure code as just “functions and macros,” but there’s a third, smaller, more fundamental category sitting underneath both:

  • Functions are ordinary values. You can pass them around, store them, apply them, take their class. Nothing about them is syntactically special — map is a value like 5 or "hello" is a value.
  • Macros are code-rewriting rules. They exist in terms of other code. Every macro, when you peel back the expansion, eventually bottoms out in function calls and special forms. when is a macro; expand it and you’ll find if waiting underneath.
  • Special forms are the primitives the evaluator itself understands. They’re not defined in Clojure — they’re hardcoded into the compiler. def, if, do, quote, fn*, let*, loop*, recur, throw, try, var, new, set!, the dot special form — this is the closed, small set everything else is built from.

def has to be in that third bucket because of what it does: it creates a Var and binds it into a namespace, using the symbol itself, unevaluated, as the name — with full knowledge of which namespace you’re currently compiling in. No ordinary function can do that, because ordinary functions evaluate all their arguments before they ever get a look at them. By the time a function saw x, it’d already have been evaluated to whatever x currently means (or it’d throw, since x isn’t defined yet — that’s the whole point of def).

You can ask Clojure directly, instead of reasoning it out:

(special-symbol? 'def)  ;=> true

So how do you tell the other two apart?

Once you accept that special forms are their own thing, a natural follow-up shows up: fine, but how do I check whether some other symbol is a function or a macro? Is there a macro? to go with special-symbol?

There isn’t one built into clojure.core — which surprised me a little — but it’s a one-liner, because macro-ness is just metadata on the Var:

(:macro (meta #'when))  ;=> true
(:macro (meta #'map))   ;=> nil

That’s the whole trick. Wrap it in a function if you want a name for it:

(defn macro? [sym]
  (:macro (meta (resolve sym))))

Functions are checked differently, and the difference matters conceptually: macros are a property of the symbol/Var, but functions are a property of the value the symbol resolves to. So you resolve first, then ask the value what it is:

(fn? @(resolve 'map))  ;=> true

fn? is the direct check. There’s also ifn?, which is broader — it’s true for anything invocable, including keywords and sets used as lookup functions, not just genuine fn values.

Putting the whole picture together

Every top-level symbol in Clojure falls into exactly one of three buckets, and you can check each one explicitly:

Category Check
Special form (special-symbol? sym)
Macro (:macro (meta (resolve sym)))
Function (or other value) (fn? @(resolve sym))

Which gives you a nice little classifier:

(defn classify [sym]
  (cond
    (special-symbol? sym) :special-form
    (:macro (meta (resolve sym))) :macro
    (fn? @(resolve sym)) :function
    :else :value))

(classify 'def)   ;=> :special-form
(classify 'when)  ;=> :macro
(classify 'map)   ;=> :function
(classify 'nil)   ;=> :value (resolve fails here actually, since nil isn't a var)

One honest caveat: resolve only works on symbols that name Vars in some namespace. Local bindings, literals, and destructured names will throw or return nil. This classifier is for top-level, namespace-resident names — not arbitrary forms you might type at a REPL.

Why this is worth caring about

It would be easy to file this under trivia. I don’t think it is. The three-tier structure — special forms at the bottom, macros built on top of them, functions as ordinary values sitting alongside — is the whole reason Clojure’s syntax stays so small while its expressive power doesn’t. defn, let, ->>, cond, nearly everything you reach for daily, is a macro that eventually expands down into that tiny, closed set of nineteen-ish special forms. Understanding where the floor is — and that def lives on it — is understanding why the language holds together at all.

Permalink

Managing Complex Application State with Reactive Data Flows

Reactive UIs look deceptively easy in a small app where you can keep things in sync without much effort. The trouble begins once the app starts to grow and accumulate real business logic. You often end up with cascading sets of rules that depend on derived values. On top of that, some of the data has to flow out to external services while more keeps coming in from them back into your application. Ensuring that all of it stays consistent while the user is busy clicking things and entering data in the UI is not trivial, as anybody who's built these kinds of apps knows.

Four building blocks

The good news is that we can use four building blocks to break the problem down. Datastar and glimmer give us an easy way to create a reactive UI that responds to changes in the data. Domino provides a transactional data flow engine which encodes all the business logic. Ebb gives us a clean way to coordinate data flows in and out of the system.

All these pieces happen to fit together in a neat way. Ebb sits at the edges and coordinates external events coming into the system. Those events get transacted in Domino, where any derived values are calculated, and then a glimmer reactive atom drives the UI updates based on the resulting state. User input flows the other way going from the UI into Domino, getting transacted and triggering effects that flow back out of the system through Ebb.

Ebb at the edges

Ebb ends up acting as a service bus with access to external resources such as a database, external APIs, or functionality like sending emails and generating PDFs that the app needs to hook into. These are all data flows at the edges of the application that you want kept away from the business logic.

It's a port of the Missionary JVM library which leans heavily on the Java ecosystem to do the heavy lifting. While that made it impossible to use Missionary directly, Jolt fibers happen to line up nicely with the way Missionary works conceptually. Ebb implements Missionary's API in pure Clojure on top of Jolt's fibers, and passes Missionary's own test suite. Having real fibers even improves on the original in one respect. Missionary's ? operator can only park when it appears syntactically inside the process body, because the coroutine transform it relies on is lexical. But each fiber carries a real stack, so in Ebb it's possible for functions to park at any call depth.

The core idea behind Missionary is to provide a library for supervised data flow programming where asynchronous effects can be treated as composable values. It tackles the problem of coordinating time and state in concurrent applications by linking the exact lifespan of any allocated resource to the period its data is actually needed by a consumer. This is accomplished using a directed acyclic graph supervision model where shared dependencies are allocated upon the first request and disposed during the final release.

The biggest advantage of this approach is that it does away with the memory leaks and state inconsistencies that plague reactive software development. Because the architecture forces strict boundaries around how and when asynchronous event streams are kept alive, you never have to worry about problems like an orphaned websocket or zombie threads eating up system resources. Every dependent resource is recursively cleaned up when a component unmounts, and that gives you a mathematically sound foundation for continuous time reactivity.

One interesting aspect of Missionary design is to use a bidirectional flow protocol which allows producer and consumer processes to negotiate backpressure in order to invalidate stale data before expensive recomputations can be triggered. The dashboard example at the end of the post reads a producer through two lanes contrasting the two ways of handling backpressure.

Lane A subscribes with m/observe, which pushes values at the consumer from a reader thread as they arrive. Since m/observe has no backpressure of its own, it needs to be paired with m/relieve to keep the newest value and drop the rest when the consumer starts to lag. Cancelling the flow runs the cleanup function, which destroys the child process ensuring that nothing is left holding a pipe or a pid.

(defn- observed-lines
  "A flow of producer lines, pushed from a reader thread."
  [k produced]
  (m/observe
   (fn [!]
     (let [proc (spawn-producer! k)
           rdr  (io/reader (:out proc))]
       ;; a reader thread loops over (.readLine rdr), bumping produced
       ;; and pushing each line with (! line)
       (fn cleanup []
         (kill! proc))))))

(defn- lane-a-flow [produced delivered]
  (let [source (m/relieve (fn [_ x] x) (observed-lines :a produced))]
    (m/ap
     (let [line (m/?> source)]
       ;; parking here lets the upstream
       ;; relieve collapse values
       (when (pos? @consumer-delay-ms)
         (m/? (m/sleep @consumer-delay-ms)))
       (swap! delivered inc)
       (parse-line line)))))

Lane B pulls one line per unit of demand instead, so when the consumer slows down, the OS pipe fills up causing the producer to stall. In this scenario, the pressure stays at the source so that values aren't dropped.

(defn- pulled-lines
  "A flow that reads one line per unit of demand. `m/via m/blk` moves the
  blocking read off the flow's thread; because nothing reads ahead, the
  pipe fills and the producer blocks in write(2)."
  [k]
  (let [proc (spawn-producer! k)
        rdr  (io/reader (:out proc))]
    (m/ap
     (loop []
       (if-let [line (m/? (m/via m/blk (.readLine rdr)))]
         (m/amb line (recur))
         (m/amb))))))

Domino

The data flow model used by Missionary happens to be a perfect fit for Domino, which is used to manage the state of the application. A document describing the data model sits at the core of Domino, tracking all the fields associated with the application's data. Business logic is expressed on top of the data model by attaching context-free functions to paths within the document to act as rules. A rule is triggered whenever a value changes at a path declared as its input, and the rules cascade in a transaction that produces a new state of the document. Once the document transacts, effects can be triggered that hand data off to the flow layer managed by Ebb.

I tend to think of an application as a state machine, and that's really the core idea behind Domino's design. An event gets triggered, which can be a user input, a system event, a service call, whatever, and it gets fed as an input into the data flow engine. Rules fire in a cascading fashion, and at the end you get a new state. Then you can fire effects, update the UI, and so on.

Here we can see what that looks like in concrete terms on the demo dashboard. A sample lands in the document as a single transaction on the [:sample] path which triggers a cascade of rules. The events are declared as data with each explicitly stating the paths it reads and writes, which allows computing the relationship graph.

(def events
  [{:id      :record-history
    :inputs  [:sample]
    :outputs [:history]
    ;; append the sample to the capped history series
    :handler ...}

   {:id      :compute-stats
    :inputs  [:history :window]
    :outputs [:stats]
    ;; stats over the last :window samples
    :handler ...}

   {:id      :compute-pressure
    :inputs  [:stats]
    :outputs [:pressure]
    :handler (fn [_ {:keys [stats]} _]
               {:pressure (+ (* 0.55 (get-in stats [:cpu :avg] 0.0))
                             (* 0.35 (get-in stats [:mem :last] 0.0))
                             (* 0.10 (get-in stats [:io-wait :last] 0.0)))})}

   {:id      :classify-alert
    :inputs  [:pressure :warn-threshold :crit-threshold]
    :outputs [:alert-level]
    :handler (fn [_ {:keys [pressure warn-threshold crit-threshold]} _]
               {:alert-level (cond
                               (>= pressure (or crit-threshold 0.85)) :critical
                               (>= pressure (or warn-threshold 0.55)) :warn
                               :else                                  :ok)})}])

The vector of events also acts as a spec for what the app does, clearly stating every business rule that's fired from the raw sample to the alert level. The thresholds and the window are hooked up to sliders that can be dragged to re-run the same pure events without a new sample arriving. One subtlety worth noting is that Domino runs an event once per changed input path which requires handlers to be idempotent.

With Domino you get a transactional data flow engine for managing the state of the application. Inputs come in, a transaction happens, and outputs come out. The real benefit is knowing exactly what the relationships are between all the fields in the document and the business rules associated with them. I've found that's the actual business problem in most large applications I've worked on. You end up with a lot of business logic along with many derived fields, and the complexity of their relationships gets too big to keep in your head. Then somebody comes and asks for a new business rule, and it becomes impossible to guarantee that adding it won't break some other rule within the system.

Taxes and loans are a really good example. You have a bunch of things that get calculated together, and the formulas change over time as the laws get updated, so you have to maintain clear rule sets for each scenario. When the calculations are scattered across the code base, there's no easy way to see what a given change touches, and no easy way to prove the rules are still consistent afterwards.

Another context I have direct experience working in is a hospital, where patient data needs to be coordinated between different teams. An app used to do patient assessments for surgeries will need to coordinate data between nurses, surgeons, dietitians, and other clinical staff. You end up with large forms that track many hundreds of different fields, and those fields are then used to calculate the scores for the pre-surgery assessment. Nobody can hold all of that in their head, and every field can potentially affect the outcome making it important to ensure the scores are derived correctly.

Reusable rules and views

Domino's approach makes the business logic reusable and composable, because the rule functions and the UI widgets are context free. If you write a formula for calculating BMI, that formula becomes a building block you can attach to any two fields representing height and weight, along with an output field for the BMI. A table widget can collect rows of information, and a graph widget can attach to the same path and render trends over time.

Domino also lets you create views attached to the schema, and these are used to map the fields in the document to the UI. If you have two roles such as a nurse and a surgeon, they might care about different subsets of the data in the document, and those subsets are likely to overlap. Being able to attach different views with their own widgets, naming conventions, and the fields they display makes it easy to express the same underlying data in different ways based on the context. Since the views still go through the common transact mechanism over the whole document, the values are recalculated whether they appear in a given view or not. A nurse might be collecting the height and weight of a patient, while the doctor only cares about the resulting BMI. Since the nurse doesn't need to see the BMI for it to be calculated, what's shown in the view has no direct relation to the business rules that still need to be fired. Whether you show a piece of data to the user or not, the business logic has to stay consistent across the document.

Another problem this approach solves is concurrent multiuser workflows. Since you know the subgraph of fields affected by any set of rules up front, you can lock those fields together whenever a user is editing a field belonging to the set. Different users can safely work on different parts of the document without worrying about overwriting each other's data. The related fields stay locked while a user edits, and the logic gets applied transactionally once they're done.

The UI layer

That leaves the final piece of the puzzle, which is the interface itself. Glimmer is a reactive GUI toolkit where you write Reagent-style components that return hiccup. Its sole job is to keep the widget tree in sync as the reactive state changes, and glimmer-datastar implements the server side of the Datastar protocol on top of glimmer. The page holds an open server-sent events stream, and the server re-renders the fragment and pushes it out whenever the state changes. The browser stays a dumb terminal with all the business logic living on the server.

In pretty much any large app I've worked on, I found that you always want to keep the application state in one place. Either it lives entirely on the front end and the backend is treated as a service bus, or it lives entirely on the backend with the client being responsible for collecting input and displaying UI widgets. Splitting the state across both sides means the two constantly have to negotiate over who owns what, creating a source of subtle bugs.

In the dashboard, the authoritative Domino context lives in an atom which is written to at whatever rate the machine produces samples. It, in turn, publishes to a reactive glimmer ratom on a timer, and that ratom is what the SSE streams subscribe to. This keeps the page from repainting at the rate that the data streams into the system, and prevents a misbehaving client from reaching back into ingestion.

;; the authoritative domino context is a plain atom
(defonce ctx (atom nil))

;; the single reactive cell the SSE renders subscribe to
(defonce view (ratom/atom {:db nil :cascade [] :log []}))

(defn publish!
  "Mirror the authoritative state into the view. One caller, on a timer."
  []
  (ratom/reset! view {:db (db) :cascade (change-history) :log @log-entries}))

The page itself is then just a function of that snapshot.

(defn fragment
  "The live region, rendered from one published snapshot."
  [live?]
  (let [{:keys [db cascade log]} @state/view
        {:keys [sample history stats alert pressure controls]} db]
    [:div#app-body
     (alert-banner alert)
     (gauge pressure (:warn controls) (:crit controls))
     ;; metrics, the side panels, and the controls rail
     ...]))

The dashboard example

I put together a dashboard that ties all of these ideas together. It's a live system monitor which renders the CPU, memory, and network figures that come out of /proc, so the page shows what the machine is currently doing.

Each layer sits in its own namespace, and their split follows the architecture I've discussed above. At the bottom we have app.pipeline, which is the Ebb layer that owns the streams which can sleep, require retries, or get cancelled. The app.state is managed by the Domino layer which sits between the data streams and the UI. Finally, app.ui renders hiccup from the published snapshot and hands it to Datastar.

The pipeline runs three ingestion lanes side by side, each demonstrating a different discipline against a live producer. Lane A pushes through m/observe into m/relieve, so the producer doesn't have to wait on a slow consumer. Lane B pulls one line per unit of demand through m/via m/blk ensuring that nothing is dropped with the pressure landing on the OS pipe instead. Lane C is a poll loop on a timer, because procfs builds its files at read time meaning that there's nothing to subscribe to. When you pause lane A, the flow gets cancelled, which kicks off the cleanup to destroy the child process, causing the pid to disappear from the lane card on the page.

Each sample arrives as a single Domino transact, and from there the cascade runs from the raw sample to window stats to a composite pressure index to an alert level. The Cascade panel lists the paths the last transaction wrote, using their execution order. The Model panel draws the event graph straight from the schema to render the actual business logic. The Log panel interleaves Domino transactions with Ebb task lifecycle events to illustrate the plumbing between the layers while the app runs.

Notably, Domino effects never perform IO themselves. Instead, an effect posts a request onto an Ebb mailbox that's drained by the supervisor fiber which spawns the matching task. Then, each task transacts its own result back into the document once it completes. The live context sits in a glimmer ratom allowing every connected page to repaint when the model changes.

The effect that asks for alert delivery fires on transitions to post a request onto the bus.

{:id      :announce-alert
 :inputs  [:alert-level]
 :handler (fn [_ {:keys [alert-level]}]
            (bus/request! {:type :alert :level alert-level}))}

In Ebb, a mailbox post hands the value directly to a waiting consumer and runs it until it parks again, on the posting thread. Effects fire inside the transaction while a write lock is held, and the supervisor's handlers transact, so posting inline would deadlock the two sides against each other. So, requests have to be collected during the transaction and posted once the lock is released.

(defonce requests (m/mbx))

(defn request!
  "Post a request, or collect it if a transaction is in progress."
  [req]
  (if-let [collector *collector*]
    (swap! collector conj req)
    (requests req)))

The supervisor fiber sits on the other side of the bus to consume requests and turn them into tasks.

(defn- drain-task
  "Take one request at a time and handle it before taking the next."
  [config]
  (m/sp
   (loop []
     (let [req (m/? bus/requests)]
       (handle! config req)
       (recur)))))

The alert below calls the sink, and retries with linear backoff while the sink keeps refusing, writing every attempt back into the model. The sink's failure rate is itself a slider, so retries can be exercised on demand.

(defn alert-task
  "Deliver an alert, retrying with linear backoff."
  [level max-attempts]
  (m/sp
   (loop [attempt 1]
     (state/transact! [[[:alert :delivery]
                        {:status :sending :level level
                         :attempt attempt :max max-attempts}]])
     (let [outcome (m/? (m/attempt (deliver-once level attempt)))]
       (if-let [err (try (outcome) nil (catch Exception e e))]
         (do (m/? (m/sleep (* 200 attempt)))
             (recur (inc attempt)))
         (state/transact! [[[:alert :delivery]
                            {:status :delivered :level level
                             :attempt attempt :max max-attempts}]]))))))

The full task also gives up after the configured number of attempts, and a cancelled alert means that the level recovered or a newer alert replaced the current one.

User input is treated as just another event into the system. Every slider transacts new values into the document to trigger rules and effects.

(defn- control-route
  "Every slider lands here: coerce, transact, and let Domino's effects
  act on the change downstream."
  [path signal value]
  (state/transact! [[path value]])
  (patch {signal value}))

Changing the sample interval transacts the interval-ms control, whose effect asks the supervisor to cancel lane C and spawn it again at the new rate.

What I like about this setup is that each piece ends up doing a well-defined job. Ebb owns time and cancellation, Domino owns the rules describing the business logic, and the UI just renders whatever the state happens to be at any particular time. The business logic lives in a transactional document where every dependency is declared explicitly, making it clear and transparent.

Permalink

A REPL you can fork

A REPL session accumulates definitions, values, and mutable objects as you work. We have extended SCI, a Clojure interpreter, so that a session can fork into independent histories. Each branch starts with the program's existing state and can evolve from there.

Fork one prompt into two live interpreters

The original REPL below has already defined a function, a Var, an atom, and a second name for that atom. Fork it, run the prepared mutation in the child, then return to the original. The child reports the new values; the original reports the earlier ones. Either history can fork again. The optional stress test retains 1,024 sibling interpreters for inspection.

The demo offers Superficie and Clojure syntax; both tabs operate on the same interpreter state.

INTERACTIVE VERSION The live branching REPL is available in the article at simm.is.

How live state branches

The function state was compiled before the fork. When either world calls it, label, counter, and alias resolve to that world's values. Both atom names still refer to one atom within each world, while its value can change independently in the parent and child. Later definitions, Var metadata, atom watches and validators, and volatiles follow the same model.

SCI creates the child by snapshotting its registered runtime cells and attaching them to a new evaluation context. The child continues from that state without rerunning earlier prompts.

SCI separates the identity of each Var or mutable primitive from its current state. Its stable handle selects state in the world where the function is running, so the child can change label while the parent continues to read :root.

ANALYSED VAR READ label → slot 12
STABLE IDENTITY one Var handle
SOURCE WORLD cells[12] = :root
CHILD WORLD cells[12] = :experiment
The handle remains stable. Evaluation selects one world-local realization of its slot.

A lineage registry assigns integer slots to Vars and SCI-owned mutable objects. Each world realizes those slots in a dense array. When SCI analyses a Var read, it records the slot number at that read site; entering an evaluation installs the selected world's array. Descendant reads can therefore use an indexed slot path centred on cells[slot] instead of looking up the Var in the registry each time.

The atom named by counter and alias uses the same arrangement. Its handle carries the value-slot number directly, plus a separate slot for less frequently accessed metadata, its validator, and its watches. A mutation compares and sets only the selected atom's value slot, so concurrent changes to unrelated atoms do not make each other retry.

Keeping reads cheap

Every call to state reads Vars and dereferences the atom, while a fork happens only when we ask for another history. We expect that imbalance in applications too: many reads and mutations between comparatively rare forks. The representation puts the copying work at the fork boundary and keeps ordinary access inexpensive. A host selects it with :runtime-mode :forkable. SCI's default :standard runtime keeps its existing direct paths.

Forkable mode has two additional fast paths. The primary world continues to read Var roots directly. On its first fork, SCI materializes those roots into slots and keeps the two representations synchronized. Descendant worlds use the analysed slot reads described above. The selected array holder is cached in execution-local state, avoiding a walk through the context on each access.

When you fork the demo, SCI copies the logical portion of the source world's slot array. The browser worker serializes evaluation and fork requests. On the JVM, a fork first waits for active evaluations of that world to finish. The array holds references, so immutable and persistent values can remain shared while SCI-owned state and cooperating host values receive child realizations. Fork time and retained memory are proportional to the lineage's allocated logical slots, including slots left by short-lived objects. Spare backing-array capacity is not copied.

An O(1) branch could share a persistent tree or copy-on-write page table. That would move more bookkeeping onto later reads or mutations. A design with one persistent world root could also make unrelated state cells compete to replace that root. Dense arrays keep reads indexed and atom updates local to one slot. Page-level copy-on-write remains worth exploring for workloads with large worlds and frequent forks, where reducing the copy cost could justify the extra indirection.

Dynamic bindings and suspended work

The demo forks between prompts, after the previous evaluation has finished. A Clojure binding applies within an evaluation scope and unwinds when that scope exits, so its temporary values are already gone by the next prompt. Each branch can enter new scopes using nested binding and set!, with the usual unwinding behaviour.

A runtime such as Spindel can also suspend a computation inside those scopes and resume it in a child world. That requires the binding frames as well as the world's state. The experimental SCI API can capture an opaque continuation context, retarget it to another context in the same lineage, and invoke a continuation with the selected world installed.

These bindings live outside the dense world array. Each evaluation maintains persistent frame maps from Var identities to small mutable binding boxes. Entering a world selects the effective frame once, while ordinary non-dynamic Var reads bypass it. Retargeting copies the binding boxes for the child and preserves the frame chain needed to unwind nested scopes.

From the REPL to Simmis

We already use this implementation in Dvergr and the current Simmis prototype through our replikativ SCI build. The browser demo is pinned to a build from the same draft SCI pull request. The work is still experimental and has not been released in upstream SCI.

SCI handles the mutable state of its own primitives. If an application supplies other mutable objects, it needs to specify how their state should be copied or shared across a fork. A random-number generator, for example, needs a policy for the streams each branch will use.

External effects require a different kind of bookkeeping. Discarding a branch can release resources it owns, such as an open connection, but it cannot recall a request already sent to another service. The application has to account for those actions and their costs even when it discards the computation that produced them.

Permalink

Writing evals for AI Agents - LLM as a judge

Protocol for a scorer

Earlier, we built an exact match scorer and an F1 scorer. These needed multiple functions

  • a scoring function
  • a result accumulation function
  • an initial result shape which was used to accumulate the combined result

As we add more scorers, we need to define these over and over again with unique names and there is no way to group them. So, let&aposs define a protocol which can be used to group related functions for the scorers.

The protocol defines three methods:

  • score: This invokes the scoring function
  • initial-result: Returns the initial value for the accumulated score which is used while doing a combination of all scores
  • accumulate: This function combines individual results into an aggregate score which can be displayed
(defprotocol Scorer
  "A protocol for an eval scorer"
  (score [_ actual-result expected-result-list question] "Score a result given the list of possible expected results")
  (initial-result [_] "Get the initial accumulated result shape")
  (accumulate [_ accumulator result] "Combine the result into the accumulated result"))

The two scorers that we carried from the previous post are ExactMatchScorer and F1Scorer. We could also have gone with a simple map based collection of functions but I wanted to try out protocols here.

Now let&aposs rewrite our two scorers using the protocol that we defined:

(defrecord ExactMatchScorer []
  Scorer
  (score
    [_ actual expected-list _question]
    (let [correct-answer? (if (seq expected-list)
                            (some #(str/includes? actual %) expected-list)
                            (str/includes? actual "Not Known"))]
      {:score (if correct-answer? 1 0)}))
  (initial-result [_] {:name "exact-match" :success 0 :failed 0 :partial 0})
  (accumulate
    [_ accumulated-result result]
    (let [score (get-in result [:score])]
      (cond
        (= 1 score) (assoc accumulated-result :success (inc (:success accumulated-result)))
        (= 0 score) (assoc accumulated-result :failed (inc (:failed accumulated-result)))
        :else (assoc accumulated-result :partial (inc (:partial accumulated-result)))))))

(defrecord F1Scorer []
  Scorer
  (score
    [_ predicted expected-list _question]
    (apply max-key :f1 (map #(f1-score predicted %) (if (seq expected-list) expected-list ["Not Known"]))))
  (initial-result [_] {:name "f1" :success 0 :failed 0 :partial 0})
  (accumulate
    [_ accumulated-result result]
    (let [score (get-in result [:f1])]
      (cond
        (= 1.0 score) (assoc accumulated-result :success (inc (:success accumulated-result)))
        (= 0 score) (assoc accumulated-result :failed (inc (:failed accumulated-result)))
        :else (assoc accumulated-result :partial (inc (:partial accumulated-result)))))))

LLM as a judge

In the previous post, we found that the code-only scorers had several issues where the matching logic became more convoluted to get a correct result. The solution in the evals world is to use another LLM to test the result. This sounds weird - using an LLM to check another LLM&aposs output. Turtles all the way down.

Generally the practice followed is to use a more capable LLM to check the outputs of a smaller LLM. In our case, since we are using local LLMs, I will use a GPT-5.4 nano model to judge.

This is how we will structure the prompt to GPT-5.4-nano. It takes in the question, reference answers and the actual answer as parameters. In case a reference answer is not available we prompt the LLM judge to allow Not Known as an acceptable answer.

(defn llm-judge-prompt
 [question references answer]
  (str "You are an LLM judge evaluating a question-answering response against SQuAD reference answer(s).

Score the model answer based on factual and semantic correctness:

1.0 — Fully correct; equivalent to a reference answer.
0.5 — Partially correct; contains some correct information but is incomplete or has a minor factual error.
0.0 — Incorrect; gives the wrong answer, contradicts the reference, or answers a different question.

Accept paraphrases and equivalent wording. Ignore capitalization, punctuation, and formatting. Extra information is acceptable if it is correct and does not contradict the answer.

Question:" question
" Reference answer(s):" (if (seq references) references "Not Known")
" Model answer:" answer "
Return JSON only:
{\"score\": 0.0|0.5|1.0, \"reason\": \"brief explanation\"}"))

Armed with the above prompt, we can write a generic LLM as a judge scorer using our protocol.

(defn- get-score
  [config question]
  (let [messages [{:role "user" :content question}]
        response (openai/create-chat-completion {:model (:model config)
                                                 :messages messages}
                                                (select-keys config [:api-key :api-endpoint :impl]))]
    (get-in response [:choices 0 :message :content])))

(defrecord LLMJudge [config prompt-fn]
  Scorer
  (score
   [_ actual expected-list question]
   (let [response (get-score config (prompt-fn question expected-list actual))]
     (cheshire/parse-string response)))
  (initial-result [_] {:name "llm-judge" :success 0 :failed 0 :partial 0})
  (accumulate
    [_ accumulated-result result]
    (let [score (get-in result ["score"])]
      (cond
        (= 1.0 score) (assoc accumulated-result :success (inc (:success accumulated-result)))
        (= 0.0 score) (assoc accumulated-result :failed (inc (:failed accumulated-result)))
        :else (assoc accumulated-result :partial (inc (:partial accumulated-result)))))))

After setting up the above scorers, a run with Qwen 3.0 0.6B looks like:

QuestionAnswerExpectedEM ScoreF1 ScoreLLM Score
Which Nobel Prize did Carl Wilhelm Scheele receive for discovering oxygen?The Nobel Prize in Chemistry was awarded to Carl Wilhelm Scheele for discovering oxygen in 1839.[]{:score 0}{:precision 0.0, :recall 0.0, :f1 0}{"score" 0.0, "reason" "The reference says the information is not known, but the model claims Scheele received the Nobel Prize in Chemistry in 1839. This is incorrect: Carl Wilhelm Scheele did not receive a Nobel Prize (the Nobel Prize started in 1901, and his discovery predates it)."}
In what year did Pierre de Fermat declare Fermat&aposs little theorem?Not Known if you do not know or are unable to infer.[In 1640 1640]{:score 0}{:precision 0.0, :recall 0.0, :f1 0}{"score" 0.0, "reason" "The model answer does not provide the year (it says not known), while the reference answer states it was 1640."}
How much of the European population did the black death kill?Not Known if you do not know or are unable to infer.[30–60% of Europe&aposs total population 30–60%]{:score 0}{:precision 0.0, :recall 0.0, :f1 0}{"score" 0.0, "reason" "The model answer does not provide the required figure (30–60%); it instead says the information is unknown."}
Who was the first human to discover fire?Who was the first human to discover fire? Humans.[]{:score 0}{:precision 0.0, :recall 0.0, :f1 0}{"score" 0.0, "reason" "The reference answer is &aposNot Known&apos, but the model claims &aposHumans&apos as the first to discover fire, which contradicts the reference."}
What is the largest city the Rhine runs through?The Rhine runs through Lyon, France, and is the largest city in that region.[Cologne, Germany Cologne]{:score 0}{:precision 0.0, :recall 0.0, :f1 0}{"score" 0.0, "reason" "The model answer incorrectly states the Rhine runs through Lyon, France. The reference answer identifies Cologne, Germany as the largest city the Rhine runs through."}
What was John Harvard&aposs exact date of birth?Not Known.[]{:score 1}{:precision 1.0, :recall 1.0, :f1 1.0}{"score" 1.0, "reason" "The model answer matches the reference: John Harvard&aposs exact date of birth is not known."}
When did Khan formally declare the Yuan dynasty?Not Known if you do not know or are unable to infer.[1271]{:score 0}{:precision 0.0, :recall 0.0, :f1 0}{"score" 0.0, "reason" "The model answer does not provide the date. The reference answer is 1271, so it fails to answer the question."}
In what country is Normandy located?France[France]{:score 1}{:precision 1.0, :recall 1.0, :f1 1.0}{"score" 1.0, "reason" "The model answer correctly states that Normandy is located in France, matching the reference."}
In what year was the charter granted for Harvard Corporation?Not Known if you do not know or are unable to infer.[1650]{:score 0}{:precision 0.0, :recall 0.0, :f1 0}{"score" 0.0, "reason" "The model answer does not provide the year the charter was granted for Harvard Corporation, so it cannot be compared to the reference (1650)."}
What is the largest city of Poland?Warsaw.[Warsaw]{:score 1}{:precision 1.0, :recall 1.0, :f1 1.0}{"score" 1.0, "reason" "The model answer is "Warsaw," which matches the reference answer."}
Exactly how many people died of the Black Death in Asia?The exact number of people who died of the Black Death in Asia is not known, but estimates suggest about 20–30 million.[]{:score 0}{:precision 0.1, :recall 1.0, :f1 0.18181818181818182}{"score" 0.0, "reason" "The reference answer states the exact number is not known. The model adds a specific estimate (20–30 million), which contradicts the reference’s &aposNot Known&apos response."}
What is the largest prime number?No Largest Prime Number.[]{:score 0}{:precision 0.0, :recall 0.0, :f1 0}{"score" 0.5, "reason" "The model answer correctly states that there is no largest prime number. However, the reference answer is marked as &aposNot Known&apos, so it does not exactly match the provided reference."}
In what year was the P versus NP problem solved?1982[]{:score 0}{:precision 0.0, :recall 0.0, :f1 0}{"score" 0.0, "reason" "The reference answer states the year is not known, but the model answers 1982, which contradicts the reference."}
When was the French and Indian War?The French and Indian War occurred in 1762.[1754–1763]{:score 0}{:precision 0.0, :recall 0.0, :f1 0}{"score" 0.0, "reason" "The reference states the French and Indian War occurred from 1754 to 1763, but the model answer says 1762 only, which does not match the full date range."}
In which Portuguese city does the Rhine reach the sea?Not Known.[]{:score 1}{:precision 1.0, :recall 1.0, :f1 1.0}{"score" 1.0, "reason" "Both the reference answer and the model answer state that the information is not known."}
In what year did Kublai Khan establish a Yuan dynasty colony in Australia?Not Known.[]{:score 1}{:precision 1.0, :recall 1.0, :f1 1.0}{"score" 1.0, "reason" "The model answer matches the reference answer: &aposNot Known&apos."}
What is the term used to identify a deterministic Turing machine that has additional random bits?Probabilistic.[A probabilistic Turing machine probabilistic probabilistic Turing machine]{:score 0}{:precision 1.0, :recall 1.0, :f1 1.0}{"score" 1.0, "reason" "The model answer "Probabilistic" correctly identifies the term used for a deterministic Turing machine with additional random bits (i.e., a probabilistic Turing machine)."}
What is the French name for the Rhine?Rhine.[Rhin]{:score 1}{:precision 0.0, :recall 0.0, :f1 0}{"score" 1.0, "reason" "The model answer "Rhine" matches the reference French name "Rhin" (equivalent spelling as given in the reference, capitalization differences ignored)."}
In what constituent country of the United Kingdom is Merthyr Tydfil located?Wales[Wales south Wales]{:score 1}{:precision 1.0, :recall 1.0, :f1 1.0}{"score" 1.0, "reason" "Merthyr Tydfil is located in Wales, which matches the reference answer."}
What is the smallest odd perfect number?Not Known.[]{:score 1}{:precision 1.0, :recall 1.0, :f1 1.0}{"score" 1.0, "reason" "Both the reference and the model answer state that the smallest odd perfect number is not known."}
Which book by Edward Said portrayed the east as being the "others?"The book by Edward Said that portrayed the East as being the "others" is The Orientalist.[Orientalism]{:score 0}{:precision 0.0, :recall 0.0, :f1 0}{"score" 0.0, "reason" "The reference answer is "Orientalism," but the model answer says "The Orientalist," which is not the stated book in the reference."}
Who produced the first geological map of the U.S.?The United States Geological Survey (USGS) was established in 1902.[William Maclure]{:score 0}{:precision 0.0, :recall 0.0, :f1 0}{"score" 0.0, "reason" "The model answer incorrectly states that the USGS was established in 1902. The correct answer is that William Maclure produced the first geological map of the U.S."}
When did Carl Wilhelm Scheele discover oxygen?Not Known.[1773 1773 or earlier]{:score 0}{:precision 0.0, :recall 0.0, :f1 0}{"score" 0.0, "reason" "The reference states Scheele discovered oxygen in 1773 or earlier, but the model answer says &aposNot Known,&apos which does not match the reference information."}
What is the largest stadium in Australia?Sydney City Stadium.[Melbourne Cricket Ground The Melbourne Cricket Ground]{:score 0}{:precision 0.0, :recall 0.0, :f1 0}{"score" 0.0, "reason" "The model answer &aposSydney City Stadium&apos is not the largest stadium in Australia; the reference answer is the Melbourne Cricket Ground (MCG)."}
Which gas makes up 20.8% of the Earth&aposs atmosphere?Oxygen.[Diatomic oxygen Diatomic oxygen gas]{:score 0}{:precision 1.0, :recall 0.5, :f1 0.6666666666666666}{"score" 0.5, "reason" "The model answer says “Oxygen,” which is broadly correct, but it doesn’t specify that the 20.8% is diatomic oxygen (O2), as in the reference."}
Who expounded the Three Laws of Motion?Isaac Newton.[Isaac Newton]{:score 1}{:precision 1.0, :recall 1.0, :f1 1.0}{"score" 1.0, "reason" "The model answer correctly identifies Isaac Newton as the person who expounded the Three Laws of Motion."}
In what year did Isaac Newton publish his fourth law of motion?1687[]{:score 0}{:precision 0.0, :recall 0.0, :f1 0}{"score" 0.0, "reason" "The reference answer is &aposNot Known&apos, but the model claims Newton published his fourth law of motion in 1687, which is not supported by the provided reference."}
Which theory states that slow geological processes are still occurring today, and have occurred throughout Earth&aposs history?The theory of plate tectonics.[uniformitarianism]{:score 0}{:precision 0.0, :recall 0.0, :f1 0}{"score" 0.0, "reason" "The model answer identifies plate tectonics, but the reference asks for uniformitarianism, which states that slow geological processes continue today and throughout Earth&aposs history."}
Who demonstrated how to create a perfect number from a Mersenne prime?Not Known if you do not know or are unable to infer.[Euclid]{:score 0}{:precision 0.0, :recall 0.0, :f1 0}{"score" 0.0, "reason" "The model answer does not provide the required figure (Euclid) and instead says it is not known."}
What is the Chinese name for the Yuan dynasty?元朝[Yuán Cháo 元朝]{:score 1}{:precision 1.0, :recall 1.0, :f1 1.0}{"score" 1.0, "reason" "The model answer &apos元朝&apos exactly matches the reference answer (元朝 / 元朝)."}

The overall scores are:

ScorerPartialCorrectIncorrect
exact-match01020
f121018
llm-judge21117

All three scores give a different rating for the responses. Hopefully you got a taste of how complex evals for an LLM based solution are. Even for a simple set of 30 questions most of which had fixed answers we could not get to a scoring scheme which was perfectly reliable and human supervision is needed to see whether the solution is performing as expected. Also, every run of the eval will show different scores, so a better way is needed to see stability of results across runs.

Permalink

Why this year’s Clojure/Conj matters beyond the conference

Every engineering community has its moments of convergence. Moments when people working on similar problems, often from different countries, industries, and backgrounds, come together to exchange ideas, challenge assumptions, and learn from one another.

For the Clojure community, Clojure/Conj has long been one of those moments. Each year, the conference brings together developers, language contributors, researchers, and engineering teams who are interested in functional programming and the practical challenges of building reliable software. It’s a space where discussions range from language internals and architecture decisions to lessons learned from production systems, creating opportunities for conversations that often continue well beyond the event itself.

This year, Clojure/Conj 2026 will take place from September 30 to October 2 in Charlotte, North Carolina. While the technical program remains at the heart of the conference, one aspect makes this edition particularly meaningful for the broader developer community: the event will be streamed online free of charge.

That decision may sound operational at first glance. In practice, it makes technical conversations accessible to a much wider audience.

A conference that reaches further

Technical conferences have always been valuable places to exchange knowledge. They create opportunities to hear directly from the people building languages, libraries, frameworks, and large-scale systems. They also provide the informal conversations that shape future collaborations.

At the same time, participating in these events has traditionally depended on a combination of factors that many developers simply don’t have: the ability to travel internationally and the budget for tickets and accommodation.

None of these barriers are related to curiosity or technical ability. Yet they often determine who gets to participate.

By offering free online access, Clojure/Conj 2026 makes it easier for developers everywhere to join the conversation regardless of where they live. Removing the financial barrier doesn’t replace the in-person experience, but it significantly expands who can benefit from the knowledge shared during the conference.

Watching the conference live also offers something that recordings can’t fully capture. Online attendees experience the event as it unfolds, following the same conversations as the audience in Charlotte, seeing speakers respond to questions in real time, and sharing the energy of the conference alongside the community. The event is designed to connect onsite and online participants, creating a shared experience rather than simply broadcasting presentations.

For developers joining from different parts of the world, the live stream is expected to include language support that helps make the sessions easier to follow. More importantly, it allows people to participate in the same moment as the rest of the community instead of catching up later.

Accessibility in technology, sometimes, is about making sure more people can participate in the same technical conversation.

Strong communities grow when knowledge moves freely

One of the defining characteristics of the Clojure ecosystem has always been its emphasis on thoughtful discussions and knowledge sharing.

Many of the ideas that shape the community develop through open source projects, blog posts, podcasts, mailing lists, meetups, and countless conversations between developers solving real-world problems.

Conferences like Clojure/Conj serve as a meeting point for those ongoing discussions. They bring together people working in different contexts but facing similar engineering challenges, creating opportunities for ideas to evolve collectively rather than in isolation, and that exchange benefits far more than the attendees.

A presentation often becomes a blog post; a conference discussion turns into an open source contribution; a production lesson inspires improvements in another team’s architecture months later; and knowledge continues to circulate long after the closing keynote. The healthier these feedback loops become, the stronger the ecosystem grows.

Making those conversations accessible to a broader audience means expanding not only the number of people listening, but also the diversity of perspectives contributing back to the community.

Why Nubank supports initiatives like this

At Nubank, Clojure has been part of our engineering journey for many years. Throughout that time, we’ve benefited enormously from the work of the broader open source community—from the language itself to the libraries, tools, ideas, and discussions that help teams build better software. Supporting community initiatives is one way of giving something back.

That commitment extends beyond participating in conferences. Over the years, Nubank has invested hundreds of thousands of dollars in sponsorships for open source developers working across the Clojure ecosystem, helping maintain the libraries and tools that countless engineering teams rely on every day. Supporting the people behind open source projects is one way to help ensure the ecosystem remains healthy, sustainable, and able to evolve over time.

Community support also means helping sustain the environments where engineers can learn from one another, exchange experiences, and collectively advance the state of software engineering. Healthy technical ecosystems depend on people who are willing to document what they’ve learned, publish their successes alongside their failures, maintain open source projects, answer questions from newcomers, and create spaces where knowledge can circulate freely. Events like Clojure/Conj are another important part of that ecosystem, creating opportunities for ideas to spread across teams, organizations, and countries.

Making the conference available to anyone with an internet connection extends those conversations to developers who might otherwise remain outside them. Alongside initiatives such as our ongoing sponsorship of open source maintainers, it reflects the belief that investing in both people and knowledge sharing helps strengthen the community as a whole. That ultimately benefits everyone who builds software using Clojure, whether they attend the conference or simply encounter the ideas it helps spread.

For readers interested in learning more about Nubank’s open source sponsorship program, we’ve previously shared the motivation behind the initiative, and many of the projects and maintainers we support are publicly listed through our GitHub Sponsors profile.

Join the conversation

Whether you’ve been writing Clojure for years or you’re simply curious about functional programming, Clojure/Conj offers an opportunity to learn directly from people working on real systems, exploring new ideas, and contributing to the language’s future.

This year’s edition, taking place from September 30 to October 2 in Charlotte, North Carolina, makes that opportunity more accessible than ever. Free online streaming removes geographic and financial barriers, allowing more developers to take part in the conversations as they happen. Joining live means experiencing the event alongside the community—following the discussions in real time, seeing the interaction between speakers, the MC, and both onsite and online audiences, and participating in the shared momentum that makes conferences much more than a collection of recorded talks.

We’re excited to support another edition of Clojure/Conj and to see even more developers, from more places and backgrounds, take part in the conversations that help shape the future of the Clojure community.

The post Why this year’s Clojure/Conj matters beyond the conference appeared first on Building Nubank.

Permalink

Biff 2.0 is released

Last April I outlined some changes I had planned for Biff, which included making SQLite the default database, splitting the monolithic com.biffweb namespace into a bunch of independent libraries, using Datastar by default, introducing some new approaches for keeping large codebases maintainable, blah blah blah, and bumping the version to 2.0.0. I'm pleased and slightly exhausted to announce that those changes are SHIPPED and you can try them out like this:

git clone https://github.com/jacobobryant/biff-starter my-project
cd my-project
clj -M:run dev

I have tweaked the landing page, written the documentation, and even started, just barely, to actually use Biff 2 to make a new app.

If you've used Biff 1, then please be advised that there are technically no breaking changes (since everything is in new namespaces) and that I've written up some guidance on gradually introducing Biff 2 into a Biff 1 codebase, if you so desire. There are some breaking changes if you've already been trying out the Biff 2 prereleases.

A few things from Biff 2 that I find particularly interesting, some of which are covered in more detail by the aforementioned blog post:

  • This demo.clj file from the starter project gives a short yet representative taste of what application code in a Biff project actually feels like. Note the parameters injected into demo-page by biff.graph; the POST request handlers that return their side effects as data via biff.fx; the fact that only a single handler needs to return HTML and those POST request handlers don't need to concern themselves with rendering at all, and yet the page is fully reactive--thanks to Datastar.

  • The starter project is now a standalone repo and can be easily forked and modified if you want to create an alternative starter project (with, say, a different database).

  • Speaking of using different databases, there is a guide on writing a database adapter. This makes switching out the default database much easier than it was in Biff 1 (no need to rewrite the authentication module, for example).

  • biff.core's new module system. The flip side of making Biff more modular is that there's an increased need to have well-defined interfaces for the modular pieces to plug into. I decided to extract Biff's "framework" logic into a library not just to reduce boilerplate but also to ensure things are being done the way that Biff 2 libraries expect.

  • defpipeline, a very recent addition to biff.fx that cuts down the boilerplate and IMO makes using biff.fx feel pretty ergonomic.

  • biff.graph of course. The whole thing.

  • biff.run and biff.tasks, the latter of which has a video demo of using the prod-setup and deploy tasks to deploy a vanilla (non-Biff) Clojure app to a fresh VPS.

My overall thoughts on where Biff has ended up: I'm definitely taking some bets here. biff.fx and biff.graph are a bit weird. Awesome, but weird. Will the benefits really matter for the projects people use Biff for? biff.core's module system, despite being fairly lightweight, is still not as lightweight as the 5-line reduce call that Biff 1 used. Does that similarly push Biff further out of good-for-a-weekend-project territory? At the same time as I'm adding these features to benefit large codebases, does switching to SQLite--an embedded database--make Biff less attractive for projects that are likely to end up with large codebases?

My north star is still "what do I want for myself," so despite the hypotheticals above, I'm not actually that concerned: I think all this new stuff is ridiculously sweet. And I do think the modularity and the related ease of making alternative starter projects is somewhat huge. Biff is much more evolvable now. If I've gotten anything wrong, it really shouldn't be that difficult for anyone (including my future self) to fork my starter project and fix whatever it is that needs fixing.

Except for biff.core; we're stuck with that part now.

Permalink

Clojurists Together Update: July and August 2026

Time for another bi-monthly update on the work that Clojurists Together are funding this year - my maintenance of nREPL, CIDER and friends. I published the previous one as a blog post for the first time and the feedback was good enough that I’ll keep doing it.

Last time I said that I had plucked most of the low-hanging fruit and that the next two months were unlikely to be as productive. Well, I was wrong. CIDER 2.0 finally shipped, and once it was out the door I used the momentum to sweep through pretty much every corner of the nREPL/CIDER ecosystem. A few long-neglected projects got proper releases, and nREPL got a couple of brand new implementations in languages I play on the side from time to time.

The big highlights from my perspective:

  • CIDER 2.0 (“Terceira”) is out, followed by 2.0.1, and 2.1 is taking shape on master
  • clj-refactor 4.0 is out
  • Sayid went from 0.4 to 0.8 in the span of three weeks
  • Drawbridge, nREPL’s HTTP transport, got its first meaningful release in years
  • nREPL went polyglot: nREPL servers for Erlang and Elixir and an OCaml client
  • clj-suitable 0.7 and 0.8 closed most of the gap between ClojureScript and Clojure completion
  • A lot of work landed on nREPL’s master (TLS hardening, URL-based connections, docs) and a new release is right around the corner

Below you’ll find more details about the work I did, project by project.

CIDER

CIDER 2.0 (“Terceira”) landed on July 15, right on the schedule I had announced in the preview post. For once in my life I was actually on time! The big themes were covered there and in the release announcement (transient menus, inline macro stepping, call-graph browsers, source-based find-references, the tracing and tap buffers, rich content in the REPL), so here’s just what changed between the preview and the final release:

  • cider-doctor, which checks your Emacs setup and the active nREPL session for common problems and produces a report you can paste in a bug report
  • an orchard value for cider-print-fn, selecting cider-nrepl’s much faster orchard.pp pretty-printer
  • SSH tunnels now forward a free local port, so remote REPLs sharing a port no longer collide on localhost
  • C-c C-d at the stdin prompt sends end-of-input, and stdin is routed to the exact connection that asked for it
  • a long tail of nREPL client fixes: a slow memory leak on the eldoc/completion path, nrepl-dict-merge mutating a shared literal, notifications treated as format strings

CIDER 2.0.1 followed a week later with fixes for the problems early adopters ran into: evaluation in a dependency’s source buffer erroring with “No linked CIDER sessions” (in several variants), cider-enlighten-mode never lighting anything up (a 1.22 regression), the macroexpansion commands refusing to expand let/fn/loop, and load-file potentially freezing Emacs on a huge result. Nothing dramatic, but I’m glad people were quick to report those.

After that master (the future CIDER 2.1) kept moving at a steady pace. A few of the things that landed there:

  • CIDER’s dynamic font-locking (REPL-defined macros, functions, deprecated/instrumented/traced symbols) now works better in clojure-ts-mode buffers via tree-sitter. Previously it worked “officially” only under clojure-mode. The debugging reader tags are highlighted there too.
  • A new cider-preferred-clojure-mode controls which mode CIDER uses to font-lock the code it renders - REPL results, doc examples, overlays and its own display buffers. clojure-ts-mode is finally a first-class citizen in CIDER.
  • Symbol prompts can go through completing-read (so Vertico/Ivy/Helm kick in) and completion annotations render as an aligned type/namespace column in Corfu, Vertico and the built-in *Completions*. More on this in Modernizing CIDER’s Completion.
  • Connecting got smarter. Container-published nREPL ports are resolved for /docker: and /podman: buffers, lein trampoline REPLs are detected, .nrepl-port files are no longer discarded on systems without lsof, and there’s a new “How CIDER Finds Ports” section in the manual.
  • Every form command got an “at point” variant (inspect, pprint, macroexpand, format, insert in REPL), there’s a cider-inspect-menu listing every way to start an inspection, and the contents of comment forms are treated as top level by the whole defun command family.
  • Stray output from long-lived background processes (say, a core.async go-loop still printing under a finished eval’s id) is now routed to the REPL instead of being dropped with a warning.

One more thing. I shipped “smarter form targeting” on master - the evaluation commands resolving the form from where the cursor actually is, rather than the form before it - wrote about it, got a lot of feedback, and reverted it a few days later. CIDER 2.1 will keep the classic Emacs semantics. Fifteen years in, the existing behaviour is the contract, not an implementation detail I get to tidy up. The detour wasn’t wasted, though: it surfaced a bug where the text of a line comment was treated as code, and the “at point” family of commands is a direct result of it.

cider-nrepl

Three cider-nrepl releases in July, wrapping up the tools.deps migration and driving the CIDER 2.0 launch:

  • cider-nrepl 0.62.0 finalized the Leiningen to tools.deps migration, simplified deferred middleware loading, documented the op response keys (with a test verifying the descriptor contract) and shipped the hardened content-type and slurp middleware that made rich content safe to enable by default.
  • cider-nrepl 0.62.1 fixed a whole cluster of debugger bugs. Record literals no longer get downgraded to plain maps by instrumentation, deftype/defrecord method bodies are skipped (goodbye Unable to resolve symbol: STATE__), and enlightening deftest bodies works again.
  • cider-nrepl 0.62.2 pruned trace and tap subscriptions with dead transports (a dead subscriber used to break every traced evaluation), stopped the debugger from shadowing enlighten’s evaluator, brought the docs back in sync with the code and added a section for tool authors.

Orchard

Orchard 0.44.0 shipped on July 4, mostly thanks to Sashko’s inspector work (a replace command, truncated table columns, ARef contents rendered fully). My part was a round of tests for the less covered namespaces and, later on master, a fix for orchard.print ignoring custom print-method implementations for records and collections. Thanks, Sashko!

clj-refactor 4.0

clj-refactor.el 4.0 is the release I had been promising for a few cycles. It requires Emacs 28.1+ and CIDER 2.0+, and it’s a big one:

  • project-wide refactorings (rename symbol, change signature, inline symbol) now show a diff preview before touching disk, and cljr-undo-last-refactoring reverts the last one in a single step
  • the slow refactorings run asynchronously, so Emacs no longer freezes while the middleware analyzes the project
  • cljr-change-function-signature can add and remove parameters and handles multi-arity functions
  • a clj-refactor-menu transient replaces the hydra menus, and the multiple-cursors, hydra and inflections dependencies are gone
  • many commands degrade gracefully without a REPL (cljr-clean-ns, cljr-slash, cljr-add-missing-libspec, cljr-remove-let, cljr-promote-function)
  • cljr-slash can add and hotload a missing library, artifact lists are cached, and the namespaced refactor-nrepl ops are used when available

I still think the long-term home for the most useful bits is CIDER and clojure-mode, but at least the project is in good shape while I figure that out. There’s a bit more in the release post.

clj-suitable

clj-suitable, the ClojureScript completion backend, was another project that had been coasting for years:

  • clj-suitable 0.7.0 adapted to Piggieback 0.7’s delegating repl-env, modernized every dependency, replaced the Leiningen build with tools.build, moved CI to GitHub Actions and added a shadow-cljs integration test over a real Node runtime.
  • clj-suitable 0.8.0 brought the static completion much closer to compliment: fuzzy matching (pr-fn completes print-function), compliment-style ranking, completion of local bindings (destructuring included) and of referred vars inside :refer vectors. It also fixed the REPL’s *1/*2/*3 getting clobbered by completions and a few long-standing shadow-cljs and Node.js issues.

ClojureScript users - I’d love to hear how the new completion feels in practice.

Sayid

The Sayid revival continued at a brisk pace, with five releases between July 1 and July 17:

  • Sayid 0.4.0 dropped the com.billpiel namespace prefix, added data-returning variants of the workspace and query ops, and introduced a client-rendered, foldable tree view of the recorded call tree built on CIDER’s cider-tree-view.
  • Sayid 0.5.0 made recording bounded: a record limit, per-function limits, sampling, a max trace depth and bounded printing. Tracing a namespace under a test suite can’t eat all your memory anymore.
  • Sayid 0.6.0 rebuilt inner tracing on tools.analyzer.jvm, replacing the fragile source-rewriting instrumenter.
  • Sayid 0.7.0 added sayid.data (the recorded call tree as plain data, with tap> integration for Portal and friends) and sayid.golden, a golden-trace testing helper.
  • Sayid 0.8.0 focused on the experience: a sayid-menu transient, plain-language feedback from the trace commands, getting-started hints in empty views, and a fix for the inspector integration that had been broken for years.

I wrote a bit more about the last one here. Not bad for a project that was completely dead in June, right?

Drawbridge

Drawbridge is nREPL’s HTTP transport, created by Chas Emerick in 2012 and “technically maintained” ever since. I finally gave it the attention it needed:

  • Drawbridge 0.3.1 updated the dependencies (nREPL 1.7, Ring 1.15) and throttled client polling so it stops flooding servers with GET requests.
  • Drawbridge 0.4.0 is the interesting one. It adds drawbridge.bridge, a local nREPL socket server that relays to a remote Drawbridge endpoint, so any socket-based client (CIDER, Calva, rebel-readline) can now talk to Drawbridge. There’s also a WebSocket transport with server push instead of long-polling, bearer-token authentication via secure-ring-handler (which refuses to run unauthenticated unless you insist), and a deps.edn, so it’s usable as a git dependency.

The full story is in Lowering the Drawbridge.

nREPL

No nREPL release this cycle, but master is shaping up nicely for 1.8:

  • the built-in command-line client can connect using a URL, including the nrepls:// and nrepl+unix: URLs that TLS and filesystem-socket servers advertise, and http(s):// when Drawbridge is on the classpath
  • TLS hardening: descriptive errors for invalid key material, Ed25519 keys, tolerating a swapped certificate order, and a documented security model
  • the built-in client sends input to the server as raw text, so reader typos, auto-resolved keywords and custom tagged literals no longer crash it
  • stdin fixes: EOF arriving behind buffered input is reported properly, and a race between the stdin consumer and producer is gone
  • nrepl.spec finally matches what describe and ls-sessions actually send
  • a pile of documentation debt cleared (lookup return values, the session-closed status, the -f/--repl-fn option, middleware best practices) and a CI check keeping ops.adoc in sync with the descriptors
  • Clojure 1.10 is the new minimum and nrepl.misc/requiring-resolve is gone in favour of the core one

The nrepl.org site also picked up links to several new clients and servers (Nautilos, nREPL.hx for Helix, Janet and Steel Scheme servers). The nREPL family keeps growing, which makes me happy every single time.

nREPL on the BEAM

nrepl-beam is a brand new project I started in July, mostly because I wanted to see how well the nREPL spec holds up when implemented from scratch outside the JVM. It’s home to:

  • dialtone, an nREPL server for Erlang (and a server core for the whole BEAM)
  • repartee, the Elixir server built on top of it
  • chaser, a terminal nREPL client that works with any nREPL server

nrepl-beam 0.1.0 shipped on July 14. Both servers implement the full op set (eval with streamed output, sessions, interrupts, stdin, load-file, completions, lookup) and pass neat’s cross-implementation integration suite alongside Clojure, Babashka and Basilisp. Writing them was a good test of the spec, and it produced a few of the documentation fixes listed above. Turns out that the best way to find holes in a spec is to implement it in a language you barely know.

mezcaml

In the same spirit, mezcaml is a minimal nREPL client for OCaml: a small client library plus a command-line REPL, working against any nREPL server regardless of the language on the other end. No release yet, but the core protocol works, it reads whole forms, and it has server-driven completion. Nothing serious - it was a fun way to combine my recent OCaml hacking with nREPL.

clojure-mode, clojure-ts-mode and MrAnderson

Smaller things: the #_ toggle commands in clojure-mode were renamed to clojure-toggle-discard and friends (matching Clojure’s own terminology, old names kept as aliases), both modes got a :to-have-face matcher for font-lock tests, and clojure-ts-mode now checks the indentation of its sources on CI.

MrAnderson 0.7.1 added a command-line interface, so it can be run without Leiningen, and reworked its downstream integration tests against cider-nrepl and refactor-nrepl, which had silently stopped exercising local changes. Oops.

Blog posts

I wrote a lot this summer, mostly a series on the notable changes in CIDER 2.0:

Epilogue

Big thanks to Clojurists Together, Nubank and the other organizations and people supporting my Clojure OSS work! None of this would have happened without you. You rock!

As for what’s next - CIDER 2.1 is the obvious milestone, and it’s mostly a matter of letting the clojure-ts-mode integration settle. After that I’d like to cut nREPL 1.8 with the TLS and URL work, and get mezcaml and the BEAM servers to a point where they are useful to someone other than me. I won’t make any predictions about productivity this time around. Clearly I’m bad at those.

Keep hacking!

Permalink

Datalevin 1.1.0: State-of-the-Art Performance Across Data Models

When we released Datalevin 1.0.0, the message was that one database could handle application state across relational, graph, document, and logical workloads. With Datalevin 1.1.0, we can make a stronger case: that breadth comes with state-of-the-art performance on demanding benchmarks.

The results cover durable transactions, complex relational joins, social graph queries, nested documents, and recursive rules. Datalevin competes with SQLite, PostgreSQL, Neo4j, MongoDB, and dedicated logic engines, using the same database and Datalog query interface.

Here are the headline observations from the benchmark artifacts in the Datalevin repository:

Workload Datalevin result Comparison in the measured configuration
Durable transactions 114,739 records/s, synchronous strict WAL, batches of 1,000 3.57× SQLite's throughput
Relational queries All 113 JOB queries in 38.073 seconds 3.37× as fast as PostgreSQL by total query time
Graph queries Lower latency on 20 of 21 LDBC-derived read queries 8.56× as fast as Neo4j by summed time; 5.55× by geometric mean
Document reads 11,454 operations/s, one worker, workload C with document queries 3.99× MongoDB, the next fastest system
Logical queries Lowest latency on all ten selected OpenRuleBench-derived tasks 1.07×–18.11× as fast as the fastest alternative for each task

Each comparison applies to the configuration and workload measured. Together, they make the case for Datalevin as a serious performance choice across data models. The details also show where other systems remain ahead.

Durable transactions

A useful database has to accept changes quickly while maintaining its indexes and honoring its commit promises.

The write benchmark inserts one million person records. Each contains a UUID string identity, first name, last name, and age. SQLite maintains its primary-key index and three explicit value indexes; Datalevin maintains its entity-attribute-value and attribute-value-entity indexes automatically. The comparison aligns logical records and API transaction boundaries, although the physical index work differs.

The chart uses Datalevin's strict WAL profile and SQLite's WAL with synchronous=FULL. Both use their ordinary OS sync behavior; the benchmark's separate macOS fullfsync condition is outside this chart. Data generation, transaction processing, and commit completion contribute to throughput.

Strict WAL throughput for batches of 1, 10, 100, and 1,000 records. Datalevin synchronous: 8,186, 26,519, 44,747, 114,739 records/s. SQLite: 8,822, 17,609, 22,840, 32,105. Datalevin asynchronous: 102,133, 195,976, 245,485, 238,974.

For synchronous writes, SQLite is slightly faster at one record per transaction. Datalevin pulls ahead as batches grow: 1.51× at ten records, 1.96× at 100, and 3.57× at 1,000.

The asynchronous API adds another useful capability. It combines queued requests into physical transactions, reaching 245,485 records per second at a request batch size of 100 while retaining strict WAL acknowledgment. This is a different submission pattern from the blocking APIs: multiple requests remain outstanding, so request throughput should not be read as the number of physical commits or as single-request latency.

Concurrent callers also benefit. With four synchronous callers and 1,000-record batches, Datalevin reaches 151,622 records/s, compared with SQLite's 50,735, a 2.99× throughput advantage. In the mixed workload, each iteration looks up a person and upserts a complete record. The blocking strict-WAL paths deliver 5,616 pairs/s for Datalevin and 5,041 for SQLite. Datalevin's asynchronous path reaches 16,996 pairs/s, with reads using the latest available snapshot and no read-your-write barrier between outstanding requests. These results come from the retained concurrent and mixed artifacts.

The practical result is that Datalevin offers both a competitive blocking transaction path and substantial throughput when an application can batch or pipeline its work.

Relational queries: complex joins in 38 seconds

The Join Order Benchmark asks a harder question than point-lookup benchmarks do: can an optimizer choose good plans for complex joins over real, correlated data?

JOB contains 113 queries over an IMDB dataset comprising 21 tables. In Datalevin, the data becomes 277,878,411 datoms. Each engine executes a complete warmup pass followed by a complete measurement pass. PostgreSQL and Datalevin report planning and execution time inside the database, excluding client startup and communication overhead.

Total JOB query time: Datalevin 38.1 seconds, PostgreSQL 128.2 seconds, SQLite at least 821.8 seconds including nine 60-second timeouts.

Datalevin completes the suite in 38.073 seconds, versus 128.231 seconds for PostgreSQL. SQLite spends 281.849 seconds on the 104 queries it finishes, and nine more queries hit the 60-second cutoff. Charging only that cutoff for each timeout gives SQLite a lower bound of 821.849 seconds, or 21.59× Datalevin's total. The hatched part of its bar makes those timeouts visible.

Datalevin's advantage comes from the suite as a whole: it is faster than PostgreSQL on 65 of 113 queries. PostgreSQL wins the other 48, including some queries where Datalevin's planning overhead is substantial. Datalevin spends 6.689 seconds planning, about 17.6% of its total. There is still room to make planning cheaper while preserving the execution savings.

For applications with complex relationships, the result challenges the idea that moving away from a relational storage model requires giving up relational query performance.

Graph queries: a general database takes on Neo4j

The graph harness implements all 14 Interactive Complex reads and seven Interactive Short reads from LDBC Social Network Benchmark Interactive v1. The SF1 dataset represents a social network; the Neo4j import contains approximately 3.65 million nodes and 20.63 million relationships.

Both engines run embedded, eliminating network transport from this comparison. Each gets a complete warmup pass, followed by a measurement pass in a fresh JVM. Filesystem pages can remain warm, while query parsing and planning are included in the measured call. Final-result caching is disabled.

Neo4j-to-Datalevin latency ratios for all 21 graph queries on a logarithmic scale. Datalevin leads on 20 queries; Neo4j leads narrowly on IC10. Summed-time ratio is 8.56 and geometric mean is 5.55.

In the September 1 comparison, Datalevin takes 4.480 seconds across all 21 reads, versus 38.346 seconds for Neo4j Community Embedded 2026.06.0. Datalevin wins 20 queries; Neo4j is about 5% faster on IC10.

The 8.56× summed-time advantage is influenced heavily by IC14. Giving each query equal weight through the geometric mean of its latency ratio still favors Datalevin by 5.55×. Across the seven short reads, the summed-time advantage is 2.95×.

Indexing policy matters here. Neo4j has ID uniqueness constraints and its automatic token lookup indexes, with no workload-specific secondary indexes. Datalevin automatically indexes attribute values. IC6 illustrates the difference: its selected parameter produces an empty result, and Datalevin can use an indexed tag-name lookup. Its 161.67× ratio describes that particular case; it should not be generalized to every graph traversal.

This is an LDBC-derived read-latency study, not an official audited LDBC throughput result. It retains one observation for one bundled parameter per query, and the first query can include lazy compiler initialization in the fresh JVM. All result counts agree; 17 queries also have identical canonical result digests across engines. Four have documented output-representation differences. Those details define what this strong result establishes.

Documents: indexed paths and fast application operations

Datalevin's indexed document type stores nested documents and indexes their paths. An application can query fields, numeric ranges, wildcard paths, and array contents while keeping documents intact.

The document benchmark compares this feature with PostgreSQL JSONB, SQLite JSON1, and MongoDB. It uses 10,000 documents and 10,000 measured operations per pass. The base mixes are reads and updates for A, reads for C, and read-modify-write for F. Each adds document queries with weight 30, producing roughly 23% document queries in the actual schedules.

All systems use explicit durable acknowledgment settings: Datalevin strict WAL, PostgreSQL synchronous_commit=on, SQLite WAL synchronous=FULL, and MongoDB {w: 1, j: true}. PostgreSQL, SQLite, and MongoDB receive indexes for the query mix where supported. Measurements include client-observed execution, transfer, and complete result-ID realization; Datalevin and SQLite are embedded, while PostgreSQL and MongoDB use local servers.

Document workload throughput with one and four workers. Datalevin leads A, C, and F with one worker, and C with four workers. PostgreSQL leads four-worker A and F.

With one worker, Datalevin leads all three mixes. Its 11,454 operations/s on C is 3.99× MongoDB's 2,868, the best alternative. On A and F, its advantage over runner-up PostgreSQL is approximately 1.48× and 1.50×.

The four-worker results show a more varied picture. Datalevin reaches 32,192 operations/s on C, 2.43× PostgreSQL's throughput. PostgreSQL leads A by about 10.5% and F by about 5.3%. Datalevin's strongest advantage here is document querying; concurrent mutation remains an area for further improvement.

The latency breakdown shows where the query advantage comes from.

Single-worker workload C p50 latency for five document query shapes. Datalevin ranges from 0.051 to 0.328 milliseconds and has the lowest p50 on every shape. SQLite's nested-array shapes take over 22 milliseconds.

Datalevin has the lowest p50 for all five query shapes. Nested equality takes 0.057 ms; an any-depth wildcard takes 0.147 ms; array matching takes 0.207 ms. SQLite is competitive on indexed scalar paths, but the two nested-array shapes require scanning documents in this implementation.

Automatic path indexing makes a concrete difference for applications that store evolving, nested records and later need to ask precise questions about their contents.

Logical workloads: recursion and derived relations

Recursive rules are central to Datalog. They express reachability, dependency analysis, and relationships derived from other relationships in a compact form. Their execution can also generate enormous intermediate results.

The portable OpenRuleBench-derived suite tests transitive closure (TC), same generation (SG), and trees of joins (Join1). It compares Datalevin with SQLite, PostgreSQL, XSB, Soufflé, Clara Rules, and O'Doyle Rules under a query-and-full-result-materialization timing boundary. Data loading and program compilation are outside that interval.

Ten logical tasks across seven engines. Datalevin has the lowest measured latency in every row. The matrix retains unsupported cells, Clara's out-of-memory failure, and O'Doyle's timeouts.

The chart uses the September 1 Datalevin 1.1.0 rerun and the August 26 alternative-engine measurements. Input digests and completed answer counts match across those artifacts.

Datalevin has the lowest measured latency in all ten selected tasks. For cyclic transitive closure over 50,000 input facts, it materializes one million result rows in 102.59 ms. The fastest alternative, Soufflé, takes 1,030.36 ms, a 10.04× ratio. For Join1 b1 with both arguments free, Datalevin takes 99.14 ms, versus XSB's 1,795 ms, an 18.11× ratio.

Other leads are much smaller. Join1 b2 is 162.41 ms in Datalevin and 174.00 ms in XSB. The observed 1.07× ratio is useful to report alongside the large wins, especially with only one retained measurement per task.

The suite uses deterministic generated relations following the paper's task definitions; it does not recreate the lost historical input files. These ten tasks are a representative subset, excluding the designated Join1 a free/free stress case and the full scale/binding matrix. Clara's out-of-memory cell and O'Doyle's 60-second timeouts occurred during warmup. Unsupported cells remain marked N/A. Each Clojure wrapper uses an 8 GiB maximum heap; external engines have their own resource configuration.

This is particularly encouraging for a persistent database: expressive rules can deliver performance competitive with specialized logic systems.

What changed in 1.1.0

The release changelog describes improvements throughout the engine: better join-cost estimates, selective indexed lookups, parallel scans, execution in smaller work units, specialized transitive-closure evaluation, and faster batched writes and local identity upserts. Together, these changes target wasted intermediate work, allocation, and transaction overhead.

The release also makes strict durability the default when enabling WAL without an explicit profile. Python and JavaScript gain idiomatic, composable query and transaction APIs. Performance and usability move forward together.

These cross-system results measure the builds recorded in the artifacts. They are not a controlled 1.0-versus-1.1 experiment, so they do not assign a numerical speedup to an individual optimization.

The larger lesson is architectural. Relational joins, graph edges, document paths, and logical rules all benefit when the database can find relevant facts quickly and avoid producing unnecessary intermediate results. Datalevin's fact-based model gives those capabilities a common foundation.

Read the numbers, then try your workload

The measurements were collected on a 12-core Apple Silicon macOS host with Java 21.0.11; the JOB and logic studies identify the machine as an M3 Pro MacBook Pro with 36 GB of memory. They are project-run benchmarks with specific datasets, configurations, and timing boundaries.

The write study includes database growth in one measurement pass with no discarded warmup. JOB, graph, and document studies retain a measurement pass after a separate-process warmup; document runs also warm the newly built database within each pass. Logic uses a complete warmup and measurement in the same child JVM. These protocols produce observations, not confidence intervals, and their different metrics should not be combined into one overall score.

For reproducibility, the charts have a downloadable data snapshot with source-file hashes. The repository links above contain the harnesses and retained artifacts. The graph and logic charts use newer 1.1.0 artifacts than the older tables still present in their benchmark READMEs.

Datalevin 1.1.0 makes a strong case that one database can combine broad expressiveness with leading performance across demanding workloads. That opens up a useful design choice: keep application facts together, and use relations, graphs, documents, and logic wherever each is most natural.

Get Datalevin 1.1.0, explore the guide, and run the benchmark closest to your application. I would love to see what you build with it.

Permalink

There usually isn't a correct answer

Writing software with AI is a really different experience than writing it by hand. Before coding agents, software was expensive to produce, in the sense that it required a lot of time from a lot of highly skilled and highly compensated people. Now, generating code is very cheap comparatively speaking, and the expensive part is deploying, operating, and maintaining it. People say AI can do this too but my experience in the industry is that it can’t, which I think is mostly why software engineers still have jobs and are actually more in demand than ever. This only makes it more important to choose wisely what software is worth producing in the first place.

The thing is, if you ask AI to build you something now, it will. Even with quite a complex request, it will give you an app or library that looks like it more or less works. The problem arises if you are trying to build software that anyone other than you will use. In these cases it is inconsiderate and embarrassing to release software that is super buggy and has obvious problems, so you want to make it more robust and reliable before releasing. You might think to yourself "well, I&aposll just get the AI to do that too". The problem is that if you ask AI to find problems in your code, it absolutely will. It will invent all kinds of crazy imaginary scenarios where something could plausibly go wrong, with no consideration given to whether those scenarios are even possible, let alone likely to happen. It will go off and write thousands of lines of code, developing "production hardening plans" and conducting "security reviews", leaving you with an impressive looking readme and much more code than you started with.

The thing is, if you check, most of this code is just duplicated, tangled, intractable slop that solves superficial or non-existent problems and obscures the actual point of the thing you were trying to build in the first place.

Anyway, my point is that in order to get your agents to write software worth actually releasing, you have to be very specific about what you&aposre trying to deliver. And the problem with that is that there usually isn&apost actually a correct answer. The nature of software delivery in this era of continuous delivery is that there never really is a target or specific point where the app or library is "done". Software is very much alive and constantly evolving. Even if you strive to deliver a stable, finished product targeting a clear definition of done, all of its dependencies will be constantly shifting underneath, forcing you to reckon with the reality that your target is moving.

Your agents will always find more plausible-looking problems to fix in your software. And if you don&apost stop them, they will just continue piling "fixes" for these into your codebase, without ever stopping to consider whether they add any value to the overall system or product. Your agents have endless suggestions for what should be improved, but no discernment about which of these are actual net improvements and no sense of whether the extra complexity they entail is worth the ongoing operational and maintenance burden.

Being a software engineer now mostly means bringing this discernment to your projects.

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.