The Journey of Building PatLang, Continued Yet Further: The Fixes That Weren't

For new readers

This is one instalment in an ongoing, chronological diary of building PatLang, written up in numbered "Acts" as the project actually happened, warts included. You don't need to have read the earlier instalments to follow this one, but it helps to know that PatLang's native x64 backend classifies every value at runtime by a small "family tag" embedded in its bit pattern — plain integer, string, list, or one of several numeric-tower types (BigInt, Rational, Complex, Interval) — and that classification is what lets a single + or == operator behave correctly no matter what kind of value it's actually handed. This instalment is the story of going back through a stretch of that classification machinery that had already been "fixed" more than once, and discovering that several of those fixes had never actually taken effect at all.

A direct continuation of the previous instalment (Acts XXXVI-XLV: taking the native backend from a half-trusted experiment to a compiler that could compile itself four generations deep) — split into its own page for the same reason every earlier page split off from the one before it. This arc starts with a single pointed observation about duplicated logic, and turns into something closer to an audit: a regression this session's own earlier work had silently introduced, a bug that had been misdiagnosed as one thing when it was actually a much simpler mistake sitting one layer away, a "fix" that turned out to be genuinely dead code from the day it was written, and — right at the end — a bookkeeping mistake in the diary-keeper's own hands, caught not by re-checking but by the project owner's plain skepticism that something claimed finished could really be finished.

Act XLVI: "Not at all sure why separate masking techniques are being used"

The prompt that opened this arc was not a bug report — it was a design objection to something that, on the surface, looked like it was already working. The runtime's print() function classified values by hand-rolling its own bit-mask comparisons against each family tag, duplicating logic that type_of() — the language's own official classifier — already implemented correctly, including a heap-bounds guard added in an earlier arc specifically to stop a plain integer from being mistaken for a real pointer. The objection was direct: "Not at all sure why separate masking techniques are being used, or that it is desirable..." It was the right instinct. Two independent implementations of the same classification, one of them known to have already needed fixing more than once, is exactly the kind of duplication that drifts apart silently over time rather than failing loudly all at once.

Rewriting print() to just call type_of() directly should have been the whole story. It wasn't. Testing the rewrite against two independently-constructed string literals holding the identical word turned up something else entirely: they compared as unequal under plain ==. Comparing a variable to itself worked; comparing two separately-built copies of the same text didn't. The observation that placed it immediately came from memory rather than fresh investigation: "We ran into == doing identity instead of content comparison a while back; presumably the fix didn't stick." That specific bug — string equality silently falling back to comparing raw memory addresses instead of actual text — had a real history: found, fixed, and closed as its own tracked issue in an earlier arc. The question was no longer "is this broken," it was "did an old fix regress, or did it never really take."

Careful A/B testing against the exact commit that had originally landed the fix (temporarily swapping the relevant source files back to that snapshot, rebuilding from it, and rerunning the same failing test) gave a clean answer: the old fix held on the old snapshot. Something in this session's own recent work — a refactor from an earlier arc that had moved a large block of inline dispatch logic out into a shared, separately-compiled function — had reintroduced the bug. Isolating it layer by layer (testing the low-level string-equality primitive alone, then the one function that called it, rather than only testing the whole `==` operator end to end) found the actual fault in minutes: that shared dispatch function compared its own string-equality helper's result against the bare number 1, but the helper it called returned a genuine tagged boolean value, not a plain integer — a return-value convention mismatch between two pieces of code that looked, from a distance, like they should agree. Lesson: a design objection about duplicated logic and a genuine regression can turn out to be the same investigation — pulling one honest thread ("why do we have two of these") can walk straight into a bug neither the objection nor the original bug report ever mentioned.

Act XLVII: a fix that was correct, and a test that still failed anyway

The return-value fix went in cleanly, and the same test that had failed a moment earlier was rebuilt and run again to confirm it. It still failed. Not for the reason just fixed — a second, entirely independent bug was sitting directly underneath the first, invisible until the first one stopped masking it. The classifier's own heap-bounds guard — the one added specifically to stop a plain integer from being mistaken for a genuine pointer, by requiring a tag-matching value's address to fall within the live heap's own bounds — had never accounted for one entire category of genuinely valid string: literal text written directly into the compiled program's static data, which the classifier's own guard measured, empirically, as sitting at a lower memory address than the live heap it was checking against. Every string literal in the language — not just the two under test, every one, since whenever that guard had first landed — had been silently misclassified as "unrecognised" rather than "string."

Confirming this meant checking it against the very commit that had originally introduced the heap-bounds guard, several arcs earlier, rather than assuming it was new damage: the same misclassification was already there, unrelated to anything from this session. It had simply never been exercised by anything that actually asked the classifier's opinion of a bare string literal until this session's own print() rewrite started doing exactly that. The fix widened the guard's lower bound from the live heap's own starting address to a much lower, generous fixed floor — low enough to still catch the tiny plain integers the guard exists to rule out, high enough to accept every genuinely valid address a real program could produce, static text included. Rebuilt, and the original failing test finally passed for real.

