Showing posts with label Vibe coding. Show all posts
Showing posts with label Vibe coding. Show all posts

Saturday, August 30, 2025

Synergy of Tools

Once you have the sequential thinking MCP working, it can interact with the eval tool and reason about Lisp programs. I tested this by asking the LLM to determine the 30th Fibonacci number via the technique of raising a matrix to a power. The LLM was given no special instructions about how to do this or how to use Lisp. It was only told that it had access to a Common Lisp environment. The LLM was able to reason through this problem and even correct its own mistakes. The following is an annotated transcript of the process.

Now we test something hard. I want you to compute the 30th fibonacci number by raising a matrix to the 30th power.

We wrap this prompt in a content object with a "user" role, append it to the context, and send it to the LLM. Every time we interact with the LLM, we send all the accumulated context. The LLM will respond with a content object as well and we also accumulate that. The LLM itself is stateless. All the state is in this accumulated context.

I'm not going to replicate the entire accumulating context here. Instead, I will just show the most recent content object accumulated onto the context.

{"parts":
   [
    {"text": "Now we test something hard.  I want you to compute the 30th
              fibonacci number by raising a matrix to the 30th power."
    }
  ],
  "role": "user"
}

The LLM decides that five steps of sequential thinking is called for. It returns this content object.

  {
    "parts": [
      {
        "functionCall": {
          "name": "sequentialthinking",
          "args": {
            "thoughtNumber": 1,
            "thought": "First, I need to define a Common Lisp function to multiply
            two 2x2 matrices. I'll represent matrices as a list of two lists, like
            `((a b) (c d))`. The function will take two such matrices and return their product.",
            "nextThoughtNeeded": {
              "value": "true"
            },
            "totalThoughts": 5
          }
        }
      }
    ],
    "role": "model"
  }

The LLM client recognizes the "functionCall" object, so instead of returning control to the user, it tail calls the sequentialThinking tool via the MCP protocol. The sequentialThinking tool returns a result that we wrap in a response object. We append this response object to the context and send it back to the LLM.

  {
    "parts": [
      {
        "functionResponse": {
          "name": "sequentialthinking",
          "response": {
            "result": {
              "content": [
                {
                  "type": "text",
                  "text": "{\n  \"thoughtNumber\": 1,\n  \"totalThoughts\": 5,\n  \"nextThoughtNeeded\": true,\n  \"branches\": [],\n  \"thoughtHistoryLength\": 1\n}"
                }
              ]
            }
          }
        }
      }
    ],
    "role": "function"
  }

The LLM responds with a multi-part response.

{
    "parts": [
      {
        "text": "Alright, pal, we're diving into the deep end. First off, I'm gonna lay down some Lisp code to handle the grunt work of multiplying two 2x2 matrices. We'll represent 'em as lists of lists. This ain't no parlor trick; it's the foundation for what's coming next.\n"
      },
      {
        "functionCall": {
          "name": "eval",
          "args": {
            "string": "(defun matrix-multiply-2x2 (m1 m2)\n  (let* ((a11 (car (car m1))) (a12 (cadr (car m1)))\n         (a21 (car (cadr m1))) (a22 (cadr (cadr m1)))\n         (b11 (car (car m2))) (b12 (cadr (car m2)))\n         (b21 (car (cadr m2))) (b22 (cadr (cadr m2))))\n    `((,(+ (* a11 b11) (* a12 b21)) ,(+ (* a11 b12) (* a12 b22)))\n      ,(+ (* a21 b11) (* a22 b21)) ,(+ (* a21 b12) (* a22 b22)))))"
          }
        }
      }
    ],
    "role": "model"
  }

The LLM client prints the text part, but it also recognizes the function call part and tail calls the eval tool. The eval tool returns a "functionResponse" object that we append to the context and pass along to the LLM.

  {
    "parts": [
      {
        "functionResponse": {
          "name": "eval",
          "response": {
            "result": ";; Value: MATRIX-MULTIPLY-2X2\n"
          }
        }
      }
    ],
    "role": "function"
  }

The LLM realizes it is time for the next step in its sequential thinking. It returns another multi-part response.

  {
    "parts": [
      {
        "text": "The matrix multiplication function is in place. Next, we'll build the engine for raising that matrix to a power. This is where the real muscle comes in, using a trick called exponentiation by squaring to keep things snappy.\n"
      },
      {
        "functionCall": {
          "name": "sequentialthinking",
          "args": {
            "totalThoughts": 5,
            "thought": "Next, I need to define a Lisp function for matrix exponentiation using the \"exponentiation by squaring\" algorithm. This will take a 2x2 matrix and a non-negative integer power.",
            "nextThoughtNeeded": {
              "value": "true"
            },
            "thoughtNumber": 2
          }
        }
      }
    ],
    "role": "model"
  },

Sequential thinking advances to the next thought.

  {
    "parts": [
      {
        "functionResponse": {
          "name": "sequentialthinking",
          "response": {
            "result": {
              "content": [
                {
                  "type": "text",
                  "text": "{\n  \"thoughtNumber\": 2,\n  \"totalThoughts\": 5,\n  \"nextThoughtNeeded\": true,\n  \"branches\": [],\n  \"thoughtHistoryLength\": 2\n}"
                }
              ]
            }
          }
        }
      }
    ],
    "role": "function"
  }

The LLM responds with a request to call to eval.

  {
    "parts": [
      {
        "text": "Alright, pal, the multiplication engine is purring. Now we build the big gun: a Lisp function to raise that 2x2 matrix to any power you throw at it, using the \"exponentiation by squaring\" trick. This ain't no slow boat to China; we're gonna get there fast.\n"
      },
      {
        "functionCall": {
          "name": "eval",
          "args": {
            "string": "(defun matrix-power-2x2 (m n)\n  (cond\n    ((zerop n) '((1 0) (0 1))) ; Identity matrix for n=0\n    ((= n 1) m)\n    (t\n     (let* ((half-power (matrix-power-2x2 m (floor n 2)))\n            (squared (matrix-multiply-2x2 half-power half-power)))\n       (if (oddp n)\n           (matrix-multiply-2x2 m squared)\n           squared)))))"
          }
        }
      }
    ],
    "role": "model"
  }

The LLM client tail calls the eval tool again. The eval tool returns a "functionResponse" object that we append to the context and pass along to the LLM.

  {
    "parts": [
      {
        "functionResponse": {
          "name": "eval",
          "response": {
            "result": ";; Value: MATRIX-POWER-2X2\n"
          }
        }
      }
    ],
    "role": "function"
  }

The LLM advances to the next thought in its sequential thinking.

  {
    "parts": [
      {
        "text": "Alright, the power-hitter is in the lineup. Now, we're setting up the initial play: the base Fibonacci matrix. This little beauty is the key to unlocking the whole operation.\n"
      },
      {
        "functionCall": {
          "name": "sequentialthinking",
          "args": {
            "thoughtNumber": 3,
            "totalThoughts": 5,
            "nextThoughtNeeded": {
              "value": "true"
            },
            "thought": "Now I will define the base Fibonacci matrix: `((1 1) (1 0))`. Then, I will raise this matrix to the 29th power, because the top-right element of M^n gives F_n."
          }
        }
      }
    ],
    "role": "model"
  }

Once again we call sequential thinking to advance to the next thought.

  {
    "parts": [
      {
        "functionResponse": {
          "name": "sequentialthinking",
          "response": {
            "result": {
              "content": [
                {
                  "type": "text",
                  "text": "{\n  \"thoughtNumber\": 3,\n  \"totalThoughts\": 5,\n  \"nextThoughtNeeded\": true,\n  \"branches\": [],\n  \"thoughtHistoryLength\": 3\n}"
                }
              ]
            }
          }
        }
      }
    ],
    "role": "function"
  }

And once again we get a multi-part response. This response has two functionCall parts to call eval

    "parts": [
      {
        "text": "The power-hitter's ready. Now, we're setting up the initial play: the base Fibonacci matrix. This little beauty is the key to unlocking the whole operation. We'll crank it up to the 29th power, and the answer to our little puzzle will be staring us right in the face in the top-right corner.\n"
      },
      {
        "functionCall": {
          "name": "eval",
          "args": {
            "string": "(defparameter *fib-matrix* '((1 1) (1 0)))"
          }
        }
      },
      {
        "functionCall": {
          "name": "eval",
          "args": {
            "string": "(defparameter *result-matrix* (matrix-power-2x2 *fib-matrix* 29))"
          }
        }
      }
    ],
    "role": "model"
  }

The result of evaluation is as follows:

  {
    "parts": [
      {
        "functionResponse": {
          "name": "eval",
          "response": {
            "result": ";; Value: *FIB-MATRIX*\n"
          }
        }
      },
      {
        "functionResponse": {
          "name": "eval",
          "response": {
            "result": "Error: Expression could not be evaluated."
          }
        }
      }
    ],
    "role": "function"
  }

