The Journey of Building PatLang, Continued Again: Hardening What Already Worked

A direct continuation of the previous instalment (Acts VII-XIV: teaching the language to induce its own code, then a third independent grammar validator and a dormant goal-oriented paradigm brought back to life) — split into its own page for the same reason that one split off from the original: this arc, on its own, grew long enough to deserve its own home rather than being appended indefinitely to an already-long page. Act XV closes out the grammar work with a misattributed bug and a silent-truncation footgun. Act XVI hosts PatLang somewhere it had never lived before — a third scripting language inside an unrelated C# application. Act XVII is a genuine status update posted mid-chase: a real native x64 code generator finally gets the self-hosted compiler to compile its own source through it for the first time, with the freshly self-compiled binary still not running reliably as that Act closes. Acts XVIII-XX are one continuous thread — "eating the dogfood": an attempt to build this very site with PatLang instead of the Python script that currently generates it, which surfaced a genuine, previously-invisible performance bug at the very heart of the interpreter, then (twice more, in Acts XIX and XX) revealed that the first fix hadn't actually reached every place the same bug lived, each time found only by insisting on running the real case again rather than trusting a synthetic benchmark or a "should be fixed now" assumption. Same warts-included approach, same commit-history sourcing, same house style as the earlier pages — see them for the full framing this page picks up from.

Act XV: the bug that wasn't in the file it appeared to be in

