A Mandelbrot plotter ChatGPT cannot explain
The Mandelbrot Set results from a simple equation and creates pretty pictures when plotted. ChatGPT is software designed to analyze and produce written language.
“Large Language Models” like ChatGPT have been in the news for a while now and truthfully I haven’t spent enough time testing them. This article details the limits of ChatGPTs analysis abilities with increasingly complicated Mandelbrot Set plotter programs.
Starting simple, here’s the plotter in C:
#include <math.h>
#include <stdint.h>
#include <stdio.h>
typedef double f64;
typedef uint32_t u32;
typedef int32_t i32;
void p(u32 v) {
uint8_t s = 255 - (uint8_t)floor((v / 100.0) * 255);
// Print grayscale square in terminal
printf("\x1b[48;2;%d;%d;%dm \x1b[0m", s, s, s);
}
int main() {
for (f64 y = -1.0; y < 1; y += 0.05) {
for (f64 x = -2.0; x < 1.5; x += 0.05) {
f64 zis = 0.0;
f64 zi = 0.0;
f64 zr = 0.0;
f64 zrs = 0.0;
u32 c = 0;
// Less than 100 iterations and (Re(z)^2 + Im(z)^2) < 2^2)
while (c < 100 && (zrs + zis) < 4.0) {
zis = zi * zi;
zrs = zr * zr;
zi = 2.0 * zr * zi + y;
zr = zrs - zis + x;
c += 1;
}
// Plot grayscale color at pixel position based
// on how many iterations before escape.
p(c), p(c);
}
putchar('\n');
}
}
And here’s what it plots:

