Skip to content

Monomorphization: key specializations on instantiation identity, not a resolved type - #50

Merged
dpmills merged 2 commits into
mainfrom
dmills/mono-spec-key
Aug 7, 2026
Merged

Monomorphization: key specializations on instantiation identity, not a resolved type#50
dpmills merged 2 commits into
mainfrom
dmills/mono-spec-key

Conversation

@dpmills

@dpmills dpmills commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

specialize_use's memo compared two values produced by two different procedures: a candidate's key was resolve_var_type(&use_expr.ty) taken before its pin, while an entry's was clone.ty — the clone's type after the pin and a full re-entrant coalesce. Neither is a faithful identity for what a clone gets specialized to, and the mismatch broke the memo in both directions.

False hits, i.e. a miscompile

A resolved type is a polarity-correct rendering: a domain resolves from upper bounds — what the definition body demands — so a position the body never touches is narrowed away, and an argument's refinement, which arrives as a lower bound of that domain, is invisible unless compact_type's opposite-polarity fallback happens to fire at that exact position. The clone's interior reads its parameter at a positive position and sees precisely those refinements. So two uses differing only in a key-invisible position shared a clone whose interior was resolved against the first use's argument:

h = \a, b -> a + b
h(1, 2) + h(1, 5)

Both uses keyed on ((1, Int) ⇒ Int) — element 0's refinement reached the key, element 1's did not — and the shared clone typed .1 as 2. The post-inline consistency wall then panicked with Type mismatch for Apply: expected 5, found 2. A difference in the first argument keyed apart and compiled fine, so the failure was confined to differences the rendering could not see.

A write-only table

For any definition whose clone type gains a refinement across the pin, no candidate key could ever equal a stored one, so every call site minted its own clone — including character-identical ones — and the table accumulated several entries under one key. The comment justifying the stored key claimed a later same-typed use would resolve through the first pin's extended chains; it cannot, because every use instantiates its own fresh variables, which the first clone's pin never touches.

SpecKey

SpecKey replaces the resolved type. It is the pair of directed reads of the use's instantiation, kept apart: the positive read is the stamping view (a domain from the definition's demands), the negative read is the clone's view (a domain from the argument that flowed in, a codomain from the consumer's demand). The negative read is the load-bearing half — it is the polarity the clone's interior reads its parameter at — and the pair is exhaustive, because use-specific information enters an instantiation through exactly two channels: an arg <: domain edge and a codomain <: demand edge.

Both reads stay directed, which matters: following both bound lists at every variable instead walks out of one use's cone and into every other (two calls' arguments meet at the shared variable of an operator's scheme), so every use keys on the whole program's literals and they all compare equal. Keeping the two views separate matters too — a merged view forgets which direction a contribution came from, and the pin the key stands in for is direction-sensitive. Within a read, merging is always union: a key that narrows can only under-split, and under-splitting is a miscompile while over-splitting is a wasted clone. An under-determined position is one canonical empty view rather than a fresh Infer placeholder, so two unexercised uses can now share.

Both sides of the comparison are now taken by one procedure at one point in the pin's lifecycle, and an entry stores the key of the use that minted it, with a debug_assert that no two entries share a key.

poly_used_directly_and_through_wrapper_shares_specializations drops from five specializations to four: the direct f(1) and the f(y) reached inside g's Int clone instantiate f identically, so they now group onto one specialization — which is what the test's name and its "the memo is per frame, not per demanding region" comment always asked for.

