What Happens When You Get Codex to Golf Ruby

How an hour prompting OpenAI Codex to keep making a piece of Ruby code smaller revealed some neat code golfing tricks and reimplemented an obscure trick from 1976.

My dad was always bemused by the investigators in shows like CSI when they’d ask for an image to be enhanced beyond credibility. “Enhance!” “Zoom!” “Look around the corner!” Now, in 2026, I feel like I’ve been having a similar experience by screaming at OpenAI Codex (using the GPT-5.6 Sol model) to make Ruby code smaller and smaller.

I’ve long been into code golf, not least as part of coming up with the niftiest, smallest Ruby solutions for Advent of Code. It’s a rewarding pastime if you like learning about language quirks, shortcuts and, eventually, figuring out unusual algorithms.

Can AI agents golf Ruby too? Yes. But it’s all just commonly known stuff they’ve seen before, right? After a recent experience, I’m not so sure.

Let’s keep it simple: we want to write the tiniest Ruby implementation of a method that generates Roman numerals.

We want a method where 1 becomes “I”, 42 becomes “XLII”, and so on. This is a fun kata to do by hand in the test-first style (where even return "I" * n might get you past your first tests!) but an agent can turn the speed of iteration up to 11 if you give it a good enough test suite to work against.

The AI golfing experience is simple: Create a basic, obvious and uninteresting first implementation, look at its size (say it’s 250 bytes), then tell the agent: “make it work and pass all the tests in 230 bytes”

I repeated this until the implementation got to 90 bytes:

def r(n)=n<1?'':r(n/10).tr('IVXLC','XLCDM')+
(n%=10;n<4??I*n:n<5?'IV':n<9??V+?I*(n-5):'IX')

Stop and think about why it works. It’s rather neat.

(One caveat: this final version drops the input validation used during the golfing process below, and assumes an integer from 1 to 3999.)

For the rest of this article, I want to focus on what tweaks and changes it took to get there. They tickled my intellectual curiosity, taught me a couple of Ruby tricks I hadn’t known before, and got me thinking about what can happen when you give agents increasingly unlikely goals (which includes cheating, as we shall see).

These are the different levels of optimizations that arose:

Level 1: Like-for-like shortcuts

The Ruby code golfer’s bread and butter!

The lowest hanging fruit is switching in a smaller set of characters for a larger set. Several of these came up during the golfing process and are largely Ruby specific:

  • ?I or ?X are the same as writing "I" or "X" but at a one character saving each.

  • Endless methods! Takes you from def x; expr; end, say, to def x=expr

  • raise ArgumentError unless condition became condition or fail ArgumentError. This continues to raise ArgumentError if the input isn’t an integer between 1-3999 (3999 is the conventional ceiling for modern unbarred Roman numerals). Note that fail is an alias for raise, saving one character, and the unless/or switcheroo also saves four characters.

  • A range (1...4000) became (1...4e3) – one character saved by notation that makes writing larger numbers more compact. 4e3 is technically 4000.0 but in this range it behaves the same.

  • *'' replaced .join (consider that %w{a b c}*'' equals "abc")

  • _1 replaced a |n| (_1 is a numbered block parameter)

Anyone who’s done code golf in Ruby by hand will be familiar with these sorts of things. There are tons more here to enjoy.

Level 2: Inlining/rebuilding data

Magic constants and similar ‘data’ can take up a lot of space or require character-costly assignments. In normal life, we might deliberately extract such values for DRY purposes, but when trying to save characters, it can make more sense to boil them into the specific places they are used.

For example, you might set up a data structure in one place, then repeatedly use it elsewhere. For example:

r='IVXLCDM'.chars
# then later in a loop..
# {
  r[i*2,3]
# }

You can save characters by inlining the constant, as long as that’s the only place it’s used:

'IVXLCDM'.chars[i*2,3]

A similar trick is to write code to create the data you need, so long as it takes up less space than data did. This trick is often used by people who write demos or games with space constraints. Why store a 64x64 texture when you can generate it with code?

My attempt at this same challenge in JavaScript gives a simple and particularly elegant example of encoding data in a smaller form. This is an example of turning an integer (n) into its ones-unit Roman numeral:

' I II III IV V VI VII VIII IX'.split` `[n%10]

Or, encoded in a shorter form:

'IIIVVIIIX'.slice('244447'[n%10-4],n%10)

It’s fun to think about how this works. Sadly this optimization doesn’t fly in Ruby since we already have %W{} to make the first form more efficient, and some range quirks that make the second form less efficient.