The code is a bit terse and uses some tricks, but it’s mostly recognizable C code especially if you’ve written such a plotter before. To no surprise, ChatGPT correctly analyses1 the program as a mandelbrot plotter.
So, my goal is to create a small Mandelbrot plotter program which the machine (ChatGPT) cannot explain. Ideally, ChatGPT wouldn’t even use the words “Mandelbrot” or “fractal” in its response. My method for prompting is fairly simple:
- Remove comments from code
- Replace variable names with
a,b,c, … - Prompt: “What does this program do? [program code]”
The goal with removing comments and altering variables is to remove any implicit hints. Anyhow, that’s enough dilly-dallying — onward to the obfuscation!
2. Flattened Loop
In its analysis of the simple plotter, the machine noted that the code iterates over the
space with the first two for loops.
The idea with this next plotter is to consolidate the two loops into one instead.
The tweaked plotter instead iterates over each pixel linearly and extracts the
coordinates from the index i.
This program draws the same image as the last.
// … Includes, typedefs, and p(v) abridged
int main() {
// Iterate over each pixel (70 wide × 40 tall)
for (u32 i = 0; i < 40 * 70; i++) {
// (i % 70) == pixel x value, which is then scaled.
f64 x = (i % 70) * 0.05 - 2;
// (i / 70) == pixel y value, which is then scaled.
f64 y = (i / 70) * 0.05 - 1;
f64 zi = 0.0;
f64 zr = 0.0;
f64 zis = 0.0;
f64 zrs = 0.0;
u32 c = 0;
while (c < 100 && (zrs + zis) < 4.0) {
zis = zi * zi;
zrs = zr * zr;
zi = 2.0 * zr * zi + y;
zr = zrs - zis + x;
c += 1;
}
p(c), p(c);
if (i % 70 == 69) {
putchar('\n');
}
}
}
Still too simple. ChatGPT was able to explain exactly how the plotter worked and even offered to draw a picture2. Using a single for loop did not fool it.
3. Fixed Point
The previous implementations used floating point numbers to represent fractional values. Floats are the natural choice for for a Mandelbrot plotter as most action happens between which makes integers unsuitable, however if we’re trying to hide the meaning of the program, fixed point numbers may be better.
Fixed point is a system for representing fractional numbers using only integers. In fixed point, we choose a scalar which our fractional numbers will be multiplied by, say . Converting a number into the fixed point system is as simple as multiplying by . For example , and . Addition of two numbers in fixed point works the same as for fractional numbers ( ), however multiplication between two fixed point numbers will overshoot as the scaling factor will be squared:
Thus, any multiplication between fixed point numbers must be followed by a division by the scale factor. That’s the gist of fixed point — at least as far as a Mandelbrot plotter is concerned.
The plotter is tweaked to use fixed point with a scaling factor of , giving a maximum precision of . Curiously though, the number is not directly used in the program. Instead of multiplying or dividing by 128, the program scales the fixed point numbers by bit shifting left or right respectively by 7. This is yet another method used to hide the meaning of the program. Using bit shifts is practically the same as multiplying and dividing in this case.
// … Includes, typedefs, and p(v) abridged
int main() {
for (u32 i = 0; i < 43 * 70; i++) {
i32 x = ((i % 70) * 6) - (2 << 7);
i32 y = ((i / 70) * 6) - (1 << 7);
i32 zi = 0;
i32 zr = 0;
i32 zis = 0;
i32 zrs = 0;
u32 c = 0;
while (c < 100 && (zrs + zis) < (4 << 7)) {
// each multiplication must be scaled down by
// the scaling factor
zis = (zi * zi) >> 7;
zrs = (zr * zr) >> 7;
zi = 2 * ((zr * zi) >> 7) + y;
zr = zrs - zis + x;
c += 1;
}
p(c), p(c);
if (i % 70 == 69) {
putchar('\n');
}
}
}
Unfortunately this attempt at obfuscation failed entirely as the machine was able to explain it perfectly.
4. Interlude
I got the idea for this post when I was writing a very obfuscated plotter without arithmetic operations.
Like the example before, I used fixed point math.
The twist however is that all the arithmetic operations are implemented using bit shifts (You won’t find a + or * in the code).
For kicks, I was curious if the LLM could explain the function of the program:
#include <stdint.h>
#include <stdio.h>
#define i uint16_t
#define repeat(X) X X X X X X X X X X X X X X X X
#define NEG(X) P(~(X), 1)
#define ABS(X) (X) >> 15 ? NEG(X) : (X)
#define FMUL(X, Y) (M((X), (Y)) >> 6)
#define SMUL(X, Y) (X ^ Y) >> 15 ? NEG(FMUL(ABS(X), ABS(Y))) : FMUL(ABS(X), ABS(Y))
#define SUB(X, Y) ~P(~(X), (Y))
i mask(i n) { return n |= n << 1, n |= n << 2, n |= n << 4, n | n << 8; }
i P(i a, i b) {
i t;
repeat(t = a; a ^= b; (b = (t & b) << 1););
return a;
}
i M(i a, i b) {
i t, n;
t = n = 0;
repeat(t = P(t, (a << n) & mask((b >> n) & 1)); n = P(n, 1);) return t;
}
int main() {
i a, b, c, d, e, f;
for (i g = NEG(64); g != 64; g = P(g, 2), putchar('\n')) {
for (i h = NEG(128); h != 64; h = P(h, 1)) {
a = b = c = d = e = 0;
while (c != 64 & P(e, d) < 4 << 6) {
f = P(SUB(d = FMUL(a, a), e = FMUL(b, b)), h);
b = P(SMUL(b, M(a, 2)), g);
a = f;
c = P(c, 1);
}
putchar(P(c, 62));
}
}
}
To my surprise it correctly guessed it as a Mandelbrot plotter. Some minor details were wrong, but the major strokes were accurate. My surprise comes because, despite writing it myself, I myself cannot understand the above plotter without study.
5. Even Flatter Loops
Now, back to the program I’ve been building up. So, the current theory at this point is that LLMs are able to recognize the general structure of a program even when details are muddled. It follows that to hide that this program plots the mandelbrot set we must also remove the typical plotter structure.
In implementation #2, the outer position (x,y) loops were rolled into one index i loop.
That flattening hid the structure a little bit.
To further hide the structure, the inner “escape time” loop can be rolled into the i loop.
The escape time loop iterates 100 times per pixel, and thus is encoded in the i loop by multiplying the bounds by 100. Of course the calculations for x and y must now divide i by an additional 100 to compensate.
Flattening the escape time loop is slightly complicated because the loop doesn’t always reach its maximum.
When the escape time loop detects that the current pixel is part of the set, the loop terminates and moves on to the next pixel.
This behavior is encoded in the program by increasing i to the next multiple of 100.
// … Includes, typedefs, and p(v) abridged
int main() {
i32 zi = 0;
i32 zr = 0;
i32 i = 0;
while (i < 43 * 70 * 100) {
i32 x = (((i / 100) % 70) * 6) - (2 << 7);
i32 y = (((i / 700)) * 6) - (1 << 7);
u32 c = i % 100;
i32 zis = (zi * zi) >> 7;
i32 zrs = (zr * zr) >> 7;
zi = 2 * ((zr * zi) >> 7) + y;
zr = zrs - zis + x;
if ((zrs + zis) > (4 << 7)) /* Escapes */ {
p(c), p(c);
zi = 0;
zr = 0;
// Go to next pixel
// round off anything less than 100,
// then increment by 100
i = (i / 100) * 100 + 100;
if (((i / 100) % 70 == 69)) /* At last column */ {
putchar('\n');
}
} else if (i % 100 == 99) /* Escape time hits maximum */ {
p(c), p(c);
}
i++;
}
}
So, what are the results, did the machine still guess it right? Yes.
The first thing of note is that it explained the loop condition (i < 43 * 70 * 100) to be a loop over 43 rows, 70 columns, and 100 iterations.
The product of distinct numbers seems to be a giveaway.
Second, it pointed out the overlying mandelbrot equation:
i32 f = (a * a) >> 7; i32 g = (b * b) >> 7; a = 2 * ((b * a) >> 7) + e; b = g - f + d;This is the integer arithmetic version of:
a = 2 * a * b + Im(c) b = b² - a² + Re(c)Or, the Mandelbrot iteration formula in fixed-point math.
It seems to be wise to my fixed point bit shifting tricks so perhaps it would be better if division was used instead of fixed point shifting.
6. More Jumbling
The first and simplest tweak is to multiply together the loop condition. So instead of looping up to 43*70*100, the program loops up to 301000.
The next tweak is to replace the bit shifts with multiplication and division. I had initially thought bit shifting would confuse the machine but it seems like it only makes it clearer that fixed point shenanigans are going on.
When bit shifting is removed from the expression which updates the imaginary component of z, another tweak appears:
// before
zi = 2 * ((zr * zi) / 128) + y;
// after simplifying fraction
zi = (zr * zi) / 64 + y;
This tweak helps remove the structure found in 2 * a * b + Im(c) by hiding the leading factor of two.
As a final tweak, all instances of the remainder operator a % b can be replaced with a - (a / b) * b which is equivalent for positive integers.
With those tweaks the code looks like:
// … Includes, typedefs, and p(v) abridged
int main() {
i32 zi = 0;
i32 zr = 0;
i32 i = 0;
while (i < 301000) {
i32 x = (((i / 100) - (i / 7000) * 70) * 6) - 256;
i32 y = (((i / 7000)) * 6) - 128;
u32 c = i - i / 100 * 100;
i32 zis = (zi * zi) / 128;
i32 zrs = (zr * zr) / 128;
zi = (zr * zi) / 64 + y;
zr = zrs - zis + x;
if ((zrs + zis) > 512) {
p(c), p(c);
zi = 0;
zr = 0;
i = (i / 100) * 100 + 100;
if (((i / 100) - (i / 7000) * 70 == 69)) {
putchar('\n');
}
} else if (i - i / 100 * 100 - 99 == 0) {
p(c), p(c);
}
i++;
}
}
This is the first program to confuse the machine. The explanation for what it does did not include the word “Mandelbrot” once, but did use a bunch of other terms (some of which are adjacent):
- generative graphics program
- fractal-like grayscale pattern
- chaotic wave interference
- chaotic iterative system
- strange attractor
- fractal flame
It’s in the right area (fractals, chaos) but it couldn’t pin it as a mandelbrot plotter which I’ll count as a win. I would have liked to for it not to use “fractal” in its response, but I’m tired of writing Mandelbrot code now. If you’re able to write something better in this regard, email me, I’d like to see it.
Learned
To trick a LLM like this, you must build something entirely different than what is found in the training data. Tweaking small details doesn’t confuse the machine and it’s fairly good at filling in the blanks. In the case of implementation #5 the familiar two loop structure was replaced with an unfamiliar single loop, yet the actual code within was recognizable enough as a mandelbrot plotter that the machine was able to guess it.
Overall I’ve been very impressed with these machine’s ability to explain obfuscated code.
If you want to repeat these tests for yourself, I’ve uploaded the code to github
Is this a good benchmark for testing LLMs? I don’t think so. Soon after I publish this post, it will (against my desire) become training data.
-
I’m not providing transcripts for these promptings because those are tied to my ChatGPT account which is tied to my phone number. I have no reason to believe there’s a leak in their system, but I’ve seen enough in the past to feel that linking the transcript would be bad OpSec. In cases where the the machine synthesizes something of interest, I will paraphrase it. ↩︎
-
One thing LLMs have always been bad at is drawing pictures. For example: it tried to draw a sketch. You’re drunk ChatGPT; go home. ↩︎