Skip to content

Speculative Overloads as Data

enumeratio edited this page Sep 30, 2026 · 5 revisions

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.

What we do today

A package extends a head it doesn't own in code, in place:

  • wrapOperator(ce, ["Add", …], applies, handler) captures the head's current evaluate and installs one that tries applies(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. widenSignature assigns, so the last package to widen Fibonacci decides its signature: adeles' clobbers number-theory's unless number-theory is declared last, though adeles depends on it. modular's Inverse does 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 widens Add for every Add: arithmetic pulls in most of the math.

The observation that makes it tractable

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.

The model

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 on matches, most specific first; then general overloads whose signature matches the operands' types, most specific type first; then the head's native evaluate. 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 overrides says 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 (and unless), beside the type and overrides it has. The manifest carries them, the drift tests check code and record agree, and the resolver reads them: an on row 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.

Migration

  1. The table and dispatcher in @enumeratio/engine (defineOverload), coexisting with wrapOperator: a head that has rows dispatches through them, then through whatever chain it had. No package changes behaviour.

  2. Pilot on the failures. Fibonacci/LucasL (adeles, number-theory) and Inverse (combinatorics, modular) become rows; the resolver test's known list loses them. Then the arithmetic chain (Add, Multiply, Divide, Negate, Power in adeles, numerals, residues, hypercomplex, analytic) with on triggers.

  3. Records and resolver. on/unless on signature rows, through the manifest into packagesFor. Measured by the same histogram as before: how many libraries the median example needs, which should fall well below 3. Landed (#450): census writes on only 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's Interval still count). It also follows canonical forms (Lb is Log). 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 on Interval. Then (#454): a row may be on symbols (a pattern over names: hypercomplex's generators, ExponentialE for 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, and Add(1, 2) none.

  4. The rest, by package. Carrier packages first (#479): adeles' remaining wrappers and widenings, and residues' ChineseRemainder of IntegerMods and QuotientRing(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, beside DECLARERS). The remaining conversions buy declare-order independence, not a smaller engine; they wait for a pass that retires wrapOperator, which needs rows that see operands as written (N holds 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 wrapOperator sites become rows, and its widenSignature calls go away.

Open

  • Specificity between carriers. on: ["Adele"] against on: ["ProfiniteNumber"] with both operands present: adeles resolves this inside one package today (onlyProfinite); across packages, overrides has to.
  • Lazy heads. Add and Multiply hand a wrapper unevaluated operands, and wrapOperator evaluates them once per wrapper. A table evaluates them once per call, which is also a speed win on the hot heads.
  • Canonicalisation hooks (Multiply's InvisibleOperator seam) 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).

Home

Project

Design: every design page, settled, speculative and archived

Clone this wiki locally