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 fileThe termWhat the term actually means
module_source.pyfront 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.pylowering 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.pyname 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.pyintermediate 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.pypasses 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.pyverifier 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.pydiagnostics 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.pyback 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).

The one thing that is unusual about your design. You have two front ends running at once. 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?


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.


status · resources · mission