We have a problem. The second evaluation errored out. The LLM notices this and revises its sequential thinking state.

  {
    "parts": [
      {
        "text": "Alright, a snag in the wire. Looks like my attempt to power up the Fibonacci matrix hit a wall. The Lisp interpreter choked on it. This ain't right. Time to put on my magnifying glass and check the details. First, I'm gonna make sure that base matrix is even on the books, and then I'll kick the tires on that `matrix-power-2x2` function with some small numbers. We'll find out what's causing this hiccup.\n"
      },
      {
        "functionCall": {
          "name": "sequentialthinking",
          "args": {
            "isRevision": {
              "value": "true"
            },
            "thoughtNumber": 4,
            "totalThoughts": 6,
            "revisesThought": 3,
            "nextThoughtNeeded": {
              "value": "true"
            },
            "thought": "The previous eval failed. I need to debug why `(matrix-power-2x2 *fib-matrix* 29)` failed. First, I'll confirm if `*fib-matrix*` is correctly defined and then test `matrix-power-2x2` with a smaller, known input to ensure it works."
          }
        }
      }
    ],
    "role": "model"
  }

Sequential thinking returns its state.

  {
    "parts": [
      {
        "functionResponse": {
          "name": "sequentialthinking",
          "response": {
            "result": {
              "content": [
                {
                  "type": "text",
                  "text": "{\n  \"thoughtNumber\": 4,\n  \"totalThoughts\": 6,\n  \"nextThoughtNeeded\": true,\n  \"branches\": [],\n  \"thoughtHistoryLength\": 4\n}"
                }
              ]
            }
          }
        }
      }
    ],
    "role": "function"
  }

The LLM decides to debug the problem.

  {
    "parts": [
      {
        "text": "Alright, the last job went sideways. The Lisp interpreter choked when I tried to crank that matrix to the 29th power. We're on a revision here, back to the drawing board. First, I'm gonna make damn sure that `*fib-matrix*` is what it's supposed to be. Then, I'll put `matrix-power-2x2` through its paces with a smaller, simpler test. We'll find out what piece of the puzzle went missing.\n"
      },
      {
        "functionCall": {
          "name": "boundp",
          "args": {
            "symbol": "*FIB-MATRIX*"
          }
        }
      },
      {
        "functionCall": {
          "name": "printSymbolValue",
          "args": {
            "symbol": "*FIB-MATRIX*"
          }
        }
      },
      {
        "functionCall": {
          "name": "eval",
          "args": {
            "string": "(matrix-power-2x2 '((1 1) (1 0)) 2)"
          }
        }
      }
    ],
    "role": "model"
  }

The LLM uses the Lisp introspection tools we provided.

  {
    "parts": [
      {
        "functionResponse": {
          "name": "boundp",
          "response": {
            "result": {
              "value": "true"
            }
          }
        }
      },
      {
        "functionResponse": {
          "name": "printSymbolValue",
          "response": {
            "result": "((1 1) (1 0))"
          }
        }
      },
      {
        "functionResponse": {
          "name": "eval",
          "response": {
            "result": ";; Value: ((2 1) 1 1)\n"
          }
        }
      }
    ],
    "role": "function"
  }

Clearly the response for the third evaluation is an improperly formed matrix. The LLM has noticed this as well.

  {
    "parts": [
      {
        "text": "Alright, the blueprints were faulty. My `matrix-multiply-2x2` function had a screw loose, building the second row of the matrix all wrong. It was spitting out a flat list instead of a nested one, making the whole operation fall apart. I'm going back in, fixing that Lisp code to properly construct the matrix rows with a `list` call. Once that's squared away, we'll re-test with a small number, and then, if it holds up, we'll hit it with the full 29-power assault. This time, no loose ends.\n"
      },
      {
        "functionCall": {
          "name": "sequentialthinking",
          "args": {
            "totalThoughts": 7,
            "nextThoughtNeeded": {
              "value": "true"
            },
            "thought": "The `matrix-multiply-2x2` function is incorrectly constructing the second row of the resulting matrix. The backtick expression `,(+ (* a21 b11) (* a22 b21)) ,(+ (* a21 b12) (* a22 b22))` creates two separate elements for the outer list instead of a nested list for the second row. I need to redefine `matrix-multiply-2x2` to correctly form the second row as a list.",
            "revisesThought": 1,
            "is-Revision": {
              "value": "true"
            },
            thoughtNumber: 5
          }
        }
      }
    ],
    "role": "model"
  }

Sequential thinking returns its revised state.

  {
    "parts": [
      {
        "functionResponse": {
          "name": "sequentialthinking",
          "response": {
            "result": {
              "content": [
                {
                  "type": "text",
                  "text": "{\n  \"thoughtNumber\": 5,\n  \"totalThoughts\": 7,\n  \"nextThoughtNeeded\": true,\n  \"branches\": [],\n  \"thoughtHistoryLength\": 5\n}"
                }
              ]
            }
          }
        }
      }
    ],
    "role": "function"
  }

The LLM now attempts to fix the problem.

  {
    "parts": [
      {
        "text": "Alright, the `matrix-multiply-2x2` function was a lemon. It was building the second row of that matrix like a busted jigsaw puzzle, not a proper nested list. I'm going back to the drawing board to rewrite that function, making sure the second row gets its own proper list. No more flat-pack matrices on my watch.\n"
      },
      {
        "functionCall": {
          "name": "eval",
          "args": {
            "string": "(defun matrix-multiply-2x2 (m1 m2)\n  (let* ((a11 (car (car m1))) (a12 (cadr (car m1)))\n         (a21 (car (cadr m1))) (a22 (cadr (cadr m1)))\n         (b11 (car (car m2))) (b12 (cadr (car m2)))\n         (b21 (car (cadr m2))) (b22 (cadr (cadr m2))))\n    (list\n     (list (+ (* a11 b11) (* a12 b21)) (+ (* a11 b12) (* a12 b22)))\n     (list (+ (* a21 b11) (* a22 b21)) (+ (* a21 b12) (* a22 b22))))))"
          }
        }
      },
      {
        "functionCall": {
          "name": "eval",
          "args": {
            "string": "(matrix-power-2x2 '((1 1) (1 0)) 2)"
          }
        }
      }
    ],
    "role": "model"
  }

And we get the results of the evaluations:

  {
    "parts": [
      {
        "functionResponse": {
          "name": "eval",
          "response": {
            "result": ";; Value: MATRIX-MULTIPLY-2X2\n"
          }
        }
      },
      {
        "functionResponse": {
          "name": "eval",
          "response": {
            "result": ";; Value: ((2 1) (1 1))\n"
          }
        }
      }
    ],
    "role": "function"
  }

Time to advance the sequential thinking.

  {
    "parts": [
      {
        "text": "Alright, the gears are greased and turning. `matrix-multiply-2x2` is fixed, and the test run at power 2 came out clean, just like it should. Now, it's time for the main event. I'm going to take our *fib-matrix*, crank it up to the 29th power, and then, like pulling a rabbit from a hat, I'll pluck out the 30th Fibonacci number from the top-right corner. This time, no funny business.\n",
      },
      {
        "functionCall": {
          "name": "sequentialthinking",
          "args": {
            "nextThoughtNeeded": {
              "value": "true"
            },
            "thought": "Now that `matrix-multiply-2x2` is fixed and tested, I will re-attempt to raise the `*fib-matrix*` to the 29th power. Then, I will extract the top-right element of the resulting matrix, which will be the 30th Fibonacci number.",
            "thoughtNumber": 6,
            "totalThoughts": 7
          }
        }
      }
    ],
    "ROLE": "model"
  }

Sequential thinking advances its state.

  {
    "parts": [
      {
        "functionResponse": {
          "name": "sequentialthinking",
          "response": {
            "result": {
              "content": [
                {
                  "type": "text",
                  "text": "{\n  \"thoughtNumber\": 6,\n  \"totalThoughts\": 7,\n  \"nextThoughtNeeded\": true,\n  \"branches\": [],\n  \"thoughtHistoryLength\": 6\n}"
                }
              ]
            }
          }
        }
      }
    ],
    "role": "function"
  }

Back to the Lisp interpreter.

  {
    "parts": [
      {
        "text": "The `matrix-multiply-2x2` function is tuned up and humming. Now for the main event: I'm taking the `*fib-matrix*`, cranking it up to the 29th power. Once that's done, I'll pull the 30th Fibonacci number right out of the top-right corner of the resulting matrix. This time, we get the goods.\n"
      },
      {
        "functionCall": {
          "name": "eval",
          "args": {
            "string": "(defparameter *result-matrix* (matrix-power-2x2 *fib-matrix* 29))"
          }
        }
      },
      {
        "functionCall": {
          "name": "eval",
          "args": {
            "string": "(cadr (car *result-matrix*))"
          }
        }
      }
    ],
    "role": "model"
  }

We return the results of eval to the LLM.

  {
    "parts": [
      {
        "functionResponse": {
          "name": "eval",
          "response": {
            "result": ";; Value: *RESULT-MATRIX*\n"
          }
        }
      },
      {
        "functionResponse": {
          "name": "eval",
          "response": {
            "result": ";; Value: 514229\n"
          }
        }
      }
    ],
    "role": "function"
  }

