Lesson 1 · Terminology
The Phase Map
You have already built a compiler. This lesson gives you its parts list.
Every question you asked is really the same question: which decision am I making right now? That question is unanswerable without names for the parts. So we start here — not because terminology is important in itself, but because the lessons that follow are unreadable without it.
The good news: you did not build something idiosyncratic. pydantic-codegen has the
canonical shape, arrived at independently.
You already have the load-bearing intuition for why that shape exists. Asked why
omit operates on the IR rather than on the Pydantic class, you said: waist problem —
N inputs and M outputs. That is precisely it, and it is the standard argument, usually drawn as an
hourglass. Without a middle, N readers times M emitters is N×M pieces of code; with one, it's N+M.
IP is the waist of the network stack, LLVM IR the waist of a compiler toolchain, and
ir.Model is yours. Everything in this parts list is a consequence of putting a waist in
the middle and then asking what has to live on each side of it. Here is the general shape:
source ──▶ [ front end ] ──▶ IR ──▶ [ middle end ] ──▶ IR ──▶ [ back end ] ──▶ target
parse, resolve passes / transforms emit
And here is yours:
Pydantic class ──▶ loader.py ──▶ ir.Model ──▶ transformers.py ──▶ ir.Model ──▶ renderer.py ──▶ Python text
module_source.py pipe(…) python_source.py
bindings.py gates.py
The parts list
| Your file | The term | What the term actually means |
|---|---|---|
| module_source.py | front end / parser | Turns text into structure. You don't write a parser — ast.parse is yours. ast.get_source_segment is a source map: a node remembering the span of text it came from. |
| loader.py | lowering | Translating from one representation into a simpler one. You lower Python AST + runtime class objects into ir.Model. Note you have two front ends fused — Lesson 2 is about why. |
| bindings.py | name resolution / scope analysis | Deciding, for each identifier, which declaration it refers to. _bound is a symbol table. Your free_names is exactly the standard term — a free variable is a name used but not bound in the enclosing expression. _bound_names collects binders. |
| ir.py | intermediate representation | The data structure passes agree on. Yours is high-level (HIR): still one node per source-level concept. LLVM IR or JVM bytecode would be low-level. |
| transformers.py | passes | An IR→IR function. pipe is a pass pipeline; in LLVM the thing that runs it is a pass manager. Each of yours is tiny, which has a name — see the last section. |
| gates.py | verifier | A pass that produces no output, only complaints. LLVM's is literally called the verifier. It checks well-formedness: invariants the IR type can't express (no duplicate names, no colliding imports). |
| rejections.py | diagnostics | The set of things you refuse. Collectively they define your supported fragment: the subset of Pydantic this tool is total over. Naming the fragment explicitly is a real design act, not a limitation. |
| renderer.py | back end / emission | Structure back into text. Doing that for a syntax tree specifically is unparsing or pretty-printing. You skip the tree and go straight to strings — that's a choice with consequences (Lesson 3). |
Three names for the whole thing
You'll see all three in the literature and they mean subtly different things:
Source-to-source compiler (or transpiler): input and output are both human-language source. True of you, but it's the weakest claim — it only says where the pipeline starts and ends.
Macro system: user code is the input, and generated code is spliced back
into the same program. This is the family you're in. Lisp macros, Template Haskell, Rust
#[derive]. The Rust one is worth staring at: the compiler hands you a token stream, you
parse it with syn into an AST, transform, and emit with quote. Read →
transform → emit. Your loader / transformers / renderer is the same three boxes with
different names.
Elaboration: filling in code the programmer left implicit. This is the
most precise term for what you actually do. The user wrote a domain model; the FastAPI payload was
always implied by it; you make the implicit explicit. When you get to Lesson 5 and generate a
fake() constructor from nothing but field types, that is elaboration in its purest form
— and the term the literature uses for that particular species is deriving
(Haskell deriving, Rust derive).
loader.py reads the runtime object (importlib.import_module,
cls.__mro__, model_fields, __pydantic_decorators__) and
the source text (module_source, ast.parse). That's
reflection plus static analysis, fused. Most
compilers only have the second; most codegen tools only have the first. You need both because
Pydantic's runtime knows the semantics but has thrown away the syntax you want to re-emit.
It is a legitimate and somewhat rare design. It is also where your subtlest bugs will live,
because the two views can disagree.
Exercise A — Recognition
Click a term, then the file it names. Eight pairs.
Term
Artifact
Exercise B — Recall
No options. Type the term. (Case and plurals don't matter.)
Exercise C — Placement
One judgement call, no auto-grading. Answer it in your head before revealing.
You want to add a check: no generated model may inherit a base that already declares one of the model's own fields.
You have already written this — it's _reject_field_carrying_bases. Which phase
does it belong to, and why is it not in loader.py?
It's a verifier check, and it must run after the
passes, not during loading — because omit() and set_bases() can each create
the violation from a model that was fine when loaded. The general rule: a check belongs at the
last point where the property could still be broken, not the first point where it could be
observed. That's why gates.py is called from writing.py and not from
loader.py. You got this right; the point is to be able to say why.
The term worth going away with
Nanopass. Sarkar, Waddell and Dybvig's argument is that a compiler made
of many single-purpose passes is dramatically easier to understand and extend than one made of a few
big ones — and that the reason people don't do it is boilerplate, not principle. Your
transformers.py is already nanopass-shaped: omit, pick,
partial_none, rename_model each do one thing and compose. You arrived at the
right structure. The
paper is 12 pages and is explicitly written for people learning this.
The half of it you have not adopted is the interesting half: in a real nanopass compiler, each pass declares the IR it consumes and the IR it produces, as a grammar — so the type system proves that a pass which is supposed to eliminate a construct actually eliminated it. You have one IR for all passes. Whether that's a problem is Lesson 3.