(Why %W{} and not %w{}, you ask? Because you can do this to make the first element an empty string! %W{#@_ a b})

Level 3: Syntax shuffling and quirks

Many space-savers lean on Ruby quirks or “clever” workarounds that don’t change the broad algorithm being used, but that move things around or take advantage of syntax you’d probably not write day-to-day:

  • The fail ArgumentError was later replaced with a ternary expression where 1.% was a possible result. That doesn’t look like legal Ruby, but it is, and when evaluated it raises ArgumentError! Ternary expressions more generally can also often save characters due to how dense (a.k.a. ‘unreadable’) they are.

  • Remember the (1...4e3) range from earlier? Due to some algorithm changes, an endless range worked just as well, taking us down to (1..).

  • Integer===n&&n>0&&n<4e3?10:1.% was an already golfed way to validate input. Is the input an integer? Is it between 1 and 3999? If so, return 10, otherwise raise ArgumentError (via 1.%). This was golfed down to n>0?10:1.% coupled with a rescue 1.% later on to, ironically, raise the ArgumentError if n is not greater than zero.

    You may say “it’s not checking if it’s an Integer now” which is true, but #digits is used later on in the algorithm which is Integer only, raises an exception for non-integers, and causes the rescue ‘failure’ nonetheless.

  • Time for more mess related to the above item. Positivity of the integer moved to being checked in the digits call like so: n.digits(n>0?10:1.%) .. but a later optimization saved more characters by just using n.digits and adding *''*(n/n.abs) as the final string join instead. This is quite a subtle optimization! Consider that "x"*1 == "x" and "x"*1*1 == "x" but both "x"*1*(0/0.abs) and "x"*1*(-1/-1.abs) raise an exception (for quite different reasons).

Level 4: Find a new algorithm!

The changes so far maintain the overall algorithm used but take shortcuts or shuffle things around. Once you’ve squeezed so far, though, changes to the algorithm become necessary to shave off more characters.

The very first implementation of the Roman numeral generator stored the values of Roman numerals like so:

{M:1000,CM:900,D:500,CD:400,C:100,XC:90,L:50,XL:40,X:10,IX:9,V:5,IV:4,I:1}

The supplied integer was then divided by each with the modulus being passed on and the characters accumulated along the way.

That data is 74 characters on its own, though, so isn’t going to fly in a 90 character implementation. Are there any other algorithms that can be represented in less code?

It turns out there are many as Tomas Langkaas has collected.

Codex decided to go with this one initially:

def r(n)=n.digits(Integer===n&&n>0&&n<4e3?10:1.%).map.with_index{|d,i|
a,b,c='IVXLCDM'.chars[i*2,3];d<4?a*d:d<5?a+b:d<9?b+a*(d-5):a+c}.reverse*''

Ignoring the input validation (which was implemented due to my first tests demanding ArgumentError be raised on non-integers or integers <= 0 or >= 4000) the core of the algorithm is this:

n.digits.map.with_index{ |d,i|
  a,b,c='IVXLCDM'.chars[i*2,3]
  d<4?a*d:d<5?a+b:d<9?b+a*(d-5):a+c
}.reverse*''

This iterates through an integer’s digits going ones, tens, hundreds, then thousands, and in each case gets converted into the correct letters by slicing a three-character window out of 'IVXLCDM'. A chained ternary then applies the standard rules of Roman numeral construction using those three symbols.

That’s clever enough, but when I asked it to go down even more characters, it came up with a solution for which I could initially find no prior art! Which leads us to our 90 byte solution:

def r(n)=n<1?'':r(n/10).tr('IVXLC','XLCDM')+
(n%=10;n<4??I*n:n<5?'IV':n<9??V+?I*(n-5):'IX')

The clever twist this time is that the core algorithm now solely works on ones-digit translation but higher magnitudes have the characters used (e.g. I V X) ‘promoted’ up to the right characters for their magnitude on a column by column basis. So I V X in the ones becomes X L C in the tens and becomes C D M in the hundreds with M on its own in the thousands.

After I took a few minutes to interpret what it was doing, I was surprised as I hadn’t seen this approach before. But.. the hard part isn’t coming up with a new algorithm, it’s finding the prior art which almost certainly exists. 😅

An hour of research later, I found a Stack Overflow code golfing contest where someone used a similar technique in a different language and pointed to a SNOBOL book from 1976 (Algorithms in SNOBOL4 by James F. Gimpel) as inspiration. I found the book and here’s the relevant quote:

snobol book image

So there’s our prior art.

I wouldn’t be surprised if that book happened to be in OpenAI’s training data, but I suspect it does not carry much weight, if so. Codex took a lot of time experimenting (with plenty of trial and error in between) to reach this solution and a fresh session expressed incredulity at it and suspected it was a novel solution.

I’ll have to wait another day until I get to proclaim something to be Cooper’s Algorithm. 😭

Level 5: Cheating

This isn’t a real level, but agents will sometimes want to indulge in a little cheating, and you need to be eagle-eyed to it. For example, Codex came up with the idea of creating a separate C program to do the conversion and then just have the Ruby file shell out to it. No. Bad Codex. No.

Sometimes there are golfing opportunities in calling out to universally available programs, but it’s broadly against the spirit of trying to write something in as few lines of Ruby as possible.

Your turn?

So this is an anticlimactic ending, but I hope you enjoyed coming along on my little journey of discovery. The good part is that if you have the right subscription, local models, or an employer’s token budget to use up this week, you can have some fun too.

Find an algorithm, a library, or anything that returns a deterministic output for an input, add some robust tests, then keep asking your agent to make it smaller and smaller. Use /goal if you really have to.

See what happens, you may be surprised!