Last updated: 2026-10-02
Chapter 13: Twenty-Three Phases, Three Corrections
For new readers
This is one instalment in an ongoing, chronological diary of building PatLang, written up in numbered "Acts" as the project actually happened, warts included. You don't need to have read the earlier instalments to follow this one. The short version of where it picks up: PatLang's native x64 backend had just become the real default, and a short run of architecture and verification work followed. This instalment covers a four-day stretch after that, where the project replaced its own execution model from the ground up — in twenty-three explicitly tracked phases — and then switched the compiler's default path over to it.
A direct continuation of the previous instalment, this arc turns from verifying what already existed to replacing how PatLang programs run at all. "Block Ownership Model" is the name the project gave its new execution engine: blocks and jumps as the literal runtime mechanism, a refcounted heap doing both ownership-checking and reclamation, and real free-variable analysis instead of wholesale closure capture. What makes this arc worth a chapter of its own isn't its size, although twenty-three phases across four days is genuinely large. It's a pattern that hadn't shown up cleanly in this history before: catching a wrong design assumption before building on top of it, not after.
Act LXXIII: a plan that corrected itself three times before it was finished
The design day came first, and it was a real design day, not a formality before coding started: five parallel forks of review (labelled A through E in the project's own notes) converged on five separate decisions — block/jump execution as the literal mechanism, full elimination of ambient global state, a hybrid static/runtime check for exclusive mutation, refcounting for both ownership and reclamation, and free-variable analysis in place of capturing a closure's entire environment wholesale. The resulting plan broke into nine phases, built self-hosted-first in a parallel tree, gated at every step by real Gherkin feature files rather than ad hoc checks.
Phases 0 through 9 ran in under a day: a standalone refcounted heap, a minimal block/jump execution skeleton, real mut parameters, the ambient-state elimination, loops rewritten as back-edge blocks, free-variable analysis for real, debugger parity, and — last — native codegen for the new engine's own instructions. The honest reporting that this series keeps returning to showed up again here, in miniature: an attempted optimisation (indexing locals by slot instead of scanning for them) was measured, found to make the native case about 8% slower than the scan it was meant to replace, and reverted outright rather than kept on the theory that it should have helped. Unlike that one, a second attempt — hashing each opcode and operator to an integer once, at lowering time, instead of comparing strings on every single instruction executed — measured a real 2.8x speedup, confirmed stable across three separate runs, and was kept. Both the kept change and the discarded one are recorded in the project's own report; a negative result got written down with the same care as a positive one.
Phases 10 through 22 then carried the new engine from a working skeleton to full language coverage: event dispatch, pattern matching, contracts, objects, cooperative fibers, logic-programming facts, numeric-tower completeness, and system integration, each phase landing as its own commit with its own completion note. None of that stretch is where this chapter's real lesson sits, though — three specific plan revisions are.
Before any native-codegen code was written for Phase 19, direct verification — grepping the actual runtime source rather than trusting an earlier note — found that two of its starting assumptions were simply wrong: several operations assumed to already compile natively had zero native support at all, and a heap module assumed to be already linked into the real build driver wasn't. The phase's scope was rewritten around what the codebase actually contained, before a single instruction of wrong-shaped code got written against it. Phase 21 corrected its own design the same way, a day later: tracing how the interpreter's execution model actually worked — rather than designing from the native backend's constraints and assuming the interpreter needed the same treatment — found that a function call with return needed no new analysis there at all, just an ordinary recursive call within the interpreter's existing loop. Tracing that same execution model also turned up a real, already-shipped bug nobody had gone looking for: a handler body's own while loop was being silently rejected as unsupported, fixed as part of the same pass rather than filed separately. Phase 22 corrected itself in the other direction — downgrading a problem, not escalating one. A gap in member-access lowering had been logged as a large, undecided design problem needing a new mechanism; checking the existing compiler pipeline first found it had already solved exactly this, and checking the native memory layout directly found the length information it needed was already there. What shipped was the narrow fix the codebase already supported, not the new mechanism the plan had assumed it would need.
Put the three next to each other and the shared discipline is plain: every one of them was a correction made by going and checking, before implementation started, rather than after a build failed or a test caught something wrong. None of the three ran a single test to find what they found; each one was static testing in the strict sense — reading the actual code and acting on what it said, rather than running anything and waiting to see what came back. That's a different and in some ways more demanding habit than the one the seventh instalment's "Fixes That Weren't" documents — finding and re-finding a bug that had already shipped. Here, nothing had shipped yet. The mistake that got caught was a plan, not a program, and no dynamic test could have reached it, because there was no running code yet for a dynamic test to run.reading code before trusting an assumption about it: static testing, not a test run
The phases themselves closed with the cutover: patc1 --x64 switched to lowering through the new engine by default, with explicit sign-off from the project's owner on a branch that could still be reverted if it went wrong. Proving the switch found one more real bug on the way through — two sequential loops at a program's top level compiled into an infinite loop, because the array order a post-order pass had always produced didn't match the order the program actually executed in; harmless for the interpreter, which follows jumps by name regardless of array position, and silently wrong for native code falling through between physically adjacent blocks. Every block now gets its own explicit terminator, so adjacency is never load-bearing again. Measured against the full self-test suite, the new default passed strictly more than the old one, with zero regressions.
What followed in the days after was a short, familiar tail: a handful of correctness bugs in arithmetic on BigInt and Rational values, structural list-equality and persistence fixes, and a run of float/boxing edge cases closed one boundary at a time. One of them is worth naming on its own: a compile of a trivial program that kept getting slower the longer the project's own source files grew, traced to a hashing routine that re-checked an entire string's encoding on every single character it read — the exact cost this project's own documentation already warns PatLang string indexing can hide, showing up for a fourth time in this history after first appearing, in a different routine, two chapters of this diary back. The epic tracking this engine's full-language rollout is close to finished as this chapter is written, with one piece — running a spawned thread from a block-model closure directly, rather than through the runtime's own closure type — named, scoped, and left for its own follow-up rather than quietly folded in here.
Lessons from this arc, the short version
- Catching a wrong assumption before you build on it is a different skill than catching a bug after you ship it. Three separate plan revisions here were made by checking the actual codebase before writing code against an assumption about it — not by a test failing afterward.
- A negative result is worth recording as carefully as a positive one. A reverted optimisation, measured to make things worse, was written down next to the change that was kept — the discipline is reporting what was actually measured, not just what worked.
- Design two things from how they actually work, not from how one of them is constrained. Assuming the interpreter needed the native backend's own classification step — rather than tracing what the interpreter's loop actually does — would have added analysis it never needed.
- Checking whether a problem is already solved is itself real investigative work, not a shortcut. A gap logged as a large open design question turned out to already be handled elsewhere in the pipeline; downgrading severity correctly took as much verification as escalating it would have.
- Array order and execution order are not the same thing, and code that only works when they happen to match will eventually meet a layout where they don't. Two adjacent loops exposed an ordering assumption that had been silently true by luck until then.
- A quietly recurring cost is worth naming as a pattern once you've seen it a third time, not treated as a fresh, unrelated bug each time it resurfaces in a new routine.
References: What the Literature Already Knew
None of the three items below were consulted while this arc's plan was being corrected — each correction was made by checking the actual code, not by recalling a principle first. They're listed here afterward because a well-known finding or a named technique already had the shape of what this arc kept rediscovering in practice.
- Three plan revisions, each made before a line of the affected code was written — the general argument for why this is worth the trouble is Boehm's widely-cited finding that a defect caught before implementation costs a small fraction of what the same defect costs once it has been built on top of and shipped1.
- An optimisation attempt measured, found to make things worse, and reverted rather than kept on faith — Knuth's famous caution against optimising before measuring is the direct precedent: most of a program's cost lives in a small fraction of it, and guessing which fraction without profiling is exactly the mistake a measured, reverted attempt avoided here2.
- Switching a default compiler path behind an explicit flag, kept revertible, with the old path left in place rather than deleted — this is close to a textbook feature toggle: a reversible, flagged cutover that lets a team ship a large change gradually and back out of it cheaply if it goes wrong, named and catalogued well after teams had already been doing it out of necessity3.
Boehm, B. W. (1981). Software Engineering Economics. Prentice-Hall. ↩
Knuth, D. E. (1974). Structured programming with go to statements. ACM Computing Surveys, 6(4), 261–301. https://doi.org/10.1145/356635.356640 ↩
Hodgson, P. (2017). Feature toggles (aka feature flags). martinfowler.com. https://martinfowler.com/articles/feature-toggles.html ↩
See also
The Journey of Building PatLang (Acts I-VI) through Retiring the Last External Dependency (Acts LXVI-LXVII) cover everything before this chapter's own window; The Switch Exposed Everything and What Design Review Doesn't Catch cover the stretch directly between that chapter and this one. The seventh instalment's "Fixes That Weren't" is the clearest earlier contrast to this chapter's own pattern of catching mistakes before they ship rather than after. Risk Management: Lessons from Testing Real Systems is where this site develops the static-vs-dynamic testing distinction this chapter's three corrections are an example of, in general terms rather than this one project's own; its static-review section makes the same argument this chapter makes from a single arc: reading code before trusting an assumption about it is a form of testing, not a substitute for one. The Block Ownership Model's own source lives under self_hosting/block_model/, documented design-doc-first in docs/plans/.