Dropping the mandatory statement separator and retiring bare { } as its own statement (covered in the previous page's grammar work) came with a bonus, asked for in the same breath as the design itself: "can we pick up on bare expressions and say 'Heyhey, you did a value thing and threw it away, sure you want to do that?'" A soft warning, gated on the expression being structurally incapable of a side effect, went in alongside the grammar change — and then, run for real against the actual codebase rather than just test snippets, immediately fired on a file that looked entirely innocent.

The reported line number pointed at a specific line of parser.patlang, and the reported token context looked plausible for that file too — so two full turns of investigation went into it as if it were true: file-truncation bisection, hand-inserted marker values, Debug-format instrumentation added twice to capture more state, and finally an independent tokenizer-based search of the entire token stream for the exact shape the parser claimed to have found. That search came back empty. The parser's own diagnostic context was lying about what it was looking at — not about the line number's math, but about which file's line 301 it even was. The real culprit was a hidden preprocessing step: any file containing the plain-English substring "syntax " anywhere — including harmless prose like "the syntax immediately after" in a comment — silently triggers a completely different file to be parsed first, as a bootstrap step for an unrelated feature, using the very same parser and the very same warning logic. The line 301 being reported was real, accurate, and about a different file's content entirely.

Once correctly attributed, the actual bug was almost anticlimactic: the flagged line was |p| { p }, a deliberately-written identity closure whose single-statement body is PatLang's implicit return value, not a discarded one — entirely correct code. The warning's own design simply never accounted for tail position: the last statement in any block is what the block evaluates to, and flagging it as "discarded" is always wrong. Fixed by peeking past trailing newlines after a candidate discard to check whether a block-terminating token comes next; if it does, the value isn't discarded, it's returned. Lesson: when a parser's own reported diagnostic state (which line, which token) contradicts an independent, ground-truth check of the same input, stop trusting the diagnostic and go looking for what's actually being parsed — the file you're staring at may not be the file the failing code is looking at.

A second, smaller finding rode along with the fix: even once correctly attributed to the right file, the warning message never said which file it was about, which is exactly what made the two-turn misattribution possible to sustain in the first place — a plausible-looking line number in the wrong file is far easier to mistake for the truth when nothing else in the message disagrees with that reading. Fixed by giving the parser an optional source-name label, set at the two real entry points where a filename is actually known, and included in the warning text going forward. Lesson: a diagnostic that reports a line number but not a source label is an invitation to misattribute it the moment more than one file is ever involved in producing it — cheap to fix, easy to omit by default, and this session is exactly the kind of quiet cost that omission has.

Act XVI: hosting PatLang somewhere it had never lived before

Everything so far had run inside the engine's own repo, or a sibling project built alongside it. This stretch asked a different question: could PatLang be embedded as a THIRD scripting language in an existing, unrelated host application — a C# WinForms app that already let users write Python or Ruby scripts and watch them draw to a canvas, one JSON command per line? The answer required no engine changes at all to prove the concept: a new canvas.patlang module mirroring the existing Python/Ruby drawing API, full ports of both the static demo and the animated solar-system example, and two small C# edits to add PatLang as a third option alongside the existing two. Verified thoroughly before ever touching the real GUI — every draw command round-tripped through PatLang's own JSON parser, the animation's star field stayed stable across frames while its planets genuinely orbited, and the exact real invocation shape the host would use (temp directory, copied helper module, piped subprocess) was reproduced directly and proven to produce valid output. Lesson: when extending a language into a genuinely new hosting context, the pre-flight check that matters most is reproducing the EXACT invocation shape the real host will use, not just running the new code standalone — a script that works perfectly from a terminal can still fail in ways only visible under the host's actual process-spawning and I/O-piping behavior.

One such pre-flight check paid off directly: PatLang's print() was suspected of being fully buffered when piped, the same class of gotcha that had made an earlier simulation run look stalled. Checked empirically before writing the animation script that would depend on real-time frame delivery — a print-plus-sleep loop's output arrived through a real OS pipe in lockstep with the sleep interval, not batched up. Piped subprocess redirection, it turned out, behaves differently from file redirection in this respect. Lesson: a gotcha discovered in one I/O context (redirecting to a file) doesn't automatically generalize to a superficially similar one (redirecting through a pipe) — worth re-checking empirically rather than assuming the same fix or the same caution applies, in either direction.

Then a real one did bite, immediately, on the very first real run: every script, even a bare print("Hihi"), silently did nothing — exit code 0, zero output, no error anywhere. Reproducing it required going one level deeper than "run the script by hand" — writing a tiny throwaway C# program using the exact same Process API the real host used turned up the actual culprit: .NET's default UTF-8 text writer prepends a byte-order-mark, a completely ordinary thing for a Windows tool to do, and PatLang's lexer choked on it. Not with an error, though — with silence. Tracing why led to something considerably more concerning than a BOM-specific bug: the lexer scans raw bytes for structural syntax, and its fallback for ANY unrecognized byte — not just this one — was to silently claim the file had already ended, as if the rest of the program simply didn't exist. One stray non-ASCII byte, anywhere outside a string literal, would make everything after it vanish without a trace. Lesson: a bug that first presents as "this one specific input fails oddly" is worth asking a broader question of before fixing narrowly — the BOM was the trigger, but the actual defect was a general silent-truncation footgun that had nothing specifically to do with byte-order-marks at all.

The fix was two-layered on purpose: tolerate the specific, genuinely harmless case (strip a leading BOM outright, matching what most language toolchains already do), but turn the general case into a real, visible error instead of silence (a declared-but-never-used error variant was sitting right there, unused, waiting for exactly this). Confirmed both halves directly — the BOM'd script now runs correctly, and a DIFFERENT malformed-character test that used to silently truncate now reports a clear parse error and a non-zero exit code instead. Fixing it surfaced one more small, honest thing worth removing rather than papering over: the very error path that had gone untested for a while turned out to carry a hint message that was years out of date, describing a far more limited pipeline than the one that had spent this entire arc proving itself capable of goal-oriented planning, SQL, and real concurrency. A stale hint is worse than no hint, so it came out rather than being patched to sound current. Lesson: "tolerate the expected case, error clearly on the unexpected one" is a better default than either silently accepting everything or rejecting everything outright — and an error path that hasn't fired in a long time is exactly the kind of place stale, confidently-wrong text likes to hide.

Act XVII: a native code generator, a self-hosted compiler asked to compile itself, and a status update mid-chase

Every path PatLang programs could run through until this point still ended, one way or another, at rustc: the interpreter, the self-hosted compiler's own text-generating backend, all of it. A parallel effort had been underway to close that dependency for good — a genuine native x64 code generator, written in PatLang itself, emitting real NASM assembly with no Rust anywhere in the pipeline, built up slice by slice: integers and control flow first, then strings, lists, and closures, then an entire numeric tower (BigInt, Rational, Complex, Interval) living behind a uniform tagged-value scheme, then a clean object model. On ordinary integer and float workloads it measured 60-116× faster than either the interpreter or the rustc-compiled path — a real, working alternative backend, not a toy. The obvious next test, named as the goal from early on, was the self-hosted compiler compiling its OWN roughly ten-thousand-line source through this new backend: if it could survive that, the rustc dependency for ongoing self-compilation would be gone entirely. The first real attempt didn't even get as far as a working binary — it found four genuine bugs (a quadratic list-append that turned a token-by-token accumulation into an O(n²) crawl, a name collision between two backends' own helper functions, a missing newline that spliced two unrelated lines of generated assembly into one malformed token, and a discarded label that vanished from the output the moment an unsupported instruction's error path returned early) before the whole 4.2 megabytes of generated assembly would even assemble and link. Lesson: a "does it even compile" milestone can hide several genuinely separate bugs stacked on top of each other — each one only becomes visible once the bug in front of it is out of the way.

Getting past that milestone revealed the real target: the resulting binary crashed the moment it ran, with a stack overflow. The working theory was that the backend had never done any register allocation at all — every local variable, in every function, spilled to its own dedicated stack slot — and that this compiler's own genuinely deep recursive-descent parsing was paying for that design choice at every level of recursion. Building a real allocator turned up an assumption that had never actually been checked: an audit of which registers were even free to hand to a local variable found that every single callee-saved register was already claimed as hardcoded scratch space somewhere in the existing code generator — the dynamic-dispatch machinery alone used two of them as its permanent operand-holding registers across dozens of call sites. Freeing even one register turned out to require the same technique repeated case by case: wherever a value only needed to survive across one specific call, replacing the register with a plain stack-memory slot instead, rather than fighting over which register got to keep it. Lesson: "we'll just use the free registers" is not a design, it's an assumption wearing a design's clothes — audit what's actually already spoken for before building anything that assumes spare capacity exists.

With three registers freed and a working (deliberately simple: no interval-sharing, just a call-boundary-aware eligibility check per local) allocator in place, the actual self-compile attempt surfaced a second, much bigger surprise: thousands of "unsupported primitive" warnings, because the native backend had only ever been taught the small set of host functions each individual demo program had needed so far — nothing close to everything the compiler's own source actually called. Most of that gap closed almost for free once a cheap, direct test confirmed something that had only ever been assumed: the language's own name resolution checks for a user-defined function with a given name BEFORE ever falling back to a built-in host primitive of the same name. That meant dozens of missing primitives, string-building and list-building among them, could simply be written as ordinary functions in the runtime library that got prepended to every native compile, rather than as new hand-written assembly — the language's own scoping rules doing the work instead of the code generator needing to. Lesson: a scoping rule nobody deliberately designed for this purpose can turn out to be the single highest-leverage tool available, once someone thinks to actually test what it does rather than assume the obvious, harder path is the only one.

The allocator's own correctness turned out not to be settled yet, either. Implementing real command-line argument handling — needed for the self-compiled binary to function as an actual command-line tool rather than just complete a compile — exposed a genuine, previously-undetected bug: a value read once at the top of a loop, with a function call later in that same loop's body, only appears ONCE in the compiler's own flat instruction listing (the loop's backward jump is what makes it repeat, not a second copy of the read). The allocator's eligibility check, which looked for calls between a variable's first and last use by instruction position, simply never saw the call that came after the loop's single, reused read — and happily put that variable in a register that a callee then silently clobbered on the very first iteration. None of dozens of regression demos, all passing cleanly after every single prior change, happened to contain that exact shape: a loop-condition variable with a call later in the same loop body. Found only because a new, real feature finally exercised it, isolated by disabling the allocator outright to confirm it was really the cause, then confirmed with a minimal, unrelated repro built purely to isolate the mechanism from argv's own complexity. Lesson: a regression suite proves nothing broke that it already exercises — it says nothing at all about a code shape it has simply never happened to contain, and the next new feature is exactly what tends to find that shape first.

With that fixed, the actual fixpoint compile finally ran to completion: the self-hosted compiler compiling its own roughly ten-thousand-line source through the native backend into a working binary, for the first time. A genuine milestone, and this page's own house style says the honest part has to come with it: as that update was being written, the freshly self-compiled binary — even with the allocator bug already fixed — was not yet running reliably, and the investigation into why was still open. That Act ended mid-chase on purpose, rather than waiting for a tidy resolution to write it up. Lesson: a milestone reached once is real progress worth recording honestly, even — especially — when the very next step immediately turns up a new, unresolved problem; "and then it all just worked" is not a promise this project's own history has ever been in a position to make on the first try.

Act XVIII: eating the dogfood, and the bug that had been there the whole time

This very site is generated by a Python script — read the manifest, parse each content fragment with BeautifulSoup, rewrite headings and citations and internal links, render the chrome, write the output tree. A reasonable question followed almost immediately from PatLang's own stated purpose: if this language is supposed to be capable of real work, shouldn't it be able to build the site that documents it? Not a rewrite for its own sake — a genuine test of whether the standard library was actually ready for a real, independent consumer, not just the compiler's own test suite. The audit turned up one real gap immediately: a JSON parser already existed (built earlier for API integration work) but nothing could turn a value back into text. Adding a stringifier, plus an indented variant matching the manifest file's own git-diffable formatting convention, was uneventful — and round-tripped a 30-kilobyte real manifest file byte-for-byte on the first real test, nested subtopic groups and all. Lesson: before assuming a promising idea needs a big new subsystem, check what's already half-built for a different reason — a JSON parser without a writer is a much smaller gap than a JSON library from nothing.

