A Million Elements
The scale probe asked one question at increasing sizes: which prelude functions survive a million elements? Sort died first and worst — quadratic and stack-hungry at once. The replacement merge sort still crashed at two hundred thousand despite every function looking tail-recursive, and the address sanitizer pointed at the fourth entry in the TCO ambush catalog: returning a tuple turns the call into sret form, and sret defeats sibling-call optimization. The fix was to stop returning tuples in hot paths; the audit that followed rewrote ten prelude functions to accumulator form. A million elements now sorts in 0.65 seconds — and Part XIII closes with the method's own ledger.
The last probe of the part asked the bluntest question available: take the prelude’s list functions, feed them ten thousand elements, then a hundred thousand, then a million, and write down which ones come back. This is the kind of measurement that feels beneath a language with four backends and a memory model — and that is exactly why it had never been done. The answers arrived in two categories: functions that were quadratic, and functions that were not tail-recursive. Sort was both. The prelude’s insertion sort — an honest placeholder from the era when lists held ten config entries — took minutes at ten thousand elements and blew the stack before it could be slow at a hundred thousand.
The fourth ambush
The replacement was a stable merge sort designed for a language with no arrays in its list library: split by length rather than pointer-chasing, merge through a reversed accumulator, every function written tail-recursive. It crashed at two hundred thousand elements anyway. Every function looked tail-recursive; the emitted C confirmed the calls were in tail position; the stack still grew. The address sanitizer earned its keep and pointed at the split function — the one that returned a tuple of two lists. A function that returns a struct by value compiles to sret form: the caller passes a hidden pointer to space in its own frame, and a call that must write into the caller’s frame cannot be a sibling call, so the optimizer quietly declines. This is the fourth entry in the project’s TCO ambush catalog — after the escaping stack address, the inlined asprintf, and the thread-local cache — and it has the same shape as the others: the source is innocent, the betrayal happens in the C toolchain’s fine print. The fix was to de-tuple the hot path: split became two single-list functions, and the sort stood up.
The audit, and the part’s ledger
If sret ambushed sort, what else was waiting? The audit walked all fifty-odd prelude functions asking two questions — does recursion grow the stack, and does the work grow quadratically? Ten functions failed and were rewritten to accumulator form: length, map, ranges, take, zip, concat, flat-map, max, min among them. None of this was visible at list lengths the tests used; all of it was fatal at scale. The re-run reads like a different library: a million-element sort in 0.65 seconds, every rewritten function flat to a million. And with that, the part’s ledger closes. Six episodes, and the pattern holds in both directions: the cheap measurements found real work (a trait system dissolved into monomorphization, short-circuit evaluation missing on two backends, wasm reclamation, a codepoint view, this scale pass) — and twice they found the work already done, which is its own data. The method’s summary fits in one line: the probes pick the work, and the probes are cheap. What the next part measures is not yet decided, which is the point — something small will be typed at the language, and the language will answer.