ADR-0103 · ∀-generalization for bound-free self-recursive generics: a call-site-monomorphization pre-pass (the List-family door)
-
Status: Accepted
-
Summary: A bound-free self-recursive generic (
length : List a -> Int, the whole List family:append/take/drop/zip/range/replicate) is realized by the SAME elaborate-to-mono move as every other generic (ADR-0075/0079/0080/0082): a pre-elabSpass discovers the FINITE set of concrete instantiations from the program's call sites, and emits ONE monomorphiclet recresidue per instantiation — exactly the two-residues-by-hand program of witness w3, auto-generated. The kernel /Source.eval/HasCTy/ soundness NEVER see a∀, a tyvar, or a bound (invariant #4/#5 hold, census byte-identical). The fork's TWO surface doors — (a) "real top-level ∀ forlet recascriptions" and (b) "a bound-FREE bounded-fn" — collapse to the same mechanism (one construct per problem): both need call-site-driven monomorphization of an ARGUMENT-position tyvar; door (b)'s reuse-the-bounded-fn-seam framing is REFUTED (witness w2 — ADR-0080'sbfnWrapperrequires the carrier in RESULT position, the fold shape… -> a; the List family's carrier is in ARGUMENT position,List a -> Int). The honest MINIMAL door (c) — monomorphic prelude entries per base type (lengthI/lengthB/…) — is priced and rejected (combinatorial blowup, no genericity). Verdict: adopt the monomorphization-pre-pass framing (door a ≡ door b); defer implementation to its own spike; the surface spelling is a bound-freelet recascription (let rec length : List a -> Int), no newwheresyntax. -
Depends-on: 0075, 0080, 0079, 0073, 0069
-
Relates-to: #120 (the machine-traced wall this resolves), #105 (the 9-10/10 List-family residue it gates), Q26 (the generic lawful stdlib), R6 /
docs/notes/lambda-cube-ascent-survey.md(the finiteness gate this lives inside), #55 (annotation-driven generic CONSTRUCTION — the append/zip half's residual dependency) -
Status: Accepted (kernel-engineer consult 2026-07-11; the OPERATOR ratifies — a type-power extension, invariant #4/#5 discipline; landed 2026-07-11:
monomorphizeLetRecimplemented as theexpandBFnstwin,Prelude.bang'stake/dropshipped as the List-consumer payoff.lengthand a kernel-providedListdeferred — see the Implementation note below for the residual findings this session established empirically) -
Date: 2026-07-11
-
Layer: C + checker/elaborator (tested superset). Frontend LEAF (
Frontend/TypeCheck, the elaborate-away seam); census byte-identical, kernel untouched. NO Spec.lean / Kernel change (a kernel change would be a REFUTATION finding — it is not needed; witnesses w0/w3 prove the residues already run).
Context — the #120 wall (machine-traced, refute-first)
Every List-family prelude entry is a self-recursive function generic over the element type with NO
trait bound (length : List a -> Int, append : List a -> List a -> List a, …). The corpus has NO
such entry today — only monomorphic List Int recursion (nqueens, Prelude's Str-typed folds) and
NON-recursive generics (mapOption/bimap, which ride let-generalization). #120 traced two failure
modes; both are reproduced against the real binary in witness-0103/:
witness program observed (bang run / check, 2026-07-11)
─────── ──────────────────────────────────────── ──────────────────────────────────────────────
w0 let rec length : List Int -> Int (mono) 2 — the RESIDUE; certifies TOTAL
w1 let rec length : List a -> Int (free a) error: unknown type name 'a' — THE WALL
w2 fn length(xs):List a->Int where Monoid a error: … result type must be 'a' (fold shape)
w3 two mono residues (Int + Unit+Unit) in one 3 — the monomorphization TARGET
w4 non-rec `id` at two types (generalizes) 5 — why let-general can't be reused
The wall is ONE line. w1 fails at resolveTy env.gen env.aliases t env.effects in the
.letRecS elaboration arm (TypeCheck.lean:3033): resolveTyG's .tName "a" arm finds no σ-entry
(no type params bind at top level) and no self? match, so resolveName fails loud "unknown type
name 'a'". No construct introduces a free top-level ∀a. Everything downstream — structOK/Div
certification, the buildLetRec μ-knot, the bidirectional checker — is INERT to the tyvar once it is
closed to a concrete type: witnessed by w0 (length : List Int -> Int type-checks, certifies total,
runs).
Why let rec can't ride the let-generalization that non-recursive generics already use (w4, the
#120 "say precisely why" ask). ADR-0075 bite-0b generalizes a let-bound THUNK's value type (the
⑦b id example — w4 runs id at two types). The obstacle for let rec is NOT the ascription
resolution alone; it is the SELF-KNOT. In the .letRecS arm the recursive name is bound in its own
body at a SINGLE fixed monomorphic thunk type uT := .U botR (embC (ctyOf t')) (TypeCheck.lean:3034),
and buildLetRec closes the μ-knot Rec = μX. Thunk(X → T) over a CONCRETE t'. Generalizing the
ascription would require the recursive CALL to instantiate the scheme at a fresh type — i.e.
polymorphic recursion, which is undecidable (the R6 §4 mono-instantiation wall: no finite
instantiation set by construction). But the List family is UNIFORM: $length t calls length at the
SAME a as the caller — monomorphic recursion, which monomorphization handles by construction.
So the barrier is "the self-knot is monomorphic", and the fix is to close the tyvar BEFORE building
the knot (once per call-site instantiation), never to generalize the knot itself.
The fork, refuted both ways
Door (a) — real top-level ∀ for let rec ascriptions. Generalize the ascription's free tyvars
(close over the decl, HM-style). Falsifier outcomes:
- The checker's self-recursion seeding: a SCHEMATIC seed breaks the fixpoint (polymorphic recursion,
above). REFUTED as stated — a genuine
∀-scheme in the self-knot is undecidable. - BUT: if "generalize" means "instantiate the scheme per call site and monomorphize each" (the
elaborate-to-mono reading, NOT a residual
∀in the knot), it is decidable and sound — this is the same monomorphization ADR-0075/0080 already run, applied to an argument-position tyvar. This reading SURVIVES.
Door (b) — a bound-free bounded-fn (fn length(xs) : List a -> Int where a = …, or for a /
bare where a). Falsifier outcomes:
- The fold-shape wall (w2, DECISIVE). ADR-0080's carrier-fixing is annotation-driven off the
RESULT type:
bfnWrapper(TypeCheck.lean:2642) requires the declared result to BE the bound var (… -> a). The List family's tyvar is in ARGUMENT position (List a -> Int, resultInt). Even with a vacuous bound, the bounded-fn path rejects it (w2, observed on the real binary). So "reuse the bounded-fn seam, minus the dictionary" does NOT work — the seam's whole carrier-discovery assumes result-position, and the ops-splicing step (env.rawImpls.find?) has nothing to splice for a bound-free fn anyway. The dictionary is not the only thing to drop; the entire carrier-from-result mechanism is wrong for this family. REFUTED as a bounded-fn variant. - The empty-dictionary question ("does the splice step become a no-op?"): moot — the splice step is
reached only AFTER the fold-shape check passes, which it never does for
List a -> Int.
Same mechanism (one construct per problem). Once door (b) is stripped of the bounded-fn framing,
what BOTH doors need is identical: discover the finite instantiation set of an argument-position
tyvar from call sites, monomorphize the let rec per element. Door (a) reaches it by
"generalize-then-instantiate-per-use"; door (b) by "bound-free fn monomorphized per use". These are
the same elaborate-to-mono pass with two surface spellings. Adopting BOTH surfaces would be two
constructs for one problem. We adopt ONE: the existing let rec … : T = … ascription form, with
resolveTy extended to admit free tyvars (closed by the monomorphization pre-pass), and NO new
where/for/fn syntax. Rationale: let rec is already the generic-recursion surface (ADR-0073);
the wall is purely that its ascription can't NAME a free tyvar. Fixing that one gap is minimal; adding
a parallel bound-free fn form is a second door onto the same room.
Decision
- A call-site-monomorphization pre-pass (structurally the
expandBFnstwin, ADR-0080): a pure fuel-boundedSurf → Surfrewrite running BEFOREelabS. For each bound-free genericlet rec, collect the concrete types at which it is applied (the instantiation set), emit one specializedlet recresidue per instantiation (resolveTy's tyvar closed to that concrete type, then the existingbuildLetRecμ-knot), and rewrite each call site to its residue. The kernel sees only the concrete residues (w0/w3 prove they run). - Surface: the bound-free
let recascription.let rec length : List a -> Int = …— a free tyvar in the declared type is admitted; NO new syntax.resolveTy/resolveTyGgain a "collect free tyvars as generalizable" reading at the.letRecS/top-level-decl entry (the ONLY sites that may introduce a top-level scheme — every otherresolveTycaller stays fail-loud on an unknown name, preserving the typo-catch). - Instantiation discovery is annotation-anchored, finiteness-gated (R6). v1 discovers the carrier
from each call site's argument/annotation (the ADR-0079/0080 annotation discipline), computing a
FINITE closed instantiation set. A call whose carrier is itself unresolved (a generic used inside
another un-instantiated generic — polymorphic recursion / an unbounded set) is a LOUD error
("annotate the use" / "cannot monomorphize"), never a guess — the R6 finiteness gate as a fitness
function, the type-system analog of
Div. - structOK/Div is inert (verified). Certification runs AFTER monomorphization on ground residues
(w0:
length : List Int -> Intcertifies TOTAL with no Div marker). No change to structOK. - Kernel/Spec.lean UNTOUCHED. No
∀reaches the kernel; residues are ordinary monomorphiclet recs. This is the fifth elaborate-away win (after ADR-0075/0079/0080/0082); the census (18→20 headline theorems) does not move.
Rejected / staged
- Door (b) as a bounded-fn variant (
where a/for a). REFUTED by the fold-shape wall (w2): ADR-0080's carrier lives in result position; the List family's is in argument position. Not a syntax tweak — a different discovery mechanism. Rejected in favor of extendinglet rec. - A residual
∀-scheme in the self-knot (naive door a). Polymorphic recursion, undecidable (R6 §4). The monomorphize-per-call-site reading is adopted instead. - (c) Monomorphic prelude entries per base type (
lengthI : List Int -> Int,lengthB : List (Unit+Unit) -> Int, …). The "ugly-but-tomorrow" floor: it WORKS today (w0/w3 are literally this, by hand) but requires one hand-written entry per (function × base type) — a combinatorial blowup with no genericity, and it does not scale pastIntto user data types. It is the fallback if the pre-pass slips, and the ground truth the pre-pass must reproduce — but not the ship target. Priced and rejected as the primary door. - A System F kernel (carry the
∀inHasCTy/the LR/soundness/CalcVM). Spine work, unnecessary — elaborate-to-mono is the standing architecture (ADR-0075). Rejected (invariant #4/#8).
The sites-to-change map (for the implementing spike)
site change
────────────────────────────────────────── ─────────────────────────────────────────────────
TypeCheck.lean:3033 (.letRecS `resolveTy`) the CHOKEPOINT: admit a free tyvar in the ascription
(collect-as-generalizable), not fail-loud
resolveTyG/.tName arm (:1821-1828) a new "free tyvar → generalizable marker" reading,
gated to the top-level/`.letRecS` entry only
(other callers unchanged — typo-catch preserved)
a new `monomorphizeLetRec` pre-pass the `expandBFns` twin (:2698): discover the finite
instantiation set from call sites, emit one
`buildLetRec` residue per element, rewrite calls
elabProg wiring run the pre-pass before `elabS` (as `expandBFns` runs)
genericPrelude / Prelude.bang (:3531) the List family becomes expressible as bound-free
`let rec` entries (the #105 payoff)
buildLetRec (:2347) UNCHANGED (each residue is a concrete `let rec`)
structOK / letRecRow (:2295) UNCHANGED (runs on ground residues, w0)
Kernel / Spec.lean / Core UNTOUCHED (no `∀`, invariant #4/#5)
R6-consistency argument (the finiteness gate)
This lives entirely inside the elaborate-to-mono rung the R6 survey already priced. length : List a -> Int is the F (∀) rung, which the survey records as SHIPPED via monomorphization
(lambda-cube-ascent-survey.md §1, the "won five times" line). A bound-free generic is STRICTLY
LESS demanding than a bounded one: no dictionary, no trait resolution, no impl lookup — just close a
tyvar. The instantiation set is discovered from call sites exactly as bounded-fn carriers are
(ADR-0080), and for the uniform (monomorphic-recursion) List family it is FINITE and CLOSED at
elaboration time — the §4 gate holds. The one place it could fail the gate is polymorphic recursion
(a self-call at a DIFFERENT type, growing the set unboundedly); that is caught loud by the finiteness
check (decision item 3), never monomorphized silently. Kernel untouched, census stable — the survey's
licence extends here conditional on the finiteness gate, exactly as it does for the bounded rung.
Implementation (2026-07-11) — the residue and residual findings, established empirically
monomorphizeLetRec (the expandBFns twin, Bang/Frontend/TypeCheck.lean) discovers a bound-free
let rec's instantiation set from ANNOTATED call-site arguments ((($f) arg : T) — the argument-
position twin of bfnWrapper's result-anchored discovery, w2's own decisive reason the two can't
share a mechanism) and emits one monomorphic residue per distinct instantiation, wired into
elabProg right before elabS. Four corpus #guards (Bang/Examples.lean §F) pin: the w3
two-residue shape now expressible as ONE bound-free let rec; lexical shadowing; the zero-call-site
DROP (an unreferenced generic costs nothing, the expandBFns/env.bfns precedent); and polymorphic
recursion REJECTED loud by the downstream type-checker (not a targeted diagnostic yet — a type mismatch, since every self-call inside one residue forces to that residue's own name regardless of
its own annotation — safe, never silently wrong, but a coarser message than "annotate the use").
CONSTRUCTION REFUTES this ADR's own residual-dependency framing above. append/take/drop/
zip all CONSTRUCT (Cons(h, tail)) inside a monomorphized residue and run correctly WITHOUT any
#55 annotation-free-inference work — because by the time elabS sees a residue, its tyvar is
ALREADY closed to a concrete type by substTyVar; Cons(h, tail) is then ordinary MONOMORPHIC
construction, not generic inference. Verified live: append/take both build + consume List Int
correctly, chained through an intermediate let with no annotation on the DOWNSTREAM use (only the
generic function's OWN call site needs the anchor). The REAL boundary is narrower and different in
kind than "construction vs consumption": it is whether the call site that discovers the
instantiation carries an explicit annotation — #55's annotation-free CONSTRUCTION wall is a
SEPARATE, still-real gap for a DIFFERENT case (an un-annotated ctor intro with no enclosing type
context at all), but it does not gate the List family's constructing half the way this ADR assumed.
Two residual gaps found, priced, and left open (not this session's scope):
- The auto-
usealias indirection (ADR-0098): an unqualified prelude call ($take …) resolves through alet take = Prelude_take in …alias, not a direct reference to the qualifiedlet rec's own name — invisible tomonoCallSpine's discovery without a dedicated collapse pass (inlineVarAliases, added this session, narrow: only the ONE bare-.var-RHS shape the module system produces, not a general copy-propagation optimizer). - A kernel-provided
Listis NOT shipped (deferred, not fixed):data List areferenced ONLY via a type ANNOTATION (never a bare.var) is invisible toinjectPrelude's mention-filter (progUsesVar/surfUsesVarscan value positions only, never a.annotS'sTyslot) — soListcan't ridePrelude.bang's mention-filtered injection the waytake/dropdo. Adding it to the UNCONDITIONALgenericPreludebucket instead (alongsideOption/Result) was tried and reverted: its natural ctor namesNil/Cons, made globally visible, collide with SEVERAL pre-existing corpus fixtures that independently chose the SAME bare names for their OWN differently-named list-shapeddatadecls (listProg'sdata IntList = Nil | Cons(…), confirmed live via ADR-0099 ambiguous-bare-ctor cascades).take/dropstill work today against a program's OWNdata List adeclaration — only the zero-declaration convenience is deferred.
What the List family costs once the door opens
The 9-10/10-universal List family (#105's final residue) becomes expressible as bound-free let rec
prelude entries. CONSUMING members (length/take/drop/sum) are unblocked immediately (w0/w3
prove the residues run + certify) — SHIPPED for take/drop; length itself is withheld (not a
mechanism gap — its NAME collides with a pervasive corpus-local helper of the same name, taxing
several tight-fuel #guards the moment it entered the mention-filtered prelude; a rename or a
fuel-bump sweep would resolve it, out of this session's scope). CONSTRUCTING members
(append/zip/range/replicate) do NOT carry the #55 dependency this ADR originally assumed
— see the Implementation note above; they are blocked only by the SAME Prelude.bang mention-filter
gap List itself hits (a construction call site needs List/Nil/Cons resolvable, which needs
either a kernel-provided List or the caller's own data List a), not by annotation-free inference.
Revisit if
Polymorphic recursion is genuinely needed (a self-call at a different type) — that pressures the
finiteness gate and is the R6 λ2/dependent frontier, its own K-ADR; OR annotation-free carrier
inference (#55) is taken up, unblocking the constructing half; OR a bound-free fn surface is
demanded independently of let rec (would reopen the one-construct-per-problem choice).
Amendment ① (2026-07-11, operator-ratified) — the List injection ships unconditionally
The Implementation note's residue 2 ("a kernel-provided List is NOT shipped… tried and
reverted") is superseded: the design probe docs/notes/listdecl-injection-design.md
(12 witnesses, run against the built binary) established that the revert's blocker is not a
wall but a priced migration. With ADR-0099's B012 in force plus the D4 type-name shadow
(witnessed arity-agnostic, w10 — a fact neither landing ADR recorded), unconditional injection
is SOUND: a user's same-name List shadows silently; a differently-named list-shaped data
with bare Nil/Cons gets a LOUD B012 ambiguity naming both candidates — a local, one-line
qualification fix at each colliding corpus fixture, not a silent break. The operator ratified
shipping data List a = Nil | Cons(a, List a) in the UNCONDITIONAL genericPrelude bucket
(alongside Option/Result; zero fuel for non-users — data registration is not a letC
step; sidesteps the mention-filter's .annotS Ty-blindness entirely) and accepted the corpus
migration cost. Candidate (f) (type-scan the mention filter) stays the priced fallback; (g)
(namespaced-only ctors) stays rejected.
length's residue is ruled: fuel-sweep (operator, over the note's cheaper rename) — the
canonical name is worth the corpus touch; the sweep carries the same regression discipline as
any contract change. Implementation rides the #105 first-slice lane.
Amendment ② (2026-07-12, operator-ratified) — result-position discovery closes #55's residual gap
This ADR's own "Revisit if" named the door: "annotation-free carrier inference (#55) is taken
up, unblocking the constructing half." docs/notes/carrier-inference-design.md (design-first,
live-witnessed against the built binary) traced the residual gap PRECISELY: a bound-free
let rec's tyvar appearing ONLY in the declared RESULT (never in any argument — e.g.
mapOpt : (a -> b) -> Option a -> Option b's b) was undiscoverable no matter how the call's
arguments were annotated, because callSitesOf's .annotS e t arm discarded t (the
annotation's own type) before this ADR's discovery mechanism (discoverAtCall) ever ran. The
note also corrected #55's own framing: the SIMPLE case #55 named ("a generic combinator cannot
construct generic data") was ALREADY CLOSED by ADR-0079's check-mode/synth-mode split (a bare
Some(3) in synth position infers today) — only the bound-free-let rec-with-a-result-only-
tyvar shape remained open, a narrower residue than #55's title suggested.
Decision: extend discoverAtCall's mechanism to a second input, discoverAtCallResult,
matched against the call's own enclosing annotation when present — reusing matchTyVars
UNCHANGED (no new inference power; the unifier already handles tArr/tProd/tSum/tApp/
tThunk structurally, it was simply never invoked on a result/annotation pair before). This is
the SAME mechanism this ADR's own decision item 1 established for arguments, extended to a
second discovery SITE, not a parallel inference system — the one-construct-per-problem
discipline this ADR already committed to for the argument-position half.
Implementation (Bang/Frontend/TypeCheck.lean): callSitesOf/redirectCalls's WHOLE
mutual groups gain a resultDom : Ty parameter (stripArrows tvs.length t, the ascription-side
twin curriedDomains already is for arguments) threaded the SAME read-only way domains
already is. The ONE structurally-interesting site is callSitesOf's .annotS inner t arm:
when inner is EXACTLY name's call spine, t is matched against resultDom and MERGED into
that spine's own binding (not a separate call site — completeInstantiation needs every
tyvar's discovery unioned per call). redirectCalls re-derives the IDENTICAL merged binding at
its own .annotS arm so the two passes agree on which residue a result-anchored call belongs
to (by construction: same inputs, same discoverAtCall/discoverAtCallResult calls).
The fail-loud extension this door needed and the argument-only door never did. Before this
Amendment, completeInstantiation's "last occurrence wins" convention silently collapsed
repeated bindings for the same tyvar — SAFE when the only source was arguments (two List a
arguments always agree on a by construction), but WRONG once a result annotation can also
contribute a binding: an argument saying Int and a result saying Char for the same tyvar is
a genuinely contradictory program, and silently preferring one is exactly the guess this whole
discovery discipline refuses to make everywhere else. findConflict (checked BEFORE the silent
collapse could hide it) detects a genuine disagreement (structurally different concrete types
for the same tyvar within one call's binding) and refuses loud, naming the tyvar and both
conflicting types (B017, Bang/Frontend/DiagCodes.lean) — never picking a winner.
Rejected (unchanged from this ADR's original door-refutation): HM-style let-generalization
(a genuine top-level ∀ deferring monomorphization to every downstream use) — still REJECTED,
still for the same reason (door (a)/(c) above: a residual ∀ in the self-knot is polymorphic
recursion, undecidable, the R6 §4 finiteness wall). Amendment ② stays entirely within the
FINITE, per-call-site discovery discipline this ADR already committed to; the kernel / Source. eval / HasCTy still never see a type variable (census byte-identical, invariant #4/#5 hold).
Witnessed (Bang/Frontend/TypeCheck.lean's compiled corpus, runTypedYieldsInt): a genuinely
result-only tyvar (mkNone : a -> Option b) resolves from the call's OWN annotation alone; TWO
independent instantiations of the same result-only tyvar in one program produce two distinct
residues (the SAME distinct-instantiation-set discipline this ADR's decision item 1 already
established for arguments); the disagreeing-annotation case fails loud with B017, never a
guess.