The harder half was BeautifulSoup itself: the Python script doesn't just read HTML, it edits it in place — assigning heading ids, promoting a "References" heading to a canonical form, renumbering citation markers, rewriting internal links to their canonical hierarchical paths. A full DOM tree felt like the obvious answer and the wrong scope at the same time; the actual operations needed were narrower — find tags by name, read and mutate attributes, extract plain text, rename a tag, re-serialize — so a flat token list stood in for a tree, with nesting handled by tracking depth on the fly rather than building parent/child pointers. It held up against real content immediately: tokenize-then-serialize reproduced a real 15-kilobyte article byte-for-byte, and the heading-id, table-of-contents, and reference-renumbering logic all matched the Python original's behavior against a genuinely complex 50-heading, 83-reference real article on the first real test. Lesson: a full general-purpose data structure is often the wrong scope when only a handful of specific operations are ever actually needed on it — build exactly those operations first, and see whether the general structure was ever really necessary.

Then the real content stopped cooperating. One file — 1.2 megabytes, a genuine article, not a pathological test case — simply never finished tokenizing. Not slow: after eight minutes, still nothing. The instinct was to suspect the new tokenizer's own design, and a methodical bisection ruled that out fast: building and mutating a 130-element list of nested records took 51 milliseconds, isolating the exact same file's tokenize step in complete isolation still hung, and a bare 200,000-character per-character scan loop — no HTML involved at all — took 5.4 seconds where it should have been near-instant. That number was the real clue. Following it down through the interpreter's own call chain landed on a single line: reading ANY local variable clones its entire value, every time, including a full string. A list-valued variable had been fixed against exactly this cost once already, earlier in this project's history, by wrapping it so a clone became a cheap reference-count bump instead of a deep copy — but a string-valued variable never got the same treatment. Every character read inside a scanning loop was silently paying for a fresh copy of the whole remaining string, turning an intended linear scan into a quadratic one purely by the accident of what a plain variable read cost. Lesson: a performance bug invisible at every test size tried so far and catastrophic at real scale is the default outcome of never actually testing at real scale, not a rare unlucky surprise — and a fix already applied to one data type is worth checking against every structurally similar type in the same system, not assumed to be a one-off.

The fix mirrored the one already on record for lists exactly, and its blast radius reached almost the entire runtime at once: this is the representation every PatLang program's every string is built from, so a change to it touches the interpreter, the compiler backend, every host function that so much as looks at a string, and the whole existing test suite's own expectations about it. An early attempt to automate the follow-on repairs backfired in an instructive way: the compiler's own diagnostic tool reports exact byte positions for each needed fix, and a script trusted those byte positions as character positions — correct for plain text, silently wrong the moment a single non-ASCII character (an em dash in a comment, as it happens) appeared anywhere earlier in the file, cascading every subsequent edit into the wrong place. The corrupted files were reverted outright rather than patched around, and the second attempt operated on raw bytes throughout instead of assuming text and bytes were interchangeable. From there it was mechanical, if extensive: around 150 call sites and a full existing test suite, fixed in batches by pattern, verified by rebuilding after each batch until nothing was left. The exact file that never finished before now tokenizes in 34 seconds, and every single existing test in the project's suite — hundreds of them, entirely unrelated to strings or to this specific feature — still passes. Lesson: a tool that reports exact positions is only as trustworthy as the assumption that those positions mean what you think they mean — verify the unit (bytes vs. characters, in this case) before trusting an automated fix at scale, and when it turns out wrong, revert cleanly rather than trying to patch around a corruption whose exact extent isn't yet known.

The site builder itself is not finished as this is being written — the page model and the content-transformation logic are built and tested against real articles, but the final rendering and file-writing stages aren't there yet, and a full run across every real article (several of them, it turns out, run past two megabytes of genuine content) still takes real minutes even after the fix, simply because that's now an honest reflection of how much text there actually is to process, not a bug hiding behind a smaller one. This Act ends here on purpose, matching this whole arc's own house style: a real, verified fix to a real bug that had nothing to do with the project that found it, reported plainly, with the project that found it still in progress. Lesson: a side project undertaken to prove a system is genuinely reusable can end up mattering more for what it finds broken in the system itself than for whether the side project ever ships.

Act XIX: the mirror that was never actually checked, and the bug that came back

Act XVIII's fix reached the interpreter cleanly: one shared type, one crate, one cargo build to catch every mismatch, hundreds of existing tests to confirm nothing broke. That was never the whole story, though — this project has (at least) four separate, textually independent implementations of "how a value is represented and a host function is called," because the compiled backend embeds its own copy of the runtime as literal Rust source text, and the self-hosted compiler that's supposed to replace that backend carries its own second copy of that same text again. A fix to the shared type in the interpreter does nothing at all for the other three copies; each one needed the identical mechanical change applied by hand, separately, because none of them share code with each other despite describing the same thing.

The record from that earlier work said the compiled-backend copy was done — roughly 170 edits applied, and explicitly flagged at the time as "believed correct, but unverified by a real compile," with a further note that the only real test would be actually generating a program from it and handing the result to a genuine rustc invocation, not cargo test. That caveat turned out to be load-bearing. Actually doing the real end-to-end rebuild — deleting the cached build fingerprint and forcing a fresh compile of the compiler's own combined ten-thousand-line source through the fixed pipeline — produced 101 genuine compile errors, all inside that supposedly-finished text. Only two of roughly eleven embedded chunks had actually been touched; string handling, file I/O, general I/O, the object model, the logic engine, and networking had all been left exactly as they were, invisible to every check that had been run so far, because none of that checking had ever actually asked a real compiler to read the text. Lesson: "believed correct, unverified by a real compile" is a real, specific flag, not a formality to note in passing — text embedded as a Rust string literal is invisible to every type checker in the ordinary toolchain, and the only way to know whether it's actually right is to run the exact downstream tool that will eventually have to consume it for real.

Fixing the 101 errors surfaced a second, genuinely new bug shape the original mechanical rename hadn't anticipated: several places extracted a string out of a value by cloning through a reference, and what that clone produced now silently differed from a sibling branch handling the same case a different way — one path yielding the new reference-counted wrapper, the other yielding a plain unwrapped copy, a mismatch the compiler could only catch once it was staring at a real generated program, because inside a string literal there's no such thing as a type to check in the first place. A third, separate bug turned up in code that was never embedded text at all: the part of the compiler that turns a plain string literal appearing in any program it compiles into the matching value-construction code had itself been missed, silently breaking real compilation for any program containing so much as one string literal, until the same rebuild-and-read-the-errors loop caught it too. Lesson: a fix that touches one shared type can still miss real call sites in more than one direction at once — not just "forgot to touch this text" but "the two branches that both got touched now disagree with each other," which only a real compiler run will ever reveal.

