← All posts

Understanding Code to Generate Better Code

Building FiboCode made me care more about understanding my own project. Thoughts on natural-language programming, architecture, and working with AI over time.

I don't like the feeling of not understanding my own project.

I came up with the requirements. The features were what I wanted. After AI generated the code, I checked that it worked. But if someone asked which modules a feature passed through, why it was implemented that way, or what another change might affect, I couldn't always give a clear answer.

While building FiboCode, I've come to care more and more about this.

Of course I want AI to write more code for me. But I plan to keep working on this project. Once today's feature is finished, there will be another idea tomorrow. I don't want every change to begin with a prayer that it won't break something else.

Fix A, and B breaks. Fix B, and a new bug appears in A. When that happens, it's easy to keep sending the symptoms back to AI and wait for another explanation and patch.

After a few rounds, though, I would feel less and less sure of things. Each change seems reasonable on its own. Taken together, what has the system actually become?

I want to understand that. Following this question has made me feel that understanding code and generating it are more closely connected than I first thought.

Natural-language programming got me thinking

From machine language to assembly to high-level languages, we have kept adding layers of abstraction.

Many details that we once had to handle ourselves have been handed over to tools. We can express more complex behavior with less code, without starting from the bottom every time.

Now we've moved up another step. We describe requirements in natural language and ask AI to generate the code.

At first glance, it might look like just another way of expressing ourselves. But I think there's a change here that matters.

Assemblers and compilers transform code according to explicit rules. Programs can still have bugs, of course, but the translation process doesn't have to guess what I meant to do.

The model does have to guess.

It uses my words, the code it can see, and the surrounding context to infer what I want, then chooses an implementation. The same sentence may produce different results. Even when the result stays the same, it may consistently implement a requirement I never expressed clearly.

I later came across an essay by Birgitta Böckeler that makes a similar distinction: large language models generate through inference; they can't simply be treated as natural-language compilers. It was close to what I'd been thinking about.

So what can we do?

Clearer requirements, written conventions, and tests for important behavior should all help reduce some mismatches. If a design already explains how modules work together and which classes and methods are involved, AI has more to work from.

The documentation does need to match the code, though. A detailed description of a design that changed long ago can be more troublesome than having no documentation at all.

I don't think these practices can remove all the uncertainty from generation. But they can leave the model with fewer things to guess on the spot.

Do I really know what I want to change?

Following that thought brings me to another problem: people also struggle to explain requirements clearly.

Especially when they don't understand the code.

I might know exactly where I want another button, or which operation should be easier. But I may not know which existing module it belongs in, what processing it needs to pass through, or which old behaviors must stay intact.

In that situation, even if I keep telling AI, "Don't affect other features," how is it supposed to interpret that?

For example, "Make this consistent" could mean making the presentation consistent, or merging the data-processing logic. Those interpretations can lead to very different scopes of change.

If I can't explain the scope myself, the model has to fill it in from what it can see. It may do more than I expected, or bypass a convention I didn't even know existed.

Then I find a problem and ask it to fix it. As changes accumulate, the original request, constraints added along the way, and existing relationships in the code may no longer all fit into the context intact.

I worry about ending up with code that works in isolated places but is full of patches left behind by the previous fix. It looks as though only AI is willing to keep changing it. People hardly dare touch it anymore.

That kind of code makes me think of AI slop. Worse, handing it back to AI doesn't mean the model really understands the whole system either. It can still produce an explanation, but that doesn't mean we finally understand it.

So I'm increasingly convinced that explaining a requirement accurately starts with knowing the broad shape of the project.

I don't need to understand every detail at the outset. But I do need to know where the change I'm asking for will land.

I want to talk to AI at the architecture level

I don't think using AI should require reviewing every generated line by hand.

Once a project becomes complex, attention is limited. I'd rather first understand the framework: the core modules, what each is responsible for, where a feature enters, what processing it passes through, and where it changes data and state.

I'd like to build another layer of understanding above the high-level-language implementation. For now, I'll call it the "architecture layer." I'm not particularly attached to the name.

What matters is that this layer connects back to the actual code.

When I see a module, I can find its implementation. When I see a feature's flow, I know which classes and methods it passes through. When I want to adjust a behavior, I can follow the relationships to the places I need to look at.

That gives me something more specific to discuss with AI.

For instance, if I know an operation already goes through a shared task queue, I can discuss whether a new capability should follow that path. If AI adds another set of state-management logic, I can ask why the existing one won't work, and whether the two might interfere later.

If I never thought about these questions, I might accept any implementation that runs. At least now I have a chance to notice early when it makes things unnecessarily complicated.

I think this kind of comparison can also improve my programming.

There are several ways to implement a feature. Why did this project choose this one? An abstraction may look elegant, but if every change touches many places, is it helping? Another module might look plain, yet several requirements have changed without disturbing anything else. What did it get right?

Those questions are worth spending time on. As I develop my own judgment, it becomes easier to tell AI what should change and to understand a different approach it proposes.

Of course, my understanding of the architecture can be wrong too. Connections on a diagram need to be checked against the code. Features I think are unaffected still need tests and actual runs. When I reach important state changes or complicated boundaries, I can look further into the implementation.

I'd like to gradually reach a point where I usually know where to look, and also know which parts I haven't understood yet.

That's one reason I'm building FiboCode's code analysis feature.

I want to connect code, documents, and relationship diagrams so that people can find the structure in an unfamiliar implementation and follow a feature through it. With that understanding, I'd feel more at ease discussing changes with AI.

In What Is Code?, Unmesh Joshi also discusses how good abstractions, types, tests, and explicit rules can guide a model's understanding and changes. I like that direction: as much as possible, our understanding of the system should stay in the project, ready for the next change.

Some time is still worth spending on reading code

AI writes quickly. It's tempting to hand it the next task too.

Features get finished one after another, and the project seems to keep moving forward. But I also wonder what I've come to understand after all that work.

If I just keep requesting things and accepting results until the project becomes too complicated to control, I may end up spending a lot of time patching it or starting over. The time I saved could disappear, along with model-call costs and the effort of learning a whole implementation again.

I'd rather spend some of that time sitting down with the code.

It doesn't have to be a lot at once. I could start by understanding why a module was divided this way, why a feature needs these steps, or what unfamiliar technique AI used this time. If I don't understand, I can ask, look for evidence in the code, and try a small change to see whether my understanding holds up.

While looking for material, I came across an Anthropic learning experiment. After completing unfamiliar programming tasks with AI, participants scored lower on an immediate quiz on average than the group coding by hand. This small experiment can't tell us how skills will develop over the long term. But it made me care more about whether I actually learned something after finishing a task.

I hope that understanding can build up gradually. The next time I encounter a similar design, I might recognize what's going on. When AI proposes a solution, I might have a question grounded in what I know.

Whether FiboCode can do this well still needs exploration. But this is a large part of why I want to build code analysis.

I want to keep writing code with AI. And as the project grows, I want to still be able to explain how it works.


A simple picture of the idea:

Some other related reading