This is a follow-up essay to my previous one about Empathy and Debuggers, where I confessed to my preference for empathically understanding the code rather than for line-oriented debugging.
Here, I would like to expand that slightly mystic notion of empathic understanding with a more exact one of mental models and share a few tips on how I build and update them.
Mental Models for Fixing Bugs
Troubleshooting why an alert has appeared, why tests started to fail after you did a change, why a command is failing for your colleague yet works for you — those are very often solved by one small change at a single place, and the difficulty lies in finding that place and that change. My usual strategy to find it is to consult the mental model I have for the pipeline/code/command in question:
- What should be the behaviour, under given circumstances?
- What circumstances would lead to the observed behaviour?
I don’t usually have an answer for both questions — one is sufficient. The first question leads to an iteration — if the behaviour suggested by my model does not agree with reality, I suspect a bug in the code. I decompose the pipeline/code/command into smaller pieces, each with its own, more detailed model, and repeat the procedure for those until I find the misbehaving one(s). Eventually, I get to the point where I just directly compare the model with the code, ideally a few lines at this stage, and identify the culprit. The second question, on the other hand, suggests that the bug is in the parameters the user provided, in the external response, in the infrastructure, etc. So I instead move away to the parameters/external service/infrastructure and investigate them.
This strategy relies on a few assumptions:
- I have a mental model for each component,
- evaluating this model can be done instantly, just in my head,
- I can decompose the model into sub-models (of sub-components),
- I can evaluate whether the model agrees with the actual code — such as by observing the logs or executing the code locally,
- my model captures the intention of the component.
There is, of course, the “trivial model”, of reading the code line by line, or executing a debugger — but I don’t consider it a legitimate model, because it is neither fast nor capturing the intention of the component! The latter part is very important — if there is a bug in the code, in the form of badly implemented business logic, then you just check that the code behaves as written, not as intended. In other words, such a model is for evaluating whether compilers/interpreters work.
Forming and Updating Mental Models
It may happen that it is impossible/cumbersome to form a mental model for a piece of code due to unclear or overcomplicated intentions. That, I believe, similar to an “I don’t know how to name this function”, suggests a poor design. Facing such situations, if re-architecting is not an option, one should at least write as many business logic tests as possible, to have an explicit instead of a mental model. The intention of code is ideally captured in some form such as specification, documentation, and readme — in a way that allows a quick refresh as well as gradual improvements and discussion between contributors.
Forming a mental model is not a one-off activity, due to programming not being a one-off activity. It is usually easy to set up and update the model when one is the sole author of the code. When working in a team, I follow these strategies:
- I read the description of every PR, even if I don’t plan to review it in detail or look at the code. If I don’t understand the description, or it is missing, I ask for clarification.
- I try to block code changes, ideally, when they are just proposed, as that would make the intention too complicated or hard to model.
- I participate in a number of troubleshooting, assistances, and PRs — that allow me to test my models, and update them. No rabbit hole is safe from me when I get this “wait this is weird” feeling.
This applies to all repositories I’m already familiar with and watch. When a new repository is for me to embrace, I additionally:
- play with it — if it is a tool, I just try to execute it locally, bring it to crash (my fav), use it in easy cases, etc,
- improve the documentation — ideally, having it reviewed by someone already knowledgeable about it (such people are usually terrible documentation writers but great reviewers),
- read the code (whoa!), at least what seems to be the most important pieces.
Lastly, of course, development and refactoring are great for both forming and updating mental models — as long as you do it consciously. The initial design phase, and the concluding review/QA phase, are prime opportunities for comparing your model with others. This is especially worthwhile with the non-developers — the product managers, the designers, and the testers. They have their own mental models, rely on them even more than I do, and their models are in some way more important than mine!
Mental Model over Code
Yuval Hariri, in the book Sapiens, poses an intriguing question — What is Peugeot? Is it the legal entity, the employees, the inventory, the existing cars made and sold, the copyright to the logo, the stock, the stakeholders, the Wikipedia page…? He calls Peugeot a kind of multifaceted social fiction — since even if you’d destroy every physical (the cars, the inventory) or legal entity, it would still be likely that it would naturally revive. I apply a similar pattern of thought on mental model vs the code itself, to claim that the model is, in some ways, more important and durable.
When I have a good model, I never need to look at the code when I want to use the executable, and even if I lost the binary and the code, I could re-implement it from the model. The best such example I know of is the Unix command line tools (wc, sed, cut, tr, join, comm, diff, sort, …). I never viewed the code of even a single of them, I use them a lot, and I have a perfect model of what they do — since they are so simply designed and have good and available docs. This is the ideal I strive for with my code.
The case of losing the code is of course not a regular occurrence, but refactoring/evolving is — and I like to do that without reading the code, or at least, extensively so. Sometimes, you don’t lose the code but lose the original author — when I have to take ownership of a code from a colleague who is leaving and I can interrogate them only for a limited time, I’m keener on obtaining a good model, as opposed to just detailed understanding of the code. And once they are gone, I often rewrite the thing completely anyway.
Refactoring/rewriting without reverse engineering saves you time because of all the not-needed-anymore legacy hacks and cruft. Similarly, when you revisit a code you wrote very long ago (for me that means last week), you realise that your memories and code affinity are gone as well.
Documentation and specification may save you if you wrote them well. What I find more reliable is the actual technical contract — in the case of backend services, it is the API it exposes, the API of other services it consumes, and its config files.
In the case of data pipelines, it is perhaps less standard, but I view as the API analogy the tables it reads/writes, the schemata or models it operates with, and the config files as well. I try to expose a lot in config files — not always due to ever wanting to configure such behaviour, but to emphasize that this is what the business logic is doing. For example, say I have a pipeline which does basically a left join from table1 and table2 — then I want my config to contain the two table paths, a field like “algorithm: left_join”, a field for which key to join on, etc, even if those would for the whole history ever allow a single value.
I like to use tools like pandera as an internal contract documenter — knowing what columns a function requires and what it expects of them refreshes my mental model well. I view this as more important than the case of actual validation which Pandera was intended for — but the fact that it does validate is crucial to ensure this contract stays up-to-date!
Similarly, config files for me are not just the actual content of the files, but also the schema. Every data pipeline I have exposes a single e.g. pydantic the class that nests all the other config subclasses, and is read and validated fully at the start. No hidden dependencies on environment variables later on, no undocumented json/cfg file parsing, … And this class is probably the closest thing to an explicit mental model I can produce.
I also like to think in SQL as I have a good mental model of it already — if I can document a pipeline via “it is basically a join, even though the actual query would be too complicated to write down”, or if I can express part of it as a query — I do so because it usually reads faster than code or even natural language description.
Conclusion
We should not view software engineering as a task of producing code, but rather, as manipulations of various representations of ideas. Modelling has always been a discipline of capturing the important relations of the real world in a compressed and compact form, and programming is a particular case of that.
To paraphrase Wittgenstein — what can be implemented at all can be modelled clearly, and whereof one cannot reason thereof one’s keyboard must stay silent.
Understanding that there is not only the physical entity of code, but multiple virtual or semi-physical entities of models, makes us better at creating useful, evolvable and maintainable programs.
I’m not mentioning anyhow LLMs here — it is left as an exercise for the reader on how they nicely fit into this picture. In the future, at this stage hypothetical, follow-up post, I’ll cast all these ideas in their natural habitat, the frameworks of information theory and category theory.
Mental Models, Programming Fictions, and Wittgenstein was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.