The sharpest irony came last. Once everything compiled cleanly, the actual point of the whole exercise — is the original quadratic-scanning bug from Act XVIII really gone, end to end, through the compiled path this time, not just the interpreter — still needed answering. It mostly wasn't. The exact host functions written to be the fast, character-level primitives for scanning a string — reading one character's code, reading a short substring — were themselves still deep-copying their entire string argument on every single call, inside the very text that had just been declared fixed. That is the identical bug this entire saga exists to kill, reappearing inside the fix for it. Correcting it (borrowing the string instead of cloning it, for exactly these two functions) took a synthetic five-hundred-thousand-iteration scanning test from never finishing at all to a little over five seconds — real, measurable progress. But the actual real-world case that started the whole investigation back in Act XVIII, a genuine 1.2-megabyte article run through the same host functions via the compiled path, still took seventy-plus seconds, nowhere near the low-single-digit-seconds a genuinely finished fix should have produced. A naive character-by-character equivalent written in plain Python, doing the identical amount of work in the identical shape — no clever library calls, just the same scan-and-compare loop — finished the same file in about a tenth of a second: a roughly six-hundred-and-sixty-times gap that has nothing to do with "compiled versus interpreted" and points at something else in the pipeline still costing far more than it should, not yet found. Lesson: the same bug can hide inside the very functions written specifically to fix a different case of it, and a synthetic benchmark improving dramatically is not proof the real-world case that motivated the fix is actually solved — only running the real case again, honestly, tells you that.

Act XX: the fourth place the bug was hiding, and the mid-chase pause after real numbers finally landed

Act XIX ended on an honestly unresolved number: a genuine article still taking seventy-plus seconds through the compiled path, a 660× gap against a naive Python equivalent, not yet explained. This Act is that explanation, in three separate parts — because it turned out to be three separate bugs again, not one.

The first was the same one already fixed once this arc, reintroduced by a completely unrelated correctness fix earlier the same session: teaching the tokenizer to treat <script> content as opaque raw text (so JavaScript comparison operators like i < bin.length stop being mistaken for HTML tags — a real, separately-motivated bug affecting twenty-six real WASM-playground pages on this very site) added a closing-tag search that itself called the exact same unfixed-feeling primitive, substr, at every character position inside the content it was searching through. substr's own "is this string ASCII" fast-path check re-scans the WHOLE string on every single call, not just once — so a search through a 1.25-megabyte embedded script paid that whole-document rescan at every one of over a million positions. The fix that had already been applied to two hot-loop primitives (char_code, the interned-string sc_code) hadn't been applied to this one, because this one hadn't existed yet when the earlier fix was made. Lesson: a fix scoped to "the functions I know about right now" doesn't cover a function written five minutes later that happens to share the exact same anti-pattern — the anti-pattern itself needs naming and searching for, not just patching wherever it's currently visible.

The second was the actual root cause behind Act XIX's whole-document scanning cost, once traced properly: the same "is this string ASCII" fast-path existed, uncached, inside the dedicated hot-loop primitives themselves (str_intern/sc_code/sc_len/sc_char) — the exact mechanism built specifically to avoid per-call scanning costs was still paying one, because nobody had cached the ASCII check at intern time and instead recomputed it on every character read. Fixed by computing it once, when a string is first interned, and simply reading the cached flag afterward. A quiet gift alongside the diagnosis: a microbenchmark of the fix that used a short, convenient test string reported success while completely missing that the same bug was still live at realistic document sizes — a reminder that a benchmark's input size matters as much as its iteration count when hunting for a cost that scales with content, not just call count.