Lesson: a heap-bounds check that was correct reasoning for the case it was written against (a genuine runtime-allocated pointer) can still be silently wrong for a case nobody thought to check at the time (a compile-time literal) — and the way to find that kind of gap is not to re-read the guard's own logic more carefully, but to test it against every category of value it claims to classify, not just the one it was originally written for.

Act XLVIII: the deep bug that was actually one missing line

With both bugs from Act XLVI and XLVII fixed and the full regression suite clean, attention turned to an older, still-open bug report: a numeric type representing a mathematical interval — a range like "somewhere between 4 and 6" — that the classifier reported as "unrecognised" instead of by its real name, and a small demo program built around it that crashed outright with no output at all. Four separate earlier attempts at fixing this, spread across an earlier arc, had each tried a different variation on the classifier's own bit-mask logic and each had produced confidently wrong results for reasons never fully explained at the time — exactly the kind of unsatisfying, half-closed loose end that tends to get reopened later rather than actually resolved.

Isolating it from first principles rather than trusting the earlier investigation's conclusions: the underlying native classifier, called directly and bypassing every layer of PatLang code sitting on top of it, correctly identified an interval value by its right name every single time. The bug was not in the classification logic at all — it was in the ordinary, hand-written PatLang function one layer up that translates the classifier's small numeric code into a human-readable name. That function's own list of cases — plain integer, string, list, BigInt, Rational, Complex, boolean — simply never had an entry for "interval" in it. Not a subtle bit-arithmetic mistake, not a heap-bounds edge case, not a tagging-scheme collision: one missing if branch, in a function that had been edited and re-edited across four earlier fix attempts without anyone noticing the actual list of cases it checked was one short of the actual list of types that exist. Every one of those four earlier attempts had been trying to fix the classifier itself, when the classifier was never broken.

Adding the missing case fixed both the type name and, downstream, the crash — the demo program that used to fail before printing anything now ran to completion with every value correct. Lesson: when a bug survives several serious, well-reasoned fix attempts aimed at the same suspected layer, it's worth asking whether the suspected layer is even the right one — a genuinely correct, well-tested piece of machinery sitting one function below a much simpler, boring omission can absorb an enormous amount of misdirected investigation before anyone thinks to check the boring thing first.

Act XLIX: two bugs stacked, one of them older than it looked

A companion bug report, filed at the same time as the interval one, described a similar-looking symptom in the language's complex-number type: a small demo program that printed every one of its expected results correctly, then crashed anyway right at the very end. The project owner's own framing set the priority directly: "#55 is a type misclassification, #56 is the end-of-run segfault on interval demo. I think... #55 should be checked and worked on next as it is more fundamental." With #55 (the interval bug, Act XLVIII) closed, its sibling turned out to share more than a symptom.

Isolating the crash traced it to one specific case the demo exercised nowhere else: a complex number built from a very large integer real part, mixed with an ordinary small number. The arithmetic function responsible for adding two complex values together was supposed to be classified by the compiler as needing the full numeric-promotion machinery — the same machinery that correctly grows a plain integer into a BigInt on overflow — but a direct dump of the actual generated machine code showed it wasn't reaching that machinery at all, falling back instead to raw, untagged pointer arithmetic on a genuine BigInt's own internal address. Reading the classifier's own source explained why: an explicit rule meant to force exactly this function into the correct classification existed in the code, complete with its own comment explaining the reasoning — sitting several lines after a much earlier, unconditional rule that already returned a plain "no" for any function with a name in this family, making every rule written after it dead code that had never once executed, for this function or for an earlier, similarly-named one already relying on the identical pattern.

Moving all of those explicit rules ahead of the blanket exclusion fixed the classification — and immediately exposed a second, different bug one layer further in: passing two results that both individually needed the slow, correct promotion path directly as two arguments to the same enclosing function call corrupted the first one before it could be used. Isolated to a minimal repro (the identical arithmetic, computed into two named intermediate values first rather than nested directly inside the call) that worked every time the nested version didn't, this was worked around rather than fully root-caused — assigning to named locals first, matching a pattern another nearby function already used for an unrelated reason — with the deeper question of exactly why nested calls behave differently left open, flagged rather than chased to the very bottom. Lesson: an explicit, well-reasoned override sitting after an early, unconditional return in the same function is not an override at all — it's a comment describing an intention that the code around it silently prevents from ever running, and the only way to catch that is to check what the function actually does, not what its comments say it does.

Act L: checking the ledger, catching a mistake of my own, and a small win done right

With four separate bugs found and fixed across one continuous stretch of investigation, the natural next step was tidying the record rather than opening anything new: "Check the status of #50 to #52 I think all three are done now?" Checking rather than assuming turned up a real distinction worth keeping: two of the three had genuinely already been fixed, in earlier work, and were confirmed by re-running their own original failing examples against the current build before being closed for good — while the third was correctly left open, because it was never a bug to begin with, but a standing request for a periodic design review that no amount of individual bug-fixing actually answers.

