Repository navigation
Speculative Overloads as Data
Speculative design. Open, not settled — thinking and proposals, not shipped behavior. See Roadmap for what's in flight.
Status: speculative (2026-09-30), for review before any migration. Follows Lazy Engine §2: the resolver can only stay small, and declare order can only stop mattering, once a package's extension of someone else's head is data rather than a wrapper.
A package extends a head it doesn't own in code, in place:
-
wrapOperator(ce, ["Add", …], applies, handler)captures the head's currentevaluateand installs one that triesapplies(ops)first and falls back to what it captured. 278 call sites. -
widenSignature(ce, name, signature)assigns the head a wider signature so boxing lets the new arguments through. 59 call sites.
Both chain by capture, so declare order is semantics. Two failures follow, both measured by
the resolver test (reference/tests/resolver.test.ts):
-
Clobbers.
widenSignatureassigns, so the last package to widenFibonaccidecides its signature: adeles' clobbers number-theory's unless number-theory is declared last, though adeles depends on it. modular'sInversedoes the same to combinatorics' permutation form. -
Opacity. From outside, a wrapper is a closure. The manifest knows a widening exists (a
record row with
overrides), not when it applies, so the resolver has to load every package that widensAddfor everyAdd: arithmetic pulls in most of the math.
Read the applies predicates and most say the same thing: an operand is one of my
carriers. adeles' Add applies when an operand's head is Adele or ProfiniteNumber (and
not both kinds); residues' when one is IntegerMod; hypercomplex's when one is a multivector.
Those are carrier heads, and carriers are already data (declareCarriers, the manifest).
That's the condition the resolver needs: an overload triggered by an Adele operand can only
matter if an Adele exists, and only adeles' own heads make one, so it comes along with them.
What remains are the genuinely general widenings (number-theory's Fibonacci(1.5),
structures' Floor(x, m)), which apply to plain numbers and have to be loaded with the head.
They're few, and saying so is the point.
An overload is a row, declared once, into a per-head table the engine dispatches from:
defineOverload(ce, "Add", {
package: "adeles",
on: ["Adele"], // applies when an operand's head is one of these carriers
unless: ["Idele"], // … and none is one of these
arity: { min: 2 },
evaluate: (ops) => …, // may decline with undefined, falling through
});
defineOverload(ce, "Fibonacci", {
package: "number-theory",
signature: "(real, real?) -> any", // general: applies by type, no carrier
evaluate: (ops) => …,
});-
Dispatch reads the table, not a chain: overloads whose
onmatches, most specific first; then general overloads whosesignaturematches the operands' types, most specific type first; then the head's nativeevaluate. An overload may decline (undefined) and the next one tries. The head's signature is the union of its rows' signatures and the native one, computed, never assigned. -
Order is data, not timing. Specificity decides first. Where two rows would both apply to
the same call and neither is more specific, the record's
overridessays which wins (the manifest already carries it). Anything still ambiguous is an error when the table is built, not whichever package loaded last. -
Records say it. A signature row gains
on(andunless), beside thetypeandoverridesit has. The manifest carries them, the drift tests check code and record agree, and the resolver reads them: anonrow doesn't pull its package; a general row does.
wrapOperator stays as the escape hatch for a condition no row can state, and each remaining
use is a flagged exception rather than the default.
-
The table and dispatcher in
@enumeratio/engine(defineOverload), coexisting withwrapOperator: a head that has rows dispatches through them, then through whatever chain it had. No package changes behaviour. -
Pilot on the failures.
Fibonacci/LucasL(adeles, number-theory) andInverse(combinatorics, modular) become rows; the resolver test's known list loses them. Then the arithmetic chain (Add,Multiply,Divide,Negate,Powerin adeles, numerals, residues, hypercomplex, analytic) withontriggers. -
Records and resolver.
on/unlesson signature rows, through the manifest intopackagesFor. Measured by the same histogram as before: how many libraries the median example needs, which should fall well below 3. Landed (#450): census writesononly where a package does nothing to the head but add rows, and the resolver skips it only for carriers it declares itself (analytic's rows on compute-engine'sIntervalstill count). It also follows canonical forms (LbisLog).Add(1, 2)went from 7 libraries to 3; the mean example from 3.4 to 2.8; the median stays 3, held there by hypercomplex's general rows and analytic's rows onInterval. Then (#454): a row may be onsymbols(a pattern over names: hypercomplex's generators,ExponentialEfor analytic's e^a·e^b), and a row on another package's carrier counts only where the carrier is named. Median 2, mean 2.4; about half the examples need at most one library, andAdd(1, 2)none. -
The rest, by package. Carrier packages first (#479): adeles' remaining wrappers and widenings, and residues'
ChineseRemainderofIntegerMods andQuotientRing(Integers, m), are rows; examples loading adeles fell from about 400 to about 100.Paused (2026-09-30). What's left mostly improves compute-engine's own heads on plain input (analytic's numerics, number-theory's Gaussian and real-index cases, structures' order on complex numbers and strings, combinatorics' list threading), so as rows they'd still be general and still load their package. A carrier-type trigger (
types, #493) moves little today, but it's how an external package extends a common head for a carrier that is a type: the resolver skips such a row only for the package that mints every type it names (CARRIER_TYPES, besideDECLARERS). The remaining conversions buy declare-order independence, not a smaller engine; they wait for a pass that retireswrapOperator, which needs rows that see operands as written (Nholds its arguments) and can call the head's own handler.Originally planned:, with the resolver test (every head, nightly) as the net: each package's
wrapOperatorsites become rows, and itswidenSignaturecalls go away.
-
Specificity between carriers.
on: ["Adele"]againston: ["ProfiniteNumber"]with both operands present: adeles resolves this inside one package today (onlyProfinite); across packages,overrideshas to. -
Lazy heads.
AddandMultiplyhand a wrapper unevaluated operands, andwrapOperatorevaluates them once per wrapper. A table evaluates them once per call, which is also a speed win on the hot heads. -
Canonicalisation hooks (
Multiply'sInvisibleOperatorseam) are not overloads; they stay global effects the resolver loads by mark. -
External registries (Lazy Engine §3) may add rows, never
wrappers: this is what makes that rule enforceable. A row can only answer or decline, and
its conditions (
on,symbols,types) are data the host reads before loading anything. New call shapes on an existing head are fine while they're disjoint from what's there; overriding or specialising someone else's case is a different, open question (conflicts, and likely forcing the host to deoptimise, i.e. load everything that could apply).