Wednesday, July 30, 2025

JRM runs off at the mouth

Although LLMs perform a straightforward operation — they predict the next tokens from a sequence of tokens — they can be almost magical in their results if the stars are aligned. And from the look of it, the stars align often enough to be useful. But if you're unlucky, you can end up with a useless pile of garbage. My LLM started spitting out such gems as Cascadescontaminantsunnatural and exquisiteacquire the other day when I requested it imagine some dialog. Your mileage will vary, a lot.

The question is whether the magic outweighs the glossolalia. Can we keep the idiot savant LLM from evangelically speaking in tongues?

Many people at work are reluctant to use LLMs as an aid to programming, preferring to hand craft all their code. I understand the sentiment, but I think it is a mistake. LLMs are a tool of extraordinary power, but you need to develop the skill to use them, and that takes a lot of time and practice.

The initial key to using LLMs is to get good at prompting them. Here a trained programmer has a distinct advantage over a layperson. When you program at a high level, you are not only thinking about how to solve your problem, but also all the ways you can screw up. This is “defensive programming”. You check your inputs, you write code to handle “impossible” cases, you write test cases that exercise the edge cases. (I'm no fan of test-driven development, but if I have code that is supposed to exhibit some complex behavior, I'll often write a few test cases to prove that the code isn't egregiously broken.)

When you prompt an LLM, it helps a lot to think in the same way you program. You need to be aware of the ways the LLM can misinterpret your prompt, and you need to write your prompt so that it is as clear as possible. You might think that this defeats the purpose. You are essentially performing the act of programming with an extra natural language translation step in the middle. This is true, and you will get good results if you approach the task with this in mind. Learning to effectively prompt an LLM is very similar to learning a new programming language. It is a skill that a trained programmer will have honed over time. Laypeople will find it possible to generate useful code with an LLM, but they will encounter bugs and problems that they will have difficulty overcoming. A trained programmer will know precisely how to craft additional clauses to the prompt to avoid these problems.

Context engineering is the art of crafting a series of prompts to guide the LLM to produce the results you want. If you know how to program, you don't necessarily know how to engineer large systems. If you know how to prompt, you don't necessarily know how to engineer the context. Think of Mickey Mouse in Fantasia. He quickly learns the prompts that get the broom to carry the water, but he doesn't foresee the consequences of exponential replication.

Ever write a program that seems to be taking an awfully long time to run? You do a back-of-the-envelope calculation and realize that the expected runtime will be on the order of 1050 seconds. This sort of problem won't go away with an LLM, but the relative number of people ill-equipped to diagnose and deal with the problem will certainly go up. Logical thinking and foreseeing of consequences will be skills in higher demand than ever in the future.

You won't be able to become a “machine whisperer” without a significant investment of time and effort. As a programmer, you already have a huge head start. Turn on the LLM and use it in your daily workflow. Get a good feel for its strengths and weaknesses (they'll surprise you). Then leverage this crazy tool for your advantage. It will make you a better programmer.

Novice to LLMs — LLM calls Lisp

I'm a novice to the LLM API, and I'm assuming that at least some of my readers are too. I'm not the very last person to the party, am I?

When integrating the LLM with Lisp, we want to allow the LLM to direct queries back to the Lisp that is invoking it. This is done through the function call protocol. The client supplies to the LLM a list of functions that the LLM may invoke. When the LLM wants to invoke the function, instead of returing a block of generated text, it returns a JSON object indicating a function call. This contains the name of the function and the arguments. The client is supposed to invoke the function, but to return an answer, it actually makes a new call into the LLM and it concatenates the entire conversation so far along with the result of the function call. It is bizarro continuation-passing-style where the client acts as a trampoline and keeps track of the continuation.

So, for example, by exposing lisp-implementation-type and lisp-implementation-version, we can then query the LLM:

> (invoke-gemini "gemini-2.5-flash" "What is the type and version of the lisp implementation?")
"The Lisp implementation is SBCL version 2.5.4."

Monday, July 28, 2025

Pseudo

I was wondering what it would look like if a large language model were part of your programming language. I'm not talking about calling the model as an API, but rather embedding it as a language construct. I came up with this idea as a first cut.