Consequences carried along: ReadPurpose::SpecializationKey becomes Instantiation (that resolution no longer keys anything — it seeds the clone's channel-domain pairings and blames a resolution failure, so its refinement exclusion is now justified by those consumers being refinement-insensitive rather than by the false claim that a refinement the read could not see can only cause a miss); HistoryKind derives Ord so a kind can key a BTreeMap; and a specialize_use doc block that had detached from its function is reattached.

The remaining imprecision is documented on SpecializeFrame::specs and in the new "Keying a specialization" design section: the key summarizes the pin's input, so two uses differing only in a position the clone never reads still key apart. Closing that means keying on the pin's output — the finished clone, deduped structurally — which cannot be a lookup, only a build-then-dedupe. The two compose, so nothing here has to be undone to get there.

Retiring "witness"

The second commit is a vocabulary sweep with no behaviour change. design/type-inference.md defined a witness as "the term for a refinement in its role as a black box to the subtyping lattice", and the word had spread through the solver, the design docs, and four identifiers.

The distinction does not earn a word. We already have base and refinement; "the refinement as the lattice sees it" is a fact about the lattice, not a second kind of object. Worse, the word names precisely a limitation — that the lattice accumulates refinements and matches them by identity instead of reasoning about what they imply — so having a term for it quietly asserts that the limitation is permanent.

So witnessrefinement throughout (185 occurrences, 26 files), including join_witnessesjoin_refinements and type_carries_witnesstype_carries_refinement (channelize), input_witnessesinput_refinements (coalesce), the flag on types_agree_modulo_unread, and the #### Refinements as witness sets section, which becomes #### Refinements on the lattice with an opening sentence that states the opacity as a property of the lattice rather than as the definition of a term.

Testing

Four end-to-end regression cases for the miscompile plus its controls (a difference in a key-visible position, and identical calls that must still share), two unit tests for the memo's two halves, and unit tests for SpecKey itself. Full ./ci.sh passes.

A function's kind is part of its identity

KeyView.fun is keyed by KindMerge, the way KeyView.history is keyed by HistoryKind and for the same reason: 𝐷 ⇒ 𝑉 and 𝐷 ⤇ 𝑉 are one shape that compiles to different code, so a clone pinned at — whose body iterates the domain — must not serve a use that never supplies one. A union at one position keeps the two apart rather than letting one shadow the other.

The kind is read through KindMerge::of, the same resolved-from-bounds view compaction takes, rather than off the FunKind itself. That distinction is the whole of it: an inferred kind is a variable, freshly minted per instantiation, so keying on its identity would give every use its own key and retire sharing altogether. Its bounds are the answer, and reading them mid-solve is the bargain every other field in the key already makes — the guarantee is agreement with the pin, not omniscience about the graph.

@groundlar groundlar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against the design docs, and verified the fix differentially rather than by inspection: built dacbbc2 in an isolated clone and ran the same 12-program probe set against both commits. One real miscompile fixed (h = \a, b -> a + b at h(1,2) + h(1,2) + h(1,5)), zero regressions in the differential, full ./ci.sh green locally.

The design is right, and the two-directed-reads-kept-apart argument — plus the "why not saturate" paragraph, which is the part most people would get wrong — is worth more than the code. Comments below are one structural concern, one follow-up bug that is not yours, and three small documentation asks.

Comment thread src/ccl/infer/solver/spec_key.rs
Comment thread src/ccl/infer/solver/compact.rs
Comment thread src/ccl/mut_elim.rs Outdated
Comment thread src/ccl/infer/solver/spec_key.rs
Comment thread src/ccl/infer/solve.rs Outdated
@groundlar

groundlar commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Context from comparing this against other monomorphizers

Not a blocker — this is the right fix. Two things I turned up that seem worth recording in the design doc, plus one concrete suggestion.

1. The resolved-type key was a fossil of the pre-dce4285 architecture. That commit removed a separate post-coalesce monomorphize pass, whose own doc comment read:

"This is classic monomorphization, deferred until after types are known so it can key specialization on the resolved type — which is what lets one definition be shared across same-typed uses."

Post-coalesce, a resolved type is all you can see: expr.ty has been overwritten and the arena is gone. dce4285 moved specialization into the walk and made var-laden instantiation types available at the point of keying — but the key stayed a resolved type. This PR retires it. Worth a sentence in Keying a specialization, because it makes SpecKey an argument for in-walk specialization rather than a cost of it — the key is only expressible because monomorphization runs where the graph is still live.

2. The key is already a function of the post-emission graph. I ran emit_node alone on let h = λp → p.0 + p.1 in (h(1,2), h(1,N)) — no coalescing, no pin — and computed spec_key on both uses:

h(1,2) vs h(1,5) h(1,2) vs h(1,2)
resolve_var_type ((1, Int) ⇒ Int)equal equal
spec_key differ equal

So the discriminating information is in the graph the instant emission finishes, and the resolved type collides exactly as the commit message describes. The pin transports it across polarity; it doesn't discover it.

That suggests a cheap test, and it closes the verification gap I raised on SpecializeFrame::specs: assert that spec_key taken right after emission equals spec_key taken at specialization time. That turns "both sides computed by one procedure at one point in the pin's lifecycle" from a discipline into a checked property, and it would fail loudly if a future change made the key sensitive to intervening pins.

3. Where Cambra sits relative to everyone else — the framing I'd suggest for that design-doc paragraph. rustc keys on Instance { def, args }, frozen by typeck; C++ on the template-argument list; Swift on a substitution map; MLton on the type-argument list in a single post-inference pass. All of them key on a finished value, because their generic bodies are already typed and instantiation is substitution.

Cambra has no Type::ForAll and never types a generic body at all ("The definition's own subtree is never coalesced in place"), so monomorphization here is the act of typing the body, not duplication of already-typed code. That puts us in C++'s category — the one mainstream compiler where instantiation genuinely re-runs semantic analysis rather than substituting — and it is the real reason the key must be read off a live graph. A reader arriving from rustc will otherwise assume that's an implementation choice.

For completeness on what is not the reason: poly-calls-poly doesn't block a phase split. Discovering the set of instantiations is a reachability fixpoint in every monomorphizer (rustc's collector, Swift's pass-manager worklist), and the pre-dce4285 pass handled it by recursing into each new specialization. The cost of deferral is only that coalesce destructively overwrites expr.ty — which is why that pass also needed rederive_dependent_types.

Prior art worth a look: Lutze, Schuster & Brachthäuser, The Simple Essence of Monomorphization (OOPSLA 2025) — monomorphization via a flow analysis explicitly inspired by algebraic subtyping (Dolan & Mycroft), i.e. this type system. Closest prior art I found, including on where monomorphization stops being possible (polymorphic recursion, which they detect as cyclic flow).

@dpmills

dpmills commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Context from comparing this against other monomorphizers

Not a blocker — this is the right fix. Two things I turned up that seem worth recording in the design doc, plus one concrete suggestion.

1. The resolved-type key was a fossil of the pre-dce4285 architecture. That commit removed a separate post-coalesce monomorphize pass, whose own doc comment read:

"This is classic monomorphization, deferred until after types are known so it can key specialization on the resolved type — which is what lets one definition be shared across same-typed uses."

Post-coalesce, a resolved type is all you can see: expr.ty has been overwritten and the arena is gone. dce4285 moved specialization into the walk and made var-laden instantiation types available at the point of keying — but the key stayed a resolved type. This PR retires it. Worth a sentence in Keying a specialization, because it makes SpecKey an argument for in-walk specialization rather than a cost of it — the key is only expressible because monomorphization runs where the graph is still live.

Added

2. The key is already a function of the post-emission graph. I ran emit_node alone on let h = λp → p.0 + p.1 in (h(1,2), h(1,N)) — no coalescing, no pin — and computed spec_key on both uses:

h(1,2) vs h(1,5) h(1,2) vs h(1,2)
resolve_var_type ((1, Int) ⇒ Int)equal equal
spec_key differ equal
So the discriminating information is in the graph the instant emission finishes, and the resolved type collides exactly as the commit message describes. The pin transports it across polarity; it doesn't discover it.

That suggests a cheap test, and it closes the verification gap I raised on SpecializeFrame::specs: assert that spec_key taken right after emission equals spec_key taken at specialization time. That turns "both sides computed by one procedure at one point in the pin's lifecycle" from a discipline into a checked property, and it would fail loudly if a future change made the key sensitive to intervening pins.

Once we have #51, this isn't entirely true. I added a section discussing

3. Where Cambra sits relative to everyone else — the framing I'd suggest for that design-doc paragraph. rustc keys on Instance { def, args }, frozen by typeck; C++ on the template-argument list; Swift on a substitution map; MLton on the type-argument list in a single post-inference pass. All of them key on a finished value, because their generic bodies are already typed and instantiation is substitution.

Cambra has no Type::ForAll and never types a generic body at all ("The definition's own subtree is never coalesced in place"), so monomorphization here is the act of typing the body, not duplication of already-typed code. That puts us in C++'s category — the one mainstream compiler where instantiation genuinely re-runs semantic analysis rather than substituting — and it is the real reason the key must be read off a live graph. A reader arriving from rustc will otherwise assume that's an implementation choice.

For completeness on what is not the reason: poly-calls-poly doesn't block a phase split. Discovering the set of instantiations is a reachability fixpoint in every monomorphizer (rustc's collector, Swift's pass-manager worklist), and the pre-dce4285 pass handled it by recursing into each new specialization. The cost of deferral is only that coalesce destructively overwrites expr.ty — which is why that pass also needed rederive_dependent_types.

Prior art worth a look: Lutze, Schuster & Brachthäuser, The Simple Essence of Monomorphization (OOPSLA 2025) — monomorphization via a flow analysis explicitly inspired by algebraic subtyping (Dolan & Mycroft), i.e. this type system. Closest prior art I found, including on where monomorphization stops being possible (polymorphic recursion, which they detect as cyclic flow).

Added this discussion to the design

@dpmills
dpmills force-pushed the dmills/mono-spec-key branch 2 times, most recently from e18f60f to 066d40f Compare August 6, 2026 22:44
dpmills added 2 commits August 6, 2026 16:59
…a resolved type

`specialize_use`'s memo compared two values produced by two different procedures: a candidate's key was `resolve_var_type(&use_expr.ty)` taken *before* its pin, while an entry's was `clone.ty` — the clone's type *after* the pin and a full re-entrant coalesce. Neither is a faithful identity for what a clone gets specialized to, and the mismatch broke the memo in both directions.

## False hits, i.e. a miscompile

A resolved type is a polarity-correct *rendering*: a domain resolves from upper bounds — what the definition body demands — so a position the body never touches is narrowed away, and an argument's refinement, which arrives as a *lower* bound of that domain, is invisible unless `compact_type`'s opposite-polarity fallback happens to fire at that exact position. The clone's interior reads its parameter at a *positive* position and sees precisely those refinements. So two uses differing only in a key-invisible position shared a clone whose interior was resolved against the first use's argument:

```
h = \a, b -> a + b
h(1, 2) + h(1, 5)
```

Both uses keyed on `((1, Int) ⇒ Int)` — element 0's refinement reached the key, element 1's did not — and the shared clone typed `.1` as `2`. The post-inline consistency wall then panicked with `Type mismatch for Apply: expected 5, found 2`. A difference in the *first* argument keyed apart and compiled fine, so the failure was confined to differences the rendering could not see.

## A write-only table

For any definition whose clone type gains a refinement across the pin, no candidate key could ever equal a stored one, so every call site minted its own clone — including character-identical ones — and the table accumulated several entries under one key. The comment justifying the stored key claimed a later same-typed use would resolve through the first pin's extended chains; it cannot, because every use instantiates its own fresh variables, which the first clone's pin never touches.

## `SpecKey`

`SpecKey` replaces the resolved type. It is the pair of **directed reads** of the use's instantiation, kept apart: the *positive* read is the stamping view (a domain from the definition's demands), the *negative* read is the clone's view (a domain from the argument that flowed in, a codomain from the consumer's demand). The negative read is the load-bearing half — it is the polarity the clone's interior reads its parameter at — and the pair is exhaustive, because use-specific information enters an instantiation through exactly two channels: an `arg <: domain` edge and a `codomain <: demand` edge.

