Learn linear least squares with me
In my last post I discussed DSLs for database querying in Clojure. These typically take the form of data structures.
I also discussed how some query languages, like SPARQL and Datomic, use variables in their queries, and that these appear in Clojure as symbols. That post also demonstrated using quoting to embed symbols easily into a structure, and unquoting to use values inside those same structures.
Some of it got messy.
A colleague was recently trying to build SPARQL queries using Flint. This is a library that allows SPARQL queries that look very similar to Datomic queries.
He was trying to programmatically build query fragments that could be appended to each other to form a complete query. Each fragment was generated by functions that returned a small structure that could be added into the query.
In most cases, he could use quoting to return his structure. For instance, the following fragment might be used to find the name of a person who had changed an entity:
[[entity :data/modifiedBy ?person] [?person :data/firstName ?name]]
This is not the final form though, since he wanted to both pass in a value for entity, while also quoting the symbols in his structure.
Using the techniques from the last post, this is relatively straightforward:
(defn modifier-name
[entity]
[[entity :data/modifiedBy '?person]
['?person :data/firstName '?name]])
However, there are occasions where the entity might be modified more than once, and so multiple names should be returned. This will need a configurable name variable:
(defn modifier-name
[entity name-var]
[[entity :data/modifiedBy '?person]
['?person :data/firstName name-var]])
This would let a developer call something like:
(concat (modifier-name '?entity '?name1) (modifier-name '?entity '?name2))
(note: There are overlaps between what ?name1 and ?name2 will bind to. This is just for illustration.)
Unfortunately, this has a bug. In both cases, the ?person variable is used, meaning that both ?name1 and ?name2 will always be bound to the same values. One way to address this is to generate a new symbol for the query.
Since we're been using quoting, then a common way to generate symbols is to use a syntactic feature called an autogensym inside a quote. This uses a symbol name with a trailing # character:
(defn modifier-name
[entity name-var]
`[[~entity :data/modifiedBy ?person#]
[?person# :data/firstName ~name-var]])
This version is using syntax quoting, and embedding entity and name-var, as discussed in the previous post.
However, this version has a bug too. The appearance of ?person# in the code tells the Clojure reader to generate a new symbol.
user=> (modifier-name '?entity '?name)
[[?entity :data/modifiedBy ?person__2__auto__]
[?person__2__auto__ :data/firstName ?name1]]
Each new use of this ?person# expression (in a new context) should result in a new symbol. However, this symbol gets reused when the function gets called again.
user=> (concat (modifier-name '?entity '?name1)
(modifier-name '?entity '?name2))
([?entity :data/modifiedBy ?person__2__auto__]
[?person__2__auto__ :data/firstName ?name1]
[?entity :data/modifiedBy ?person__2__auto__]
[?person__2__auto__ :data/firstName ?name2])
The symbol ?person__2__auto__ was returned from both calls, because the generation actually occurred when the function was read, not when it was executed.
This is the same issue that was discussed in an Ask Clojure Question. Syntax quoting and autogensyms are most often used in macros, and the scope of a generated value is typically restricted so that any generated symbols cannot interact with each other. The case discussed in that Clojure question was when a macro was recursive. In that case, the symbols generated during recursion were all the same, since they all shared scope.
Our query is not using recursion, but instead it is capturing the name of this symbol and returning it to the calling scope. This means that the scope of the generated name is extended to the calling context, allowing it to interfere with other generated names in that context. i.e. the scope "escaped".
In other words, despite autogensyms being common when quoting expressions (most commonly in macros), they are not appropriate for anything that can escape the current context.
One solution to this is to generate a symbol on each execution of the function. This can be done manually, rather than using the autogensym syntax:
(defn modifier-name
[entity name-var]
(let [?person (gensym "?person")]
[[entity :data/modifiedBy ?person]
[?person :data/firstName name-var]])
This creates a new value every time.
Using functions to generate fragments of queries can result in conflicting fragments, particularly in graph languages that have variables in their syntax. In these cases, a new variable is needed for each fragment.
Clojure has a facility for creating new variable names easily, called "autogensym". However, the new name is only generated when the code is read, meaning that any function using this construct will always return the same symbol. "Autogensym" can be helpful, but only if the context of the generated symbol can never overlap with the context of another call to the same autogensym.
In the first post I mentioned that macros can simplify a query DSL. The next post will demonstrate this.
Domain Specific Languages (DSLs) are a popular technique for writing database queries. There are a few reasons for this, including:
Some languages implement DSLs in libraries, some allow the language to be extended to include the DSL, and some can use already existing syntax and data structures to implement the DSL. While most languages can implement DSLs in libraries, Clojure also has the option of extending the language via macros, as well as providing syntax for regular data structure that can also be convenient to use.
While basic macros are relatively easy to write in Clojure, they can become quite complex. In general, when it comes to Clojure macros, I think Sandra Sierra's 2010 advice holds up well: You do not write macros.
That said, some libraries may use macros to make writing queries easier for developers. This can make it easier to incorporate names and labels into expressions that would otherwise lead to errors on "unbound" values. Many Clojure DSL libraries eschew this, since keywords often work just as well. As an example, a SELECT expression for reading the name and age columns of a table would need a macro if a developer wanted to write:
(select name age)
However, the complexity of a macros can be avoided by switching to keywords instead:
(select :name :age)
Clojure code is almost always written to use the regular data structures that are built into the syntax of the language. These are:
{key value …} Also called a "Dictionary" in languages like Python.
#{data …}
[data …] Called "Lists" in Python.
(data …) Implemented as Linked Lists.
Lists are a little different, as they are "executed" by defautl in Lisp dialect like Clojure. This is avoided by introducing the list with a ' quote character. e.g. '(1 2 3)
Since these structures are part of the language, they can be a simple way to build a DSL for querying. For instance, Sean Corfield's HoneySQL can represent an SQL query using a map and vectors:
{:select [:a :b :c]
:from [:foo]
:where [:= :foo.a "baz"]}
Datomic does something similar, using either a map or a vector for the queries:
'[:find ?title
:where [?e :movie/title ?title]
[?e :movie/release-year 1985]]
There is an issue here though. Unlike previous examples, this last query is using variables (marked with a ? prefix) as a part of the query language. This is especially common in graph languages like Datomic or SPARQL.
Using a : to turn these variables into keywords makes it harder to distinguish variables from actual values stored in the database (since Datomic stores keywords directly, and SPARQL libraries assume keywords to be CURIEs). Instead, Clojure Symbols are used. The problem is that symbols are the mechanism that Clojure uses for associating data with a name, so using a symbol usually results in Clojure looking for that data, which will be an error if the symbol is not bound, and inserts a value where you wanted a variable if it is found. This is avoided using the ' quote character.
To illustrate this, let's look at that Datomic query again, this time without the quote. I'll show what happens at a REPL (the Clojure prompt), where the prompt includes the current namespace (user by default, though it can be something else):
user=> [:find ?title
:where [?e :movie/title ?title]
[?e :movie/release-year 1985]]
Syntax error compiling at (REPL:0:0).
Unable to resolve symbol: ?title in this context
user=> (def ?title "not a variable")
user=> (def ?e "also not a variable")
user=> [:find ?title
:where [?e :movie/title ?title]
[?e :movie/release-year 1985]]
[:find "not a variable" :where ["also not a variable" :movie/title "not a variable"] ["also not a variable" :movie/release-year 1985]]
This is read, but we can see that the use of the symbols has placed their saved values into the query, rather than a variable like we wanted.
We can avoid this problem by quoting the symbols that we want to keep as symbols:
user=> [:find '?title
:where ['?e :movie/title '?title]
['?e :movie/release-year 1985]]
[:find '?title :where ['?e :movie/title '?title] ['?e :movie/release-year 1985]]
We can also quote entire structures that contain multiple variables:
user=> '[:find ?title
:where [?e :movie/title ?title]
[?e :movie/release-year 1985]]
[:find ?title :where [?e :movie/title ?title] [?e :movie/release-year 1985]]
Note that the result is printing the data structure, and not trying to evaluate it. Because of this, the symbols are not printed with a quote.
Quoting entire structures makes it easy to include multiple symbols, but it also makes it hard to include values from a program. For instance, a user may be asking to get all titles from a year that they provide in a user-interface:
user=> (let [release-year (get-user-input)] ;; user input 1985
'[:find ?title
:where [?e :movie/title ?title]
[?e :movie/release-year release-year]])
[:find ?title :where [?e :movie/title ?title] [?e :movie/release-year release-year]]
That's put a symbol into the query when we wanted a number associated with that symbol.
There are lots of ways to address this. One is to only quote the parts we need to:
user=> (let [release-year (get-user-input)] ;; user input 1985
[:find '?title
:where '[?e :movie/title ?title]
['?e :movie/release-year release-year]])
[:find ?title :where [?e :movie/title ?title] [?e :movie/release-year 1985]]
This works, but it can (and frequently does, at least in my code) lead to lots of quote characters everywhere, which can be a bit bug prone too (LLMs will usually catch it, but I'm here to discuss how WE write code, not LLMs).
Another way is to bind the names to symbols (binding to a symbol with the same name would reduce confusion):
(let [release-year (get-user-input) ;; user input 1985
?e (symbol "?e")
?title (symbol "?title")]
[:find ?title
:where [?e :movie/title ?title]
[?e :movie/release-year release-year]])
But now we're asking the developer to increase their code significantly. That's not really helpful.
For completeness, I'll also mention that sometimes it works to build the parts you need, and create the query structure with code:
(let [release-year (get-user-input)] ;; user input 1985
(conj '[:find ?title :where [?e :movie/title ?title]]
['?e :movie/release-year release-year]])
This is sometimes useful, but is too cumbersome for basic querying.
Another option is the "unquote". This tells the Clojure reader that is reading quoted data that the next item is not to be considered as quoted. This is done with the ~ character. However, it does not work quite as you might expect.
For simplicity, I will reduce the evaluation to just the last part:
user=> (def release-year 1985)
user=> '[?e :movie/release-year ~release-year]
[?e :movie/release-year (clojure.core/unquote release-year)]
Unfortunately, this has shown us what the ~ unquoting gets translated to. Quoting actually gets translated like that too. We just haven't seen it before:
user=> '['?e :movie/release-year ~release-year]
[(quote ?e) :movie/release-year (clojure.core/unquote release-year)]
Instead, we need to use a different kind of quoting: syntax quoting. This is done with a single "back-quote" or .
clojure
user=> (def release-year 1985)
user=> `[?e :movie/release-year ~release-year]
[user/?e :movie/release-year 1985]
release-year
This embedded the value ofas we wanted, but it has also change the?esymbol. Now it tells us that it's the the symbol?ein the namespaceuser`. That's the current namespace, so that's correct, but we want to embed the symbol without the namespace.
This is a tricky form. We want to "unquote" from the syntax-quote, but then we want to immediately "quote" again:
clojure[~'?e :movie/release-year ~release-year]
user=>
[?e :movie/release-year 1985]
`
Going back to the complete query, we can see the full form:
clojure[~'?e :movie/release-year ~release-year]])
(let [release-year (get-user-input)] ;; user input 1985
[:find '?title
:where '[?e :movie/title ?title]
`
This is not entirely satisfactory, and it explains why macros may be attractive, but it does show an approach.
This post demonstrates some of the approaches of using Domain Specific Languages (DSLs) in Clojure, focusing on data structures to represent database queries. Query languages like Datomic and SPARQL use symbols in their queries, and we looked at a few ways that these can be embedded into a query structure.
When LLMs work, it can feel like magic, but when they fail, it feels like you are arguing with a confident bullshit artist. It took me many months of daily use to develop some intuition for where LLMs are likely to produce code that is useful and where they are likely to fail. It also took me a bit of time to figure out how to limit scope and provide enough scaffolding to ensure I get useful results reliably. Having invested the time to learn to use the tool effectively, I very much see the benefits, as I am able to build projects on a scale I would not have attempted before.
In a way, the process is the inverse of regular programming. We tend to build up programs step by step when writing code by hand as we add each function with intention. LLMs tend to produce a lot of code out of the gate and the focus shifts to whittling the code down to what you actually need.
A good way to look at the agentic loop is to view the process as a genetic algorithm. Agentic harnesses are effective because you have an evolutionary process happening. The model outputs something roughly correct before the code gets tested, and then the model gets feedback to iterate on the code. Through this process, it gradually converges on a solution that fits the parameters being tested. In that sense, it is not actually all that different from how humans write code either. You almost never solve a non-trivial problem in one shot. You write your first approximation and then iterate on it. The difference is that the LLM can do this process a lot faster.
LLMs are trained on massive amounts of public code, which makes them excellent at completing typical tasks. These are things that have been done a million times before and constitute what largely amounts to boilerplate. Throwing a sample JSON response at an LLM and having it write a service endpoint or throwing a bunch of API endpoints at it and having it build a UI using them can be very effective. These are the kinds of common tasks the agent will have a lot of training on, and they can produce something reasonable in one shot. It will probably put more diligence into that task than you would by adding tests and handling all the obvious edge cases.
They are also great at doing explorative work. Identifying a particular call graph and tracing through the steps to figure out how a particular service endpoint is implemented or what parameters you have to pass it are all tasks an LLM can do easily. This can save an enormous amount of time tracing through a codebase and mapping out a particular workflow that you are interested in.
These tools are also great at handling language specific syntax. If you know conceptually what you want to do, like looping through a collection and filtering by a specific parameter, but you are working in a language you are rusty in, then LLMs are great for bridging the gap. They can easily express the logic you want using idiomatic syntax. You can describe the algorithm in pseudo code where you write out the steps and it will handle the rest.
For example, I recently had to work on a JavaScript project, and I have not touched the language in over a decade. I am not familiar with modern tooling or libraries or best practices, and I just did not have the time to get up to speed on all that.
Using DeepSeek allowed me to use JavaScript as effectively as I do Clojure, which I am well versed in. It completely removed the friction of figuring out all the incidental things like syntax or tooling. If you are an expert in a particular domain and you understand the problem you are trying to solve, then LLMs can be a huge amplifier for what you are able to do. They do not replace your skills, but they do allow you to move a lot faster and focus on the big picture of the problem you are trying to solve.
In my experience, the biggest place where agents trip up is dealing with context and creativity. You have to remember that the AI does not know the specific quirks of your project. For example, if you just tell it to use a Clojure dialect, it might reach for the JVM toolchain it learned Clojure on, such as clojure and lein, none of which exist in that context, or it might assume a tree walking interpreter and try to run the source directly. You need to give it the exact logic, like telling it explicitly that the runtime is pure Chez Scheme and that everything builds through make commands via a chez --script execution, while specifying that the authoritative sources are host/chez/*.ss and jolt-core/*.clj over anything JVM flavored.
Then there is also the trap of the naive implementation. Often, when you give an agent a vague goal, it will hand you something that looks correct on the surface but ends up being structurally wrong. For example, the agent might decide that string method calls should be routed through a generic dispatch table, which ends up re-deriving the receiver type on every single invocation. The proper fix here is to do a type inference pass to prove that those values are strings at compile time, which allows you to emit a direct native call and skip dispatch entirely. An agent told to make the string methods fast will almost certainly keep the generic path by reordering a few cond arms and never bother designing a proper solution. Similarly, if you ask it to implement count on a sequence, it will likely walk the whole thing allocating a fresh cell per element when the collection already knows its own length that can be called in constant time. Ask it to join strings and you will probably get repeated concatenation instead of a single walk. It is akin to an evil genie that will interpret your queries in the worst way possible, leading to the solution having a completely wrong shape. The trick is that you have to spell out the constraint, which incidentally forces you to think through the problem as well.
The key to using LLMs effectively is to make sure you already have a solid understanding of what you are aiming to build before you start. You always have to be explicit regarding what you want done at a structural level. The more scaffolding you provide up front the less room the agent has to go outside your design. A corollary to this observation is that you do have to understand the domain to make effective use of LLMs. If you are not equipped to evaluate whether the code it produced solves the problem in a correct way, then you basically end up at a casino pulling a lever on a slot machine and hoping for a decent solution to fall out. LLMs are good at filling in the gaps and doing boilerplate, but you still have to do design and architecture the same way you always did.
Here are some tricks that I found useful for keeping it on the rails.
Always start out by planning out the task. Make sure you have a clear picture of what you are aiming to do along with what algorithms you are intending to use and how the code should be structured to fit within the existing architecture. You must be able to answer these questions before you even think about delegating to the LLM.
Once you have a clear picture in your head, you can move on to the planning stage with the agent. Give it the requirements and spell out the goals before asking the model to write a phased plan in Markdown. Even better, ask it to generate a Mermaid.js diagram of the flow.
After it makes the diagram, you can visually inspect the logic. If a particular step looks wrong in the diagram, you tell it to change that specific step to do something else. Doing that is a lot easier than simply arguing with it using text prompts. Once there is a clear structure for the steps being performed, it is easy to identify parts that you do not like. Review the plan and get the model to break it up into independent tasks, each focusing on implementing a specific feature. Have the model create a branch and then make a pull request for the task. At that point you can review the code fairly easily because you know what the scope of the change is and what specific problem it solves.
It can be very helpful to have the model do research on prior work for steps where you are not sure which approach to take. It is rare that the problem being solved is entirely novel, and agents are great for looking up relevant papers you can review to get a better idea of what is more likely to work. Again, it is important to spend the time to familiarize yourself with the different paths you can take and to pick one consciously.
I would also argue that having a clean architecture with low coupling becomes extremely important when using LLMs. They tend to do best on smaller tasks that do not have dependencies because there is less context to consider. So if you can break up your project into small pieces that can be worked on in isolation, then you can give the agent a task with clear boundaries. That also makes it much easier to review its output as well.
I find that functional style maps particularly well here because it focuses on context isolation and passing state around explicitly. The same tricks that make large code bases manageable by humans also help LLMs for the same reasons. Aggressively controlling the context is a key tactic for using LLMs effectively.
It bears repeating that you never want to give the AI a blank canvas. Always do the work of laying out what the scaffolding should look like yourself. Make sure you intentionally set up the file structure and decide on the components before asking the agent to fill in the blanks.
But even with all these great functional tools, we still tend to tangle two rather different kinds of code together. We tend to mix code that cares what the data means and the code that decides how it travels from one component to another. Traditional software design structures embed the routing implicitly in the function call graph. Control logic often ends up being coupled with the internal implementation details in an ad hoc manner. Breaking things up into independent steps helps control the scope.
Routing logic should be elevated to first class citizenship in the design. State machines are the natural fit for this, since they force the separation of what to do from how to do it. The control flow logic can be largely declarative and expressed as a graph such as the Mermaid diagram I mentioned earlier, while the implementation details live at each step in the flow and become the tasks the agent works on.
Doing these steps forces the agent to work within your architecture rather than inventing its own structure, which largely avoids the problem of it going off the rails. Once you get it to build a diagram and you have reviewed it, you can create the initial project structure based on that.
I find it is useful to think of tests as the ultimate requirement doc when working with LLMs. If you define your desired functionality as tests first, you can get the agent to work through them using test driven development until they pass. It will typically do a decent job running the tests and analyzing the failures and fixing its own code to meet the spec. The tests are the contract that the agent works against. Going back to the whole genetic algorithm analogy, these are the selection pressures that drive the evolution of the code.
Having tests up front gives you a solid guarantee that the code is doing what you intended functionally. It is also your best defense against regressions. Without tests, an agent adding a new feature is just as likely to silently break three old ones. Having a contract for the existing functionality avoids that problem.
The types of tests that tend to be most valuable are the ones that focus on the functionality of different components along with end to end integration tests. They do not need to be too granular because issues will get shaken out as the whole workflow gets exercised. I can also highly recommend making storybooks and creating automated testing using Playwright for web apps where the test goes through the entire workflow end to end driving the page as the user would.
Additionally, since tests do not capture performance characteristics, it is helpful to create a benchmarking suite to check performance metrics such as CPU and memory usage. Having one from the start has been very informative in guiding my development of Jolt.
You can think of Git like having a quick save in a video game. Every single time the agent gets into a stable state where tests pass and the code looks like it is doing what you want, you should commit that code immediately. This gives you the freedom to let the agent try different experiments or complex refactors. If the agent makes a mess or the idea does not pan out, you do not have to untangle it manually. You just revert to the last good commit and move in a different direction.
I have noticed that if the agent does not get the solution mostly right on the first shot, it is unlikely to make it work properly later. The agent is not going to step back to understand the underlying problem when you point out a bug. Instead, it just adds kludges to fix your specific complaint and the problems tend to multiply as a result. If the original solution was not a good fit, then adding more kludges on top only makes a huge mess that will never work right. If it starts spiraling, then it is time to reframe your problem statement and start from scratch.
A related point is that LLMs make it very cheap to do exploration with your codebase. I mentioned earlier that you always want to understand the problem before you get the agent to start working on it and that is true for code you intend to keep. However, working through a problem is a great way to understand it better. So when you hit a point where you are not sure what to do or which approach might be best, that is when you can spike up different ideas and see how they pan out. Since you have version control, it is trivial to roll back to a known stable commit and try something new from there.
This sort of thing used to take a significant amount of effort, but the barrier to exploration is a lot lower. For example, when I started working on Jolt, I picked Janet as the runtime for it. My rationale was that Janet was superficially similar to Clojure and had a compact runtime while being embeddable. However, I quickly realized that the lack of generational garbage collection did not mesh well with the lots of short lived objects that persistent data structures generate. So I did a bit of research and landed on Chez Scheme instead. I was able to do the whole Janet spike in around a week, and that is something that could have easily been a months long project without LLM use. Similarly, proving out a solution on top of Chez only took a few days to get to the point where it was clear that it would work better.
There are a lot of agentic harnesses around, and they all optimize for different use cases. What I found to be important is that the harness meets the expectations of the model and provides the flexibility to customize the workflow to fit a specific project.
In the end, I ended up building my own harness, which I discussed in a previous post here. I spent some time observing how models like DeepSeek and GLM behave within the agentic loop and where they appear to get tripped up. Dirge also integrates proven tricks from existing tools like the official deepseek-harness to avoid reinventing the wheel here. Additionally, I used Janet to provide a plugin system similar to Pi. You can create a .dirge folder per project to place custom plugins there, allowing the harness to evolve alongside each project.
I also spent some time on addressing the common pitfalls that I kept seeing to make the workflow smoother. For example, one common problem is that the model will produce mismatched parens in code. If you simply send the code back to the model, then it is going to burn tokens trying to figure out where the missing paren is. Often, it ends up doing things like writing python scripts to count them. Doing the repair inside the harness solves the problem mechanically so that the model never has to be involved.
Another thing I found was that tracking things using Markdown files tends to be fragile. These files can get stale, which leads them to be misleading and the models do not do a good job keeping them up to date. My solution was to use sqlite as the datastore for the harness and to use it as project memory. I extended that to track tasks as well, modelled on the way beads works. The harness asks the model to create tasks before it starts work and then tracks the active tasks and injects the task being worked on at the top of the context. This helps keep the model focused and continue working on larger features. When the task is done, I use a separate critic role to review it by examining the diff and then provide feedback, which helps avoid cases where the model decides to ship a half baked solution.
I have also integrated some ideas from papers such as Behavior Trees Enable Structured Programming of Language Model Agents, which focus on having the language model act as a leaf node in a larger deterministic control structure instead of trusting it to make decisions end to end. The main idea is to move from treating the model as the whole agent to making it a primitive, which produces a behavior. The workflow is then composed with a small set of classical control structures. The finalization gates like the verifier and critic, along with the code reviewer, form a fixed sequence of deterministic checks the model must clear before a run is allowed to finish. The failure ladder rungs use retry fallback nodes to catch a stuck or failing model, and mechanisms like publish state guard enforce safety constraints structurally. Once you have the right structure around it, even a local model can solve fairly complex tasks competently.
You are still the engineer who is responsible for understanding the problem you are trying to solve and what the project is meant to be doing. Your job is to provide the high level thinking and the architecture while understanding what the correct solution should look like. The LLM is there to save you from the boring and repetitive work like typing out boilerplate and looking up syntax.
The key part to keep in mind is that an LLM is just another tool in your belt. It cannot help you solve problems that are outside your existing expertise effectively. These tools lets you work faster once you learn their sharp edges, but they do not do your thinking for you.
In fact, an LLM on its own is not able to do much of anything useful. It requires its user to have domain expertise to apply it effectively. My ability to build a Clojure compiler using these tools stems from nearly two decades of experience working with the language. I know how it works internally along with what the end state needs to look like and what pitfalls to avoid.
Trying to solve a problem I have no familiarity with would just be me throwing darts at the board. Maybe the LLM will produce the right solution and maybe it will not. I would not be equipped to evaluate that one way or the other.
It&aposs difficult to find reasonable, pragmatic takes on using coding agents these days. Ray Myers is a good voice in that space right now and I thought this was a great survey of what people in the industry are actually saying about reading AI-generated code.
but those seem like weak-sauce countermeasures [my addition for clarity: (training employees to recognize LLM manipulation and deploying judge agents)] if you mean to help someone “fight back” against a “bombing”. They suggest pushing workarounds onto the user and adding more agents. Think for a second. What’s missing?
We could use fewer agents!
This always drives me crazy. More or less every week at work now people are complaining about some negative effect of AI, and the standard answer from leadership is universally and consistently "use more AI". Instead of trying to use more AI to solve our AI-generated problems, what if we just tried using less AI in the first place? Why is nobody asking that?
I understand why the LLM companies don&apost want us to use less AI. But so far I do not understand why executives who are paying through the nose for their ever-increasing LLM inference bills do not want us to use less AI.
Where are the women?
When we find ourselves citing exclusively men, something may have gone wrong.
I appreciate this take. Most industry conversations are male dominated. My take on this is that it&aposs mostly because being female in public really, really sucks, and speaking up in these conversations is almost never worth the inevitable harassment, bullying, and gatekeeping you&aposre in for. Men simply do not understand what it&aposs like to have their very reasonable technical opinions regularly met with "you should not exist in this industry". I know that&aposs not true, but hearing it too often is bad for my mental health, so I mostly stick to my read-only corner of the internet over here and do not engage in public conversations. I&aposve been bullied my whole life and learned a long time ago that the best way to deal with bullies is to just remove their access to you. You can&apost get bullied if the bullies can&apost contact you.
Frowning on those who live in the present is a luxury that evaporates with proximity to the pager.
This is the first time I&aposve seen this "proximity to the pager" phrase, but I love it. I think it cuts to the core of what is so divisive in the industry right now. In my experience, the people who are most enthusiastic about "AI" and agentic coding and all of that stuff are the ones who are nowhere to be found when prod goes down. Conversely, everyone I know who has ever had to work after hours to fix a downed system that people are paying for is much more realistic and honest about the current capabilities of coding agents.
They then hand those constraints down to our department to
do their homework for themwork out the details.
It does feel strange that OpenAI and Anthropic constantly claim they are building a Superintelligence capable of doing real work, yet most of the industry is now spending most of its time doing the real engineering work of making the LLMs they produce do anything actually useful. Un-guided and unrestrained, LLMs do far more harm than good, but we&aposve all been effectively mandated to use them day in and day out, so now we spend most of our time just learning how to build better harnesses, guardrails, and systems to try to cajole them into doing meaningful work.
Rigorous automated testing is much more feasible than is generally realized. This is largely a failure to budget for coaching and training.
This is definitely true in my experience. At the risk of sounding like a gatekeeping asshole, I do believe that people who write poorly tested code (or did before AI could do it all for them), both fundamentally misunderstand how to write good test-driven software and dramatically underestimate the cost of all the manual verification steps they do instead. All the engineers I know who write bad or inadequate tests spend ridiculous amounts of time manually QAing their own software. There are some true agents of chaos out there who just yolo code into production, but I would say most software engineers, even very mid ones, do want to confidently answer the question "does this code work" before they ship it. I think the people who don&apost believe that automated testing is the fastest way to do that just don&apost really know how to do it well and at this point are too embarrassed or set in their ways to admit they need to upskill.
They emphasize speed of code gen as a proxy for value.
This is a really deeply embedded misunderstanding in the industry. The velocity at which your software is produced doesn&apost tell you anything at all about the value it delivers to users. But because it is easy to measure many teams have massively over-indexed on it and mistake velocity for productivity, which is unfortunate because it&aposs really easy to juice your own velocity metrics while actually causing damage to project outcomes. But that doesn&apost matter when nobody is measuring outcomes and only velocity is rewarded.
Authorised by governments around the world to assess medical AI, we remove unnecessary delays from every regulatory approval we do so patients get devices from the future, today. We are proud to count the world’s most ambitious companies building medical technology as customers. You will be joining a team with product-market fit, flowing data, and exponentially growing revenue.
Product and engineering challenges go hand in hand at Scarlet. We know our mission can only be accomplished if we:
Our engineering problems are plenty and we have chosen Clojure as the tool to solve them.
The team is everything at Scarlet and we aspire to shape and nurture a team where every team member:
We all have our fair share of experience working with startups, open source and various problem spaces. We wish to expand the team with team members that can balance our strengths and weaknesses and help Scarlet build fantastic products.
We’re looking for ambitious teammates who have at least a few years of experience, have an insatiable hunger to learn, and want to do the most important work of their career!
Our ways of working are guided by a desire to perform at the highest level and do great work.
If this sounds exciting to you, we believe Scarlet may be a great fit and would love to hear from you!
We believe that the potential for a great fit is even higher if you have one or more of the following:
Though the order may change, the interview steps are:
We want your experience with Scarlet to be a good one and we do our utmost to ensure that you feel welcomed throughout the interview process.

I was recently fixing up the feeds on my website and wanted to make sure a) that they render probably and b) that the filtered ones contain the correct entries, so I built this little tool to preview atom or rss feeds.
For years, advocates of statically typed languages have made the same argument: types catch mistakes earlier, compilers provide better feedback, IDEs offer better assistance, and large codebases become safer to maintain.
That argument rests on an assumption that is rapidly becoming outdated: The person writing the code is human.
AI is not human. It does not prefer Python because Python feels simple. It does not admire Rust because Rust feels rigorous. It has no taste, no emotional attachment, and no programming-language identity.
To an AI, languages differ primarily in how much code, and therefore how many tokens, it must generate to express the same idea.
The more complicated the language, the more tokens it requires. The more tokens it requires, the more opportunities the model has to make a mistake.
It really is that simple.
Compiler feedback matters when humans write code.
People forget function signatures. They confuse return types, overlook null values, miss fields, and call methods that do not exist. A type checker acts as a guardrail, catching these mistakes before the program runs.
It is therefore tempting to apply the same logic to AI:
Statically typed languages give AI more feedback, so AI produces better code in them.
This is mostly cargo-cult reasoning inherited from human programming.
When was the last time you saw a capable coding model remain stuck on an ordinary compilation error?
A missing parenthesis, an incorrect primitive type, or a nonexistent method is no longer the central problem in AI-generated software. Such errors occasionally happen, but the model reads the compiler message and fixes them almost immediately.
AI's expensive mistakes are not usually compilation errors. They are misunderstandings.
The model implements the wrong business rule. It overlooks an edge case. It misinterprets the meaning of the data. It breaks an unstated concurrency assumption. It produces a system that is perfectly type-correct and logically wrong. A type checker cannot save you from that.
The claim that AI needs "more compiler feedback" sounds technical, but it often amounts to repeating an old argument without looking at the reality: AI almost always one-shot the code, and compiler feedbacks are not involved for the most part.
Types are usually described as protection. They are rarely counted as cost.
For AI-generated code, however, a type declaration is first and foremost additional information that must be generated, maintained, and kept consistent.
Types are valuable when they encode real domain constraints:
But much type information does not express constraints like these. It merely repeats facts that are already obvious from the implementation:
In the human-programming era, this repetition helped programmers understand unfamiliar code. It also allowed IDEs and compilers to catch simple mistakes.
But an AI model is already an extraordinarily capable pattern recognizer. It can often infer these relationships from names, implementations, call sites, tests, and surrounding context.
Requiring the model to state everything again does not automatically improve correctness. It increases output length and adds another consistency obligation.
If a constraint cannot eliminate a meaningful business error but requires dozens of additional tokens, it may be providing ceremony rather than safety.
The most commonly advertised benefit of static typing is that it moves errors into the compilation stage. In AI-assisted development, compilation errors are among the cheapest errors possible.
The expensive errors are the ones the compiler cannot see:
A program compiling successfully proves only that it satisfies the small subset of rules represented by its type system. Nowadays, this does not buy much, because any frontier AI can almost always meet this narrow requirement in a single shot.
Since AI-generated code already spends very little time stuck on basic compilation failures, continuing to present compiler feedback as a decisive advantage is like advertising a self-driving car on the strength of its gear-change indicator. It may not be entirely useless, but it is simply no longer an important issue.
For AI, one of the most meaningful differences between languages is how many tokens are required to express the same behavior.
In a static typed language, a simple operation may require:
Those additional structures are not free. Longer code requires more generated tokens. More symbols must remain consistent across the context. Changes touch more declarations and more files. Every additional abstraction creates another place where the model can misunderstand the programmer's intent.
AI does not automatically become more correct merely because the code looks more rigorous. It simply now has more things to keep consistent.
More tokens mean more opportunities for error. More abstraction layers mean more room for misunderstanding. More type machinery means more code that does not directly express the business requirement.
On the other hand, dynamic languages may express the same behavior in a lot less number of lines of code. For example, Clojure, a dynamic language, is shown to be the most token efficient in this study.
This does not mean types have no value. Types can document interfaces, define module boundaries, support tooling, and encode genuine domain constraints.
But that cost must now be evaluated honestly. Static typing should not be treated as inherently superior.
Historically, type systems added code and complexity in exchange for reducing human cognitive load and catching human mistakes.
Now, an increasing share of code is generated, modified, and interpreted by AI. AI does not have the same memory limitations, and it rarely remains stuck on syntax or elementary type errors. Its weaknesses lie elsewhere: ambiguous requirements, hidden assumptions, sprawling context, and imperfect semantic understanding.
The old benefit is shrinking while the old cost remains.
And now that tokens are a measurable expense, that cost is more visible than ever.
AI does not care about language ideology. It is not participating in the culture war between static and dynamic typing. It is generating tokens.
If two languages can solve the same problem, but one requires more declarations, more boilerplate, more adapters, and more type gymnastics, that complexity does not disappear. It becomes a longer context, a higher generation cost, and a larger surface area for mistakes.
The supposed advantage of statically typed languages was built on a world in which humans were the primary producers of code. That premise has changed. The conclusion should change with it.
Projectile 3.4 is out! After the four releases that preceded it this summer, this one is polish rather than ambition.
Most of it came out of an annoyance I’d been living with for years without ever quite naming it: a lot of what I work on isn’t one directory. CIDER is really CIDER plus cider-nrepl plus orchard plus clj-refactor plus a handful of others. RuboCop is RuboCop plus rubocop-ast plus rubocop-rails and the other extension gems plus the style guide. I’ve been maintaining families of repositories for the better part of a decade.
Lately I’ve also been reaching for git worktrees far more than I used to, so the same repository is now checked out two or three times on my disk at any given moment.
Projectile saw all of that as a pile of unrelated projects, which is exactly what it looks like from the outside.
projectile-switch-worktree (s-p W) offers the other checkouts of the repository you’re
in, each labelled with whatever tells it apart - the branch for git, the workspace name for
Jujutsu:
Switch to worktree:
~/src/myapp-main/ (main)
~/src/myapp-hotfix/ (hotfix/crash-on-open)
Git worktrees are the obvious case. Projectile asks git about those, so a worktree you’ve never opened in Emacs shows up anyway. Jujutsu workspaces work the same way.
The case I care about more is the one without any plumbing: a second git clone of the
same upstream. Same workflow, done by hand. I looked at my own ~/projects while writing
this and found four separate clones of CIDER sitting in it. Nothing records those anywhere,
so Projectile can’t ask git to list them - it matches them among your known projects by
their remote instead.
The other half is projectile-switch-sibling-project (s-p n), which offers the projects
related to the one you’re in rather than every project on the machine.
The interesting question was what “related” should mean. My first instinct was to compare directory names and look for a shared prefix. Before writing anything I tried the candidates out on my actual project directory, all ninety-odd repositories of it.
Comparing names does find rubocop, rubocop-ast and rubocop-rails. But grouping by the
owner of the upstream remote finds this:

Half of those names have nothing in common with cider, and no amount of staring at
directory names would ever have related them. haystack parses stack traces,
port is a printer registry, sayid is a tracing debugger. Nothing in those
names says “CIDER”.
What they do have in common is an org: they all live under clojure-emacs on GitHub. That’s not incidental, it’s how the project is actually organised - the org is the boundary of the effort, and the individual repositories are just where the pieces ended up when they got big enough to split out. Same story for the rubocop org, and for nrepl. Once I saw that, keying on the owner of the remote was obviously right, because it’s the same fact GitHub is already recording for me.
It needs a bound, though, and the same experiment showed why: about 40% of my checkouts are
under my own GitHub account, and “we’re both under bbatsov” doesn’t relate anything. So an
inferred group covering more than a quarter of your known projects gets dropped and the
next signal takes over.1 If you’d rather just say what belongs together,
projectile-project-groups is there and is never second-guessed.
Switching is only the obvious thing to do with this. Now that Projectile can tell which projects belong together, the same grouping could back a find-file or a search across the whole family - one prompt that reaches every repository in the org rather than just the one you happen to be sitting in. I go looking for “which of these fifteen repos defines this var” often enough that I suspect it’s the more useful half. Nothing built yet, but that’s where I expect this to go.
Once Projectile knows two directories are the same repository, the command history can follow you between them.
Press M-p at the compile or test prompt in a worktree you made this morning and you get
the commands the project is actually built with, instead of an empty history. That’s an
issue from 2022 that a stale bot had
helpfully closed for me at some point.
What doesn’t follow you is anything that runs without asking - what a prompt is pre-filled
with, and what projectile-repeat-last-command replays. I had those shared too in the
first draft, and then watched a repeat in one worktree rebuild the tree next door. A
remembered command can carry absolute paths back to where it was typed.
projectile-run-test-at-point (s-p c .) arrived in 3.1 knowing Python, Go and JS/TS. It
now also knows Ruby (both RSpec and Minitest), Rust, Elixir, Java, Erlang and F#.
Ruby is written the same way whichever framework you use, so there the project type picks
the runner rather than the syntax; Java’s picks between Maven and Gradle. Elixir tests get
addressed as FILE:LINE, because ExUnit can’t select a test by name from the command line.
OCaml deliberately gets nothing - its tests are ordinary values you register with Alcotest
or OUnit, so there’s no syntax to recognize.
projectile-find-file-of-kind (s-p j) and projectile-toggle-related-file (s-p J)
learned Phoenix, Laravel and Next.js. Rails and Django had been the only frameworks with
file-kinds tables out of the box, which felt like a strange place to have stopped.
The dashboard and the doctor from 3.3 both got a pass. They’re no longer plain text - section headers, field labels and findings are faced by meaning, with findings colored by severity and sorted so anything wanting your attention comes first. The faces only inherit from standard ones, so your theme styles them without knowing Projectile exists.
The doctor’s findings now come with a button that acts on them:

[enable] for a projectile-mode you forgot to turn on, [enable caching] on a big
uncached project, [open dirconfig], [edit .dir-locals.el]. Pressing one regenerates the
report. Findings Projectile can’t act on stay plain advice.
And since a doctor report usually ends up pasted into an issue, w copies the buffer as
plain text, without the faces and buttons.
projectile-find-changed-file (s-p C) completes over what git reports as staged,
unstaged or untracked - or, with a prefix argument, everything that differs from a
revision you pick.projectile-run-task discovers rake tasks now, read out of your Rakefile and .rake
files rather than by running rake -T, which would load the whole application.projectile-ignored-project-patterns is the regexp-matching sibling of
projectile-ignored-projects, so keeping a whole area of your machine out of the known
projects no longer needs a lambda.[Projectile] now, and
the ones answering a command you just invoked aren’t. There were five different
conventions in there before, which I’d somehow never noticed.locate-user-emacs-file, so they land in the right place if your configuration lives in
~/.config/emacs. Nothing moves if it doesn’t.foo_test.exs rather than foo_test.ex, a
script ExUnit will actually run. Project types can declare their test file extension now.Nothing here should break a working setup, but two things are worth knowing.
A batch of options were renamed or folded together. The six
projectile-<cmd>-use-comint-mode options became one projectile-use-comint-mode;
projectile-per-project-compilation-buffer and projectile-per-command-compilation-buffer
became projectile-compilation-buffer-scope; and a handful of options that had broken
their own naming schemes were renamed to match their siblings.2 Every one of them is
still honored under its old name, so your config keeps working - you’ll just see an
obsolescence notice.
Two options are gone: projectile-tags-file-name and projectile-go-project-test-function
were only ever read as Projectile loaded, which means setting them from your init file
afterwards did precisely nothing.
And the command history is now the repository’s rather than the directory’s. Set
projectile-command-history-scope to project if you’d rather have it per directory.
Histories you already have are adopted, not dropped.
The full changelog is here, and the manual is at docs.projectile.mx. The cross-repository features have a page of their own, limitations included.
Five releases in six weeks is not a pace I intend to keep up, and this is the natural place for the burst to land. The big pieces from 3.0 through 3.3 have had their corners sanded down, and what’s left on my list is smaller and less interesting to write about. Which is roughly where a fifteen-year-old package ought to be.
That’s all I have for you today. Keep hacking!
Which is why projectile itself comes back with no siblings on my machine. The cap is working; the answer is a configured group. ↩
projectile-global-ignore-file-patterns, projectile-cmd-hist-ignoredups, projectile-related-files-fn-function, projectile-auto-discover, and the three reviewable-search options that were named after replace. ↩
A couple more Biff 2 libraries are out the door:
biff.datastar: the dumbest/awesomest possible way to make a reactive, server-side-rendered web app. Over the past several years I haven't put much priority on making it easy to make fancy reactive/real-time/collaborative UIs since my UI needs are typically pretty simple. But this architecture is actually really nice even when you don't need the fancy stuff.
biff.ring: mostly
some Biff-related plumbing code. I think the defroute macro is pretty nice.
There's a cool wrap-csrf-protection middleware that doesn't use
tokens.
The last "big" Biff 2 library I need to fix up and release will be biff.tasks, the one for all the CLI tasks. Other than that there's biff.authentication, the email-powered authentication module thing (now with a default sign-in form included) and a few other doodads. And then a starter app. And some documentation that ties all the libraries together, not just documentation for the individual libraries (which I've been writing as I go).
I'm still shooting to have all that released before the conj, which is... coming up. On the bright side, in the window of time between writing the first draft of this post and sending it out, I've already finished editing the biff.tasks code and only need to write the documentation. So I'd say we're well on our way.
Jolt now provides opt-in support to run go blocks on fibers instead of OS threads. This post will discuss how that works along with the different trade-offs made compared to other implementations, and why core.async turns out to be a great fit for the mechanism. All the numbers below are from an Apple M1 Pro with 10 cores, measured with the harness in bench/fibers on the current tree.
As you probably know, core.async's programming model is best served by cheap green processes that communicate over channels. Jolt's original implementation backed every go block with a real OS thread. Using system threads affords the same semantics, and it has a nice property that there's nothing special about a go body. But real threads have significant overheads putting a limit on the number you can reasonably have, and they're not particularly cheap to start.
Let's take a look at what that ceiling looks like for threads and fibers when spawning K processes that each immediately park on an empty channel:
| backend | K | created | per spawn |
|---|---|---|---|
| fiber | 10,000 | 10,000 | 1.09 µs |
| fiber | 100,000 | 100,000 | 0.74 µs |
| thread | 1,000 | 1,000 | 53 µs |
| thread | 10,000 | 4,080 | 168 µs |
| thread | 100,000 | 4,079 | 159 µs |
As the number of threads goes up, they effectively stop working. My machine runs out somewhere around 4,080 live threads, and by that point, the spawn cost has already gone up 3x. Memory story isn't encouraging either with a parked process costing 4,160 bytes of live heap on a fiber while sitting at 68,729 bytes on a thread. That's a 16.5x difference as a base, and measured as peak RSS rather than live bytes the gap widens to 44x since a thread needs a guard page along with a real stack mapping. So the motivation to have a light weight mechanism should be pretty obvious.
A fiber consists of a record holding a state, a body thunk, a continuation slot, an intrusive run queue link, a slice of per-fiber dynamic state, and the carrier it belongs to. In addition, the scheduler needs a little bookkeeping to track the pending step, the fiber's registered monitors, and the interrupt depth it parked at. Parking captures the current continuation with call/1cc and jumps to the scheduler, and that continuation is what gets invoked when resuming. That's all there is to it at a high level, and on Chez a bare continuation switch measures just 8.6 ns.
The current fiber lives in a Chez virtual register costing about 2 ns to read. Since the cost is so low, a scheduler can do millions of switches per second. The full scheduler yield, including swapping the fiber's dynamic slice and arming the preemption timer described further down, comes to around 137 ns.
Another consideration here is that a continuation on Chez is a stack segment, which is not free. A completed fiber that holds no continuation costs just 108 bytes, but the moment it parks, the cost jumps to about 4,177 bytes. Interestingly, that number barely moves with stack depth:
| shape | bytes per fiber |
|---|---|
| completed, no continuation | 108 |
| parked, 1 frame | 4,177 |
| parked, 3 nested calls | 4,187 |
parked inside a dynamic-wind | 4,281 |
So a fiber isn't actually cheap because its stack is small, but due to 4 KB being much less than the 69 KB an OS thread costs, which makes it possible to have hundreds of thousands of them running concurrently.
On the JVM, go had to be a macro that CPS-transforms its body into a state machine because the JVM had no continuations when core.async was originally written. That's the key reason why <! and >! only work lexically inside a go block. Putting a parking take inside a function then calling it from a go body does not work since the macro cannot see past the call boundary to rewrite it.
Jolt, on the other hand, has real continuations, so there's no need for the transform to park. A fiber's <! registers a waiter on the channel and captures its continuation, wherever it happens to be. With this approach, parking works through arbitrary call depth, helper functions, callbacks, or even eval. The whole limitation core.async has on the JVM goes away on the Chez runtime.
Jolt ships its own native channels, but it implements the same design for the waiter protocol. A channel operation that cannot complete immediately registers a handler and waits to be woken. The only difference from threads is that they wait on a condition variable while fibers wait by parking. The channel core doesn't need to know which it is talking to. All it has to do is commit to a handler under its lock, write a mailbox, and call a wake function. The immediate-completion path, where a buffered value or a waiting putter is already there, captures nothing and never touches the scheduler at all.
Adding fibers meant adding a second wake strategy, and the blocking variants fall out from that as well. On a fiber, <!! and >!! park exactly the way <! and >! do. Blocking semantics are preserved, in that the process does not proceed until the value arrives.
Yet, having 4 KB per parked fiber still bothered me since for a large class of go bodies it should be avoidable. When the park site is visible to the compiler, the rest of the body can be turned into a closure of a few hundred bytes rather than necessitating a whole stack segment.
And that brings us back to the JVM's transform, which I originally avoided, but the big question was how to avoid inheriting the JVM's limitation along with it. Having to prove that the pass can rewrite every park in a body means a closed world analysis with anything calling park within the body resulting in a compilation time error.
Jolt makes the choice per park site instead, with the continuation park being used as the runtime fallback. A CPS pass in clojure.core.async rewrites the body where it can so that a park it rewrote stores a closure and switches with no capture, but a park it could not rewrite is left as written and parks by capturing. Both mechanisms coexist inside a single fiber, and can be mixed freely. Since there is no static claim to defend, a park inside a called function, a try, a nested fn, a collection literal, or reached through eval still works. It just keeps the continuation park, which is what a park cost before any of this existed.
The result on a parked process:
| park mechanism | live bytes | continuations held |
|---|---|---|
| rewritten body | 877 | 0 of 10,000 |
| captured continuation | 4,785 | 10,000 of 10,000 |
That's a 5.5x difference in memory. The park and resume round trip, meanwhile, is 1,040 to 1,049 ns rewritten against 984 to 1,018 ns captured, so the actual win here is memory usage rather than time.
It's also worth noting that alts! still captures, because threading a continuation through the waiter registration would be its own major piece of work, and so does any park inside a try, because the rewrite would have to carry the exception frame explicitly. The pass also treats a bare fn as opaque, since it cannot see what the closure is handed to, and that covers a larger set than it sounds like since binding, dosync and locking all hand their body over as a function, and a park inside any of them takes the capture too. All of these are correctness-preserving fallbacks rather than failures, however. A rewritten park does not rewind the dynamic-wind chain on the way back in, so a park that sits inside a wind has to be one the pass left alone.
A cooperative scheduler assumes that go bodies reach a channel op reasonably often. When a body is pure computation instead, the fiber holds its carrier for as long as it runs, and every fiber queued behind it is simply stuck since fibers cannot migrate carriers. That creates potential for an unbounded starvation window.
The way to deal with the problem is to make the scheduler preemptive. Chez polls an engine timer at procedure calls and loop back edges, which means even a tight Scheme loop is preemptible, and the timer handler can turn the fiber's quantum into a yield. The default quantum is about 0.45 ms. A queued fiber stuck behind a fiber spinning in a bare loop on the same carrier gets to run within about a millisecond, and a 200 ms compute-bound spin gets preempted around 265 times.
The clojure.core.async/*fiber-preempt-ticks* var sets the quantum, subject to a floor, and is read once when the carrier pool starts. No value turns preemption off, so code that wants effectively cooperative behaviour can ask for a very long quantum instead. However, it's worth noting that preemption cannot help with a fiber that's inside a blocking foreign call because the timer is only polled in Scheme.
To ensure that preemption works safely, every lock in the runtime routes through a common counting wrapper, and the scheduler refuses to switch a fiber that holds one, re-arming on a short retry so the preemption lands just after the region instead of being dropped. That works because those regions measure around 55 ns against a 0.45 ms quantum, but the locks whose region is a user body are a special case. These include locking, dosync, a delay being forced, and java.util.concurrent.locks.ReentrantLock. Those regions are as long as the caller's code and the caller may park inside them, so they must carry ownership in a field keyed on the fiber rather than in an OS mutex. A field survives a context switch, and no counted lock is held while user code runs, which makes a long locking body preemptible.
The upshot for anyone writing jolt code is that a lock is a lock. You can hold a monitor across a <!, run a transaction that parks in the middle, and force a delay whose body blocks on a channel, with exclusion holding in each case.
The awkward thing about concurrency work is that it's both notoriously difficult to reason about and to test exhaustively. So, I decided to try proving certain properties of the design using Z3 through the chiasmus MCP to help ensure that my approach was sound. The pattern is to state the rule along with the property it is supposed to enforce, then have the solver either hand back a counterexample or report that none exists.
The lock ownership rule is a good example to walk through because it is small enough to show in full. The entire question here is which identity an acquire writes into the owner field and what the next acquire does with it. In the runtime that comes down to two pieces:
;; who is asking: the FIBER when there is one, else the OS thread's identity
(define (monitor-self) (or (jolt-current-fiber) (current-interrupt-box)))
;; and what the acquire does with the answer
(let ((me (monitor-self)))
(let loop ()
(let ((owner (vector-ref m monitor-i-owner)))
(cond
((eq? owner me) (vector-set! m monitor-i-count (fx+ 1 (vector-ref m monitor-i-count))))
((not owner) (vector-set! m monitor-i-owner me)
(vector-set! m monitor-i-count 1))
(else (monitor-wait! m) (loop))))))
The model, written in SMT-LIB, consists of four facts. Two execution contexts have identities, and the design either gives them the same one, which is the thread they share, or different ones, which is the fiber. The first context takes a free lock and parks inside the section without releasing. The second then runs the acquire decision exactly as the code writes it. And the property under test is that no state has both contexts inside the section at once.
(assert (! (= by_thread (= id_f1 id_f2)) :named identity-model))
(assert (! (= owner_after_f1 id_f1) :named f1-owns))
(assert (! f1_in_section :named f1-still-inside))
(assert (! (= f2_enters (or (= owner_after_f1 NONE)
(= owner_after_f1 id_f2)))
:named f2-lock-decision))
(assert (! (and f1_in_section f2_enters) :named seeking-violation))
The correspondence we're interested in is that by_thread records which branch monitor-self took, since the thread branch is the one that hands two fibers on a carrier the same identity, and f2_enters is the disjunction of the two cond arms that get in without waiting.
Asking whether that violation is reachable at all comes back SAT, and the assignment the solver hands back is the bug itself: by_thread true, both identities equal, and both contexts inside the section. Pinning the design to context identity and asking the same question comes back UNSAT. Here the unsat core names the identity choice alongside the acquire rule, which says the property depends on that choice rather than holding by accident of how the rest of the model happened to be written.
I could have reasoned through this by hand and been fairly sure, but having a formal proof takes the guesswork out of the rule itself. The solver quantifies over every assignment the model admits, so an UNSAT is exhaustive rather than a sample, and a SAT identifies the exact assignment that breaks it.
Of course, there is a limit to how much a solver can help since it proves a property of the rule I described to it, rather than of the code itself, and it knows nothing about implementation details such as Chez mutexes or the winder chain. Hence, the result can only be as strong as the model is faithful. However, there is a lot of value in knowing that the approach is fundamentally sound, while the actual implementation can be covered by the tests.
Not every invariant has a shape that lends itself well to this approach, and you have to know when to reach for it. The things generally worth formalizing are the rules for the load-bearing decisions that determine whether the approach itself is sound or not.
Go's goroutines start with a small stack, around 2 KB, and grow by copying when they need more. Since the Go runtime has precise stack maps, it allows relocating a goroutine's stack, so goroutines can migrate freely between OS threads, allowing the scheduler to steal work. A goroutine that blocks on IO parks on the netpoller, and a goroutine that makes a genuinely blocking syscall causes its processor to be handed to another thread.
JVM virtual threads keep their stack as heap-allocated chunks that mount and unmount from a carrier thread. Unmounting copies the stack out while mounting copies it back. A virtual thread on the JVM can also remount on a different carrier than the one it last ran on.
Unfortunately, Jolt cannot do either, which leads us to the central trade-off. A Chez continuation captured on one OS thread raises "attempt to return to stale foreign context" when you try to resume it on another. So a fiber is bound to its carrier for life. There is no way to load balance the work since an idle carrier cannot take another carrier's queued fibers.
Preemption means the fibers sharing a carrier at least take turns, avoiding a starvation problem. What is left is that a carrier's work cannot be moved somewhere else, which shows up as skew. You can see both halves in the scaling benchmark. Forty CPU-bound fibers across carriers scale nearly linearly, from 903 Mops/sec on one carrier to 6,499 on ten, which is 7.2x on my 10-core machine. Give one fiber ten times the work of the others and the batch takes 112 ms, which is how long that one fiber takes.
Go and the JVM are able to rebalance because they have a stack representation that can be moved around. Jolt doesn't have a similar mechanism to lean on, so the carrier pool acts as a throughput knob, and growing it does not rescue work that is already skewed onto one carrier.
Another key challenge for a green thread system comes from blocking operations such as read on a fiber pinned to a carrier. Since continuations cannot migrate, everything queued up behind it ends up having to wait for it to finish.
Jolt's socket layer sets O_NONBLOCK and treats EAGAIN as "wait for readiness". Waiting means asking a per-process poller, kqueue on macOS and epoll on Linux, to report when the fd is ready. If there is a current fiber, the poller registers the fd and the fiber parks. And when there is not, the caller does a plain blocking wait on its own thread. The user-facing code is identical in both cases, so the same socket code implicitly works on a fiber and on a thread.
The wait has to be collect-safe, because Chez's collector stops the world, and a thread sitting inside a foreign call that is not marked collect-safe still counts as active. As a result, a collection from any other thread fails outright with "cannot collect when multiple threads are active". A poller stuck in kevent is essentially blocked all the time, which means that getting this wrong would result in the process never being able to collect. A full collect must succeed while the poller is blocked, and the failure mode is easy to miss.
Another tricky bit is that registration races need a control pipe. A fiber can register an fd while the poller is already inside kevent, and that registration has to interrupt the wait rather than sit there until the next unrelated event. The pipe read end is permanently in the poller's set, a registration writes a byte, and the poller drains pending registrations on every wake. This approach avoids needing timed polls or doing sleep in the wait path.
Finally, the commit to park has to be atomic with the wake, which is the same race the channel layer has, and gets solved the same way. The fiber marks itself parked under the poller's table lock, the poller collects woken fibers under that same lock and resumes them after releasing it.
We can see how this trade-off shows up in channel throughput:
| workload | thread | fiber |
|---|---|---|
| ping-pong, 2 processes | 3.64 µs/roundtrip | 6.25 µs/roundtrip |
| ping-pong, pool pinned to 1 carrier | 1.89 µs/roundtrip | |
| fan-in, 8 producers x 2,500 values | 84,382 values/sec | 139,441 values/sec |
Two processes ping-ponging are actually a good margin slower on fibers than on threads. When two fibers land on different carriers, every handoff requires a cross-thread wakeup to take a lock, signal a condition variable, then wake another OS thread. That's strictly more work than two live OS threads doing handoffs directly between each other. But when the pool is pinned to a single carrier, the benchmark runs at 1.89 µs. Since the handoff is now a continuation switch that happens on the same thread, it's 1.9x faster than thread communication.
The fan-in case, which is closer to what people actually build, goes the other way giving 1.65x in favor of fibers. Having eight producers and one consumer is a shape where holding eight OS threads would be significantly more expensive.
Context switch costs, for calibration:
| operation | cost |
|---|---|
| bare continuation switch | 8.6 ns |
| scheduler yield including slice swap | 137 ns |
| OS thread channel handoff | 1,819 ns |
Memory usage characteristics are generally good, and stay flat under churn. Creating 16,000 fibers in waves of 2,000, with a full collect between batches, settles at about 234,000 bytes and stays there from the second wave on, since fibers release their memory as they finish.
Fibers are provided as an opt-in mechanism which is enabled using clojure.core.async/*go-backend*. The var defaults to :thread, and you bind it to :fiber around the spawn. The thread backend has no pinning story to worry about, and it remains the right default for code that does unpredictable things. The pool size is managed using clojure.core.async/*fiber-carrier-count*, which defaults to the machine's processor count and gets read when the pool starts.
The rough guidance is that if you have many processes that spend most of their time parked, fibers win by a lot, on both spawn cost and memory. If you have a small number of processes doing tight channel handoffs then you have to pin the pool. A process that just computes for a long time is fine since the scheduler preempts it. The case that still needs care is a process that blocks a carrier on something the poller does not cover, and that's where thread should be used, since it always spawns a real OS thread regardless of the backend setting.
The part I find most satisfying is that adding fibers ended up being a matter of implementing a different wake strategy because core.async's channel protocol doesn't assume what a waiter is. And having real continuations means parking is no longer confined to places where the macro can see it. Thus, Jolt avoids the single most annoying restriction of core.async on the JVM, while keeping the compiler transform around as a memory optimization for the cases where it applies.
Greetings folks!
Clojurists Together is pleased to announce that we are opening our Q3 2026 funding round for Clojure Open Source Projects. Applications will be accepted through the 24th of August 2026 (midnight Pacific Time). We are looking forward to reviewing your proposals! More information and the application can be found here.
We will be awarding up to $29,000 USD for a total of 4-5 projects. The $2k funding tier is for experimental projects or smaller proposals, whereas the $9k tier is for those that are more established. Projects generally run 3 months, however, the $9K projects can run between 3 and 12 months as needed. We expect projects to start around mid-September 2026.
A BIG THANKS to all our members for your continued support. We also want to encourage you to reach out to your colleagues and companies to join Clojurists Together so that we can fund EVEN MORE great projects throughout the year.
We surveyed members in July to find out what what issues were top of mind and the types of initiatives they would like us to focus on for this round of funding. While our goal for the survey is to surface the broadest, most consistently-raised themes, it is not meant to be prescriptive, as we are always interested in nurturing new ideas and approaches. As always, there was a lot of great input and we hope it will be useful in informing your project proposals.
Demonstrated Impact of Past Funding Roughly three-quarters of respondents draw on CJT-funded work on a near-daily to weekly basis, with the remainder spread across occasional, project-dependent, or passive-interest use. This is strong evidence that past funding has produced tools and libraries with real, sustained utilization — which is why we exist!
Adoption and Growth of Clojure Continue to be of Concern. This theme is closely linked to employment challenges cited. These themes require broader or more strategic solutions that may be best addressed by the Core Team. However, Clojurists Together can support smaller and more focused efforts. Some ideas include:
About 88% of Members Surveyed are Using AI tools in some capacity - with members calling out the need for Clojure-specific support.
Developer Experience Tools are Respondents' Top Priority for Clojure and ClojureScript with Error Messaging identified in the top 4 for both. There is plenty of work that needs to get done in these categories. The good news is that the Clojure core team along with the CLI Task Force is actively working on improving the user experience of the command-line tooling. More to come in the few months….
This summary includes a selection of member comments.
Before weighing the themes below, it’s worth noting who answered this survey. The respondent base skews heavily toward long-tenured Clojure developers, and server-side JVM use dominates how members actually deploy Clojure.
Of 41 respondents, the overwhelming majority have used Clojure for a long time:
| Tenure | Share of respondents |
|---|---|
| 6 years or more | ≈87.8% |
| 1–5 years (combined) | ≈10.7% |
| Less than 1 year | ≈1.5% |
*87.8% reported 6 years or more of Clojure experience, with only a small remainder spread across 1–5 years and under 1 year combined. This is an important caveat for every other theme in this report: the feedback is disproportionately the voice of veteran users, not newcomers.
Respondents identified overwhelmingly as mentors rather than newcomers. Combined with the tenure data above, this confirms the survey sample is dominated by experienced members who are already invested in growing the community.
| Platform | Responses | % of respondents |
|---|---|---|
| Clojure – JVM server | 40 | 97.6% |
| ClojureScript – Browser | 28 | 68.3% |
| Clojure – JVM client application | 8 | 19.5% |
| ClojureScript – Node server | 5 | 12.2% |
| ClojureScript – Mobile platform | 5 | 12.2% |
| ClojureScript – Desktop application | 3 | 7.3% |
| ClojureDart | 2 | 4.9% |
| Clojure – Mobile platforms | 1 | 2.4% |
| Babashka / Node.js / Scittle (write-ins, ~1 each) | 1 each | 2.4% each |
| Clojure CLR – Server | 0 | 0.0% |
| Clojure CLR – Client | 0 | 0.0% |
*Clojure on the JVM server remains the dominant deployment target by far (97.6%), with ClojureScript in the browser a strong secondary use case (68.3%). Notably, ClojureDart shows minimal current usage (4.9%) despite being repeatedly and enthusiastically flagged in the open-ended “magic wand” and ecosystem-support answers (Section 8) — a gap between current adoption and member enthusiasm worth factoring into funding decisions.
Members were asked which areas of Clojure and ClojureScript most need improvement (select-many). Developer Experience Tools ranked #1 in both languages, and data/error-handling concerns dominate the Clojure-specific results.
| Rank | Area | Responses | % of respondents |
|---|---|---|---|
| 1 | Developer Experience Tools | 16 | 45.7% |
| 2 (tie) | Data Analysis / Processing Frameworks | 14 | 40.0% |
| 2 (tie) | Error Messages | 14 | 40.0% |
| 4 (tie) | IDE Support | 8 | 22.9% |
| 4 (tie) | Debuggers | 8 | 22.9% |
| 6 (tie) | Documentation | 7 | 20.0% |
| 6 (tie) | Test Tooling | 7 | 20.0% |
| 8 (tie) | Build Tooling | 6 | 17.1% |
| 8 (tie) | Profilers | 6 | 17.1% |
| 10 (tie) | Linters | 5 | 14.3% |
| 10 (tie) | Code Coverage | 5 | 14.3% |
| 12 | Online Services | 3 | 8.6% |
| 13 (tie) | Backend framework (write-in) | 1 | 2.9% |
| 13 (tie) | Performance (write-in) | 1 | 2.9% |
| 13 (tie) | AI-supported development (write-in) | 1 | 2.9% |
| Rank | Area | Responses | % of respondents |
|---|---|---|---|
| 1 | Developer Experience Tools | 11 | 42.3% |
| 2 | Build Tooling | 7 | 26.9% |
| 3 | Documentation | 6 | 23.1% |
| 4 | Error Messages | 5 | 19.2% |
| 5 (tie) | IDE Support | 4 | 15.4% |
| 5 (tie) | Debuggers | 4 | 15.4% |
| 5 (tie) | Code Coverage | 4 | 15.4% |
| 8 | Test Tooling | 3 | 11.5% |
| 9 (tie) | Linters | 1 | 3.8% |
| 9 (tie) | Data Analysis / Processing Frameworks | 1 | 3.8% |
| 9 (tie) | Profilers | 1 | 3.8% |
| — | Online Services | 0 | 0.0% |
Write-in responses (ClojureScript, 1 mention / 3.8% each): reduced or near-zero NPM dependency, ability to do full-stack development without a separate backend, less reliance on NPM generally, AI-supported development, and “N/A, I don’t use ClojureScript."
“Developer Experience Tools” was the single highest-ranked improvement area for both Clojure (45.7%) and ClojureScript (42.3%), and it recurs throughout the open-ended answers as well. For Clojure specifically, error messages and data analysis/processing frameworks tied for second place (40% each) — well ahead of documentation, IDE support, and debuggers. For ClojureScript, build tooling (26.9%) and documentation (23.1%) stand out as the next-biggest gaps after developer experience, suggesting the ClojureScript toolchain still feels heavier to maintain than the Clojure one.
Claude Code was named most often in the open-ended answers, alongside Cursor, Copilot, Gemini, Aider, ECA, bhauman’s MCP Server, Amazon Kiro (via CP in IntelliJ), Several members pointed specifically to REPL-driven, Clojure-aware tooling (e.g., clojure-mcp / clj-nrepl-eval integrations) as the feature that makes AI genuinely useful for Clojure — but also noted that generic AI tools frequently mishandle Clojure’s syntax (parentheses/brackets) and that few tools understand Clojure idioms well.
| Task | Responses | % of respondents |
|---|---|---|
| Debugging | 26 | 68.4% |
| Code Completion | 25 | 65.8% |
| Learning | 25 | 65.8% |
| Testing | 24 | 63.2% |
| Documentation | 22 | 57.9% |
| Other (write-in) | 10 | 26.3% |
Sub-themes:
Supporting comments:
“clj-nrepl-eval from bhauman/clojure-mcp-light is central. REPL is the killer feature for AI assisted Clojure dev compared to other languages.”
“I wish ECA would work well with local AI models using Ollama. I dont want to use big tech companies… I dont trust them.”
“Each client provides a chatbot, which might be inside the IDE but I have no idea how to make it work with Clojure and not mess up the brackets.”
“The sad truth is in an ever increasing LLM driven development world there is less incentive to use Clojure than something like Rust. All the downsides in making that switch are alleviated if LLMs are doing the coding for you.”
“I like to use free models and run them locally, if there is a large amount of repeatable and we’ll defined work to do it can be good, like a refactor. Sometimes it’s good to use to test an idea or prototype I would not have time to do otherwise. I generally take it that if an llm agent can do something then it’s likely not that hard to do. If the llm struggles on something that should be simple it’s interesting to find out why.”
“I rarely write code “by hand” anymore. My workflow is primarily prompting various coding agents (Claude code, codex, open code using models via open router) and reviewing their output, but rarely dropping into the editor myself.”
“LLMs are a scourge upon the human race with no actual profitability, and I hope every day to see this bubble finally pop.” “It has basically taking over everything. Agent harnesses.”
“While our company doesn’t forbid the usage of (generative) AI tooling, it doesn’t encourage it either. It is up to each individual developer to use it or not. But the agreed contract is that whatever code a developer produces using AI tooling must meet the same established conventions (e.g., code style, idioms to be used, code and architecture estructure, etc) and quality levels of code produced by human developers. And that the code pushed by that developer must be owned by him/her, and that it is his/her own responsability to maintain, and fix if needed.”
The single most repeated theme in response to “the biggest challenge facing Clojure developers” was one of perception rather than technology: Clojure is widely seen — inside and outside the community — as niche, shrinking, or even dead, which makes it harder to justify on new projects, hire for, or pitch to business leadership and investors.
Supporting comments:
“It is a challenge using it on new projects and justifying it over mainstream alternatives. The biggest complaint I always hear is ‘how will we find developers’. " I think this is more of a perception challenge, the easiest way to reply would be to just point to success stories, or a very visible app or product.”
“Outreach. Many people think language is dead”
“In the world of startups Clojure is generally seen as a niche language and therefore a hindrance to selling a company and maybe even also just to getting funding (sometimes). A friend of mine is the CTO of a startup that was in talks for an acquisition, and the company backed out of the deal because Clojure was used.”
Members suggested amplifying success stories and visible production use cases, supporting community “influencers” and advocates, and funding outreach/evangelism efforts aimed at both developers and business decision-makers.
Closely tied to the perception theme is a concrete, recurring concern about the Clojure job market: too few open positions, hiring managers who default to languages with larger corporate backing, and no “gateway” framework (comparable to Rails or ML Frameworks) that pulls new developers into the language the way it once did.
Supporting comments:
“Lack of job opportunities. Big companies are quite skeptical about non-mainstream languages.”
“The people that make hiring decisions view developers as fungible goods, which then leads them to choose languages based on which one they believe will have the lowest salary/hourly cost which tend to be the languages with large corporate backers, and Clojure is not one of those languages.”
Members flagged specific maintenance gaps in the ecosystem: unmaintained libraries with no clear owner, documentation gaps in widely-used projects. Support for projects, tools and platforms cited: (5) CIDER; (4) Malli; (3) ClojureDart, re-frame, Pathom, Babashka, Jank; (2) reagent, Reitit, Shadow-CLJS, Scicloj; (1) HugSQL, clj-kondo, duct, nrepl, datalevin, datahike, Fulcro, Datascript, Glojure, Grain, eca, Telemere, rama, replicant, http-kit, Clojure Civitas, Clay, Datastar, calva, ring, figwheel-main.
Supporting comments:
“Clojurists Together could act as a broker for finding maintainers for out-of-support libraries.”
“Some great projects could use better documentation; two examples of amazing libraries that could use better documentation being Malli and Specter.”
“Port all major libraries to tools.deps”
The strength, generosity, and openness of the community is seen as a core strength - along with its engineering rigor. Members feedback included a desire for stronger central coordination, more inclusive and welcoming spaces, and a return to in-person connection.
Supporting comments:
“Clojure’s culture of engineering rigour is unmatched in the industry. I think clojurists’ attention to detail and care for their craft is a huge advantage right now in this age of slop and endless downtime. Also the community is warm, welcoming, and friendly, which is not the case anywhere else I “hang out” online”.
“Once people start using Clojure, they usually love it. REPL is great, Clojure is very fast, well designed language, also JVM interop has improved.”
“Resilience of Clojure communities and their support structures seems to be a challenge… In community spaces, some divide and disagreement often appear, and not everybody feels at home and supported.”
“I would create a Clojure foundation that would lead central decision-making for the continued growth and development of the language.” “Community in one digital place, a Clojure language spec.”
“There would be in-person meetups again!”, “That everyone and all events we’re in the same country :) I miss not being able to go everywhere!”
“I don’t know if this counts or not, but my favorite part of the ecosystem is how stable it is. I love that library updates rarely, if ever, break existing code. Having dealt with the churn and instability of the JS and Rails ecosystems, the fact that updates so rarely force me to do tedious work is a godsend.”
When asked directly what areas of the ecosystem need support, “advocacy,” “outreach,” “evangelism,” “mentoring,” and “community and growth” were named repeatedly and independently — more often than any single technical gap — reinforcing that members see growing and renewing the community as at least as urgent as improving the tools themselves.
คุณเขียน if-else ทุกวัน
คุณรัน code ใน terminal แล้ว REPL มันตอบกลับมา
คุณ lambda ใน Python, arrow function ใน JavaScript, closure ใน Rust
— ทั้งหมดนี้ เกิดจากภาษา LISP
และที่น่าทึ่งคือ... LISP ไม่เคยถูก planned ให้เป็นภาษาโปรแกรมด้วยซ้ำ
1958 — John McCarthy เริ่มพัฒนาแนวคิด LISP ที่ MIT
เมษายน 1960 — McCarthy วัย 32 ตีพิมพ์ paper ใน Communications of the ACM (vol. 3, หน้า 184-195)
"Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I"
ใน paper 12 หน้านี้ McCarthy เสนอไอเดียของภาษาโปรแกรมที่:
McCarthy เขียนมันขึ้นมาเป็น ทฤษฎีทางคณิตศาสตร์ — ไม่ได้ตั้งใจ implement
แต่ก่อน paper จะตีพิมพ์ — ระหว่างปี 1958-59 Steve Russell นักศึกษา grad student อ่าน manuscript
"I told him, 'Steve, why don't you program this eval?' and he said to me, 'Oh, I misread what you meant. I thought you meant I should implement the interpreter.'"
— John McCarthy, ACM interview
นักศึกษา เขียน interpreter ให้ทฤษฎีของอาจารย์ — และภาษา LISP ก็เกิด
code ตัวแรกที่ Russell เขียน ใช้เวลาแค่ 2-3 วัน (ตัว LISP 1.5 Programmer's Manual ฉบับเต็มออกตามมาทีหลัง ในปี 1962)
ย้อนไปปี 1958 — ภาษาส่วนใหญ่มีแค่ GOTO กับ branch แบบ assembly
McCarthy ให้กำเนิด cond (conditional expression) — จุดเริ่มต้นของ if-else ที่เราเห็นแทบจะในทุกภาษา
(cond ((< x 0) 'negative)
((= x 0) 'zero)
(t 'positive))
C, Java, Python, JavaScript, Go, Rust — ภาษาเหล่านี้ได้รับมรดกนี้มาหมด Conditional branching ในทุกภาษาสมัยใหม่มีโครงสร้างแบบเดียวกับที่ McCarthy คิดไว้ตั้งแต่ก่อนมนุษย์ไปดวงจันทร์
ก่อน LISP — programmer จัดการ memory เอง 100% ทุกบรรทัดของ malloc และ free
LISP สร้าง garbage collection ตัวแรกของโลก — ต้นฉบับคือ mark-and-sweep algorithm (implement โดย Daniel Edwards นักศึกษา MIT)
ทุกวันนี้ GC คือ default ในเกือบทุกภาษา high-level — Java, Python, JavaScript, Go, C#, Ruby ล้วนใช้แนวคิดนี้ต่อยอด
(lambda (x) (* x x))
LISP ทำให้ function เป็น first-class citizen — ส่ง function เป็น parameter ได้, return function ได้, เก็บลง variable ได้เหมือนเป็น string หรือ integer
นี่คือต้นทางของ:
(x) => x * x (Brendan Eich ถูกจ้างไป Netscape เพื่อทำ Scheme ใน browser — แต่ management เปลี่ยนใจให้ syntax เหมือน Java)lambda x: x * x
Read-Eval-Print Loop — LISP ให้กำเนิดมันในทศวรรษ 1960s
ก่อนหน้านั้น: เขียน code → compile → run → debug → repeat
หลังจากนั้น: พิมพ์ expression → กด enter → เห็นผลทันที
ทุกวันนี้ถ้าคุณเปิด Python REPL (>>>), Node.js console, Ruby IRB, Chrome DevTools, Rust Playground, หรือ Elixir IEx — คุณกำลังนั่งอยู่ในห้องเรียนเดียวกับ programmer LISP เมื่อ 60 ปีที่แล้ว
'(+ 1 2) ; ← นี่คือ list
(eval '(+ 1 2)) ; ← นี่คือ code ที่รัน list
LISP เขียนด้วย... LISP — code กับ data ใช้โครงสร้างเดียวกัน (S-expression)
แปลว่า โปรแกรมแก้โปรแกรมตัวเองได้ — ไม่ต้องใช้ parser แยก AST, ไม่ต้องเขียน transformer
นี่คือรากฐานของ macro system ที่ทรงพลังที่สุดในสายภาษาโปรแกรม
ไม่มีภาษาไหนทำได้เต็มระบบเท่า LISP — แต่แนวคิด "code as data" ไปอยู่ใน:
LISP (1958)
├── Scheme (1975) — minimalist, lexical scoping
│ ├── JavaScript (1995) — Brendan Eich ตั้งใจทำ Scheme-like ใน browser
│ │ └── arrow functions, closure, first-class functions
│ └── Racket (1995) — ภาษาเพื่อการสอนและการวิจัย
├── Common Lisp (1984) — ภาคอุตสาหกรรม, pragmatic
│ └── Emacs Lisp (1985) — editor scripting (GNU Emacs)
├── Clojure (2007) — LISP บน JVM, immutable by default
│ └── จุดประกาย functional programming ในโลก enterprise
└── Python, Ruby, Elixir, Julia, Rust, Swift — ทุกภาษาเอาแนวคิด LISP ไปปรับใช้
ทั้งที่สร้างนวัตกรรมเกือบทุกอย่างที่เราใช้ — ทำไม LISP ถึงไม่ชนะ?
Paul Graham (ผู้ก่อตั้ง Y Combinator, แฟนพันธุ์แท้ LISP) อธิบายไว้ใน essay "Beating the Averages":
Graham ยืนยันว่า:
"Lisp is a language that was discovered, not invented."
ฝั่งนักวิจารณ์ LISP (รวมถึงคนที่เคยใช้ใน production แล้วเปลี่ยนไปภาษาอื่น) ชี้ปัญหาเพิ่มเติมที่ Graham ไม่พูดถึง:
สรุป: ไม่มีสาเหตุเดียว — มันคือ perfect storm ของ syntax ต่าง + เกิดผิดเวลา + community แตก + ไม่มี corporate sponsor (ต่างจาก Java ที่ Sun ทุ่ม, C# ที่ Microsoft ทุ่ม)
บทความนี้ไม่ได้ตั้งใจจะบอกว่า "คุณควรเขียน LISP"
แต่ทุกครั้งที่คุณ:
numbers = [1, 2, 3]
squared = list(map(lambda x: x * x, numbers))
const result = data
.filter(x => x.active)
.map(x => x.value);
let squared: Vec<_> = numbers.iter().map(|x| x * x).collect();
— คุณกำลังเขียน LISP โดยไม่รู้ตัว
📅 สิงหาคม 2026 | ⚠️ ตรวจสอบข้อมูล ณ วันที่เขียน
|  |
|---|
| It’s infectious |
I am very happy to announce that my Clojure book has received a massive update. Every line of my book was proofread and corrected by AI, and I read it to make sure that the AI corrections were right.
English is not my native language. Hence, before the AI era, the book I released had many grammatical errors. Now that AI has corrected them, my book is really good to read. In many places, AI has made my book terse and to the point. I’m very happy about it.
This AI proofread also prompted me to proofread the book myself so that no errors would slip by. I have done my best. What was done by AI in less than two hours took me more than three weeks to proofread. There were only a very small number of mistakes that AI made, which I corrected. The book is now far, far better.
I’m sure even Western / English audiences will find my book very enjoyable to read now.
Highlights of this new release are:
I hope you all read my book. Please suggest corrections. And please tell me what material I could add so that my book becomes much better.
I thank all those who have suggested things to improve my book. Clojure has given me a lot, and I’m ready to give back as much as I can. This book is one tiny effort.
Hi, I am Niki, and I am looking for my next role.
I am a π-shaped specialist:
My mission is simplicity, performance and software that helps people.
I have worked with and built databases:
Sync engines:
Frontend:
Performance:
I write Grumpy.Website, a blog on UI/UX with 2,000+ subscribers.
My articles have been referenced by Notion, ATP.fm, Daring Fireball and Marcin Wichary.
Earlier this year, I wrote a widely shared critique of excessive menu icons in macOS Tahoe. Apple later removed many of the icons discussed in the article.
In 2024, I launched AlleKinos.de, which quickly reached 1,500 daily visitors without marketing or SEO.
I created Fira Code, which became one of the world’s most popular programming fonts.
My Clojure Sublimed extension became the go-to Clojure development environment in Sublime Text.
I also created many other smaller products, libraries, fonts, color schemes, all available on my GitHub.
If you want to dive deeper, here’s the usual stuff:
I also made a two-page PDF CV:

If you are working on a compiler, a database, an IDE, a programming language or another technically ambitious product, touching graphics, typography, algorithms, low-level programming, and you think my experience can help, let’s talk: niki@tonsky.me.