The pseudo macro allows you to embed pseudocode expressions in your Common Lisp code. It takes a string description and uses an LLM to expand it into an s-expression. You can use pseudo anywhere an expression would be expected.

(defun my-func (a b)
  (pseudo "multiply b by factorial of a."))
MY-FUNC

(my-func 5 3)
360

(defun quadratic (a b c)
  (let ((d (sqrt (pseudo "compute discriminant of quadratic equation"))))
    (values (/ (+ (- b) d) (* 2 a)) (/ (- (- b) d) (* 2 a)))))
QUADRATIC

(quadratic 1 2 -3)
1.0
-3.0

The pseudo macro gathers contextual information and packages it up in a big set of system instructions to the LLM. The instructions include

  • the lexically visible variables in the macro environment
  • fbound symbols
  • bound symbols
  • overall directives to influence code generation
  • directives to influence the style of the generated code (functional vs. imperative)
  • directives to influence the use of the loop macro (prefer vs. avoid)
  • the source code of the file currently being compiled, if there is one

pseduo sets the LLM to use a low temperature for more predictable generation. It prints the “thinking” of the LLM.

Lisp is a big win here. Since Lisp's macro system operates at the level of s-expressions, it has more contextual information available to it than a macro system that is just text expansion. The s-expression representation means that we don't need to interface with the language's parser or compiler to operate on the syntax tree of the code. Adding pseudo to a language like Java would be a much more significant undertaking.

pseudo has the usual LLM caveats:

  • The LLM is slow.
  • The LLM can be expensive.
  • The LLM can produce unpredictable and unwanted code.
  • The LLM can produce incorrect code; the more precise you are in your pseudocode, the more likely you are to get the results you want.
  • You would be absolutely mad to use this in production.

pseudo has one dependency on SBCL which is a function to extract the lexically visible variables from the macro environment. If you port it to another Common Lisp, you'll want to provide an equivalent function.

pseudo was developed using Google's Gemini as the back end, but there's no reason it couldn't be adapted to use other LLMs. To try it out, you'll need the gemini library, available at https://github.com/jrm-code-project/gemini, and a Google API key.

Download pseudo from https://github.com/jrm-code-project/pseudo.

You'll also need these dependencies.

If you try it, let me know how it goes.

Saturday, July 19, 2025

GitHub updates 19/Jul/2025

https://github.com/jrm-code-project/dual-numbers

This library implements dual numbers for automatic differentiation.


https://github.com/jrm-code-project/function

This library implements higher-order functions, composing, currying, partial-application, etc.


https://github.com/jrm-code-project/generic-arithetic

This library redefines the standard Common Lisp arithemetic with generic functions so that math operations can be extended with defmethod.


https://github.com/jrm-code-project/named-let

This library implements some Scheme-inspired macros.

  • define — a Lisp-1 define that binds in both the function and value namespaces
  • flambda — a variant of lambda that binds its arguments in the function namespace
  • overloaded let — a redefinition of the let macro that enables a named-let variant
  • letrec and letrec* — binds names with values in the scope of the names so that recursive function definitions are possible

Friday, July 18, 2025

A Lot of Packets

A War Story

I once worked for a company that provided mobile data services to public safety organizations: we put PCs in police cars. Our value proposition was that we could connect the PC to the base station using off-the-shelf packet radio on the same frequency as the voice radio. This was attractive to small town police departments that could not afford to install a separate data radio system.

The company was started by a few ham-radio enthusiasts who were trying to leverage their expertise in packet radio to provide a low-cost product to the public safety market. One of the guys had written network code at DEC and wrote the network stack. This was in the days before the web and before TCP/IP was the standard protocol, so he wrote an ad-hoc protocol for the system.

I was called to one on-site installation to solve a vexing problem. The system would work for a while, but then it would hang and need to be completely reset. It only happened when a certain file was being sent. There was nothing unusual about the file, it was a text file.

The way the system worked was, let us say, imaginative. The base station had a command line interface, and the PC in the car would send commands to the base station over packet radio. It would literally type the received command into the base station's prompt. This violated all the best practices for network protocols. It was vulnerable to dropped packets, injection attacks, replay attacks, etc. It had no privacy, no integrity, and no authentication. I was horrified. But it was a working system, so I had to work with it.