It appears to work, but there is still a problem.

  {
    "parts": [
      {
        "text": "Alright, the numbers are in, but a shadow of doubt remains. My calculations show F_29 is 514229. The request was for F_30. I've been wrestling with matrix exponentiation, and it looks like I pulled the wrong rabbit out of the hat. The top-right element of M^n is F_n, and the top-left is F_{n+1}. Since I powered up to M^29, the top-left element of *that* matrix should be F_30. No use crying over spilled milk. Let's tap into that `*result-matrix*` again and pull the correct number. This time, we get it right.\n",
      },
      {
        "functionCall": {
          "name": "sequentialthinking",
          "args": {
            "thought": "I previously extracted F_29 from `*result-matrix*` (M^29). To get F_30, I need the top-left element of `*result-matrix*` (M^29), which corresponds to F_(n+1) when the matrix is raised to the n-th power. I will now extract `(car (car *result-matrix*))` to get the 30th Fibonacci number.",
            "totalThoughts": 7,
            "revisesThought": 6,
            "nextThoughtNeeded": {
              "value": "false"
            },
            "isRevision": {
              "value": "true"
            },
            "thoughtNumber": 7
          }
        }
      }
    ],
    "role": "model"
  }

Sequential thinking advances us to the next thought.

  {
    "parts": [
      {
        "functionResponse": {
          "name": "sequentialthinking",
          "response": {
            "result": {
              "content": [
                {
                  "type": "text",
                  "text": "{\n  \"thoughtNumber\": 7,\n  \"totalThoughts\": 7,\n  \"nextThoughtNeeded\": false,\n  \"branches\": [],\n  \"thoughtHistoryLength\": 7\n}"
                }
              ]
            }
          }
        }
      }
    ],
    "role": "function"
  }

We call eval once again.

  {
    "parts": [
      {
        "text": "Alright, I was off by a beat. I pulled F_29 when the score called for F_30. My `*result-matrix*`, which is M^29, holds F_30 in its top-left corner. No more guesswork. I'm going in for the top-left element, and that, my friend, will be the 30th Fibonacci number.\nThe 30th Fibonacci number is 832040."
      },
      {
        "functionCall": {
          "name": "eval",
          "args": {
            "string": "(car (car *result-matrix*))"
          }
        }
      }
    ],
    "role": "model"
  }

The result is returned.

  {
    "parts": [
      {
        "functionResponse": {
          "name": "eval",
          "response": {
            "result": ";; Value: 832040\n"
          }
        }
      }
    ],
    "role": "function"
  }

And the LLM formulates the answer.

  {
    "parts": [
      {
        "text": "The 30th Fibonacci number is 832040."
      }
    ],
    "role": "model"
  }

The text is printed and the LLM client sees no function calls, so it returns control to the user.

This is remarkable. The system has created some Common Lisp code to solve a problem, encountered a bug, debugged it, and used the debugged code to solve the original problem. But no Lisp debugger was written. The entire debugging behavior is an emergent property of sequential thinking interacting with eval. This is the kind of magic that coding agents are known for and we barely had to do any work to get it.

Monday, August 11, 2025

Why LLMs Suck at Lisp

In my experiments with vibe coding, I found that LLMs (Large Language Models) struggle with Lisp code. I think I know why.

Consider some library that exposes some resources to the programmer. It has an AllocFoo function that allocates a Foo object, and a FreeFoo function that frees it. The library his bindings in several languages, so maybe there is a Python binding, a C binding, etc. In these languages, you'll find that functions that call AllocFoo often call FreeFoo within the same function. There are a lot of libraries that do this, and it is a common pattern.

Documents, such as source code files, can be thougth of as “points” in a very high dimensional space. Source code files in a particular language will be somewhat near each other in a region of this space. But within the region of space that contains source code in some language, there will be sub-regions that exhibit particular patterns. There will be a sub-region that contains Alloc/Free pairs. This sub-region will be displaced from the center of the region for the language. But here's the important part: in each language, independent of the particulars of the language, the subregion that contains Alloc/Free pairs will be displaced in roughly the same direction. This is how the LLM can learn to recognize the pattern of usage across different languages.

When we encounter a new document, we know that if it is going to contain an Alloc/Free pair, it is going to be displaced in the same direction as other documents that contain such pairs. This allows us to pair up Alloc/Free calls in code we have never seen before in languages we have never seen before.

Now consider Lisp. In Lisp, we have a function that allocates a foo object, and a function that frees it. The LLM would have no problem pairing up alloc-foo and free-foo in Lisp. But Lisp programmers don't do that. They write a with-foo macro that contains an unwind-protect that frees the foo when the code is done. The LLM will observe the alloc/free pair in the source code of the macro — it looks like your typical alloc/free pair — but then you use the macro everywhere instead of the explicit calls to Alloc/Free. The LLM doesn't know this abstraction pattern. People don't write with-foo macros or their equivalents in other languages, so the LLM doesn't have a way to recognize the pattern.

The LLM is good at recognizing patterns, and source code typically contains a lot of patterns, and these patterns don't hugely vary across curly-brace languages. But when a Lisp programmer sees a pattern, he abstracts it and makes it go away with a macro or a higher-order function. People tend not to do that in other languages (largely because either the language cannot express it or it is insanely cumbersome). The LLM has a much harder time with Lisp because the programmers can easily hide the patterns from it.

I found in my experiments that the LLMs would generate Lisp code that would allocate or initialize a resource and then add deallocation and uninitialization code in every branch of the function. It did not seem to know about the with-… macros that would abstract this away.

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.

Friday, May 30, 2025

Vibe Coding, A Kind of Success

Yeah, these posts about vibe coding are a bit tedious, but I got an interesting result. My hypothesis about vibe coding in Common Lisp was that there is simply not enough training data. But the most popular lisp is not Common Lisp but Emacs Lisp. It should have enough training data. So I tried vibe coding an application in Emacs Lisp. Again I used minesweeper as the example.

The model was Claude 3.7 Sonnet and I used VSCode in "Agent" mode. It generated a "minesweeper.el" file with 455 lines of code. There were some errors when I tried to run it, so I simply copied the error message into the AI prompt and let the VSCode agent fix it. After a couple of iterations, the code seems to work. It isn't the most polished minsweeper clone, but it is sort of a serviceable game.

So I guess the conclusion is that vibe coding works in Emacs Lisp. The result isn't all that slick, but what do expect from an Emacs game? This tends to support the hypothesis that you need ample training data.

Here is the code:

;;; minesweeper.el --- A Minesweeper game for Emacs -*- lexical-binding: t -*-

;; Author: AI Assistant
;; Keywords: games
;; Version: 1.0
;; Package-Requires: ((emacs "24.3"))

;;; Commentary:

;; This package implements the classic Minesweeper game for Emacs.
;; Use M-x minesweeper to start the game.

;;; Code:

(require 'cl-lib)

;;; Constants and Variables

(defvar minesweeper-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "<down>") 'minesweeper-move-down)
    (define-key map (kbd "<up>") 'minesweeper-move-up)
    (define-key map (kbd "<left>") 'minesweeper-move-left)
    (define-key map (kbd "<right>") 'minesweeper-move-right)
    (define-key map (kbd "RET") 'minesweeper-reveal)
    (define-key map (kbd "SPC") 'minesweeper-flag)
    (define-key map (kbd "r") 'minesweeper-restart)
    (define-key map (kbd "q") 'minesweeper-quit)
    map)
  "Keymap for ‘minesweeper-mode'.")

(defgroup minesweeper nil
  "Minesweeper game for Emacs."
  :group 'games)

(defcustom minesweeper-board-width 10
  "Width of the Minesweeper game board."
  :type 'integer
  :group 'minesweeper)

(defcustom minesweeper-board-height 10
  "Height of the Minesweeper game board."
  :type 'integer
  :group 'minesweeper)

(defcustom minesweeper-mine-count 15
  "Number of mines on the Minesweeper board."
  :type 'integer
  :group 'minesweeper)

(defface minesweeper-hidden-face
  '((t :background "gray80" :box (:line-width -1 :color "gray50")))
  "Face for hidden cells."
  :group 'minesweeper)

(defface minesweeper-flag-face
  '((t :background "gray80" :foreground "red" :weight bold :box (:line-width -1 :color "gray50")))
  "Face for flagged cells."
  :group 'minesweeper)

(defface minesweeper-mine-face
  '((t :background "red" :foreground "black" :weight bold :box (:line-width -1 :color "gray50")))
  "Face for mine cells."
  :group 'minesweeper)

(defface minesweeper-empty-face
  '((t :background "gray95" :box (:line-width -1 :color "gray50")))
  "Face for empty revealed cells."
  :group 'minesweeper)

(defface minesweeper-number-1-face
  '((t :background "gray95" :foreground "blue" :weight bold :box (:line-width -1 :color "gray50")))
  "Face for cells with 1 adjacent mine."
  :group 'minesweeper)

(defface minesweeper-number-2-face
  '((t :background "gray95" :foreground "green" :weight bold :box (:line-width -1 :color "gray50")))
  "Face for cells with 2 adjacent mines."
  :group 'minesweeper)

(defface minesweeper-number-3-face
  '((t :background "gray95" :foreground "red" :weight bold :box (:line-width -1 :color "gray50")))
  "Face for cells with 3 adjacent mines."
  :group 'minesweeper)

(defface minesweeper-number-4-face
  '((t :background "gray95" :foreground "purple" :weight bold :box (:line-width -1 :color "gray50")))
  "Face for cells with 4 adjacent mines."
  :group 'minesweeper)

(defface minesweeper-number-5-face
  '((t :background "gray95" :foreground "maroon" :weight bold :box (:line-width -1 :color "gray50")))
  "Face for cells with 5 adjacent mines."
  :group 'minesweeper)

(defface minesweeper-number-6-face
  '((t :background "gray95" :foreground "turquoise" :weight bold :box (:line-width -1 :color "gray50")))
  "Face for cells with 6 adjacent mines."
  :group 'minesweeper)

(defface minesweeper-number-7-face
  '((t :background "gray95" :foreground "black" :weight bold :box (:line-width -1 :color "gray50")))
  "Face for cells with 7 adjacent mines."
  :group 'minesweeper)

(defface minesweeper-number-8-face
  '((t :background "gray95" :foreground "gray50" :weight bold :box (:line-width -1 :color "gray50")))
  "Face for cells with 8 adjacent mines."
  :group 'minesweeper)

(defvar minesweeper-buffer-name "*Minesweeper*"
  "Name of the Minesweeper game buffer.")

(defvar minesweeper-board nil
  "The game board.
Each cell is a list of the form (MINE-P REVEALED-P FLAGGED-P MINE-COUNT).")

(defvar minesweeper-game-over nil
  "Whether the current game is over.")

(defvar minesweeper-game-won nil
  "Whether the current game is won.")

(defvar minesweeper-flags-placed 0
  "Number of flags placed on the board.")

(defvar minesweeper-current-pos '(0 . 0)
  "Current cursor position as (ROW . COL).")

;;; Game Functions

(defun minesweeper-init-board ()
  "Initialize the game board."
  (setq minesweeper-board (make-vector minesweeper-board-height nil))
  (let ((board-cells (* minesweeper-board-width minesweeper-board-height))
        (mine-positions (make-vector (* minesweeper-board-width minesweeper-board-height) nil)))
    
    ;; Initialize all cells
    (dotimes (row minesweeper-board-height)
      (let ((row-vec (make-vector minesweeper-board-width nil)))
        (dotimes (col minesweeper-board-width)
          (aset row-vec col (list nil nil nil 0))) ; (mine-p revealed-p flagged-p mine-count)
        (aset minesweeper-board row row-vec)))
    
    ;; Randomly place mines
    (dotimes (i minesweeper-mine-count)
      (let ((pos (random board-cells)))
        (while (aref mine-positions pos)
          (setq pos (random board-cells)))
        (aset mine-positions pos t)
        (let* ((row (/ pos minesweeper-board-width))
               (col (% pos minesweeper-board-width))
               (cell (aref (aref minesweeper-board row) col)))
          (setcar cell t)))) ; Set mine-p to t
    
    ;; Calculate adjacent mine counts
    (dotimes (row minesweeper-board-height)
      (dotimes (col minesweeper-board-width)
        (unless (car (aref (aref minesweeper-board row) col)) ; Skip if it's a mine
          (let ((count 0))
            (dolist (r (list -1 0 1))
              (dolist (c (list -1 0 1))
                (unless (and (= r 0) (= c 0))
                  (let ((new-row (+ row r))
                        (new-col (+ col c)))
                    (when (and (>= new-row 0) (< new-row minesweeper-board-height)
                               (>= new-col 0) (< new-col minesweeper-board-width))
                      (when (car (aref (aref minesweeper-board new-row) new-col))
                        (setq count (1+ count))))))))
            (setcar (nthcdr 3 (aref (aref minesweeper-board row) col)) count))))))
  (setq minesweeper-game-over nil
        minesweeper-game-won nil
        minesweeper-flags-placed 0
        minesweeper-current-pos '(0 . 0)))

(defun minesweeper-get-cell (row col)
  "Get the cell at ROW and COL."
  (aref (aref minesweeper-board row) col))

(cl-defun minesweeper-reveal (row col)
  "Reveal the cell at ROW and COL."
  (interactive
   (if current-prefix-arg
       (list (read-number "Row: ") (read-number "Column: "))
     (list (car minesweeper-current-pos) (cdr minesweeper-current-pos))))
  
  (when minesweeper-game-over
    (message "Game over. Press 'r' to restart.")
    (cl-return-from minesweeper-reveal nil))
  
  (let* ((cell (minesweeper-get-cell row col))
         (mine-p (nth 0 cell))
         (revealed-p (nth 1 cell))
         (flagged-p (nth 2 cell))
         (mine-count (nth 3 cell)))
    
    (when flagged-p
      (cl-return-from minesweeper-reveal nil))
    
    (when revealed-p
      (cl-return-from minesweeper-reveal nil))
    
    (setcar (nthcdr 1 cell) t) ; Set revealed-p to t
    
    (if mine-p
        (progn
          (setq minesweeper-game-over t)
          (minesweeper-reveal-all-mines)
          (minesweeper-draw-board)
          (message "BOOM! Game over."))
      
      ;; Reveal adjacent cells if this is an empty cell
      (when (= mine-count 0)
        (dolist (r (list -1 0 1))
          (dolist (c (list -1 0 1))
            (unless (and (= r 0) (= c 0))
              (let ((new-row (+ row r))
                    (new-col (+ col c)))
                (when (and (>= new-row 0) (< new-row minesweeper-board-height)
                           (>= new-col 0) (< new-col minesweeper-board-width))
                  (minesweeper-reveal new-row new-col)))))))
      
      (minesweeper-check-win)))
  
  (minesweeper-draw-board))

(cl-defun minesweeper-flag (row col)
  "Toggle flag on cell at ROW and COL."
  (interactive
   (if current-prefix-arg
       (list (read-number "Row: ") (read-number "Column: "))
     (list (car minesweeper-current-pos) (cdr minesweeper-current-pos))))
  
  (when minesweeper-game-over
    (message "Game over. Press 'r' to restart.")
    (cl-return-from minesweeper-flag nil))
  
  (let* ((cell (minesweeper-get-cell row col))
         (revealed-p (nth 1 cell))
         (flagged-p (nth 2 cell)))
    
    (when revealed-p
      (cl-return-from minesweeper-flag nil))
    
    (if flagged-p
        (progn
          (setcar (nthcdr 2 cell) nil) ; Remove flag
          (setq minesweeper-flags-placed (1- minesweeper-flags-placed)))
      (setcar (nthcdr 2 cell) t) ; Add flag
      (setq minesweeper-flags-placed (1+ minesweeper-flags-placed))))
  
  (minesweeper-draw-board))

(defun minesweeper-reveal-all-mines ()
  "Reveal all mines on the board."
  (dotimes (row minesweeper-board-height)
    (dotimes (col minesweeper-board-width)
      (let* ((cell (minesweeper-get-cell row col))
             (mine-p (nth 0 cell)))
        (when mine-p
          (setcar (nthcdr 1 cell) t)))))) ; Set revealed-p to t

(defun minesweeper-check-win ()
  "Check if the game is won."
  (let ((all-non-mines-revealed t))
    (dotimes (row minesweeper-board-height)
      (dotimes (col minesweeper-board-width)
        (let* ((cell (minesweeper-get-cell row col))
               (mine-p (nth 0 cell))
               (revealed-p (nth 1 cell)))
          (when (and (not mine-p) (not revealed-p))
            (setq all-non-mines-revealed nil)))))
    
    (when all-non-mines-revealed
      (setq minesweeper-game-over t
            minesweeper-game-won t)
      (message "You win!")
      (minesweeper-flag-all-mines))))

(defun minesweeper-flag-all-mines ()
  "Flag all mines on the board."
  (dotimes (row minesweeper-board-height)
    (dotimes (col minesweeper-board-width)
      (let* ((cell (minesweeper-get-cell row col))
             (mine-p (nth 0 cell))
             (flagged-p (nth 2 cell)))
        (when (and mine-p (not flagged-p))
          (setcar (nthcdr 2 cell) t))))))

;;; UI Functions

(defun minesweeper-draw-cell (row col)
  "Draw the cell at ROW and COL."
  (let* ((cell (minesweeper-get-cell row col))
         (mine-p (nth 0 cell))
         (revealed-p (nth 1 cell))
         (flagged-p (nth 2 cell))
         (mine-count (nth 3 cell))
         (char " ")
         (face 'minesweeper-hidden-face)
         (current-p (and (= row (car minesweeper-current-pos))
                         (= col (cdr minesweeper-current-pos)))))
    
    (cond
     (flagged-p
      (setq char "F")
      (setq face 'minesweeper-flag-face))
     
     (revealed-p
      (cond
       (mine-p
        (setq char "*")
        (setq face 'minesweeper-mine-face))
       
       ((= mine-count 0)
        (setq char " ")
        (setq face 'minesweeper-empty-face))
       
       (t
        (setq char (number-to-string mine-count))
        (setq face (intern (format "minesweeper-number-%d-face" mine-count))))))
     
     (t
      (setq char " ")
      (setq face 'minesweeper-hidden-face)))
    
    (insert (propertize char 'face face))
    
    (when current-p
      (put-text-property (1- (point)) (point) 'cursor t))))

(defun minesweeper-draw-board ()
  "Draw the game board."
  (let ((inhibit-read-only t)
        (old-point (point)))
    (erase-buffer)
    
    ;; Draw header
    (insert (format "Minesweeper: %d mines, %d flags placed\n\n"
                    minesweeper-mine-count
                    minesweeper-flags-placed))
    
    ;; Draw column numbers
    (insert "  ")
    (dotimes (col minesweeper-board-width)
      (insert (format "%d" (% col 10))))
    (insert "\n")
    
    ;; Draw top border
    (insert "  ")
    (dotimes (col minesweeper-board-width)
      (insert "-"))
    (insert "\n")
    
    ;; Draw board rows
    (dotimes (row minesweeper-board-height)
      (insert (format "%d|" (% row 10)))
      (dotimes (col minesweeper-board-width)
        (minesweeper-draw-cell row col))
      (insert "|\n"))
    
    ;; Draw bottom border
    (insert "  ")
    (dotimes (col minesweeper-board-width)
      (insert "-"))
    (insert "\n\n")
    
    ;; Draw status
    (cond
     (minesweeper-game-won
      (insert "You won! Press 'r' to restart or 'q' to quit."))
     
     (minesweeper-game-over
      (insert "Game over! Press 'r' to restart or 'q' to quit."))
     
     (t
      (insert "Press 'r' to restart, 'q' to quit.\n")
      (insert "Use arrows to move, ENTER to reveal, SPACE to toggle flag.")))
    
    ;; Restore point or set to position of cursor
    (goto-char (point-min))
    (let ((result (text-property-search-forward 'cursor)))
      (if result
          (goto-char (prop-match-beginning result))
        (goto-char old-point)))))

;;; Movement Functions

(defun minesweeper-move-up ()
  "Move cursor up."
  (interactive)
  (let ((row (car minesweeper-current-pos))
        (col (cdr minesweeper-current-pos)))
    (when (> row 0)
      (setq minesweeper-current-pos (cons (1- row) col))
      (minesweeper-draw-board))))

(defun minesweeper-move-down ()
  "Move cursor down."
  (interactive)
  (let ((row (car minesweeper-current-pos))
        (col (cdr minesweeper-current-pos)))
    (when (< row (1- minesweeper-board-height))
      (setq minesweeper-current-pos (cons (1+ row) col))
      (minesweeper-draw-board))))

(defun minesweeper-move-left ()
  "Move cursor left."
  (interactive)
  (let ((row (car minesweeper-current-pos))
        (col (cdr minesweeper-current-pos)))
    (when (> col 0)
      (setq minesweeper-current-pos (cons row (1- col)))
      (minesweeper-draw-board))))

(defun minesweeper-move-right ()
  "Move cursor right."
  (interactive)
  (let ((row (car minesweeper-current-pos))
        (col (cdr minesweeper-current-pos)))
    (when (< col (1- minesweeper-board-width))
      (setq minesweeper-current-pos (cons row (1+ col)))
      (minesweeper-draw-board))))

;;; Game Management Functions

(defun minesweeper-restart ()
  "Restart the Minesweeper game."
  (interactive)
  (minesweeper-init-board)
  (minesweeper-draw-board))

(defun minesweeper-quit ()
  "Quit the Minesweeper game."
  (interactive)
  (kill-buffer minesweeper-buffer-name))

(define-derived-mode minesweeper-mode special-mode "Minesweeper"
  "Major mode for playing Minesweeper."
  (setq buffer-read-only t)
  (setq-local cursor-type nil)
  (buffer-disable-undo))

;;;###autoload
(defun minesweeper ()
  "Start a new game of Minesweeper."
  (interactive)
  (switch-to-buffer minesweeper-buffer-name)
  (minesweeper-mode)
  (minesweeper-init-board)
  (minesweeper-draw-board))

(provide 'minesweeper)
;;; minesweeper.el ends here

To run it, you can save the code to a file named "minesweeper.el" and load it in Emacs with M-x load-file. Then start the game with M-x minesweeper.

Wednesday, May 28, 2025

Vibe Coding Common Lisp Through the Back Door

I had no luck vibe coding Common Lisp, but I'm pretty sure I know the reasons. First, Common Lisp doesn't have as much boilerplate as other languages. When boilerplace accumulates, you write a macro to make it go away. Second, Common Lisp is not as popular language as others, so there is far less training data.

Someone made the interesting suggestion of doing this in two steps: vibe code in a popular language, then ask the LLM to translate the result into Common Lisp. That sounded like it might work, so I decided to try it out.

Again, I used "Minesweeper" as the example. I asked the LLM to vibe code Minesweeper in Golang. Golang has a lot of boilerplate (it seems to be mostly boilerplate), and there is a good body of code written in Golang.

The first problem was that the code expected assets of images of the minesweeper tiles. I asked the LLM to generate them, but it wasn't keen on doing that. It would generate a large jpeg image of a field of tiles, but not a set of .png images of the tiles.

So I asked the LLM to vibe code a program that would generate the .png files for the tiles. It took a couple of iterations (the first time, the digits in the tiles were too small to read), but it eventually generated a program which would generate the tiles.

Then I vibe coded minesweeper. As per the philosophy of vibe coding, I did not bother writing tests, examining the code, or anything. I just ran the code.

Naturally it didn't work. It took me the entire day to debug this, but there were only two problems. The first was that the LLM simply could not get the API to the image library right. It kept thinking the image library was going to return an integer error code, but the latest api returns an Error interface. I could not get it to use this correctly; it kept trying to coerce it to an integer. Eventually I simply discarded any error message for that library and prayed it would work.

The second problem was vexing. I was presented with a blank screen. The game logic seemed to work because when I clicked around on the blank screen, stdout would eventually print "Boom!". But there were no visuals. I spent a lot of time trying to figure out what was going on, adding debugging code, and so on. I finally discovered that the SDL renderer was simply not working. It wouldn't render anything. I asked the LLM to help me debug this, and I went down a rabbit hole of updating the graphics drivers, reinstalling SDL, reinstalling Ubuntu, all to no avail. Eventually I tried using the SDL2 software renderer instead of the hardware accelerated renderer and suddenly I had graphics. It took me several hours to figure this out, and several hours to back out my changes tracking down this problem.

Once I got the tiles to render, though, it was a working Minesweeper game. It didn't have a timer and mine count, but it had a playing field and you could click on the tiles to reveal them. It had the look and feel of the real game. So you can vibe code golang.

The next task was to translate the golang to Common Lisp. It didn't do as good a job. It mentioned symbols that didn't exist in packages that didn't exist. I had to make a manual pass to replace the bogus symbols with the nearest real ones. It failed to generate working code that could load the tiles. I looked at the Common Lisp code and it was a horror. Not suprisingly, it was more or less a transliteration of the golang code. It took no advantage of any Common Lisp features such as unwind-protect. Basically, each and every branch in the main function had its own duplicate copy of the cleanup code. Since the tiles were not loading, I couldn't really test the game logic. I was in no mood to debug the tile loading (it was trying to call functions that did not exist), so I left it there.

This approach, vibe in golang and then translate to Common Lisp, seems more promising, but with two phase of LLM coding, the probability of a working result gets pretty low. And you don't really get Common Lisp, you get something closer to fully parenthesized golang.

I think I am done with this experiment for now. When I have some agentic LLM that can drive Emacs, I may try it again.

Tuesday, April 1, 2025

Vibe Coding, final word

I couldn't leave it alone. This AI was going to write some Lisp code if I had to force it. This isn't “vibing” anymore. We're going to be pecise, exact, and complete in our instructions, and we're going to check the results.

Again, I'm taking on a Minesweeper clone as the problem. All the code was to be written in a single file using a single package. The AI simply didn't understand the problem of forward references to symbols in other packages. Perhaps a game loop is beyond the ability of the AI. I wrote a basic game loop that initializes all the required libraries in correct order with unwind-protects to clean up in reverse order. I wrote a main function that creates a window and a renderer to draw on it, and a game loop that polls for events and handles keypresses and the quit event. This is a basic black window that has no behavior beyond the ability to quit. There should be no need for the AI to modify this code.

The AI used the GPT-4o model. Instructions were given in precise, imperative English. For example,

“Each cell on the board is in one of these states: hidden, flagging, flagged, unflagging, exposing, exposed Cells start out in hidden state. When a cell is hidden, it renders as a blank square. When a cell is hidden and the mouse is over the cell and the right button is down, the cell enteres the flagging state. When a cell is flagging and the mouse is over the cell and the right button is up, the cell enters the flagged mode. When a cell is flagged and the mouse is over the cell and the right button is down, the cell enters unflagging. When the cell is unflagging, the mouse is over the cell and and right button is up, the cell enters hidden. Cells that are flagging or flagged display as the flag texture. Cells that are hidden or unflagging display as the blank texture.”

This is programming, not vibing. There is always room for misunderstanding, but I spelled out the details of part of the state transitions that I wanted the AI to implement. In particular, notice that when flagging a cell, there are hidden states beyond the flagged and unflagged states. These are necessary to make the effect of flagging and unflagging be edge triggered. I didn't trust the AI to know about this, so I spelled it out.

Sometimes I could write simple directions, such as:

“When rendering a cell, if it is under the mouse, highlight the cell with a red border.”

Or:

“When the cell is in the exposing state, it should display as a blank, depressed cell.”

But then again,

“When the mouse is over a cell, if the cell is in hidden state and left mouse button is down, the cell enters the exposing state. If a cell is in exposing state and the mouse is not over the cell, the cell reverts to hidden state. If a cell is in exposing state, and the mouse is over the cell, and the left mouse button is raised, the cell enters the exposed state.”

The AI did a surprisingly good job at handling the parenthesis. There was one function that it kept adding a stray parenthesis to. I went back more than once to delete that extra parenthesis when the reader barfed.

At one point, the AI accidentally removed the abiity to place a flag on a cell. I asked it to restore that ability, but I didn't give instructions in as great detail as I did before. It added transitions to and from the flagged state, but it didn't add the intermediate flagging and unflagging states as I had explicitly directed it to before. As a result, flagging ended up being non-deterministic, with the flag being toggled on and off every 16 ms. I had to go back and add the intermediate states by hand again to restore the determinism.

After a few hours of this, I had a working Minesweeper clone. Let's look at the generated code.

;; -*- mode: lisp; package: "VIBE" -*-

(in-package "VIBE")

;; Define constants for magic numbers
(defparameter *tile-size* 32) ;; Size of each tile in pixels
(defparameter *game-width* 25) ;; Number of columns
(defparameter *game-height* 15) ;; Number of rows
(defparameter *frame-delay* 16) ;; Delay between frames in milliseconds

;; Define cell states
(defconstant +hidden+ 'hidden)
(defconstant +flagging+ 'flagging)
(defconstant +flagged+ 'flagged)
(defconstant +unflagging+ 'unflagging)
(defconstant +exposing+ 'exposing)
(defconstant +exposed+ 'exposed)

;; Define game states
(defconstant +playing+ 'playing)
(defconstant +won+ 'won)
(defconstant +lost+ 'lost)

It is a little odd to define the state constants as symbols. This adds a pointless extra level of symbolic indirection. I would have used keywords instead of symbols.

;; Global game state
(defparameter *game-state* +playing+)

;; The game board
(defparameter *board* nil)

;; The game board content (e.g., :mine or a number)
(defparameter *board-content* nil)

(defun initialize-board ()
  "Initialize the game board with all cells in the hidden state and place mines."
  ;; Initialize the board state
  (setf *board* (make-array (list *game-height* *game-width*)
                            :initial-element +hidden+))
  ;; Initialize the board content
  (setf *board-content* (make-array (list *game-height* *game-width*)
                                    :initial-element 0))
  ;; Place 50 mines randomly
  (loop repeat 50
        do (let ((row (random *game-height*))
                 (col (random *game-width*)))
             ;; Ensure no duplicate mines
             (unless (eql (aref *board-content* row col) :mine)
               (setf (aref *board-content* row col) :mine)
               ;; Update adjacent cells
               (update-adjacent-cells row col)))))

(defun update-adjacent-cells (row col)
  "Increment the mine count for all adjacent cells."
  (loop for r from (max 0 (1- row)) to (min (1+ row) (1- *game-height*))
        do (loop for c from (max 0 (1- col)) to (min (1+ col) (1- *game-width*))
                 do (unless (eql (aref *board-content* r c) :mine)
                      (incf (aref *board-content* r c))))))

You can tell that I didn't write this code. I hate loop.

The textures are loaded from PNG files just before beginning the game loop. The AI decided to maintain them as four separate values that are passed around to the functions that need them. This will get out of hand quickly. A table of resources would be better.

(defun render-frame (renderer blank-texture flag-texture mine-texture digit-textures)
  (sdl2:set-render-draw-color renderer 0 0 0 0)
  (sdl2:render-clear renderer)
  ;; Render game state
  (loop for row from 0 below *game-height*
        do (loop for col from 0 below *game-width*
                 do (render-tile renderer row col blank-texture flag-texture mine-texture digit-textures))))

(defun render-tile (renderer row col blank-texture flag-texture mine-texture digit-textures)
  "Render a single tile based on its state and highlight it if under the mouse."
  (let ((state (aref *board* row col))
        (content (aref *board-content* row col)) ;; Content of the cell (e.g., :mine or a number)
        (dest-rect (sdl2:make-rect (* col *tile-size*) (* row *tile-size*) *tile-size* *tile-size*)))
    ;; Render the tile based on its state
    (cond
      ;; Hidden or unflagging state: render blank texture
      ((or (eql state +hidden+) (eql state +unflagging+))
       (sdl2:render-copy renderer blank-texture :dest-rect dest-rect))
      ;; Flagging or flagged state: render flag texture
      ((or (eql state +flagging+) (eql state +flagged+))
       (sdl2:render-copy renderer flag-texture :dest-rect dest-rect))
      ;; Exposing state: render blank texture with a depressed effect
      ((eql state +exposing+)
       (sdl2:render-copy renderer blank-texture :dest-rect dest-rect)
       (sdl2:set-render-draw-color renderer 50 50 50 100) ;; Semi-transparent dark overlay
       (sdl2:render-fill-rect renderer dest-rect))
      ;; Exposed state: render mine or digit
      ((eql state +exposed+)
       (cond
         ;; If the cell contains a mine, render the mine texture
         ((eql content :mine)
          (sdl2:render-copy renderer mine-texture :dest-rect dest-rect))
         ;; If the cell contains a digit (positive number), render the corresponding digit texture
         ((and (integerp content) (> content 0))
          (let ((digit-texture (gethash content digit-textures)))
            (when digit-texture
              (sdl2:render-copy renderer digit-texture :dest-rect dest-rect))))
         ;; Otherwise, render a default exposed tile (e.g., light gray)
         (t
          (sdl2:set-render-draw-color renderer 200 200 200 255) ;; Light gray for exposed
          (sdl2:render-fill-rect renderer dest-rect)))))
    ;; Highlight the tile with a red border if it is under the mouse
    (multiple-value-bind (mouse-x mouse-y _) (sdl2:mouse-state)
      (declare (ignore _))
      (let ((mouse-row (floor (/ mouse-y *tile-size*)))
            (mouse-col (floor (/ mouse-x *tile-size*))))
        (when (and (= row mouse-row) (= col mouse-col))
          (sdl2:set-render-draw-color renderer 255 0 0 255) ;; Red color
          (sdl2:render-draw-rect renderer dest-rect)
          (sdl2:set-render-draw-color renderer 0 0 0 255)))))) ;; Reset color to black

Each time we go around the game loop, we update the state the cell under the mouse. The AI decided to pass the state input as spread arguments to the update function.

(defun update-cell-state (row col mouse-over left-button-down left-button-up right-button-down right-button-up)
  "Update the state of a cell based on mouse input."
  (let ((state (aref *board* row col)))
    (cond
      ;; If the cell is hidden and the left mouse button is down, it enters the exposing state
      ((and (eql state +hidden+) mouse-over left-button-down)
       (setf (aref *board* row col) +exposing+))

      ;; If the cell is exposing and the mouse is not over it, it reverts to hidden
      ((and (eql state +exposing+) (not mouse-over))
       (setf (aref *board* row col) +hidden+))

      ;; If the cell is exposing, the mouse is over it, and the left mouse button is raised, it enters the exposed state
      ((and (eql state +exposing+) mouse-over left-button-up)
       (setf (aref *board* row col) +exposed+)
       ;; If the cell contains a mine, the game is lost and all mines are exposed
       (when (eql (aref *board-content* row col) :mine)
         (setf *game-state* +lost+)
         (expose-all-mines))
       ;; If the cell has zero neighboring mines, recursively expose neighbors
       (when (and (integerp (aref *board-content* row col))
                  (= (aref *board-content* row col) 0))
         (expose-neighbors row col)))

      ;; If the cell is hidden and the right mouse button is down, it enters the flagging state
      ((and (eql state +hidden+) mouse-over right-button-down)
       (setf (aref *board* row col) +flagging+))

      ;; If the cell is flagging and the right mouse button is up, it enters the flagged state
      ((and (eql state +flagging+) mouse-over right-button-up)
       (setf (aref *board* row col) +flagged+))

      ;; If the cell is flagged and the right mouse button is down, it removes the flag
      ((and (eql state +flagged+) mouse-over right-button-down)
       (setf (aref *board* row col) +unflagging+))

      ((and (eql state +unflagging+) mouse-over right-button-up)
       (setf (aref *board* row col) +hidden+)))))

(defun poll-mouse-and-update ()
  "Poll the mouse position and button states, and update the board accordingly."
  (when (eql *game-state* +playing+) ;; Only process mouse input if the game is playing
    (multiple-value-bind (x y buttons) (sdl2:mouse-state)
      (let ((row (floor (/ y *tile-size*)))
            (col (floor (/ x *tile-size*)))
            (left-button-down (logbitp 0 buttons))  ;; SDL_BUTTON_LEFT is bit 0
            (right-button-down (logbitp 2 buttons))) ;; SDL_BUTTON_RIGHT is bit 2
        (when (and (>= row 0) (< row *game-height*)
                   (>= col 0) (< col *game-width*))
          ;; Update the cell state based on mouse input
          (update-cell-state row col
                             t ;; mouse-over is true for the current cell
                             left-button-down
                             (not left-button-down)
                             right-button-down
                             (not right-button-down)))))))

This illustrates that while the lights appear to be on, no one is at home. The mouse-over variable is always true, there is no need for it to exist at all. There is no need to pass both left-button-down and its complement. Same with right-button-down.

I did allow the AI to modify game-loop, but the modifications were subject to careful scrutiny to make sure that the game would continue to run. In particular, one time it wanted to add handlers for mouse events. I told it no, and that it could poll the mouse state as necessary instead.

(defun game-loop (window renderer blank-texture flag-texture mine-texture digit-textures game-over-texture)
  "Main game loop."
  (declare (ignore window))
  ;; Main game loop
  (sdl2:with-event-loop (:method :poll)
    (:idle ()
           ;; Clear the screen
           (sdl2:set-render-draw-color renderer 0 0 0 255) ;; Black background
           (sdl2:render-clear renderer)

           ;; Poll mouse and update game state
           (poll-mouse-and-update)

           ;; Render the game frame
           (render-frame renderer blank-texture flag-texture mine-texture digit-textures)

           ;; Render the "Game Over" overlay if the game is lost
           (when (eql *game-state* +lost+)
             (let ((screen-width (* *tile-size* *game-width*))
                   (screen-height (* *tile-size* *game-height*)))
               ;; Set blend mode and alpha for transparency
               (sdl2:set-texture-blend-mode game-over-texture :blend)
               (sdl2:set-texture-alpha-mod game-over-texture 192) ;; 75% transparency
               ;; Render the texture as a full-screen overlay
               (let ((dest-rect (sdl2:make-rect 0 0 screen-width screen-height)))
                 (sdl2:render-copy renderer game-over-texture :dest-rect dest-rect))))

           ;; Present the rendered frame
           (sdl2:render-present renderer)

           ;; Delay for the next frame
           (sdl2:delay *frame-delay*))
    (:keydown (:keysym keysym)
              (cond
                ;; Reset the game when the 'o' key is pressed
                ((eql (sdl2:scancode keysym) :scancode-o)
                 (reset-game))
                ;; Quit the game when the 'x' key is pressed
                ((eql (sdl2:scancode keysym) :scancode-x)
                 (sdl2:push-quit-event))
                ;; Lose the game and expose all mines when the 'p' key is pressed
                ((eql (sdl2:scancode keysym) :scancode-p)
                 (progn
                   (setf *game-state* +lost+)
                   (expose-all-mines)))))
    (:quit () t)))

Notice that in this game loop, we're not accounting for the time it takes to update the game state and render the frame. If this game really tried to animate anything, the animation would be jittery. A better game loop would track real time and refresh accordingly.

For a simple game such as this, it makes sense to load the all the bitmaps into memory at the get-go. For a more complicated game with many levels, you might not be able to fit them all in memory.

Passing the surfaces around as arguments is not going to work when you have a lot of them.

(defun initialize ()
  "Initialize the game, load textures, and create the game board."
  (initialize-board) ;; Initialize the game board
  (let ((blank-surface nil)
        (flag-surface nil)
        (mine-surface nil)
        (game-over-surface nil)
        (digit-surfaces (make-hash-table)))
    (unwind-protect
         (progn
           ;; Load PNG surfaces
           (setq blank-surface (sdl2-image:load-image
                                (asdf:system-relative-pathname "vibe" "textures/blank.png")))
           (setq flag-surface (sdl2-image:load-image
                               (asdf:system-relative-pathname "vibe" "textures/flag.png")))
           (setq mine-surface (sdl2-image:load-image
                               (asdf:system-relative-pathname "vibe" "textures/mine.png")))
           ;; Load digit textures (e.g., "1.png", "2.png", etc.)
           (loop for i from 1 to 8
                 do (setf (gethash i digit-surfaces)
                          (sdl2-image:load-image
                           (asdf:system-relative-pathname "vibe" (format nil "textures/~a.png" i)))))
           ;; Create the "GAME OVER" surface
           (setq game-over-surface (create-game-over-surface))

           ;; Create the window and renderer
           (sdl2:with-window (window
                              :title "Vibe"
                              :x 0 :y 0
                              :w (* *tile-size* *game-width*)
                              :h (* *tile-size* *game-height*)
                              :flags '(:shown))
             (sdl2:with-renderer (renderer window :index -1 :flags '(:accelerated))
               (let ((blank-texture (sdl2:create-texture-from-surface renderer blank-surface))
                     (flag-texture (sdl2:create-texture-from-surface renderer flag-surface))
                     (mine-texture (sdl2:create-texture-from-surface renderer mine-surface))
                     (digit-textures (make-hash-table))
                     (game-over-texture (sdl2:create-texture-from-surface renderer game-over-surface)))
                 ;; Convert digit surfaces to textures
                 (maphash (lambda (key surface)
                            (setf (gethash key digit-textures)
                                  (sdl2:create-texture-from-surface renderer surface)))
                          digit-surfaces)
                 (unwind-protect
                      (game-loop window renderer blank-texture flag-texture mine-texture digit-textures game-over-texture)
                   ;; Cleanup textures
                   (sdl2:destroy-texture blank-texture)
                   (sdl2:destroy-texture flag-texture)
                   (sdl2:destroy-texture mine-texture)
                   (sdl2:destroy-texture game-over-texture)
                   (maphash (lambda (_key texture)
                              (declare (ignore _key))
                              (sdl2:destroy-texture texture))
                            digit-textures)))))))
      ;; Cleanup surfaces
      (when flag-surface (sdl2:free-surface flag-surface))
      (when blank-surface (sdl2:free-surface blank-surface))
      (when mine-surface (sdl2:free-surface mine-surface))
      (when game-over-surface (sdl2:free-surface game-over-surface))
      (maphash (lambda (_key surface)
                 (declare (ignore _key))
                 (sdl2:free-surface surface))
               digit-surfaces)))

In Minesweeper, if you click on a cell with no neighboring mines, all the neighboring cells are exposed. This will open up larger areas of the board. The AI did a good job of implementing this, but I was careful to specify that only the hidden cells should be exposed. Otherwise, the recursion would not bottom out because every cell is a neighbor of its neighbors.

(defun expose-neighbors (row col)
  "Recursively expose all hidden neighbors of a cell with zero neighboring mines."
  (loop for r from (max 0 (1- row)) to (min (1+ row) (1- *game-height*))
        do (loop for c from (max 0 (1- col)) to (min (1+ col) (1- *game-width*))
                 do (when (and (eql (aref *board* r c) +hidden+)) ;; Only expose hidden cells
                      (setf (aref *board* r c) +exposed+)
                      ;; If the neighbor also has zero mines, recursively expose its neighbors
                      (when (and (integerp (aref *board-content* r c))
                                 (= (aref *board-content* r c) 0))
                        (expose-neighbors r c))))))

We need a way to get the game back to the initial state.

(defun reset-game ()
  "Reset the game by reinitializing the board and setting the game state to playing."
  (initialize-board)
  (setf *game-state* +playing+))

The AI writes buggy code. Here is an example. It is trying figure out if the player has won the game. You can state the winning condition in couple of different ways.

  • All the cells that are not mines are exposed.
  • All the cells that are mines are flagged, all flagged cells contain mines.

This does't quite achieve either of these.

(defun check-win-condition ()
  "Check if the player has won the game."
  (let ((won t)) ;; Assume the player has won until proven otherwise
    (loop for row from 0 below *game-height*
          do (loop for col from 0 below *game-width*
                   do (let ((state (aref *board* row col))
                            (content (aref *board-content* row col)))
                        (when (and (not (eql state +exposed+)) ;; Cell is not exposed
                                   (not (or (eql state +flagged+) ;; Cell is not flagged
                                            (eql content :mine)))) ;; Cell does not contain a mine
                          (setf won nil)))))
    (when won
      (setf *game-state* +won+))))

create-game-over-surface prepares a surface with the words “Game Over” writ large.

(defun create-game-over-surface ()
  "Create a surface for the 'GAME OVER' splash screen using SDL2-TTF."
  (let ((font nil)
        (text-surface nil))
    (unwind-protect
         (progn
           ;; Load the font (adjust the path and size as needed)
           (setq font (sdl2-ttf:open-font (asdf:system-relative-pathname "vibe" "fonts/arial.ttf") 72))
           ;; Render the text "GAME OVER" in red
           (setq text-surface (sdl2-ttf:render-text-solid font "GAME OVER" 255 0 0 255)))
      ;; Cleanup
      (when font (sdl2-ttf:close-font font)))
    text-surface))

The main function initializes the SDL2 library and its auxiliar libraries along with unwind-protects to uninitialize when we leave the game. The AI was not permitted to change this code.

(defun main ()
  (sdl2:with-init (:video)
    (unwind-protect
         (progn
           (sdl2-image:init '(:png))
           (unwind-protect
                (progn
                  (sdl2-ttf:init)
                  (initialize))
             (sdl2-ttf:quit)))
      (sdl2-image:quit))))

If you step on a mine, it exposes the other mines.

(defun expose-all-mines ()
  "Expose all mines on the board."
  (loop for row from 0 below *game-height*
        do (loop for col from 0 below *game-width*
                 do (when (eql (aref *board-content* row col) :mine)
                      (setf (aref *board* row col) +exposed+)))))

Conclusion

This wasn't “vibe coding”. This was plain old coding, but filtered through an English language parser. It added an extra level of complexity. Not only did I have to think about what should be coded, I had to think about how to phrase it such that the AI would generate what I had in mind and not disturb the other code.

Whenever I tried to let go and “vibe”, the AI would generate some unworkable mess. Programming is a craft that requires training and discipline. No dumb pattern matcher (or sophisticated one) is going to replace it.

In languages other that Common Lisp, you might get further. Consider Java. It takes a page and half of boilerplate to specify the simplest first-class object. An AI can easily generate pages and pages of boilerplate and appear to be quite productive. But you've missed the point if you think that it is better to generate boilerplate automatically than to use abstractions to avoid it and a language that doesn't need it.

Friday, March 28, 2025

Vibed into non-functioning

Continue vibing? Well, why not go all the way.

The AI wasn’t doing so well with the main game loop, so I gave it enough help that a blank window would come up. The window would respond to the X key being pressed in order to exit, but you could also close the window to exit as well.

I told the AI that I wanted a grid of tiles. Some tiles had mines. The remaining tiles had an integer which was the number of mines in adjacent squares. The AI wanted to load some textures from files 0.png through 8.png. I asked it to generate those files, but it didn’t want to. So I broke out Paint and generated some crude 32x32 png images of numbers, a mine, a blank, and a flag.

The AI tried to load these images directly, so I had to instruct it that you need a dependency on SDL2-image and that you can load the image on to a surface, and then you can load a texture from the surface (think of a texture as a bitmap on the GPU and a surface as a bitmap on the CPU). There were several rounds of trying the code, getting an error, and pasting the error in to the AI. As per the philosophy of vibe coding, I just accepted the suggested changes without looking at them. I did have to direct it to not to try to “use” packages because that simply introduced name conflicts.

I got to the point where I could compile and load the game so far with no errors. I was testing the code at each step. It wasn’t making much progress in so far as displaying anything, but it at least didn’t regress.

Until it did. I had vibed to the point where I got a nice black rectangle on the screen that did not display anything or respond to any input. No errors were printed. Time to debug. The problem is that I only had a vague idea of what it was doing. I wasn’t paying much attention to changes being made. I dove into the code that had been generated.

What a mess. I had my suspicions as to what was wrong. Some of the newly added code needed to use the SDL2 image library. It needs to initialize the SDL2 image library, load the surfaces, and load the textures in that order. When it exits, it has to unload things in reverse order. When I wrote my Platformer Tutorial, I wrote a set of with-... macros that would pair up loading/unloading and initialize/uninitialize steps with an unwind-protect. If you use the with-... macros, you automatically get the LIFO order of operation that you need for the code to function, and the unwind-protects make sure that the uninitialization occurs even if you error out or abort the application.

The vibed code had none of this. It didn’t know about unwind-protect. It didn’t even know about nesting. It simply tried to run the initialization code first, the inner code next, and the cleanup code after that. But it combined the code through concatenation, not composition, so the necessary LIFO properties were absent. In addition, the initialization code was not paired with the cleanup code. It was pure coincidence that a cleanup happened after an initialization. The initialization code was spread about several functions in an ad hoc manner and the cleanup code was clumped in different sections. It was spaghetti code, and you needed to analyze it carefully to determine if the code initialized things in the right order or cleaned up correctly. One obvious bug was the code destroying the surfaces while the textures were still in use.

I poked at it a little bit, but there was no easy way to massage the code into a working state. It was just too disjoint. I eventually just deleted the vibed code. Firt of all, it didn’t work. Second of all, when I removed it, I regained the lost functionality of the close box and the X key for exit. It is a bad sign when removing code increases functionality.

Vibing is 0 for 2 at this point. If I have time in the next few days, I may revisit this once again and see how much hand-holding I have to give the AI to generate a working display.

Tuesday, March 25, 2025

Vibe Coding in Common Lisp, continued

I unwedged the AI with regard to the package system, so I asked the AI to write the game loop.

Now there are a number of ways to approach a game loop, but there is one strategy that really wins: a nested set of with-... macros. These win because they tie resource management to the dynamic execution scope. You write with-window, and upon entry to the form, a window is allocated and initialized and comes into scope during the body of the code. When the body returns, the window is destroyed and deallocated.

These with-... macros are built around allocation/deallocation pairs of primitives that are called from an unwind protect. The abstraction is the inverse of a function call: instead using a function to hide a body of code, you use a function to wrap a body of code. The body of code doesn’t hide in the callee, but is passed as an argument from the caller. One important feature of programming in this way is that resources are never returned from a function, but are only passed downwards from the allocation point. This keeps objects from escaping their dynamic scope.

The entry to the game loop consists of a few nested with-... macros that initialize the library, allocate a window, allocate a drawing context, and enter a event loop. When the event loop exits, the resources are torn down in reverse order of allocation leaving the system in a clean state.

But the AI did not use with-... macros. The code it generated had subroutines that would create a window or allocate a drawing context, but it would assign the created objects into a global variable. This means that the object is set to escape its dynamic scope when it is created. There is nothing to prevent (or even discourage) access to the object after it has been deallocated. There were no unwind-protects anywhere, so objects, once allocated, were eternal — you could never close a window.

In the large, the code was built to fail. In the small, it immediately failed. Calling conventions were not even followed. Keyword agument functions were called with positional arguments, or with an odd number of arguments, irrelevant extra arguments were passed in, the AI would pass in flags that didn’t exist. We’ll grant that the AI does not ultimately understand what it is doing, but it should at least make the argument lists superficially line up. That doesn’t require AI, a simple pattern match can detect this.

The event loop did not load, let alone compile. It referred to symbols that did not exist. We’ll we can expect this, but it needs to be corrected. When I pointed out that the symbol didn’t exist, the AI began to thrash. It would try the same symbol, but with asterisks around it. It would try a variant of the same symbol. Then it would go back and try the original symbol again, try the asterisks again, try the same variant name again, etc. There is nothing to be done here but manual intervention.

There are some macros that set up an event loop, poll for an event, disptach to some code for that event while extracting the event particulars. You can roll your own event loop, or you can just use one of pre-built macros. When the AI began to thrash on the event loop, I intervened, deleted the code it was thrashing on and put in the event loop macro. The AI immediately put back in the code I had removed and started thrashing on it again.

Again, it is clear that the AI has no knowledge at all of what it is doing. It doesn’t understand syntax or the simplest of semantics. It cannot even tell if a symbol is bound to a value. Even the most junior developer won’t just make up functions that are not in the library. The AI doesn’t consult the documentation to validate if the generated code even remotely passes a sniff test.

You cannot “vibe code” Common Lisp. The AI begins to thrash and you simply must step in to get it unwedged. It doesn’t converge to any solution whatsoever. I suspect that this is because there is simply not enough training data. Common Lisp would appear to need some semantic understanding in order to write plausibly working code. Just mimicking some syntax you found on the web (which is ultimately what the AI is doing) will not get you very far at all.

Monday, March 24, 2025

Vibe coding in Common Lisp

Can you “vibe code” in Common Lisp?

Short answer, no.

I set out to give it a try. The idea behind “vibe coding” is to use an AI to generate the code and to blindly accept everything it generates. You offer some feedback about error messages and whether the code is doing something close to what you want, but you specifically don’t try to analyze and understand the code being generated. You certainly don’t try to debug it and fix it.

A.I. is trained on a lot of open source code. If your language is popular, there is a lot of code to train on. Chances are, if you have a problem, then not only has someone attempted to code it up in Python, but several people have given it a try and someone has ported it to JavaScript. Someone has solved your problem on StackOverflow.

Common Lisp is not a popular language. There is not a lot of code to train on, and most of it is someone’s homework. So for any particular problem, the A.I. doesn’t have a lot to go on. It becomes clear pretty quickly that the A.I. has no understanding of what it is doing, it is just trying to synthesize something that is similiar to what it has seen before, and if it hasn’t seem much, you don’t get much.

So I tried to vibe code in Common Lisp. I decided to try to write a Minesweeper game. That seemed like it had probably been done enough times before that the A.I. might be able to generate some vibe code.

I told it that we were going to write a video game and that it should generate an asd file for the game, and some skeleton code that would be a good start. It generated an asd file and four small files: main.lisp, game.lisp, input.lisp, and graphics.lisp. There was little actual code in the files, just some function stubs, but you could see where the cross-file dependencies were going to be.

The asd file wouldn’t load. The generated files had some minor dependencies upon each other and imported the required symbols from the other files. This imposed an implicit load order because a file couldn’t be loaded until the file it depended on had created the package for the symbols that it referenced. This is an old problem in Common Lisp, and the solution is to set up all the packages before loading anything. But I was vibing, so I told the AI that the forward references of the symbols were causing problems.

The AI added require statements into the files to try get them to load in a working order. It didn’t help. require statements have a whole bunch of issues and aren’t used very much thes days. The modern solution is to make the dependencies explicit in the system definition file. I gave the AI a direct instruction to make sure that the package.lisp file loaded before any other. Rather than edit the asd file, it decided to add even more require statements.

I declared failure at this point and manually edited the package.lisp file to create the packages and import the inital symbols, I added a dependecy on the package.lisp file to every other file, and I removed all the spurious require statements. It was clear the AI was not going to hit upon this solution.

The AI obviously has no model of what the package system is. It doesn’t reason that you need to load it first. It simply knows that it can insert require statements to express a dependency. So it thrashes around added require statements in the hope that it will converge to a solution. It converged to a circular dependency instead.