The third was found only because the user watching the investigation refused to accept "CPU usage is climbing" as evidence of healthy progress: "I find it amusing you regard 'CPU time consumed' as 'real progress' routinely; I'd argue it means no such thing (necessarily) as it could be twiddling its metaphorical thumbs instead." Direct measurement (comparing CPU-time deltas against wall-clock time, and separately checking whether the system's antivirus process was itself burning cycles) confirmed the work really was genuine, single-threaded, CPU-bound computation — not a stall, not contention, not scanning — which meant the remaining cost had to be real, avoidable work still hiding somewhere. It was: head_extras, the function deciding whether a page needs syntax-highlighting or diagram-rendering JavaScript included at all, checked for three telltale substrings inside the FULL rendered page body using the exact same unmigrated linear-scan pattern as the first bug in this Act — for a page with a large embedded script, that's three full-document rescans of over a million characters each. Fixing it (again, intern once, scan with the cached-flag primitives) took one specific real page from 258 seconds down to 4.

Having now found and patched the same shape of bug in four separate functions across two separate sessions, the actual fix stopped being "patch the fifth one when it's found" and became architectural, on direct instruction: "You really need to make sure those large non-html chunks (scripts, wasm) are in a separate file, and only included back into the html after any processing is done... I mean, that is common sense 101." Two new functions, extract_raw_blocks and splice_raw_blocks, now pull every <script>/<style> element's content out of a document BEFORE any tokenizing, heading-numbering, citation-renumbering, or link-rewriting ever sees it, replacing it with a short placeholder, and splice the real content back in only once every other stage of processing is completely finished. Every downstream function now operates on a document that's back to its real, small, visible-content size, no matter how large an embedded payload happens to be — closing off the entire CLASS of bug at once, rather than requiring a fifth or sixth point-fix the next time a large-content page is found. Verified with a direct round-trip check (extract, then splice, then compare byte-for-byte against the original — identical) before trusting it, not just trusting that it compiled.

The numbers, once all of this landed: the article that started Act XVIII now processes in under two seconds, not seventy. The whole real site — all 151 pages, the actual point of this entire three-act detour — builds in roughly eight minutes through the compiled path, down from two and a half hours before any of this Act's fixes. One page, an interactive in-browser IDE demo with its own large embedded script, still takes about forty seconds on its own — nearly identical in file size and structure to the article that finished in two, for reasons not yet found. Rather than chase a fourth bug immediately, the investigation paused here on purpose, at the user's own call, once the numbers already in hand were substantial enough to be worth writing up and checking against the rest of the site before going further: "we already achieved massive speed ups and should document those first... need to actually check other pages are genuinely up to date too." Lesson: a real, working milestone is worth stopping at to document honestly, with the next open question named plainly, rather than always chasing the investigation one step further just because one more thread is still dangling — this page's own house style has said that from the start, and it's still true three acts later.

Act XXI: precompiling the prelude, and the demand that it be done properly everywhere

Every ordinary --patc compile paid the same tax: the compiled backend's prelude — the runtime support text every generated program needs, roughly 2.5 megabytes of Rust once every host-function chunk is concatenated in — got handed to rustc fresh, from scratch, on every single compile, even though that text is byte-identical across nearly every program. The fix was the obvious one once named: precompile each chunk into its own cached library once, keyed by a hash of its own fully-assembled source, and link a program's own small generated text against those cached libraries instead of re-optimizing the whole prelude every time. On a trivial test program the difference was immediate and large — a warm-cache compile through the new path took 0.64 seconds against 3.67 seconds for the old one, roughly 5.7× faster, with the compiled program's own output confirmed correct throughout. Lesson: a cost paid identically on every single invocation, even when the underlying work is provably the same each time, is exactly the shape of problem a cache solves cleanly — the hard part is rarely deciding to cache, it's getting the cache key right.

Getting the cache key right took several real misses. A dead-simple approach — hash each chunk's own static text — looked correct and passed its first tests, then produced a baffling "can't find crate" error the moment two different programs needing the same chunk but different companion chunks were compiled back to back: one chunk's text is genuinely identical whichever numeric representation a program uses, but the compiled library it becomes is NOT interchangeable between them, since the two differ in exactly which other library it was linked against. The fingerprint had been hashing the input that felt obvious rather than everything that actually determined the output. A second, subtler miss: a chunk's own source text, once made linkable as a separate library, sometimes needed a small header of standard imports prepended — and the naive way of avoiding a duplicate-import error (skip adding a header line if it already appears anywhere in the chunk's text) got fooled twice, once by an unrelated chunk that happened to already contain one of those exact import lines for its own reasons, and then again, ironically, by the doc comment written to explain the first instance of that exact bug, which quoted the dangerous text verbatim and thereby reproduced the very problem it was documenting. Lesson: a fingerprint or a deduplication check is only as good as its understanding of everything that can vary the output — and a fix's own explanatory comment is not exempt from causing the bug it describes, if it quotes the bug's trigger literally inside the same text being checked.

Sixteen real, separately-diagnosed issues surfaced before this was genuinely solid — struct fields needing to be made cross-crate-visible in both single-line and multi-line declaration shapes, a Rust inner attribute that has to be the very first thing in a file even after several lines of newly-added headers, an interpreter with its own completely separate host-function registry that a new capability had to be registered into a second time, and more. None of them were a flaw in the core idea; every one was a genuine boundary the mechanism hadn't been asked to cross yet. Lesson: a mechanism proven correct in isolation still has to survive contact with every real caller before it's actually done — each new caller is a new chance to find a boundary nobody thought to check.

The sharpest correction of this whole arc, though, came from a direct question rather than a bug report. Wiring the ordinary compiled path through the new mechanism and reporting the win, the honest next statement was that the self-hosted compiler's OWN build — and especially the fixpoint where it compiles itself again — couldn't use the same trick, because a standalone compiled program has no way to reach the cache machinery living inside the interpreter's own process. That was wrong, and said so directly: "if we are going to use [the compiler] to compile things, it should have the ability to do the job properly and that means it has to use the [caching] approach — otherwise it is not doing it properly!" On inspection the premise was simply false: the self-hosted compiler already carries, as real compiled code, its own mirror functions capable of reconstructing every chunk's source text, and it can already shell out to a real compiler process directly — what was actually missing was just that nobody had written the caching algorithm in a form that didn't assume it was running inside the interpreter's own process. Generalizing it to take chunk text as a plain argument, rather than reaching for it via an internal lookup, closed the gap for real: the self-hosted compiler now genuinely uses the same cached-library approach for its own build, for compiling itself again, and for anything it goes on to compile after that. Lesson: "it can't be done properly here" is a claim worth checking rather than accepting, especially from oneself — the actual obstacle is very often narrower than the first framing of it, and a design that only works for one caller usually just hasn't been asked the right question about what it's assuming access to.

Proving that last claim true, rather than merely asserting it, took one more honest surprise: the freshly-rebuilt self-hosted compiler used to compile itself again crashed the moment that inner compile ran, complaining that a piece of itself was missing — because a generated mirror of the compiler's own runtime text had been updated in one place but never regenerated in the file that's supposed to always match it byte-for-byte, exactly the kind of drift this project's own standing rule about keeping mirrors in sync exists to catch. It was caught only because the test insisted on was the full chain — build the compiler, use it to compile itself again, and then use THAT result to compile a third, ordinary program — rather than stopping the moment the middle step reported success. Lesson: a generated mirror file is exactly the kind of thing that's easy to forget mid-session, and the only test that actually proves a self-hosting claim is the one that exercises every generation in the chain, not just the one that happens to run first.

Act XXII: an example gallery that kept asking harder questions than it answered

The prompt was modest: write a short, real, verified-running example for each language paradigm PatLang supports, then one for every pair of them, as a teaching gallery for the site. Twenty-three small programs, each run for real and checked against its own printed output before any prose was written about it — and one of them, mixing the functional and logic paradigms, turned out to have quietly proven nothing at all. It printed exactly the number the write-up claimed it would, and that was the problem: query(pred, subject, X)'s third argument is always a variable to BIND, never a value to check equality against, so the example would have printed the identical "1" even if the function computing the classification it was supposedly demonstrating had been simply wrong. Lesson: an example whose output matches what the prose claims is not automatically a good example — if the mechanism it exercises can't actually distinguish a correct answer from a wrong one, matching output proves nothing.

Fixing it properly took three more rounds, each one a direct, specific push rather than a vague "make it better." First: route the classification through the predicate name itself, where equality really is checked. Then the user, unprompted: "I think I'd use something more like: fact("person","bob") / fact("bob",30) / rule <- relating age field of a person to a classification / then query for adults?" — pointing at the genuine Prolog-style rule/solve engine (real unification, real variables) rather than the plain fact/query pair, which surfaced a real engine boundary worth stating plainly: rule bodies only unify, there is no arithmetic predicate, so an age threshold has to be decided once in ordinary code when a fact is asserted, not re-derived per query. Then: "Can we not save the classify function as the function within a rule (or fact) so it executes the function at query time rather than when set?" — answered honestly (facts and rules only ever store strings; there's no way to store a callable and invoke it later inside the engine), but the practical equivalent (store only the raw fact, call the function fresh per result pulled back out) was built anyway, and it needed a real map_list/filter_list written from scratch, because the language's only built-in "apply a function across many things" primitive is a threaded, compiled-only one — the wrong tool entirely for a five-line demo. Building it surfaced two more real, previously-undocumented gotchas at once: a plain named function is not itself a first-class value (has to be wrapped in a closure literal to be passed as data), and solve's returned solutions are the FULL substituted argument list, not just the free variables, so the wrong index silently produced a wrong answer that still looked plausible. Lesson: a user who keeps asking "wouldn't it be more real if X" after each fix is doing exactly the job a good reviewer does — each round exposed something true and previously unstated about the runtime, not just a cosmetic complaint about the example's prose.

The next question landed on real objects: could the same reasoning run over a list of proper objects, built and read with dot notation, asserted as facts from there? Trying it surfaced the biggest bug of this whole Act: obj.prop = value had never actually been reachable in the parser at all. A bare = is also a tolerated equality operator (a deliberate, tested tolerance, if 1 = 1 then ...), and the general expression parser's own operator-precedence loop always consumed "= value" as a comparison before the dedicated member-assignment code ever got a chance to see a pending = — the AST node, the lowering code, all of it had existed for who knows how long, entirely unreachable. The first fix attempt was wrong in a genuinely instructive way: reinterpreting the completed parse tree after the fact (a finished "Member equals something" node becomes an assignment) cannot work, because a bare = and a real == both collapse into the exact same tree shape once parsing is done — the fix broke a real, previously-passing test the moment it shipped, a closure using genuine == as its return value. The real fix had to happen earlier, at the token itself, before that distinction was lost: stop the parser's operator loop, without consuming the token, the moment it sees a bare = sitting right after a property-access expression outside of an if/while condition, and hand it back to the code that already knew what to do with it. Lesson: once two different meanings collapse into the same representation, no amount of cleverness downstream can tell them apart again — the only fix that can possibly work is the one applied before the information is lost, not after.

Fixing the parser exposed the mirror gap immediately, in two separate places at once. Rebuilding the self-hosted compiler to check it could compile the same file surfaced a completely unrelated, much older piece of drift: the self-hosted mirror of the "fast" (non-numeric-tower) value representation still described strings and lists as plain, unwrapped types, years behind the interpreter's real reference-counted versions — invisible until now because the one script responsible for regenerating these mirrors had, in its own comment, flagged this exact chunk as the one deliberately left out, "if that ever changes, add it here too." It had changed, quietly, a long time ago. Once that was fixed and the assignment could actually compile, a second bug surfaced immediately behind it: the self-hosted compiler's own lowering code for the newly-reachable assignment called a host function that has never existed, because nothing had ever exercised that code path before either. Lesson: a feature that's dead code in one implementation is often dead code — untested and silently wrong — in every implementation that mirrors it, not just the one where the deadness was first noticed.

Told the self-hosted parser still couldn't handle a couple of closure-body shapes the new object example needed, the user's answer was immediate: "Should fix that then." And, on being told the fix touched how the language treats a semicolon, came the necessary check: "I hope you aren't inveigling semicolons as a requirement into my lovely grammar..." It wasn't, and saying so plainly mattered — the fix only taught the self-hosted lexer to recognise a token the main compiler had already tolerated as a fully optional statement separator for a long time; newlines remain the only separator anyone is expected to use. The second, larger fix replaced three separate hand-rolled special cases in the self-hosted parser's statement dispatch (a bare call, a bare reassignment, one specific assignment shape) with a single call to the general expression parser already used everywhere else, then reinterpreted a trailing pending = the same way the main compiler's fix did — closing an entire family of gaps (index expressions, comparisons, anything the ad hoc list hadn't enumerated) in one pass rather than adding a fourth special case for whichever shape turned up next. Verified all the way through: the self-hosted compiler now compiles the object example correctly, compiles ITSELF into a working copy, and that copy compiles and correctly runs the same example again. Lesson: when a hand-rolled special case keeps needing one more branch added to it, the fix is usually to delete the special-casing entirely and call the general mechanism that already exists nearby, not to keep enumerating cases.

With the parser finally correct, the smaller, satisfying close: every object-oriented example in the gallery that had been written using explicit send/get calls got rewritten in the dot notation that had just been made to actually work, each one re-verified byte-identical both interpreted and self-hosted-compiled. One of the six, a custom-syntax example, still can't compile self-hosted at all — confirmed as a real, separate, pre-existing gap (the self-hosted compiler has no equivalent of the main compiler's pass that expands a custom syntax block into ordinary code before parsing begins), noted honestly as backlog rather than quietly worked around. And converting the examples surfaced one clean, small confirmation of something worth knowing on its own: an event handler couldn't see a variable declared outside it (a known limitation, worked around by re-declaring it fresh inside the handler), while an ordinary closure captured the exact same kind of outer variable correctly on the very first try — which is what prompted the next idea, offered unprompted rather than asked for: event handlers could, in principle, be migrated to work like real closures instead of the isolated functions they're synthesized into today, closing that whole class of gotcha at the root instead of teaching around it. Filed as real, scoped backlog, not yet started. Lesson: a small, deliberately narrow cleanup task can still be the thing that surfaces exactly which architectural inconsistency is worth fixing next — not because anyone went looking for it, but because doing the small thing carefully is what makes the inconsistency visible in the first place.