After a few hours of debugging, I found that the base station was getting stuck in the packet receive loop. The cause was amusing, if pathetic. The command protocol was sloppy. About half the time, a command would be sent with one or more trailing newlines. The base station dealt with this by stripping trailing newlines, if present from the command before executing it.

The file transfer command would include the length of file in blocks. The length was sent in binary over the ascii channel.

The problem was a file that was 13 blocks long. The command would be sent as file \x0d. But the base station would recognize the \x0d as a trailing newline and strip it. The file command, expecting a length byte, would read off the end of the end of the command and get the null byte. This started the file receive loop which would pre-decrement the unsigned length (to account that first packet had been sent) and ended up with a value of 65535. It then would sit and wait for the next 65535 packets to arrive.

So the system wasn't exactly hung, it was waiting for the rest of the file.

There was no way I was going to try to repair this monstrosity. I suggested that we use a proper network protocol, like TCP/IP, but I pushed this back to the guy that wrote this mess. As a founder of the company, he wasn't about to change the perfectly good protocol that he had authored, so he came up with a workaround.

I didn't last too long at that company. I saw the writing on the wall and moved on.

Wednesday, July 16, 2025

Dual numbers

Let's build some numbers. We begin with the natural numbers, which capture the idea of magnitude. Adding them is simple: on a number line, it's a consistent shift to the right. But what about shifting left? To handle this, we could invent a separate flag to track direction and a set of rules for its manipulation. Or we can be clever and augment the numbers themselves. By incorporating a "sign," we create positive and negative integers, baking the concept of direction directly into our numerical system and embedding its logic into the rules of arithmetic.

This pattern of augmentation continues as we move from the number line to the number plane. Beyond simple shifts, we want to introduce the concept of rotation. Again, we could track rotation as an external property, but a more integrated solution emerges with complex numbers. By augmenting real numbers with a so-called "imaginary" unit, i, we create numbers of the form a + bi. If b is zero, we have a standard signed number. If b is non-zero, the number represents a rotation in the plane. A 90-degree counter-clockwise rotation is represented by i, and a clockwise rotation by -i. Notably, two 90-degree rotations result in a 180-degree turn, which is equivalent to flipping the number's sign. This geometric reality is captured by the algebraic rule i² = -1. Once again, we don't just track a new property; we weave it into the fabric of the numbers themselves.

Now, let us turn to the world of calculus and the concept of differentiation. When we analyze a function, we are often interested in its value and its slope at a given point. Following our established pattern, we could track the slope as a separate piece of information, calculating it with the familiar rules of derivatives. Or, we can be clever and augment our numbers once more, this time to contain the slope intrinsically. This is the innovation of dual numbers.

To do this, we introduce a new entity, ε (epsilon), and form numbers that look like a + bε. Here, a represents the number's value, and b will represent its associated slope or "infinitesimal" part. The defining characteristic of ε is unusual: we assert that ε is greater than zero, yet smaller than any positive real number. This makes ε an infinitesimal. Consequently, ε², being infinitesimally small squared, is so negligible that we simply define it as zero. This single rule, ε² = 0, is all we need. Our rules of arithmetic adapt seamlessly. Adding two dual numbers means adding their real and ε parts separately: (a + bε) + (c + dε) = (a + c) + (b + d)ε. Multiplication is just as straightforward, we distribute the terms and apply our new rule:

(a + bε)(c + dε) = ac + adε + bcε + bdε² = ac + (ad + bc)ε

Notice how the ε² term simply vanishes.

Extending the arithmetic to include division requires a method for finding the reciprocal of a dual number. We can derive this by adapting a technique similar to the one used for complex numbers: multiplying the numerator and denominator by the conjugate. The conjugate of a + bε is a - bε. To find the reciprocal of a + bε, we calculate 1 / (a + bε):

1 / (a + bε) = (1 / (a + bε)) * ((a - bε) / (a - bε))
= (a - bε) / (a² - abε + abε - b²Îµ²)
= (a - bε) / (a² - b²Îµ²)

Using the defining property that ε² = 0, the denominator simplifies to just a². The expression becomes:

(a - bε) / a² = 1/a - (b/a²)ε