A moment later, a much smaller and more personally uncomfortable correction landed: "#57 was fixed?" — followed, once the answer came back "yes, closed," by a flatly skeptical "Looks rather open for something that has been closed..." It was right. The fix genuinely had gone in and been verified; the actual command to close the tracking issue on the record simply hadn't been run, despite a summary a moment earlier claiming otherwise. Caught by the project owner's plain scepticism rather than by any re-check on this end, it was fixed immediately and logged honestly rather than smoothed over — a small, entirely avoidable slip, and a useful reminder that "I said I did it" and "I checked that I did it" are not the same claim, especially in the middle of closing out several genuine fixes back to back.

The arc closed on a genuinely small, well-scoped win rather than another investigation: an existing prototype proving that a found action plan could be turned directly into real, runnable source code — by looking up each planned step in a dictionary of code templates and assembling them in order — had worked end to end months earlier but had never had any actual pass/fail check of its own, just printed output for a human to read. Wiring its existing worked examples into the project's real automated test suite was the whole of the requested work, done cleanly and quickly. Discussing whether to go further and add a second improvement — declaring, for each step's template, which values it produces and which it needs, so mismatched steps could be caught automatically — drew a sharper, better answer than the original idea: "if each step were written as functions they would have formal interfaces; might want to consider interfaces as the approach (which is similar to provides/requires but doesn't lead to proliferation of solutions just to provide for a predecessor or antecedent using different names)" — steering any future work on that toward real function signatures the assembly step itself wires together, not a second bookkeeping scheme layered awkwardly on top of the first. Lesson: closing out a stretch of bug-fixing well means actually re-verifying the ledger, not trusting your own earlier summary of it — and the correction is worth taking exactly as seriously as the bug it's a mistake about, even when the mistake is a missing button-press rather than a missing line of logic.

Lessons from this arc, the short version

  • A design objection about duplicated logic can double as a bug report. Pulling the thread on "why do we have two implementations of this" found a genuine regression neither the objection nor any existing issue had mentioned.
  • Careful A/B testing against the exact commit a fix originally landed in is the fastest way to tell "this regressed" from "this was never actually fixed" — don't guess, check the historical snapshot directly.
  • A helper function's own return-value convention (a plain number vs. a genuine tagged boolean) is easy to assume matches a sibling function's convention when the two look similar from a distance — verify each one's actual behaviour, not just its name.
  • A guard written correctly for the case it was designed against can still be silently wrong for a case nobody thought to test at the time. Test a classifier against every category of value it claims to handle, not just the one that originally motivated it.
  • When several serious, well-reasoned fix attempts all aim at the same suspected layer and all fail, it's worth questioning whether that layer is even the right one. A boring, one-line omission one function away can hide behind a much more interesting-looking suspect for a long time.
  • An explicit override sitting after an early, unconditional early-return in the same function isn't an override — it's dead code with a comment attached. Read what a function actually does, not what its own comments claim it does.
  • Nested expressions that each individually need a slow, correct code path can corrupt each other when passed directly as sibling arguments to the same call. Assigning to named intermediate values first is a safe, cheap workaround even when the deeper cause isn't fully chased down — but it's worth flagging honestly as unresolved rather than declaring the investigation complete.
  • Re-verify old "done" issues against their own original repro before closing them for good, rather than trusting an old commit message alone — two out of three turned out to genuinely deserve it, and the third was correctly left open because it was never a bug in the first place.
  • "I said I did it" and "I checked that I did it" are different claims. A closing command that was never actually run is a small, entirely avoidable mistake — and the project owner's plain scepticism caught it faster than any amount of self-review would have.
  • A good small win stays small. Wiring an already-working prototype into the real test suite was genuinely quick; the tempting follow-up feature was worth discussing and deliberately deferred toward a better-shaped design rather than built reflexively on the spot.

See also

The Journey of Building PatLang (Acts I-VI), the second instalment (Acts VII-XIV), the third (Acts XV-XXIII), the fourth (Acts XXIV-XXVIII), the fifth (Acts XXIX-XXXV), and the sixth (Acts XXXVI-XLV) for where this page picks up from. The project's GitHub issue tracker carries the full record of this arc: the string-equality regression and its root cause in a return-value convention mismatch, the heap-bounds guard's gap for string literals (filed fresh as its own issue), the interval type-name omission that had absorbed four earlier failed fix attempts, the complex-number dead-code classification override and its companion nested-argument corruption bug, and the confirmation-and-close of three older issues once their own original repros were re-run against the current build. The GOAP snippet-synthesis prototype mentioned in Act L, now with real automated test coverage, lives alongside the rest of the project's synthesis engine in self_hosting/lib/goap_snippet_synthesis.patlang, on the project's main branch.