Act XXIII: a preprocessor that already existed, and the compile that took thirty minutes

Two backlog items sat side by side: give the self-hosted compiler the ability to expand custom syntax NAME { ... } blocks the way the main compiler already could, and consider migrating event handlers to real closures. Asked which to tackle first, the answer was practical rather than ambitious: the contained one first, the architectural one later. The expected task was porting roughly six hundred lines of Rust from scratch. It turned out not to be needed at all — a full, working, already-tested self-hosted implementation of exactly this preprocessor already existed in the tree, committed, with its own passing selftest. It had simply never been wired into the compiler's actual build script or its actual compile path. The whole "port" collapsed into two files, two include lines, and one function call in the right place. Lesson: before assuming a substantial feature needs to be built, check whether it already exists somewhere in the tree and just isn't connected to anything — a wiring gap and a missing implementation can look identical from the outside, and only one of them is a small fix.

Wiring it in immediately produced total silence: a compile that printed nothing at all and exited as if it had succeeded, when it plainly hadn't. What followed was an hour of bisecting a file by function boundary (bisecting by raw line count first, and getting nonsense answers, because truncating half a function produces invalid syntax that confuses everything downstream) to find that one function's parameter list read takes def, rule, line — and rule, a real keyword in this language, isn't a valid identifier where the parser expected one. Rather than erroring, the parameter list quietly stopped one name early and corrupted the rest of that function's body, with nothing printed anywhere to say so. Worse, once that corrupted program reached the interpreter and something inside it panicked, the panic vanished too — a thread-join wrapper meant to give deeply recursive programs more stack space was silently discarding any failure from that thread, reporting success no matter what actually happened inside it. Two real, separate diagnosability bugs stacked on top of each other, neither one a design flaw so much as a missing alarm. Lesson: a keyword silently refusing to be a variable name, and a crash silently refusing to be reported, are two different kinds of "the tool didn't tell me anything was wrong" — both deserve a loud, specific error, not a quiet default.

