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

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

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

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

Writing evals for AI Agents - basic eval setup

Evals dataset

I could have generated random questions for this exercise but there is a nice publicly available dataset called SQuAD. This has a set of 100K+ questions which can be answered by a model. Let me pick 20 random questions from the set. The questions are such that no context is needed for answering those. These are 20 questions with actual answers and 10 made up questions. I used Opus 5 to make up some unanswerable questions as the actual dataset unanswerable questions depend on the context in the dataset which we are not using here.

The complete dataset and raw results are on a separate page.

Get an answer to a question from the LLM

We will use an LLM to get an answer for a question. Let&aposs keep it simple, pass in a config, system-prompt and a question and get a response back.

(defn- get-answer
  [config system-prompt question]
  (let [messages [{:role "system" :content system-prompt}
                  {: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])))

A simple scorer

Let&aposs write a simple scorer. It will only check if one of the expected answers is fully present within the LLM response. Also, if the LLM response contains "Not Known" it will match an unanswerable question.

(defn scorer
  [expected-list actual]
  (let [correct-answer? (if (seq expected-list)
                          (some #(str/includes? actual %) expected-list)
                          (str/includes? actual "Not Known"))]
    (if correct-answer? 1 0)))

Scoring the list of questions

Now armed with the function to get an answer from a LLM endpoint and a scorer, we can write a simple score-question function.

(defn- score-question
  [config prompt question]
  (let [system-prompt (:content prompt)
        q (:question question)
        a (:answers question)
        actual (get-answer config system-prompt q)]
    {:question q
     :expected-answers a
     :actual actual
     :score (scorer a actual)
    }))

Results from Qwen 3.0 0.6B

The following table shows results from running the evals against a Qwen 3.0 0.6B model. It is a simple model so, the results are not very impressive. Even then they are still good for such a small model - 13 answers correct out of 30, a 43.3% correctness rate. The prompt given to the model was:

Answer questions concisely. There is no need for full sentences. Say, Not Known if you do not know or are unable to infer.

And, that prompt shows up partially in some of the answers - like the question about the European population killed by the Black Death. There are some interesting hallucinations also like the Portugese city where the Rhine reaches the sea. Our scorer also shows its limitations where case mismatches cause an answer to be marked as fail. Like the 20.8% gas question. Also, the model sometimes gives correct answers albeit partial (like Newton instead of Isaac Newton)

The full Qwen result table is on the results page.

Results from Gemma-4-E2B

Gemma being a larger model scores better 15/30 - around 50%. Again the limitations of our scorer show up which flags correct answers as wrong due to the case mismatch or punctuation issues. Let&aposs look at alternate scorers to fix this issue.

The full Gemma substring result table is on the results page.

F1 scorer

The F1 scorer combines values of precision and recall to generate a score of the answer. Where Precision (P) = matching tokens/predicted tokens and Recall (R) = matching tokens/expected tokens. Precision punishes padding of answers, whereas Recall punishes omission of answers.

The F1 score is defined as 2PR/(P + R)

Ideally we should normalize the generated answers to improve the precision and recall metrics but to keep things simple I will just do a lower case of the words.

(defn- normalize
  [answer]
  (map str/lower-case (filter #(> (count %) 0) (str/split answer #"\s+|\.|,|-|!"))))

We the normalized tokens we can compute precision and recall for the answer as below:

(defn- get-precision-recall
  [predicted expected]
  (let [predicted-set (set (normalize predicted))
        expected-set (set (normalize expected))
        common (clojure.set/intersection predicted-set expected-set )
        predicted-count (float (count predicted-set))
        expected-count (float (count expected-set))
        common-count (float (count common))]
    {:precision (precision common-count predicted-count)
     :recall (recall common-count expected-count)}))

And using the precision and recall scores we can compute the f1 score:

(defn- f1-score
  [predicted expected]
  (let [{:keys [precision recall]} (get-precision-recall predicted expected)
        denom (+ precision recall)
        score (if (> denom 0) (/ (* 2 precision recall) denom) 0)]
    {:precision precision
     :recall recall
     :f1 score}))

Gemma-4-E2b f1 scores

With the f1 scorer we can see better matches. We have 14 perfect scores and if we include partial matches 21/30 answers are good. Again, the f1 scorer is better than the exact match scorer but still leaves a lot to be desired. Semantically similar words will still be flagged as incorrect answers. Negations of answers are ignored. This leads us into more complex scorers like semantic scoring or LLM as a judge which we will look at in the next post.

The full Gemma F1 result table is on the results page.

Permalink

Writing a Minecraft server in Clojure: it was fun

This is a cool example of the kind of problem that Clojure makes so much simpler to solve. Having a coherent model of time and not sharing mutable state makes parallel computing so much easier, because you’re not trying to coordinate multiple mutators contending for a shared resource. If you just don’t share the resource and let the language level primitives handle serializing operations for you, you can stop worrying about entire classes of bugs and just focus on your problems.

The ideas parallel servers reinvent by hand - immutable snapshot, pure read phase, changes as data - are the language&aposs defaults. If writing from many threads is difficult, then don&apost write: all game logic is pure functions (fn [world events]) that read the snapshot in parallel and return changes as deltas, merged in one place. The merge preserves order, so the parallel run is bit identical to the sequential one, and no mechanic cares how many cores it runs on.

I think it’s so cool when people experience the benefits of Clojure’s paradigm in real projects. It’s often a tough sell at first but it really is amazing how far you can get with pure functions operating on immutable data. My favourite way to fix bugs is to just make them impossible by default, which often means rethinking the way your program models the world and time. It can be a bit trippy at first but it pays off.

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.