JakeChampion / JakeChampion/lang

architecture: irlower's RC/ownership facts are a scopeless, string-multiplexed side table keyed by variable NAME — the second half of the #5531 brittleness

Open
#7,253 13 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

architecture enhancement ir self-host
Dominant language
Go
Stars
1
Forks
0
Avg merge
1h 37m
Merged PRs (30d)
977

Description

Companion to #5531 / #5986, covering the axis those two do not.

#5531 and #5986 diagnose one half of irlower.fern's brittleness precisely: type
facts are re-derived at lowering time instead of read from the checker, and the fix is
annotate-and-consume. That work is live and landing (docs/TYPED-IR-REWRITE.md,
carriers through ExprSlice.ty).

The other half is untracked: ownership facts — which slot is fresh, borrowed,
aliased, moved, reclaimable, a view, reusable — are carried in a hand-rolled untyped
map that is keyed by the variable's name and has no notion of scope.

The shape

LowerState.reclaimable_names: string[] is not a list of reclaimable names. It is a
multiplexed side table: entries are "<TAG>:<name>" or "<TAG>:<name>|<value>",
decoded by reclaim_has / tagged_value_of. Measured on origin/main today:

distinct "TAG:" namespaces multiplexed into that one field 73
reclaim_has(s.reclaimable_names, …) probe sites 38
sibling name-set fields on LowerState (aliased_names, borrowed_names, moved_names, own_params, grow_sole, grow_exempt, append_inplace, xblock_pending, optret_pending, defer_slots, move_sites, …) 30-field struct, ~half of it
name-keyed module registries on FnSigs 27, all string[]
LocalInfo flags encoding one slot's type as parallel booleans + comma-joined strings 21 fields

LocalInfo shows the cross-product problem directly: is_arr, is_strarr,
is_boolarr, is_f64arr, is_arrarr, is_closurearr, is_fnarr are seven flags
spelling one fact (Array[elem]) that a structured type spells once.

Why it is brittle, not merely ugly

The key has no scope. Fern has lexical scoping; a name in this table does not. So
every consumer that could be wrong about which binding a name refers to has to
hand-guard it. irlower.fern mentions shadowing 79 times, and carries
body_declares_name at 14 sites — a predicate that exists solely to ask "did anyone
rebind this name?", because the table cannot answer. docs/rc-log/2026-08-20-tostring-callee-local-recv.md
states it plainly: "The scan has no scopes. A nested block may shadow the name."

Each of those guards is correct where it was written and absent where it was not. That
is the defect generator:

  • #6191 — the lift's name-keyed lookup resolved a fn-typed parameter to a module
    function of the same name. Fixed at that site.
  • #6283 — the return-type registry did the same thing one level over: a user
    function named f returning i64 silently retyped the parameter f inside
    std/array's map/flat_map, producing an IR bail on x86-64 and a wasm module
    that fails to load
    , blaming stdlib the program never called. Fixed at that site
    (#6290).
  • docs/RC-PERCEUS-SELF-HOST-PORT.md records ~a dozen more shapes where a slice had to
    add its own shadow refusal ("refusing a local that shadows the declaration",
    "a frame that shadows the callee refuses the store", §9 lines 7145, 8347, 8398,
    9078, 9117, 9169, 9406). One of those notes says outright: "No probe witnesses that
    guard."

Two independent instances of the same root cause were each closed as their own bug.
Neither closed the class.

The encoding also costs. §4d.4 of docs/PERFORMANCE-AUDIT-2026-08.md is four PRs
(#7020, #7026, #7036, #7046) spent making the string decoding of these registries
cheap — self-compile 78.8 s → 42.4 s user, ≈ −46% — without changing the design. #7046
alone deleted ~83,000 string concatenations per module that existed only because the
facts are strings. A typed, slot-keyed table would not have generated any of that work.

And it is growing. docs/TYPED-IR-REWRITE.md sized irlower.fern at 39.8k lines
(2026-07-23); #5986 at 43,204 (2026-08-02); it is 60,339 today — +51% in under a
month, against checker.fern at 13.1k and parser.fern at 19.0k. The four largest
functions are 1,786 / 1,629 / 1,603 / 1,486 lines. Every goal-2 slice adds another
"TAG:" namespace and another set of hand-guards, so the eventual consolidation gets
larger at roughly the rate the feature work goes in — the same argument #5986 makes for
the type axis, on the axis with no issue behind it.

What the fix looks like

Not a rewrite, and deliberately not blocked on Phase B (SSA). The move is the same
"stop re-deriving what is already known" as #5531, applied to ownership:

  1. Key by slot, not by name. Every consumer already has the slot index (LocalInfo
    is indexed by it). Replacing the string key with the slot number makes shadowing
    structurally impossible and deletes the 14 body_declares_name guards and the 79
    shadow caveats along with it. This alone closes the #6191 / #6283 class.
  2. Give the facts a type. Replace the 73 string prefixes with a SlotFacts struct
    (or a small tagged enum per fact family) hanging off LocalInfo. The prefixes are
    already a de-facto enum; the compiler should be checking it. Deletes reclaim_has,
    tagged_value_of, and the concatenation traffic #7046 was fighting.
  3. Collapse the LocalInfo cross-product into the structured type the typed-IR
    carriers are already delivering — this is where the two issues meet, and why they
    should be sequenced together rather than merged.
  4. Split the file. 60k lines in one module is its own hazard: the four >1.4k-line
    functions are where the sibling-arm bugs live (docs/TYPED-IR-REWRITE.md:
    "the tuple DESTRUCTURE has its own copy of that ExprIndex arm … only one of them
    was fixed"
    ).

Same migration discipline as #5986: one fact family at a time, byte-identical
self-compile fixpoint per step, delete the string form when no consumer reads it. Note
the #5986 caveat applies with more force here — these facts drive the exit dec-sweep,
so internal/e2eselfhost is the primary gate and the fixpoint is secondary
(docs/TEST-GATES.md).

Acceptance test for step 1

function f(): i64 { return 12i64; }
function main(): i32 { return f() as i32; }

with import "std/array"; — #6283's repro, which passes today only because #6290
patched one lookup. A slot-keyed table makes the whole family unreachable rather than
individually guarded.

Refs #5531, #5986, #6191, #6283, #4451.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with irlower.fern, especially LowerState, LocalInfo, reclaim_has, and tagged_value_of, then read docs/TYPED-IR-REWRITE.md and docs/TEST-GATES.md. Begin with the #6283 acceptance program and run internal/e2eselfhost. Done means one ownership fact family is slot-keyed, its string form and consumers are removed, and the self-host gate passes.

Written by the indexing model from the issue text.

Assessment

Domain
compilers
Issue type
Refactor
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.