Thus, the reciprocal is 1/a - (b/a²)ε, provided a is not zero. This allows for the division of two dual numbers by multiplying the first by the reciprocal of the second, completing the set of basic arithmetic operations.

But what is it good for? Based on the principles of Taylor series or linear approximation, for a very small change bε, a differentiable function's behavior can be described as:

F(a + bε) = F(a) + F'(a)bε

The result is another dual number. Its "real" part is F(a), the value of the function at a. Its "infinitesimal" part is F'(a)b, which contains the derivative of the function at a. If we set b=1 and simply evaluate F(a + ε), the ε part of the result is precisely the derivative, F'(a). This gives us a direct way to compute a derivative, as captured in this conceptual code:

(defun (derivative f)
  (lambda (x)
    (infinitesimal-part (f (+ x ε)))))

This method provides an alternative to traditional numerical differentiation. Standard finite-difference methods, such as calculating (F(x+h) - F(x))/h, force a difficult choice for h. A large h leads to truncation error from the approximation, while a very small h can introduce significant rounding error from subtracting two nearly identical floating-point numbers. Dual numbers sidestep this issue entirely. The process is algebraic, not approximative. The derivative is computed numerically, but exactly, with no truncation error and without the instability of manipulating a vanishingly small h.

By extending our number system to include an infinitesimal part, we have baked the logic of differentiation — specifically, the chain rule — into the rules of arithmetic. We no longer need a separate set of symbolic rules for finding derivatives. By simply executing a function with dual numbers as inputs, the derivative is calculated automatically, as a natural consequence of the algebra. Just as the sign captured direction and i captured rotation, ε captures the essence of a derivative

If we want to combine dual and complex numbers, we have a choice: dual numbers with complex standard and infinitesimal parts, or complex numbers with dual real and imaginary parts. From an implementation standpoint, the former is easier because complex numbers are already supported.

Friday, July 11, 2025

Gemini experiment

I took my sent email archive, cleaned it, and uploaded it to Gemini. I now have a specialized chat box (a "Gem") that can answer emails like me. If you send me email with "VIRTUAL JOE" in the subject, I'll feed it through.

Thursday, July 10, 2025

An observation

Go programs make a lot more sense if you pronounce if err != nil as “inshallah”.

Monday, July 7, 2025

LLM failures

I recently tried to get an LLM to solve two problems that I thought were well within its capabilities. I was suprised to find them unable to solve them.

Problem 1: Given a library of functions, re-order the function definitions in alphabetical order.

It can be useful to have the functions in alphabetical order so you can find them easily (assuming you are not constrained by any other ordering). The LLM was given a file of function definitions and was asked to put them in alphabetical order. It refused to do so, claiming that it was unable to determine the function boundaries because it had no model of the language syntax.

Fair enough, but a model of the language syntax isn't strictly necessary. Function definitions in most programming languages are easily identified: they are separated by blank lines, they start at the left margin, the body of the function is indented, the delimiters are balanced, etc. This is source code that was written by the LLM, so it is able to generate syntactically correct functions. It surprised me that it gave up so easily on the much simpler task of re-arranging the blocks of generated text.

Problem 2: Given the extended BNF grammar of the Go programming language, a) generate a series of small Go programs that illustrate the various grammar productions, b) generate a parser for the grammar.

This is a problem that I would have thought would be right up the LLM's alley. Although it easily generated twenty Go programs of varying complexity, it utterly failed to generate a parser that could handle them.

Converting a grammar to a parser is a common task, and I cannot believe that a parser for the Go language does not have several on-line examples. Furthemore taking a grammar as an input and producing a parser is a well-solved problem. The LLM made a quick attempt at generating a simple recursive descent parser (which is probably the easiest way to turn a grammar into a parser short of using a special tool), but the resultant parser could only handle the most trivial programs. When I asked the LLM to extend the parser to handle the more complex examples it had generated, it started hallucinating and getting lost in the weeds. I spent several hours redirecting the LLM and refining my prompts to help it along, but it never was able to make much progress beyond the simple parsing.

(I tried both Cursor with Claude Sonnet 4 and Gemini CLI with Gemini-2.5)

Both these problems uncovered surprising limitations to using LLMs to generate code. Both problems seem to me to be in the realm of problems that one would task to an LLM.