Both reads stay *directed*, which matters: following both bound lists at every variable instead walks out of one use's cone and into every other (two calls' arguments meet at the shared variable of an operator's scheme), so every use keys on the whole program's literals and they all compare equal. Keeping the two views separate matters too — a merged view forgets which direction a contribution came from, and the pin the key stands in for is direction-sensitive. Within a read, merging is always union: a key that narrows can only under-split, and under-splitting is a miscompile while over-splitting is a wasted clone. An under-determined position is one canonical empty view rather than a fresh `Infer` placeholder, so two unexercised uses can now share.

Both sides of the comparison are now taken by one procedure at one point in the pin's lifecycle, and an entry stores the key of the use that minted it, with a `debug_assert` that no two entries share a key.

`poly_used_directly_and_through_wrapper_shares_specializations` drops from five specializations to four: the direct `f(1)` and the `f(y)` reached inside `g`'s `Int` clone instantiate `f` identically, so they now group onto one specialization — which is what the test's name and its "the memo is per frame, not per demanding region" comment always asked for.

Consequences carried along: `ReadPurpose::SpecializationKey` becomes `Instantiation` (that resolution no longer keys anything — it seeds the clone's channel-domain pairings and blames a resolution failure, so its refinement exclusion is now justified by *those* consumers being refinement-insensitive rather than by the false claim that a refinement the read could not see can only cause a miss); `HistoryKind` derives `Ord` so a kind can key a `BTreeMap`; and a `specialize_use` doc block that had detached from its function is reattached.

The remaining imprecision is documented on `SpecializeFrame::specs` and in the new "Keying a specialization" design section: the key summarizes the pin's *input*, so two uses differing only in a position the clone never reads still key apart. Closing that means keying on the pin's *output* — the finished clone, deduped structurally — which cannot be a lookup, only a build-then-dedupe. The two compose, so nothing here has to be undone to get there.

## Retiring "witness"

The second commit is a vocabulary sweep with no behaviour change. `design/type-inference.md` defined a *witness* as "the term for a refinement in its role as a black box to the subtyping lattice", and the word had spread through the solver, the design docs, and four identifiers.

The distinction does not earn a word. We already have `base` and `refinement`; "the refinement as the lattice sees it" is a fact about the lattice, not a second kind of object. Worse, the word names precisely a **limitation** — that the lattice accumulates refinements and matches them by identity instead of reasoning about what they imply — so having a term for it quietly asserts that the limitation is permanent.

So `witness` → `refinement` throughout (185 occurrences, 26 files), including `join_witnesses` → `join_refinements` and `type_carries_witness` → `type_carries_refinement` (`channelize`), `input_witnesses` → `input_refinements` (`coalesce`), the flag on `types_agree_modulo_unread`, and the `#### Refinements as witness sets` section, which becomes `#### Refinements on the lattice` with an opening sentence that states the opacity as a property of the lattice rather than as the definition of a term.

## Testing

Four end-to-end regression cases for the miscompile plus its controls (a difference in a key-visible position, and identical calls that must still share), two unit tests for the memo's two halves, and unit tests for `SpecKey` itself. Full `./ci.sh` passes.

## A function's kind is part of its identity

`KeyView.fun` is keyed by `KindMerge`, the way `KeyView.history` is keyed by `HistoryKind` and for the same reason: `𝐷 ⇒ 𝑉` and `𝐷 ⤇ 𝑉` are one shape that compiles to different code, so a clone pinned at `⤇` — whose body iterates the domain — must not serve a `⇒` use that never supplies one. A union at one position keeps the two apart rather than letting one shadow the other.

The kind is read through `KindMerge::of`, the same resolved-from-bounds view compaction takes, rather than off the `FunKind` itself. That distinction is the whole of it: an inferred kind is a *variable*, freshly minted per instantiation, so keying on its identity would give every use its own key and retire sharing altogether. Its bounds are the answer, and reading them mid-solve is the bargain every other field in the key already makes — the guarantee is agreement with the pin, not omniscience about the graph.
Review follow-ups on `SpecKey`, plus one negative result worth recording.

## A key is not a function of the post-emission graph

The memo's comparison is self-consistent per use — each key is taken before that use's own pin — but the two keys in a comparison are not taken at the same *instant*: an entry's was taken before the pin of the use that minted it, a candidate's later, with every intervening pin already in the graph. The tempting strengthening is that a pin only *transports* use-specific information across polarity and never creates it, so no other use's pin could move a key. That would make the key a function of the post-emission graph, and it would make the cross-walk comparison checkable rather than argued.

It is false. A pin does not only transport; for a *nested* use it deposits the consumer's demand. In `f(f(3))`, `coalesce_node`'s `Apply` arm takes function before argument, so the outer use specializes first and its pin drives the outer clone's domain concrete — and that domain *is* the demand on the inner call's result, which reaches the inner use through the `codomain <: demand` edge, one of the two channels the negative read follows by design. The inner use is keyed against a demand that did not exist at end of emission.

Whether that deposit is *visible to the key* depends on how much structure the demand carries. Where it resolves to a bare base the two reads agree, which is why snapshotting every `Var` node's key on entry to `coalesce_pass` and comparing against the key `specialize_use` takes later passes on this branch. It stops passing as soon as a demand carries structure a key records — once an operator's effect on types is itself a type, the same program splits, the inner use's negative read gaining exactly the layer the outer pin deposited. The positive read does not move, and no key moves for any other reason.

So key equality is walk-order sensitive. The residue is **over-splitting** — a use keyed against a thinner demand does not match an entry keyed against a fatter one, costing a redundant clone — and it is not symmetric, since a thin key and a fat key are unequal and a use carrying a real demand cannot be served by an entry that never saw one. The design section now carries the counterexample rather than the invariant, and `SpecializeFrame::specs` says the same thing at the memo itself.

## Documentation

- `compact_go` gains the reciprocal half of its coupling to `key_go`. The two walk `Type` in lockstep — the same polarity flip on a `Fun` domain, the same no-flip on `History` children, the same `then(edge_subst, subst_acc)` composition at a bound edge, the same binder shadowing, the same `(uid, pol)` cycle guard — and that agreement *is* the soundness argument for a key: a bound the key cannot see is one the clone's own resolution cannot see either. Nothing enforces it, so the coupling now reads from both ends rather than only from `AtomKey::from_type`.

- The `Type::Hole` arm of `key_go` says why the `Hole`/under-determined-`Infer` collision cannot merge two uses that actually differ: `normalize_annotation` turns a `Hole` into a fresh `Type::Infer` at emit, so no `Hole` reaches a use's instantiation type in the first place.

- `SpecializeFrame::specs` drops "a binding has a handful" for what actually bounds it. Each comparison is a deep structural `SpecKey` walk, so the scan is quadratic in a binding's specialization count — and that count is bounded per *argument tuple*, not per distinct type, so a definition called with a fresh literal tuple at every site grows `specs` with call sites. What holds it down today is `inline` beta-reducing scalar UDFs, leaving the collection-producing ones. If it ever bites, the scan is what has to change, not the key.

- `mut_elim`'s `collect_writes` still said "a witness acquired by erasure" — a miss in the vocabulary sweep, in the same breath as its updated citation.

- The Σ-type material in `type-inference.md` keeps *witness* in its standard sense — the inhabitant that picks which summand you are in — and now says so, so a future sweep for the retired refinement sense leaves it alone.

## Keying a specialization

Beyond the negative result above, two additions to the design section:

- **The key is only expressible in-walk.** A monomorphizer running *after* coalesce would have no choice but to key on a resolved type, because by then a resolved type is all there is: `expr.ty` has been overwritten in place and the bound graph it was resolved from is gone. Specializing inside the walk is what leaves a use's instantiation var-laden with its bounds live at the moment the key is taken, which makes `SpecKey` an argument *for* in-walk specialization rather than a cost of it.

- **Why other monomorphizers key on a finished value.** rustc keys an instance on its definition plus its generic arguments, C++ on the template-argument list, Swift on a substitution map, MLton on the type-argument list in a single post-inference pass. Every one keys on a finished value, and can, because their generic bodies are already typed and instantiation is substitution. Cambra has no `Type::ForAll` and never types a generic body at all, so monomorphization here *is* the act of typing the body — C++'s category, the one mainstream compiler where instantiation genuinely re-runs semantic analysis — and that is the real reason the key has to be read off a live graph. With a pointer to the closest prior art: Lutze, Schuster & Brachthäuser, *The Simple Essence of Monomorphization* (OOPSLA 2025), monomorphization as a flow analysis over an algebraic-subtyping system.
@dpmills
dpmills force-pushed the dmills/mono-spec-key branch from 066d40f to c825cef Compare August 7, 2026 00:00
@dpmills
dpmills merged commit 240763e into main Aug 7, 2026
1 of 2 checks passed
@dpmills
dpmills deleted the dmills/mono-spec-key branch August 7, 2026 00:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants