The Journey of Building PatLang, Continued: Teaching It to Write Itself
For new readers
This is the 2nd instalment in a chronological development diary — it tells the story of how PatLang was built, warts included, rather than documenting what it currently does (see Capabilities & Honest Limitations for that). The plain-English gist of this instalment: teaching the language to induce its own rule-based code from worked examples, a genuinely new capability layered on everything from the first instalment. Read that one first if you haven't already.
A direct continuation of The Journey of Building PatLang (Acts I-VI: the one-day MVP sprint through the self-hosting fixpoint through four "PatLang first" features) — split into its own page because this arc, on its own, ended up as long as everything before it combined. Acts VII through XIV cover a run of genuinely different work: teaching the language to write PatLang code from examples and testing that against a real external project (VII-IX), a structural bug fixed six times before being fixed once (X), closing the language's last named induction gap alongside compiler self-diagnosis (XI), hunting down two genuinely flaky tests (XII), composing processes that don't know about each other yet (XIII), and a third independent grammar validator, a long-dormant goal-oriented paradigm brought back to real life, and an honest benchmark regression that turned directly into a real, measured performance fix (XIV). The next instalment picks up from here — a misattributed bug found while removing a mandatory statement separator, hosting PatLang inside an unrelated C# application for the first time, a real native x64 code generator reaching (and then debugging) its own self-compile fixpoint, and a three-act performance saga that started with this very site's own build pipeline and ended with a from-scratch architectural fix, not just another patch. Same warts-included approach, same commit-history sourcing, same house style as the main page — see there for the full framing and for what these lessons draw on before this page picks up.
Act VII: teaching the language to write itself from examples
The most recent stretch layered something genuinely new on top of everything before it: an inductive logic programming (ILP) system, built entirely in PatLang, that reads BDD-style Given/When/Then scenarios and induces real, compilable PatLang rule clauses from them — see the full write-up for worked examples. What makes this act worth its own entry isn't the ILP theory (Metagol-style metarule search, bottom-up witness search, anti-unification by longest-common-prefix — all standard technique) but the shape of the five-milestone build that got there, and one late course-correction that's its own lesson.
Each milestone was scoped as its own working, tested increment — the same staging discipline from Act I, applied to a much more abstract problem: flat classification (group examples by output label), then a fixed recursive metarule (reproducing a hand-written dependency-graph rule from example queries), then arbitrary conjunctions with distractor rejection, then multi-hop relational chains (the classic ILP "grandparent" benchmark), then finally bottom-up evidence-driven induction that can diagnose why it failed, not just that it failed. Every milestone was proven against a real demo already in the codebase, not just synthetic data — reproducing the router DSL demo's routing table caught a real bug the toy data never could:
Found a real gotcha in the native A1 resolver: any argument string matching
^[A-Z] is treated as a Prolog logic VARIABLE, not a ground constant.
Request strings like "GET /users" start with an uppercase letter, so they
silently unified with EVERYTHING -- the compiled router always returned
the first category, no matter the input. The toy digit corpus ("1", "5",
"9") never hit this because digits never match ^[A-Z].
Lesson: a toy corpus and a real corpus test different things. The digit classifier proved the mechanism worked; only pointing the same mechanism at real data (HTTP verbs, which are conventionally uppercase) surfaced a genuine bug in a resolution engine that had already shipped and passed its own tests. If a system is meant to generalize, testing it on data drawn from an actual domain — not just data invented to be easy — belongs in the suite from early on, not as an afterthought.
One test design mistake is worth naming honestly, because it's a useful example of a test author's intuition being wrong in an instructive way. A "conflict" scenario meant to prove the engine correctly rejects an over-general rule used a negative example one relation-hop shorter than the positive examples, expecting the shorter chain to wrongly pass. It didn't — the anti-unification step correctly generalized to the shorter, genuinely shared chain length instead, which was the right answer, not a bug. The test had to be redesigned around a negative example with the exact same witnessed structure as the positives (standing in for a real-world distinction the given facts simply can't express) to produce a genuine conflict. Lesson: when a test's failure mode surprises you, check whether the system found a better answer than the one you assumed was correct before assuming the system is wrong.
The act closes with a small but deliberate constraint, applied after the fact: the whole eleven-suite selftest corpus had been wired into cargo test via the same cucumber harness Act VI's browser-layer bugs were caught by — appropriate there, since those bugs lived at a real Rust/WASM/DOM boundary, but this system has no such boundary; it's PatLang calling PatLang throughout, with the base interpreter as its only real dependency. The suite was pulled back out of cucumber and given a small PatLang-native runner instead (self_hosting/tools/run_synthesis_selftests.patlang) that reproduces the exact same check — process succeeded, stdout says "ALL TESTS PASSED" — using nothing but exec_capture and the base pat runtime. Lesson: a test harness should depend on exactly the boundary it's actually testing across, not the toolchain that happened to be convenient to wire up first. Act VI's four features genuinely needed a Rust/JS/WASM harness because their real bugs lived at that boundary; a system that never leaves PatLang doesn't, and carrying the heavier dependency anyway is a cost with no corresponding benefit.
The write-up's own honest "current limits" section became the next milestone's spec: the bottom-up witness search took the first matching background fact at every relation hop, so a fact graph with genuine branching — one parent with two children, say — only ever explored whichever child's fact happened to be declared first. The fix (explore every witnessed branch, anti-unify across each example's full set of witnessed chains rather than a single greedily-chosen one) came with its own new failure mode worth naming as a diagnosis in its own right, not lumped into an existing one: no_common_structure, for when every example has real evidence but that evidence never agrees on a shape — distinct from a missing fact (no_witness) or a hypothesis that mismatches one example (conflict). The regression test built to prove the fix does something is itself worth noting: a family tree where the dead-end branch is declared before the real one, specifically so a first-match-only search would have committed to the wrong branch and failed the test — proof the fix isn't just plausible-looking code, but code that changes the answer on a case constructed to require it. Lesson: a published list of honest limitations isn't just a disclaimer — treated as a live backlog, it's a ready-made source of well-scoped next milestones, each with its own natural regression test already implied by the limitation's own description.
The next item on that same limitations list turned out to be the deepest change in the whole arc: replacing a fixed library of separately hard-coded clause-shape strategies — plain conjunctions, relation chains, each blind to the other's existence — with one genuinely general representation both could go through. The tell that the two were really separate all along was a rule neither could express alone: "a grandparent whose grandchild is young" needs a relation chain and a condition on where it ends, and milestone 3 (conjunctions only) and milestones 4-6 (chains only) each covered exactly one half. The fix generalized the clause body into a real literal list and replaced "keep the common prefix of a predicate-name sequence" with actual anti-unification: at every node visited along a witnessed chain, not just its endpoint, record every fact true there, then intersect that evidence across examples the same way. A predicate survives into the induced rule only if it held at the same position for every example merged — proven not just at the chain's endpoint but at an intermediate node too, where a distractor fact on one example's midpoint got correctly dropped for not appearing at the other's. Both earlier, narrower cases still worked identically once run through the general engine — nothing was lost collapsing two special cases into one general one, which is usually the actual test of whether a generalization was a genuine simplification or just a rewrite. Lesson: when a system has grown two or more separately-implemented special cases that could never combine, look for the rule that neither one can express alone — it's usually the sharpest test of whether they were ever truly separate problems, or one problem that hadn't been named yet.
The third item on that limitations list was the one case where the honest answer wasn't "generalize the representation further" — it was "some concepts genuinely shouldn't be generalized toward each other at all." A predicate true for two structurally unrelated reasons (eligible as a veteran, or as an enrolled student, sharing no evidence whatsoever) has no single anti-unified structure to converge on, and forcing one anyway would silently invent a shared condition that doesn't exist. The fix didn't touch the anti-unification engine at all — it wrapped it with a clustering step: group examples whose evidence does share structure, open a new group only when one can't join an existing one, then emit one clause per group. The insight that made this cheap rather than a second resolution engine: multiple clauses for the same predicate head are already ordinary logical disjunction in the underlying resolver — the entire feature is "emit N clauses instead of always exactly 1," nothing more exotic. The regression test worth naming here inverted the usual shape: rather than checking the new capability fires, it checked that examples which do share real structure still collapse into one clause, proving the fix only splits when a split is actually needed rather than fragmenting every induction into unnecessary pieces. Lesson: when a generalization mechanism keeps failing on a specific category of case, check whether the fix is "generalize harder" or "stop trying to generalize these together at all" — treating every failure as evidence the representation needs to grow can miss the simpler fix of admitting some things are genuinely disjoint.
The fourth and final item asked to be tackled specifically alongside a second, separate one — the user's own hunch, stated as a question rather than an instruction: "search-cost scaling and clustering are related, can we aim for a better clustering at the same time?" The literal scaling problem was real: every candidate hypothesis needed its own subprocess, because the resolver has no way to un-register a rule once tried. But the fix wasn't to give it one — it was noticing that several different hypotheses could safely share one process anyway, as long as each used its own uniquely-named head predicate, since resolution only ever follows rules for the predicate actually asked about. That single trick — batch several candidates into one script, one subprocess spawn instead of many — is what turned the previous item's greedy, order-dependent clustering into something worth improving at all: a second, order-insensitive clustering strategy was cheap to add only because comparing it against the original no longer cost a second process spawn. Proving the comparison actually did something required resisting the temptation to hand-pick a favorable example — an actual probing run against real code turned up a domain where the new order-insensitive strategy did worse (four clauses against the original's two), which became the regression test: not "does the new strategy win," but "does the system correctly keep the better of the two regardless of which one that turns out to be." Lesson: when a user connects two problems that look separate, take the connection seriously before assuming it's coincidental — here, the thing that made the second problem worth solving well was solving the first problem first. And when testing a "pick the better of two" mechanism, don't test it on a case you already know favors your new option — go find, empirically, a case where the new option loses, and confirm the mechanism still makes the right call.
Act VIII: throwing it at a real project instead of another toy
Every milestone in Act VII, including the four "current limits" that got closed one by one, was proven against either a purpose-built toy corpus or a real demo that already lived inside PatLang's own codebase — a fair test of the mechanism, but not a fair test of what happens when the input is genuinely someone else's mess. The next step was explicit about that gap: "I suspect we need to throw it at some much bigger problems, and will find some bugs crawl out of the woodwork as we do so." The chosen target was an entirely separate, pre-existing project — a Ruby fantasy-population simulator with no meaningful test suite of its own, so its source code was the only honest record of what it actually did, not what it was supposed to do.
The first pass wasn't code at all — it was reverse-engineering. A research pass over the Ruby surfaced real, concrete bugs: two different relatedness calculations in the same codebase that disagree with each other (one correctly special-cases siblings, the other doesn't and reports them as "0th cousin"); a self-as-sibling bug traced to a hash being subtracted from an array of integer IDs, a type mismatch that silently does nothing; a marriage routine that randomly samples one candidate but proposes to a different one. None of this was guessed at — it came from reading the actual code, the same discipline the "toy vs real corpus" lesson from earlier in this history already argued for, just applied to someone else's project this time instead of PatLang's own router demo.
Then came the part that mattered most: writing BDD requirements for the *corrected*, intended behavior and handing them to the induction engine — and finding, before a single line of new engine code was written, that the engine structurally could not express two of the three things the domain actually needed. Not "hasn't been tried yet" — genuinely couldn't, by construction. A family-tree sibling relationship needs two people to each independently trace a path back to a shared ancestor; every chain-search function built across four milestones only ever walked outward from one starting point. Digging one level further revealed an even more basic gap underneath that one: no chain-based relation exposed its endpoint as a real second argument at all — every induced chain could only prove "X reaches *something*," never bind what that something actually was. The "easy" case and the "hard" case turned out to share the same root cause, which only became visible by trying to build the hard case and watching the easy case fail too.
Rather than force a bad induction attempt at a shape the engine couldn't hold, the honest move was to scope down to what genuinely was inducible (an existential "has *some* grandchild" predicate, a standalone eligibility check with no cross-person comparison), ship that, and write the blocked capabilities up as named, numbered gaps — the same "limitations list as a live backlog" habit from earlier in Act VII, just applied one level deeper: two new milestones (binding a chain's endpoint as a real second argument; searching outward from two points at once to find where they meet) closed two of the three gaps on the first real attempt at each, reusing the machinery each earlier fix had already built rather than starting over. Lesson: when a domain feels obviously too hard for a system to express, don't assume the system just needs to try harder — see whether the domain is quietly asking for a structural capability that was never actually built, and whether the "hard" version of that capability and an "easy" version you thought already worked share the same missing piece.
One gap was deliberately left open, and demonstrated rather than hidden: the newly-induced sibling relation has no way to say "these must be two different people," because the engine still has no equality or inequality comparison between two entities at all. The regression test doesn't pretend this away — it explicitly queries the induced rule with the same individual for both arguments and asserts that it wrongly succeeds, a passing test whose entire purpose is to prove a known limitation is real and unaddressed, not to prove the code works. That's a different use of a test than every other one in this history: not "does this do what it should," but "does this honestly fail exactly where we said it would."
A correction arrived mid-session, from the user, not from the code, and reshaped the very domain that had just been modeled: marriage across social classes and between people of the same sex were treated as hard exclusions in the first pass, and both are actually meant to be *possible, just less common* — a probability, not a rule. That distinction matters structurally: a single example can confirm or refute a boolean rule, but it says nothing about a probability. The fix was a small, separate piece of testing infrastructure — run an implementation many times, and check that the observed success rate falls inside a statistically-sized tolerance band around the declared probability, rather than asserting one outcome. Kept deliberately apart from the induction engine itself: this doesn't derive a probability from data, it verifies that a human-declared probability and an implementation's actual behavior agree.
The chapter closed with a small but telling fix, prompted by simply trying to use the finished engine the way an outside project actually would: every hypothesis-testing function called its own interpreter via a path relative to the current working directory, so the engine only worked correctly by accident of which directory a script happened to be run from. It had never been run from anywhere else before, so the bug had never had a chance to matter. Lesson: a capability that has only ever been exercised from inside its own project hasn't really been tested as a capability *other* projects can use — the moment something is meant to be a reusable engine rather than a self-contained demo, actually calling it from somewhere else, even once, is worth doing before assuming it's ready.
Act IX: fantpop-patlang becomes a real target, not just a stress test
The fantpop-patlang stress test earned its keep so thoroughly it stopped being a stress test and became a real, ongoing application in its own right — the user's actual goal all along, it turned out: simulate a population across potentially thousands of generations, track full family trees and life-event history, and eventually let a caller query the living population for a character to play, complete with real family history. Development continued the same way the engine itself was built: small, single BDD-scoped milestones, checked in on before moving to the next one.
The first milestone — a per-individual life-event ledger, recording birth/death/marriage/migration — was deliberately hand-authored rather than induced, and said so plainly in its own BDD file: "at most one birth event" is a counting constraint, "chronological order" is a numeric-comparison constraint, and the induction engine's equality-only unification can express neither. Not every capability needs to come from the new machinery; naming which ones don't, and why, is as much a part of the discipline as building the machinery itself.
A small suggestion arrived mid-session that turned into the most productive detour of the whole arc: swap the event ledger's storage from an in-memory list to the real SQL engine built in an earlier session, since the actual target ("hundreds or thousands of generations") needs data that survives past one process. The immediate payoff was one already-established habit paying for itself again: the existing BDD acceptance suite, unchanged, became the regression test for the storage swap — and it failed on the first run. The cause: this SQL engine's tables are backed by a persistent virtual filesystem, not scoped to the handle a caller passes around, so "start fresh" doesn't actually clear anything unless you know to ask it to. A one-line fix, found only because a real test was pointed at real (if still small) behavior instead of being trusted to just work.
Testing "at scale" for the first time — something the user flagged as genuinely untested territory before it was tried — surfaced something much bigger: a database whose INSERT silently rewrote its ENTIRE table on every single row added. Invisible at demo scale (a handful of rows), catastrophic at real scale (a planned few-thousand-row test simply never finished). The underlying cause traced all the way down to the virtual filesystem itself, which had a way to replace a file's whole contents but no way to add to it — so a proper fix meant adding a genuine new primitive to the runtime, not just patching the SQL layer on top of what it already had, then routing the common case through it. Confirmed by direct before-and-after timing rather than assumed: a batch of inserts that had taken seconds before completed in a fraction of that afterward, with the growth curve visibly flattening from a curve to a line. A second, structurally identical bug turned up one layer up, in the newly-SQL-backed application code itself — a duplicate-check query run before every insert, silently re-introducing the same quadratic cost the database-level fix had just removed, fixed with a lightweight index kept outside the database entirely rather than by trying to add real indexing to the database in the same pass. Lesson: a performance bug that's invisible at demo scale and catastrophic at real scale is not rare — it's the default outcome of never having tested at real scale, and the fix for one instance of "no indexing" doesn't mean every instance of the same root cause has been found; the same shape of bug reappeared one layer up in code written specifically to fix the first one.
Not yet started, named openly rather than left implicit: whether the next milestone (a genuinely threaded, multi-generation simulation loop) can lean on the induction engine at all is an open question, not a settled one — the user pushed back directly on an assumption that it couldn't, pointing out that BDD requirements about thread safety and result collation might make it inducible after all. Worth taking seriously when that milestone actually starts, rather than resolving by assumption in either direction ahead of time.
Act X: the same bug, fixed six times, before finally being fixed once
A pattern kept recurring while building out fantpop-patlang's simulation code: list_push/list_set silently cloning the entire backing collection on every call, an O(n) cost hiding inside what looked like an O(1) operation. It was found and individually worked around six separate times across six different functions in one session, each time by routing that one call site through a separate handle-based vector primitive instead of asking why the cheap-looking operation wasn't actually cheap. The sixth fix was the one that finally prompted the right question instead of the sixth patch.
Reading the interpreter's value representation showed the bug was never really about list_push/list_set specifically — the language's list type derived a plain deep-copying clone, so every clone of a list value, including an ordinary variable read in the tree-walking interpreter, paid the full copy cost. The six individually-patched call sites were just the six places that happened to build a list big enough, often enough, for the cost to be noticed. Lesson: fixing the same shape of bug more than once or twice in a session is itself a signal — worth stopping to ask whether the individual fixes are treating six symptoms of one structural cause, rather than reaching for a seventh patch.
The actual fix reused a standard technique rather than inventing one: wrap the list's backing storage in an atomically-reference-counted pointer, so a clone becomes a cheap refcount bump instead of a deep copy, and mutate through a "copy only if shared" helper that mutates in place when nothing else is watching (the overwhelmingly common case) and falls back to a real copy only when another reference is genuinely still alive. The choice of the thread-safe reference-counting type over its single-threaded cousin was deliberate and forward-looking, not merely cautious: the language already had a genuine multi-threaded parallel_map, and a list value now needed to be safely shareable across that boundary too, not just cheap to clone on one thread.
The scope turned out to require touching three genuinely separate places, not one: the real interpreter, a second, textually-separate copy of the exact same value type embedded as string literals inside the native-code generator (since compiled programs bootstrap their own runtime from scratch, with no shared crate to depend on), and a self-hosted mirror of that same generator, written in the language itself, which exists specifically so the compiler compiling the language is itself written in the language, not just implemented in it. Lesson: a fix that "obviously" only touches one file can hide two more copies of the same logic elsewhere, especially in a self-hosting system where a generator's own templates are a second, easy-to-forget copy of whatever they generate.
Verifying it meant more than a green test suite. A dedicated parity check confirmed all three independently-maintained copies of the fix produced byte-for-byte identical generated code once resynced — and along the way surfaced a real, pre-existing gap in the resync tooling itself: two of the affected chunks had quietly never been covered by the automated regenerator at all, meaning any past change to those two chunks had only ever been fixed by hand, with no automated check confirming it. Finding that gap wasn't the goal of the session, but the parity check existing at all is what surfaced it. A small standalone test program run three separate ways — interpreted, natively compiled, and self-hosted-compiled — then proved all three execution paths agreed on identical output, byte for byte, not just "each one runs without crashing." Lesson: a passing test suite proves the code someone thought to test still works; a system with several independently-maintained implementations of the same logic needs a check that those implementations still agree with each other, which is a different property and needs its own explicit verification, not an assumption that "tests pass" implies it.
A second, unrelated mistake from the same stretch of work is worth recording here rather than letting it disappear: a long, expensive simulation run finished and produced a large report file, and almost immediately afterward, an unrelated small smoke test was run in the same working directory — one that happened to write its own, much smaller output to the exact same filename, silently destroying the large report before it had been copied anywhere safe. A routine cleanup step run shortly after deleted the small file too, leaving nothing recoverable except summary statistics captured in a log. Lesson: when a long-running process has just produced a genuinely expensive-to-regenerate artifact, don't run anything else that touches the same default output location in the same directory until that artifact has been copied somewhere safe — the cost of one extra copy command is trivial next to the cost of silently losing hours of computation to a smoke test that was never meant to touch production output at all.
Act XI: closing the last named gap, then turning the same lens on the compiler itself
Two backlog items sat side by side, both traceable to the same fantpop-patlang stress test that had already produced two of this arc's earlier milestones: a genuine cross-entity attribute comparison (the third and final capability gap named back in Act VIII), and "the compiler ought to be able to reason about what causes a compilation error and suggest a fix" — the user's own idea, explicitly modeled on the induction engine's own gap-diagnosis output. Asked which to build first, the answer was the attribute comparison: concrete, already scoped, closing a named gap rather than opening a new area.
The fix split cleanly into two cases with very different costs. "X and Y have the same social class" needed no new engine capability at all — it's an ordinary two-atom body sharing one variable, already expressible the day the resolver could unify anything. "X and Y are different sexes" was the genuinely new case: nothing in the resolver could express "these two already-bound values must differ," so a real built-in comparison predicate (neq/2) was added directly to the logic engine's conjunction resolver — not a fact looked up in the rule table, a special-cased pass/fail check on two already-walked terms. The technical write-up now documents both worked examples with real induced rule text; see there for the details. Lesson: when a capability gap splits into "needs a new representation" and "needs a genuinely new primitive," don't assume both halves cost the same — here, one was free and the other required touching the resolver directly.
Verifying the fix surfaced its own aside worth naming: a background cargo test run that should have taken under a minute instead sat silent for over half an hour. Rather than assuming it was just slow, checking actual process CPU usage confirmed it was genuinely hung at 0%, and killing it (with explicit go-ahead first) left behind an orphaned child process still holding a file lock, which then caused one confusing, unrelated-looking failure on the very next attempt. Lesson: a background build showing zero output for an extended stretch is worth checking against real process state, not assumed to just be slow — and a process that needed killing can leave children behind that need cleaning up too, not just the one process itself.
With the third gap closed, the same "report on it, and suggest a fix" idea got turned on the compiler's own errors — but scoped firmly by explicit instruction: "Never want to know about rust and its errors really, as we are still moving to get rid of it and be entirely self-hosted when we can." Not a preference about this one task; a standing direction for anything diagnostic going forward. What got found immediately on looking was worse than expected: patc1_main.patlang, the actual driver behind the self-hosted compiler, had no parse-error handling at all — a syntax error simply vanished into the pipeline rather than being reported. The new write-up covers the fix and its worked examples in full; the short version is that fixing it surfaced a second, much older bug purely by exercising it: the AST-to-text renderer used to detect errors had no case at all for rule/fact declarations, so it had been silently misidentifying every single one as a parse error for who knows how long — harmless until the moment something finally started trusting that signal, at which point it broke sixteen of twenty existing test suites in one step. Lesson: a latent bug in a diagnostic path can sit completely harmless for a long time specifically because nothing downstream has ever acted on its output — the moment something finally does, that dormant bug becomes a real regression instantly, and the fix is to find it then, not to have been able to predict it earlier.
Act XII: "flakiness is never a good thing" — hunting down two tests that only sometimes failed
Two tests in the Rust-side regression suite had been intermittently failing for a while, always passing again on a retry, and had simply been noted as "known flaky" rather than investigated — the kind of low-grade rot that's easy to keep tolerating because each individual occurrence costs nothing more than re-running the suite. Picking this off the backlog came with a one-line mandate: "flakiness is never a good thing." That framing mattered: it ruled out the tempting shortcut of marking the tests #[ignore] or padding them with retries, and insisted on an actual root cause for each.
The first culprit turned out to be a genuine correctness bug, not a test artifact. The test spun up a tiny compiled HTTP server and sent it two requests over a real TCP connection, and one specific failure showed the server echoing back "echo: GET /again" with a Content-Length of exactly 16 — one byte short of what a correct response needed, missing the trailing " HTTP/1.1" entirely. That's not a flaky assertion; that's the server genuinely returning wrong data, just rarely enough that it looked like noise. The underlying primitive, tcp_read, was already honestly documented as doing exactly one read() call and returning whatever bytes happened to be there — TCP is a byte stream, and nothing guarantees a client's single write reaches the server in a single read, especially under the kind of scheduler pressure a parallel test run creates. Lesson: "intermittent" and "flaky" are not synonyms for "not a real bug" — a failure that only shows up occasionally under load is often exactly the signature of a race condition or an unhandled partial-data case, and deserves the same suspicion a deterministic failure would get, not less.
Before touching anything, it was worth checking whether the fix belonged in the primitive itself or in its callers — and the answer was callers. A separate library already in the codebase (a small Ollama HTTP client) called the exact same tcp_read function in an explicit loop, accumulating chunks until it got an empty read back, which is the correct pattern for a stream primitive with no built-in framing. Changing tcp_read itself to loop until it saw a particular terminator would have silently broken that caller's own, already-correct handling. The actual fix went into the four example programs that had each, independently, assumed one call was enough: a small loop that accumulates chunks until it's seen the HTTP header terminator, mirroring the pattern the Ollama client already used correctly elsewhere in the same codebase. Lesson: before fixing what looks like the root cause, check whether something else already depends on the current behavior being what it is — the "obviously correct" fix at the shared primitive can be the wrong one if a different caller relies on exactly the semantics being changed.
The second test's flakiness had a much more mundane cause, found by a simple discipline: running the suspect test on its own, ten times in a row, before touching any code. It never failed in isolation, not once — a strong, simple signal that whatever was wrong wasn't in the test itself, but in how it interacted with its neighbors when the whole suite ran in parallel. The actual cause was almost embarrassingly plain: a dozen different tests across two files all wrote their compiled output into the exact same shared temporary directory. Different filenames, so no test ever directly overwrote another's file — but under Windows, concurrent writes, antivirus scanning of freshly-written executables, and simultaneous compiler invocations all sharing one directory is a well-known source of exactly this kind of transient, load-dependent failure. The fix was almost anticlimactically simple once identified: give every test its own subdirectory, named after the test itself, so no two tests ever touch the same filesystem path again, concurrently or not. Lesson: before hypothesizing about a subtle race condition, check the cheap explanation first — running a suspect test alone, repeatedly, either reproduces the bug (ruling out "only happens under parallel load") or it doesn't, and that one experiment narrows the search enormously before any code gets changed.
A third bug came out purely from investigating the first two: a standard, well-known Rust pitfall where a spawned child process is not killed automatically when its handle is dropped. Three tests spawned a small server process, then ran several operations that could fail (connecting, writing, reading, asserting on the response) before their own cleanup code was reached. Any failure in between silently leaked the server process, which kept running, and on Windows kept its own executable file locked — meaning the very next run of that same test would fail to overwrite the file, producing a completely different, unrelated-looking compile error with no obvious connection back to the real cause several test runs earlier. This was already known first-hand, not theorized: a hung background build earlier in the same session had left exactly this kind of orphaned process behind, and cleaning it up by hand was the direct inspiration for looking at whether the test suite's own code had the same latent flaw. The fix was a small wrapper that guarantees the child process is killed if it's ever dropped without being explicitly, successfully waited on first. Lesson: a bug encountered and manually fixed once, live, during a debugging session is worth asking "does this same shape of bug exist anywhere else, systematically, in code I haven't looked at yet" — the manual fix solved the immediate problem, but the real fix was recognizing the pattern and finding where else it lurked.
Verification here mattered as much as the fixes themselves: twenty consecutive full runs of the previously-flaky tests, with zero failures, against a baseline of roughly one-in-three failing before. A single clean run proves very little for an intermittent bug; only a real stress test earns the claim that it's actually fixed.
Act XIII: composing processes that don't know about each other yet
A genuinely new kind of question this time, not another bug hunt: could a running PatLang program find other running PatLang programs it had no prior knowledge of, learn what they could do, and treat them as callable objects? See the full technical write-up for the five-part design and every worked example; this section is the shorter story of how it actually got built, and what almost went wrong along the way.
The first real design decision wasn't about the discovery mechanism at all — it was recognizing that two completely unrelated pieces of existing infrastructure were secretly the right building blocks. Runtime signals already let one process message another; a durable file-backed queue, built for a totally different saga-pattern work-tracking purpose, turned out to solve the "how does a process find out who else exists" problem far better than the network-based design first proposed for it, specifically because a filesystem read that finds nothing just returns nothing, where a network connection attempt to an empty port is fatal in a language with no exception handling at all. Lesson: before building a new mechanism to solve a hard sub-problem, check whether something already in the codebase — built for an unrelated reason — happens to have exactly the right failure characteristics for the NEW problem, not just the same-sounding purpose.
Two honest limitations were raised before a line of the relevant code existed, not discovered afterward and patched in. First: a capability a discovered task advertises about itself is entirely self-reported, and nothing verifies it. Second, arriving as a direct, specific question rather than vague unease: how does a program even know who to ask in the first place, before any discovery mechanism exists to ask through? Both shaped the actual design rather than being appended as disclaimers once something was already built. Lesson: naming a system's trust boundary and its bootstrapping problem before writing the code that will have to live with both is worth more than documenting them afterward — the second question here is what led directly to reusing the message queue instead of the network-registry design that had already been half-proposed.
A concrete, previously-learned lesson got reused deliberately rather than relearned the hard way a second time: an encoding scheme needed to separate several fields inside one string, and the choice was multi-character markers, not single characters, specifically because a single-character delimiter had already caused a real bug earlier in this same broad session (a compiler diagnostic's own message text happened to contain the literal delimiter character being used to parse it). Applying a hard-won lesson from one part of a codebase to an unrelated new feature, proactively, before the same mistake could repeat itself, is a different and cheaper skill than fixing the same mistake twice. Lesson: a lesson learned once, written down clearly, is worth actively checking against every time a similar-shaped decision comes up again, not just when the exact same code path is being touched.
The last piece — a way to actually start another process without blocking — got scoped carefully before being built, not rushed into. The decisive question wasn't "can this be built" but "what happens when a caller forgets to clean up after it," and the honest answer was worse than the equivalent gap in a language with real destructors: a leaked background process here has no possible automatic recovery at all, not just a harder-to-diagnose one. That risk was named plainly, weighed against the real value on the other side (it directly closed a testing gap already hit twice), and built anyway with the risk documented rather than hidden. Lesson: "this could leak forever if used carelessly, and there's no way to change that" is a fine reason to still build something, as long as it's said out loud before shipping, not discovered by a future user the hard way.
The one implementation bug in the whole arc came from an unexamined assumption about a system default, and was caught immediately by simply looking at the output of the very first real end-to-end demo rather than assuming success from a clean exit code. A spawned process, by an ordinary platform default neither designed nor considered, shared its parent's own output stream — so a routine internal auto-print from the child process's own interpreter showed up as a stray, confusing extra line mixed into the parent's own output. Fixed the moment it was noticed, with the safer of two possible fixes chosen deliberately: discarding the child's output outright rather than capturing it for later, because capturing without ever draining it would have traded a cosmetic bug for a real one (a filled buffer silently blocking the child forever). Lesson: read the actual output of the first real end-to-end run of something new, don't just check that it exited successfully — a default inherited unexamined from an underlying platform can produce output that's technically correct and practically confusing at the same time, and only actually looking catches that.
Act XIV: a third opinion on grammar, and a dormant paradigm brought back to life
Two threads this stretch, run back to back, each closing a gap the project had been carrying, named openly, for a while. The first: could the mirror-drift problem that kept resurfacing between the native and self-hosted parsers — a stray extra end here, a missing fn keyword there, each caught by hand — be attacked at the root, with a single grammar file both implementations answer to, rather than another one-off fix each time it recurred? The second: the goal/pursue/activate surface syntax had sat as pure parser-level no-ops since early in the project, waiting on a design decision — the goal-oriented write-up itself named the exact ambiguity blocking it months earlier: "there's no unambiguous mapping onto solve/plan."
The grammar work started deliberately scoped down, not up: a proof-of-concept covering only what the existing fuzzer already generated, proving the architecture — one .peg file, two independent interpreters sharing nothing else — actually held together before investing in full coverage. It did, cleanly, on the first attempt at each engine. Expanding it to the real language's full syntax surfaced the actual interesting problem: three places where PatLang's grammar depends on parser-level state, not grammar shape — a bare = meaning assignment or equality depending on position, a trailing { } block attaching as a closure argument everywhere except inside a condition, and rule meaning three different things depending on what follows it. None of the three needed new engine machinery once correctly framed: a dedicated statement-level production tried first, two parallel expression towers diverging only at one tier, and PEG's own ordered-choice backtracking respectively. Lesson: when a hand-written parser handles several cases with ad hoc state flags, look for whether each one is really a shape problem in disguise before assuming a declarative grammar can't express it — sometimes the "state" was standing in for a structural distinction the grammar format already has a clean way to say.
The single hardest bug in the whole grammar effort had nothing to do with PEG semantics at all: tokenize() returns a vec (PatLang's mutable, growable sequence type), not a list (its other, structurally distinct sequence type) — and calling the wrong accessor on it doesn't error, it silently returns 0 for a length that's actually 5. Lesson: a language with two superficially similar collection types that fail silently into each other's accessors, rather than erroring, will eventually cost real debugging time on exactly this class of bug — worth knowing about, and worth flagging as a real language-design sharp edge rather than a one-off gotcha, the next time this kind of API surface gets designed.
The actual point of building a general PEG interpreter — not validating syntax for its own sake, but answering whether it could ever be fast enough to replace a hand-written parser — got a real, measured answer instead of an assumption either way: benchmarked against the real 176-file codebase, it ran at roughly 1.8× the native parser's wall-clock time, with zero memoization. A first unpreprocessed benchmark pass gave a badly misleading result before that number was trusted — the PEG grammar appeared to accept more files than the native parser, the opposite of what its narrower scope should produce, because neither side had gone through the same include-resolution the real pipeline actually applies first. Lesson: a benchmark comparing two things that each recover differently from the same malformed input can produce a number that looks meaningful and is actually measuring whose error-recovery is more lenient — match whatever preprocessing a real pipeline actually does before trusting a timing or accuracy comparison at all.
The design conversation this arc produced two moments worth recording verbatim, because both reshaped the plan more than any code change did. Asked to expand the grammar's dependency syntax, the answer to a proposed choice between two options was a direct correction of the framing itself, not a pick between them: "everything is a function, everything is an object... dependencies only need a way of letting us know if they have been met — might be a value, might be a function." That collapsed what looked like a design fork into one uniform mechanism. Later, told the planner's dispatch design assumed a found plan always completes, a one-line correction reshaped the whole execution model: "A function dependency might not always result in it being true — the function might fail for some reason." Lesson: when a proposed design choice gets answered with a reframing rather than a selection, that's usually a sign the options offered were both instances of a narrower assumption worth checking directly, not a request to pick again from the same menu.
Implementing activate (run each planned action's bound closure in order) hit a real architectural wall immediately: host functions cannot call back into the interpreter to invoke a closure — confirmed by reading the actual function signature, not assumed. Rather than inventing a new IR instruction to work around it, the fix stayed inside the existing instruction set entirely: synthesize ordinary control-flow AST (a Let/While/If/Call tree, built directly in code) at the point where the language's own syntax gets turned into runnable instructions, and feed it back through the exact same machinery a hand-written loop would go through. Lesson: when a clean new primitive turns out to need touching every backend a language has (native codegen, the interpreter, a self-hosted mirror, and the compiled-binary output format all at once), check first whether the same effect is reachable by generating ordinary source-level constructs the existing pipeline already handles correctly — a bigger, more mechanical diff across fewer new concepts can be the actually-simpler fix.
Bringing the self-hosted compiler's own parser up to the same syntax it had just gained natively wasn't optional follow-through this time — it was asked for directly, the moment it was flagged as deferred: "OK self-hosted compiler should catch up... slowcoach!" Closed the same session: real parsing added to the self-hosted parser and lowerer, verified by rebuilding the self-hosted compiler and confirming it produced byte-for-byte identical output to the native pipeline on the same program. Lesson: flagging a mirror gap explicitly rather than burying it is what makes it possible to close quickly once someone decides it's worth closing now rather than later — the entire second half of this fix was straightforward specifically because the first half had already been honest about exactly what was missing.
The session's next stretch turned its own honesty discipline on itself: re-running an existing self-timing benchmark rather than assuming performance hadn't moved, and finding a genuine, reproducible ~15% slowdown against a measurement from six days earlier. The likely cause (a numeric-tower change landing in between the two measurements, touching every arithmetic operation on the benchmark's hot path) was named directly, but explicitly labelled as correlation rather than a verified cause, since no bisection was actually run to confirm it. Lesson: "probably caused by X, not confirmed" is a genuinely different, more honest claim than either silence or an unqualified diagnosis — and it costs nothing to say plainly which one you actually have evidence for.
That honest, unverified suspicion turned out to be worth acting on directly: asked afterward whether there was any optimisation available, reading the actual arithmetic dispatch path the benchmark had just implicated found something concrete rather than requiring a guess — add/sub/mul/cmp routed even a plain Int+Int or Float+Float pair, the overwhelmingly common case in any tight loop, through the numeric tower's full cross-kind promotion machinery before ever checking whether that machinery was needed. A second, similarly-shaped fix turned up alongside it in the same pass, asked for explicitly ("any other similar ones we could do at the same time?"): reassigning an already-declared local variable was unconditionally heap-allocating a fresh copy of the variable's own name on every single write. Both fixes were small, surgical, and left every other case — mixed types, BigInt, first-time variable declarations — running through the exact same code as before. Lesson: an honestly-flagged "I suspect X but haven't proven it" is not a dead end — it's a specific, well-aimed place to go looking, and often cheaper to actually verify than it was to name in the first place.
Because compiled PatLang programs embed their own separate copy of the runtime rather than linking against a shared crate, both fixes needed a second, independently-adapted implementation in the native code generator's own embedded text before the compiled-binary execution path would benefit at all — the same "a fix that obviously touches one file can hide two more copies of the same logic elsewhere" lesson from Act X, recognized immediately this time rather than rediscovered the hard way. Re-measuring afterward, across all four ways a PatLang program actually runs, showed roughly a 30% improvement uniformly across every single one of them — not just the interpreted path a narrower fix might have targeted — and, tellingly, every path now beat even the original pre-regression baseline from six days earlier, a genuine net win across the whole window rather than a return to parity. Lesson: when a fix targets logic that's genuinely duplicated across an interpreter, a compiler backend, and a self-hosted mirror of that backend, budget for touching all of them from the start — the alternative isn't "fix it once, mirror it later," it's "the fix silently only half-happened," which is worse than not having started.
Asked what to do with the larger, structural version of the same idea — resolving variables to indexed slots instead of a string-keyed hash map, which would fix read costs too, not just the write-side cost this pass addressed — the answer was explicitly to prepare for it rather than build it immediately: name it, scope its real cost (touching the lowerer on both the native and self-hosted sides, changing the IR's own instruction shape), and leave it for a dedicated pass. Lesson: not every optimisation opportunity found during a smaller fix needs to be built in the same sitting — naming the bigger one precisely, so it's ready to pick up deliberately later, is a legitimate and different kind of progress from actually building it.
Lessons from this arc, the short version
- Test a real corpus, not just a convenient one. Toy data proves a mechanism works in principle; only real-shaped data (with real-shaped quirks, like uppercase HTTP verbs) reliably surfaces the bugs that matter.
- A test harness should depend on exactly the boundary it's actually testing across. A heavier toolchain wired up because it was convenient for an earlier, different feature is a cost worth removing once you notice it's no longer buying you anything for the feature at hand.
- A published list of honest limitations is a backlog, not just a disclaimer. Each one usually implies its own regression test already — build the test around the specific case that would have failed under the old behaviour, so the fix is provably a fix, not just plausible-looking code.
- Two special cases that can never combine are a sign a more general rule hasn't been found yet. Look for the case neither one can express alone — collapsing them into one system that still handles both original cases identically is the real test of whether the generalization was genuine.
- Not every case that resists generalization needs a more powerful generalizer. Sometimes the honest fix is admitting two things are genuinely unrelated and handling them as separate cases (or, here, separate clauses) rather than stretching one mechanism to cover both.
- Take a user's hunch that two problems are related seriously before assuming it's coincidental. Solving the cheaper one first sometimes turns out to be exactly what makes the harder one affordable to attempt at all.
- When testing "pick the better of two options," don't test on a case that already favors the new one. Go find, empirically, a case where the new option actually loses, and confirm the mechanism still chooses correctly.
- When a domain feels too hard for a system to express, look for a missing structural capability before assuming the system just needs to try harder. An "easy" case that quietly fails alongside a "hard" one is often a sign both share the same actual gap.
- Some tests should prove a limitation is real, not that the code works. A test that deliberately demonstrates a known gap, rather than hiding it, is more honest than silently working around it.
- A capability only ever exercised from inside its own project hasn't really been tested as reusable. Try calling it from somewhere else, even once, before assuming it's ready for that.
- Not every needed capability has to come from your newest, fanciest machinery. Naming which parts of a problem a given tool genuinely can't handle, plainly, is as much a discipline as building the tool.
- A performance bug invisible at demo scale and catastrophic at real scale is the default outcome of never testing at real scale, not a rare surprise. Test the size you actually need, not the size that's convenient to type out.
- Fixing one instance of a root cause doesn't mean every instance is fixed. The same shape of bug can reappear one layer up, in code written specifically to work around the first one.
- Patching the same shape of bug more than once or twice in a session is itself a signal. Stop and ask whether several individual fixes are really six symptoms of one structural cause before reaching for a seventh patch.
- A fix that "obviously" touches one file can hide two more copies of the same logic elsewhere. Especially true in a self-hosting system, where a generator's own templates are a second, easy-to-forget copy of whatever they generate.
- A passing test suite and "several implementations agree with each other" are different properties. A system with more than one independently-maintained implementation of the same logic needs an explicit check that they still agree, not just that each one individually passes its own tests.
- Don't let anything else touch a freshly-produced, expensive-to-regenerate artifact's default output location before it's copied somewhere safe. A stray smoke test that happens to share an output filename can silently destroy hours of computation in one careless run.
- When a capability gap splits into "needs a new representation" and "needs a genuinely new primitive," don't assume both halves cost the same. One can be free with what already exists; the other can require touching the core resolver directly.
- A background build with zero output for an extended stretch is worth checking against real process state, not assumed to just be slow. A killed hung process can leave orphaned children behind that hold locks and cause confusing, unrelated-looking failures next.
- A latent bug in a diagnostic path can stay completely harmless for a long time, purely because nothing downstream has ever acted on its output. The moment something finally does, it becomes a real regression instantly — the fix is to find it then, not to have predicted it earlier.
- "Intermittent" is not a synonym for "not a real bug." A failure that only shows up occasionally under load is often exactly the signature of a race condition or an unhandled partial-data case, and deserves the same suspicion a deterministic failure would get.
- Before fixing what looks like the root cause, check whether something else already depends on the current behavior. The "obviously correct" fix at a shared primitive can be the wrong one if a different caller relies on exactly the semantics being changed.
- Before hypothesizing about a subtle race condition, check the cheap explanation first. Running a suspect test alone, repeatedly, either reproduces the bug or it doesn't — one experiment narrows the search enormously before any code changes.
- A bug fixed once, manually, during a debugging session is worth asking whether the same shape of bug exists elsewhere, systematically. The manual fix solves the immediate problem; the real fix is recognizing the pattern and finding where else it lurks.
- A single clean run proves very little for an intermittent bug. Only a real stress test — many consecutive runs against a known-bad baseline — earns the claim that it's actually fixed.
- Before building a new mechanism, check whether something already in the codebase, built for an unrelated reason, happens to have the right failure characteristics for the new problem. A same-sounding purpose isn't the test that matters; how it fails is.
- Name a system's trust boundary and its bootstrapping problem before writing the code that has to live with both. Answering "how does this even get asked in the first place" up front can redirect the whole design, not just add a caveat to it afterward.
- A hard-won lesson from one part of a codebase is worth actively checking against every similar-shaped decision elsewhere, not just re-learning when the exact same code path gets touched again. Reusing a lesson proactively is cheaper than repeating the mistake it came from.
- "This could leak forever if used carelessly, and there's no way to change that" is a fine reason to still build something — as long as it's said out loud before shipping. A risk documented plainly beats one discovered by a future user the hard way.
- Read the actual output of the first real end-to-end run of something new; don't just check that it exited successfully. A default inherited unexamined from the underlying platform can produce output that's technically correct and practically confusing at the same time, and only looking catches that.
- When a hand-written parser handles several cases with ad hoc state flags, check whether each is really a shape problem in disguise. A declarative grammar format can often express what looked like parser-level state, once correctly framed.
- Two collection types that fail silently into each other's accessors, rather than erroring, will eventually cost real debugging time. Worth naming as a sharp edge, not just fixing the one bug it caused.
- A benchmark where both sides recover differently from the same malformed input can measure whose error-recovery is more lenient, not what you think it measures. Match the real pipeline's actual preprocessing before trusting a comparison.
- When a proposed choice gets answered with a reframing instead of a pick, that's a sign the options offered were both instances of a narrower assumption. Worth checking the assumption directly rather than re-offering the same menu.
- When a new primitive seems to need touching every backend a language has at once, check whether the same effect is reachable by generating ordinary source-level constructs instead. A bigger, more mechanical diff across fewer new concepts can be the actually-simpler fix.
- Flagging a mirror gap explicitly, rather than burying it, is what makes it fast to close once someone decides it's worth closing now. Being honest about exactly what's missing is most of the work of fixing it later.
- "Probably caused by X, not confirmed" is a genuinely different, more honest claim than either silence or an unqualified diagnosis. It costs nothing to say plainly which one you actually have evidence for.
- An honestly-flagged, unproven suspicion is a specific place to go looking, not a dead end. It's often cheaper to actually verify than it was to name in the first place.
- When a fix targets logic duplicated across an interpreter, a compiler backend, and a self-hosted mirror of that backend, budget for touching all of them from the start. "Fix it once, mirror it later" tends to become "the fix silently only half-happened."
- Naming a bigger optimisation precisely and deliberately leaving it for later is legitimate progress, not procrastination. It's a different, equally real kind of work from actually building it in the same sitting.
See also
The Journey of Building PatLang (Acts I-VI) for where this arc picks up from, and its own takeaways for language- and compiler-building specifically. Inductive Synthesis: from BDD scenarios to PatLang code is this arc's own full technical write-up, with the worked examples, gap-diagnosis output, and fantpop stress-test findings these Acts summarize, including Act XI's cross-entity attribute comparison. Compiler Error Reasoning is Act XI's other half in full technical write-up form, with real before/after examples of faulty PatLang source and the diagnostics it now produces. Presence, Discovery, and Non-Blocking Spawn is Act XIII's full technical write-up, with every worked example from the presence beacon through the live dashboard to the readiness-confirmation pattern. The SQL console and virtual filesystem demo pages cover the O(n²)→O(n) fix from Act IX in their own worked-example form. The PEG grammar validator and the published grammar file itself are Act XIV's grammar half in full technical form; the goal/pursue/activate demo is its GOAP half, with the exact worked example the act's design quotes refer to. The self-timing benchmark page has the full before/after numbers behind Act XIV's optimisation story, with the project-navigation companion and the performance-debugging companion drawing the generalized lessons out of that whole stretch of work. Designing Well-Behaved PatLang Programs collects the signal/discovery/queue conventions the goal-oriented and signals work in this arc depends on. What Building a Code-Synthesis Engine Taught About Any Software Project is this arc's own generalized companion in the Project Guidance section, for the lessons above with the PatLang specifics stripped away. Capabilities & Honest Limitations applies the same warts-included approach to PatLang's current state rather than its history. The next instalment continues directly from here — Acts XV through XX.