Correct in the Interpreter
A generic pairing heap — the language's first polymorphic data structure built in anger — type-checked, ran perfectly in the interpreter, and produced C that did not compile. Chasing it exposed two independent holes in monomorphization: tuple shapes that exist only inside a poly function's body were never declared, and a function resolved at one type could never learn it was also needed at another. Fixing both put a comparator-driven Dijkstra on all backends — and left one honest measurement for the biggest design question still open: living without type classes.
While the memory work was being staged, a different probe was measuring a different debt.
The language’s ad-hoc polymorphism is compile-time specialization — show, JSON,
structural equality and ordering are derived per concrete type, with no trait system —
and the honest question was how far that carries when you build a real generic
container. The probe was a pairing heap: type 'a heap, a comparator closure for
ordering, two instantiations — plain integers and (distance, node) pairs — and Dijkstra
on top as the payoff. The typechecker accepted it. The interpreter ran it perfectly. The
C backend emitted code that referenced types that did not exist. That split —
correct in the interpreter, broken in the binary — is the most instructive kind of
failure this project knows, because it means the language’s meaning and its
implementation have quietly diverged, and only a program of the right shape can say
where.
The tuple that was never declared
The first hole was in what the backend chooses to declare. C needs a struct typedef for
every tuple shape a program uses, and the collector that finds those shapes walked the
main expression and every function’s signature — but never the function bodies. A
polymorphic function that matches on a pair of its arguments — match (h1, h2) with …,
the most natural way to write a two-heap merge — has that tuple type nowhere in any
signature. It exists only as a body annotation, and only becomes concrete inside a
monomorphized instance’s cloned body. So the emitted C used tuple_heap_int_heap_int
and friends without any such struct having been declared, and the build failed at the C
compiler. The fix was one honest sentence long: walk the bodies too. Instance bodies are
cloned and type-resolved, so walking them yields exactly the per-instance shapes; the
concreteness guard still skips anything still polymorphic.
The function that couldn’t change its mind
Declaring the tuples surfaced the second hole, which was deeper. The monomorphizer finds
a poly function’s concrete uses by scanning — but it scanned only the main expression and
the bodies of multi-instance functions. A use inside a function that had itself
resolved to a single type was invisible. So hp_pop, seen at the pair type in main, was
“single-resolved”: the compiler unified the original skeleton with that one arrow —
destroying its polymorphism — and every other instantiation became impossible. When
drain, resolved at int, called hp_pop at int from inside its body, the emitted C
called the pair-typed instance with int-typed structs. The repair had three parts:
keep a pristine clone of every skeleton from before any unification touches it; include
resolved functions’ bodies in the discovery scan; and when a second type shows up for an
already-resolved function, promote it to multi-instance by cloning fresh specs from the
pristine copy. The fixpoint loop already knew how to iterate; it just needed permission
to change its mind.
Dijkstra as the receipt
With both holes closed, the receipt was satisfying: the generic heap and a Dijkstra over
the textbook six-node graph run natively, byte-identical to the interpreter, and the
example lives in the repository as a permanent regression test. The probe also delivered
what it was originally sent for — a measurement of life without type classes. Writing the
heap was fine; threading a comparator closure through every operation is a visible but
bearable tax; what is genuinely impossible is using the derived ordering through a type
variable: fn a -> fn b -> a < b pins to int, because specialization needs a concrete
type to specialize on. That is now a measured cost, not a speculation, and it sits in the
design queue as the trait question’s first real data point. But it stayed in the queue —
because the same week, the type system was caught permitting something much worse than an
inconvenience.