Both were fixed properly rather than routed around: the parser now names the exact reserved word and suggests a rename the moment it appears where an identifier belongs, and a panicked compile now exits with a real failure code instead of a cheerful zero. Getting the actual feature working after that surfaced two more real gaps in the self-hosted compiler in quick succession — a chunk of its own generated-code mirror that had quietly drifted years out of date, and a call composing an object's method the way Router.RegisterRoute(...) does, which the grammar had simply never been taught to parse at all. Told the fix meant adding real syntax the compiler had never supported, the answer was immediate and unambiguous: "Updating the grammar is acceptable for this..." — permission given plainly, for a genuine new capability, not a retrofit of an existing rule. Lesson: a user who distinguishes cleanly between "make my existing rules stricter" and "teach the language something new" is doing real design work on your behalf — both answers matter, and they are not the same question.

With the feature finally working end to end, a full self-compile — the compiler building a working copy of itself — was left running as a verification step. It ran for over thirty minutes with no sign of finishing. The direct question that mattered came unprompted: "It does seem odd that this compile is taking so much longer - reminds of the bad old days ... better check nothing recently changed might be responsible." It was exactly right. A scaling test on synthetic input (eight times the size taking roughly sixty-four times as long, the unmistakable signature of quadratic growth) confirmed a real regression before anything else was touched: the new preprocessor built its output one character at a time using plain string concatenation, and PatLang's strings are immutable, so each append silently copied everything accumulated so far. Fixing that barely helped. The actual dominant cost turned out to be one level deeper: the primitive used to read a single character back out of the string was itself recomputing a whole-string "is this ASCII" check on every single call — the exact cost a caching mechanism already built elsewhere in this runtime exists specifically to avoid, just never applied here. Only fixing both together turned a hard hang into genuinely linear behaviour. Lesson: a costly anti-pattern already diagnosed and fixed once in a codebase can still be reintroduced somewhere else that never got the memo — the fix belongs in the shared primitive, or it will keep needing to be rediscovered.

Confirming the fix against the real several-hundred-thousand-character file it needed to actually handle turned up a third bug, a correctness one this time rather than a performance one: the preprocessor had never tracked whether it was scanning inside a quoted string at all, unlike its Rust-native equivalent, which does. A string literal anywhere in that real file that happened to contain text shaped like a genuine block definition — entirely plausible, since large chunks of literal generated source text live inside string literals in this codebase — got misread as real, corrupting everything parsed afterward until it crashed outright. It had never been caught before because nothing had ever actually run this preprocessor's full scan against a large real file containing lots of quoted strings until this exact moment. Lesson: a scan that only tracks "am I in a comment" without also tracking "am I inside a string" will eventually misread a string that happens to look like the thing being scanned for — and the failure waits patiently for a large enough real input to appear.

Asked directly whether the tooling itself could catch the anti-pattern that started this whole detour, rather than relying on it being noticed by hand next time: "Could probably modify compile and interpreter to give strict loud warnings about string appending giving n^2 if the code does it, and recommend string builder instead." Built as a genuine static warning at compile time: any loop reassigning a variable to itself plus something else, where that variable had been given a string literal to start with, is flagged with a direct recommendation to use the string-builder functions instead. The obvious naive version of that check was wrong the moment it was tested against real code in this very codebase: this project's own idiom writes let x = x + ..., with the let keyword repeated, for what is really a loop-local mutation — not the bare reassignment form the first version of the check was looking for, so it silently never fired on a single real example until the mistake was caught and corrected. The check was also deliberately narrowed to variables that started life as a string literal specifically, so an entirely ordinary let total = total + n running total, which has the exact same shape in the syntax tree, would never be wrongly flagged. Once corrected, the warning immediately did its job unprompted, surfacing three more genuine instances of the same anti-pattern sitting quietly inside the compiler's own tokenizer — not yet fixed, now impossible to miss. Lesson: a diagnostic is only as good as its first real test against a real, working codebase, not a synthetic example built to prove the rule works — and a rule based on syntax shape alone has to be narrowed carefully, or it drowns its own useful signal in false alarms on completely ordinary code.

The end-to-end number, once every piece of this Act was actually fixed: the self-hosted compiler building a working copy of itself, on its own real several-hundred-thousand-character source, went from over thirty minutes with no end in sight to just over two minutes — confirmed correct the whole way through by using that freshly-built copy to compile the very example that had started this entire detour, and getting back the identical output the original, much simpler compiler had always produced. Lesson: "it already works, just slower than it should" and "it doesn't actually finish" look identical from a distance — only measuring the real case, at the real scale that actually matters, tells you which one you're looking at.

Lessons from this arc, the short version

  • When a parser's own reported diagnostic state contradicts an independent, ground-truth check of the same input, stop trusting the diagnostic. The file you're staring at may not be the file the failing code is actually looking at.
  • A diagnostic that reports a line number but not a source label invites misattribution the moment more than one file is ever involved. Cheap to add, easy to omit by default, and the omission can cost a full investigation before anyone notices it's missing.
  • When extending a language into a new hosting context, reproduce the exact invocation shape the real host will use before trusting anything works. Code that runs perfectly from a terminal can still fail under a host's specific process-spawning and I/O-piping behavior.
  • A gotcha discovered in one I/O context doesn't automatically generalize to a superficially similar one. Redirecting to a file and redirecting through a pipe can behave differently — check each empirically rather than assuming.
  • A bug that first looks input-specific is worth a broader question before it gets fixed narrowly. The trigger and the actual defect are not always the same size — one bad byte can be standing in for a much more general silent-failure footgun.
  • Tolerate the expected case, error clearly on the unexpected one — not silently accept everything, and not reject everything outright. A byte-order-mark is harmless and common enough to strip; a genuinely malformed character deserves a real, visible error, not silence in either direction.
  • An error path that hasn't fired in a long time is exactly where stale, confidently-wrong text likes to hide. A wrong hint is worse than no hint — remove it rather than patch it to sound current.
  • A "does it even compile" milestone can hide several genuinely separate bugs stacked on top of each other. Each one only becomes visible once the bug in front of it is out of the way.
  • "We'll just use the free registers" is not a design, it's an assumption wearing a design's clothes. Audit what's actually already spoken for before building anything that assumes spare capacity exists.
  • A scoping rule nobody deliberately designed for a given purpose can turn out to be the highest-leverage tool available. Worth actually testing what an existing rule does rather than assuming the harder, more obvious path is the only one.
  • A regression suite proves nothing broke that it already exercises — it says nothing about a code shape it has simply never contained. The next new feature is exactly what tends to find that shape first.
  • A milestone reached once is real progress worth recording honestly, even when the very next step immediately turns up a new, unresolved problem. "And then it all just worked" is rarely the honest version of the first attempt at anything.
  • Before assuming a promising idea needs a big new subsystem, check what's already half-built for a different reason. A parser without a writer is a much smaller gap than a library from nothing.
  • A full general-purpose data structure is often the wrong scope when only a handful of specific operations are ever actually needed on it. Build exactly those operations first, and see whether the general structure was ever really necessary.
  • A performance bug invisible at every test size tried so far and catastrophic at real scale is the default outcome of never testing at real scale. A fix already applied to one data type is worth checking against every structurally similar type in the same system, not assumed to be a one-off.
  • A tool that reports exact positions is only as trustworthy as the assumption that those positions mean what you think they mean. Verify the unit (bytes vs. characters) before trusting an automated fix at scale, and revert cleanly rather than patching around a corruption whose exact extent isn't yet known.
  • A side project undertaken to prove a system is genuinely reusable can end up mattering more for what it finds broken in the system itself than for whether the side project ever ships.
  • "Believed correct, unverified by a real compile" is a real flag, not a formality. Text embedded as a string literal is invisible to every type checker in the ordinary toolchain — the only way to know it's actually right is to run the exact downstream tool that will eventually consume it for real.
  • A fix that touches one shared type can still miss real call sites in more than one direction at once. Not just "forgot to touch this text" but "the two branches that both got touched now disagree with each other" — only a real compiler run reveals that.
  • The same bug can hide inside the very functions written specifically to fix a different case of it. Don't assume a "fixed" primitive is actually free of the bug it was written to eliminate — check it directly.
  • A synthetic benchmark improving dramatically is not proof the real-world case that motivated the fix is actually solved. Only running the real case again, honestly, and comparing against a naive equivalent in another language, tells you whether the gap is really closed.
  • A fix scoped to "the functions I know about right now" doesn't cover a function written five minutes later that happens to share the same anti-pattern. Name and search for the anti-pattern itself, not just the currently-visible instances of it.
  • A microbenchmark's input size matters as much as its iteration count when hunting for a cost that scales with content, not call count. A convenient short test string can hide the exact bug it was meant to prove was fixed.
  • Rising CPU time proves a process isn't deadlocked — it proves nothing about whether the work being done is useful. Get an actual progress signal, or a direct measurement of what the CPU time is actually being spent on, before treating "it's still running" as "it's making healthy progress."
  • Patching the same shape of bug in a third, fourth, or fifth function is a sign the fix belongs at the architectural level, not the call-site level. Extracting large opaque payloads before processing and splicing them back afterward closes a whole class of bug at once, rather than requiring the next instance to be found and patched individually.
  • A real, working milestone is worth stopping at to document honestly. The next open question, named plainly, is a better place to end a chapter than always chasing the investigation one more step before writing anything down.
  • A cost paid identically on every single invocation, when the underlying work is provably the same each time, is exactly the shape of problem a cache solves cleanly. The hard part is rarely deciding to cache — it's getting the cache key right.
  • A fingerprint or deduplication check is only as good as its understanding of everything that can vary the output. A fix's own explanatory comment is not exempt from causing the bug it describes, if it quotes the trigger literally inside the same text being checked.
  • A mechanism proven correct in isolation still has to survive contact with every real caller before it's actually done. Each new caller is a new chance to find a boundary nobody thought to check.
  • "It can't be done properly here" is a claim worth checking rather than accepting, especially from oneself. The actual obstacle is very often narrower than the first framing of it.
  • A generated mirror file is exactly the kind of thing that's easy to forget mid-session. The only test that actually proves a self-hosting claim is the one that exercises every generation in the chain, not just the one that happens to run first.
  • An example whose output matches what the prose claims is not automatically a good example. If the mechanism it exercises can't distinguish a correct answer from a wrong one, matching output proves nothing.
  • A user who keeps asking "wouldn't it be more real if X" after each fix is doing exactly the job a good reviewer does. Each round can expose something true and previously unstated about the runtime, not just a cosmetic complaint.
  • Once two different meanings collapse into the same representation, no amount of cleverness downstream can tell them apart again. The only fix that can possibly work is the one applied before the information is lost, not after.
  • A feature that's dead code in one implementation is often dead code — untested and silently wrong — in every implementation that mirrors it. Not just the one where the deadness was first noticed.
  • When a hand-rolled special case keeps needing one more branch added to it, delete the special-casing and call the general mechanism that already exists nearby. Don't keep enumerating cases one at a time.
  • A small, deliberately narrow cleanup task can still be the thing that surfaces exactly which architectural inconsistency is worth fixing next. Not because anyone went looking for it, but because doing the small thing carefully is what makes the inconsistency visible.
  • Before assuming a substantial feature needs to be built, check whether it already exists somewhere in the tree and just isn't connected to anything. A wiring gap and a missing implementation look identical from the outside, and only one of them is a small fix.
  • A keyword silently refusing to be a variable name, and a crash silently refusing to be reported, are both "the tool didn't tell me anything was wrong." Both deserve a loud, specific error, not a quiet default.
  • A user who distinguishes cleanly between "make my existing rules stricter" and "teach the language something new" is doing real design work on your behalf. Both answers matter, and they are not the same question.
  • A costly anti-pattern already diagnosed and fixed once in a codebase can still be reintroduced somewhere else that never got the memo. The fix belongs in the shared primitive, or it will keep needing to be rediscovered.
  • A scan that only tracks "am I in a comment" without also tracking "am I inside a string" will eventually misread a string that happens to look like the thing being scanned for. The failure waits patiently for a large enough real input to appear.
  • A diagnostic is only as good as its first real test against a real, working codebase, not a synthetic example built to prove the rule works. A rule based on syntax shape alone has to be narrowed carefully, or it drowns its own signal in false alarms on ordinary code.
  • "It already works, just slower than it should" and "it doesn't actually finish" look identical from a distance. Only measuring the real case, at the real scale that actually matters, tells you which one you're looking at.

See also

The Journey of Building PatLang (Acts I-VI) and the previous instalment (Acts VII-XIV) for where this page picks up from. Capabilities & Honest Limitations applies the same warts-included approach to PatLang's current state rather than its history. PatLang Best Practices now covers the str_intern/sc_len/sc_code/sc_char handle-based string-scanning idiom this Act's fixes depend on, alongside the existing vec_*/sb_* list-and-string-builder guidance, plus the chunk-precompile caching pattern from Act XXI. The standard library reference's codegen_bootstrap section lists rustc_build_chunked/rustc_build_chunked_texts alongside the older rustc_build. The PatLang Compiler Pipeline covers the fuller architecture this Act's mechanism became part of, including the self-hosted meta-circular interpreter added afterward and system diagrams of both.