ADR 0116: Double-Dispatch Coercion for Number-on-the-Left Arithmetic

Status

Accepted (2026-08-25)

Context

ADR-less Phase 1 of the "operators as messages" epic (BT-2708) — implemented in BT-2709 and BT-2710 — made + - * / and < > <= >= dispatchable messages so a user value-type can overload them as the receiver: aMoney + bMoney works because Money defines +. The codegen shape (generate_binary_op in crates/beamtalk-core/src/codegen/core_erlang/operators.rs) is:

This is receiver-dispatch only, by design (BT-2709's explicit scope cut). It has no answer for 5 + aVector — a number on the left, a non-numeric value-type on the right. Concretely:

5 + aVector    "aVector is a Vector, not a Number"

Here the left operand is a numeric literal, so receiver_is_statically_numeric is true and codegen emits the bare erlang:'+'(5, X) with no guard at all. At runtime this crashes with a raw badarith — not even a does_not_understand, since the dispatch layer is never reached. The same gap exists for any receiver that is guarded but turns out numeric at runtime (guarded_op_doc's is_number branch also calls the bare BIF): whenever the left operand is a number, the right operand is assumed to be one too, and nothing catches the case where it isn't.

Constraints carried over from Phase 1

  1. The numeric fast path must stay zero-cost. BT-2709 measured guard-vs-bare overhead specifically to protect this; any fix here must not turn total + delta (right operand a plain numeric variable) into an always-guarded operation.
  2. Static typing must stay informed. BT-2709 deliberately avoided perform:-based dynamic dispatch so the type checker keeps a known return type at each call site (covariant-return refinement, ADR 0068). Any number-on-the-left mechanism must preserve that — a reflected hook needs a declared, checkable signature, not a string-keyed perform:.
  3. No scalar tower exists on BEAM. Beamtalk has no Fraction, ScaledDecimal, or Rational class — erlang:'+' already fuses int↔float natively. Classic Smalltalk "generality" (rank types on a total order, coerce the lower-ranked operand, retry) was built to solve exactly that mixing problem, which BEAM has already solved at the BIF level.
  4. Comparison operators are out of scope for this ADR. < > <= >= share the same operator-guard machinery (BT-2710) but fail differently: Erlang's < never raises, so 5 < aVector today silently term-orders instead of crashing — a worse failure mode (silent wrong answer vs. a crash) tracked as a separate follow-up, not designed here.

Decision

Beamtalk adopts double dispatch via per-operator reflected methods — not a generality/coercion tower, and not a perform:-based reflective retry.

Reflected method protocol

A value-type that wants aNumber <op> self to work declares one reflected method per arithmetic operator it supports, using the <verb>FromNumber: naming convention:

Value subclass: Vector
  field: components :: Array = #()

  "receiver-dispatch: aVector + 5, aVector - 5"
  + other :: Number -> Vector => self collect: [:c | c + other]
  - other :: Number -> Vector => self collect: [:c | c - other]

  "number-on-the-left: 5 + aVector, 5 - aVector"
  plusFromNumber:  n :: Number -> Vector => self collect: [:c | n + c]
  timesFromNumber: n :: Number -> Vector => self collect: [:c | n * c]
  minusFromNumber: n :: Number -> Vector => self collect: [:c | n - c]

Operand order matters for non-commutative operators and is easy to get backwards: minusFromNumber:'s receiver (self) is the value that was on the right of the original expression, and its parameter (n) is the value that was on the left5 - aVector sends aVector minusFromNumber: 5, computing n - self (5 minus each component), not self - n. aVector - 5 (receiver-dispatch -, unchanged from BT-2709) computes the opposite: each component minus 5. The two methods above are not one implementation reused for both directions — the subtraction is genuinely reversed between them, same as divFromNumber:.

Each reflected method is an ordinary, fully-typed method — no perform:, no dynamic selector construction. There is exactly one reflected method per operator (plusFromNumber: / minusFromNumber: / timesFromNumber: / divFromNumber:), not the adaptToInteger: / adaptToFloat: / adaptToFraction: fan-out Pharo needs — because BEAM fuses int/float into a single effective Number category on the left, one hook per operator suffices. (divFromNumber: reflects /, Beamtalk's single division operator — distinct from the separately-named div: keyword message for integer division on Integer, so the shared "div" substring doesn't collide as a selector, just as a reading hazard worth a beat of attention.)

When an operator doesn't apply: implement it anyway, to reject with intent

Not every reflected operator makes sense for every type — 5 / aVector ("a number divided by a vector") rarely has an obvious meaning the way 5 * aVector (scale) or 5 + aVector (broadcast-add) do, and some types are point-like rather than vector-space-like (a Temperature sensibly supports 5 + aTemperature, an offset, but not 5 * aTemperature, which has no physical meaning). For those, the recommended pattern is to implement the method anyway, with a body that rejects explicitly:

divFromNumber: n :: Number -> Vector =>
  self error: "Cannot divide a number by a Vector — did you mean (aVector / n)?"

This is better than simply omitting the method. Omitting it leaves does_not_understand to fire on the synthesized selector ('divFromNumber:') — a name the caller never typed (they wrote 5 / aVector) — which is harder to connect back to their own code than an ordinary DNU is (see the dispatch-mechanism hint below, which narrows but doesn't eliminate this gap for methods left unimplemented). A deliberate self error: names the actual problem and, where relevant, points at the supported alternative. Practically, this means a type author who commits to number-on-the-left arithmetic at all will typically implement all four methods — some as real arithmetic, the rest as intentional rejections — not a variable subset; see § Consequences, Negative for what this means for the earlier boilerplate estimate.

Dispatch mechanism (codegen)

Trigger condition, refined. receiver_is_statically_numeric (BT-2709) already exists and takes an arbitrary Expression, not just the receiver — so the same check applies to the right operand for free. The mechanism below only engages when the left operand is (statically or dynamically) numeric and receiver_is_statically_numeric(right) is false — i.e. the right operand's type genuinely isn't known at compile time. When the right operand is statically numeric too (a literal, a :: Number-family param, self in Integer/Float, or a numeric/untyped field — the exact same rule already applied to the left operand), codegen skips this mechanism entirely and emits the bare BIF, identical to today. Concretely: total + delta where delta :: Integer — the common case this ADR must not tax — never enters a try at all; only a right operand whose type is genuinely unknown (5 + aVector) does. This also converts the "does the try cost anything on the happy path" question from "prove it's negligible everywhere" to "prove it's negligible on the strictly smaller set of already-dynamically-dispatched call sites" — the de-risking spike (§ Implementation) measured exactly that narrower question and found the cost comparable to the already-accepted is_number guard's on those same call sites.

For that remaining case, the call site wraps the BIF call in a try/ catch on badarith, following the same catch-clause shape already used throughout the codebase (control_flow/exception_handling.rs's on_do_catch_preamble: bind the raw type/error/stack as variables, case- match on them with an explicit fallback arm, re-raise via primop 'raw_raise', never erlang:raise/3 — which expects a pre-built stacktrace term, not the raw internal trace a catch clause binds). Both operands are let-bound before the try (mirroring guarded_op_doc's own left_var/right_var binding), so the catch handler can reference Right without re-evaluating it — referencing the inlined expression twice, the way the existing bare fast path embeds left_code/right_code directly, would double-evaluate a non-trivial right operand:

let BinLeft = <left operand> in
let BinRight = <right operand> in
try
    call 'erlang':'+'(BinLeft, BinRight)
of <TryResult> -> TryResult
catch <Type, Error, Stack> ->
    case {Type, Error} of
        <{'error', 'badarith'}> when 'true' ->
            case call 'erlang':'is_number'(BinRight) of
                <'true'> when 'true' ->
                    %% BinRight IS a number — badarith wasn't a coercion
                    %% miss (e.g. `5 / 0`, or float overflow). Re-raise
                    %% unchanged so the existing badarith classification
                    %% (ADR 0028/BT-2704) still handles it.
                    primop 'raw_raise'(Type, Error, Stack)
                <'false'> when 'true' ->
                    call 'beamtalk_message_dispatch':'send_number_coercion'(
                        BinRight, 'plusFromNumber:', [BinLeft], '+')
                %% is_number/1 is boolean-exhaustive — `erlc`'s own BIF
                %% return-type inference proves this arm unreachable (it
                %% warns "clause cannot match" if present, confirmed by the
                %% spike below) — but `guarded_op_doc`'s existing
                %% `case_clause_fallback` convention adds it anyway,
                %% defensively, rather than depending on that inference
                %% holding across OTP releases. `error({case_clause, _})`,
                %% not `raw_raise` — this is an internal-invariant guard, not
                %% a real exception to propagate.
                <NoMatch> when 'true' ->
                    call 'erlang':'error'({'case_clause', NoMatch})
            end
        %% Not badarith at all (shouldn't occur for this specific BIF call,
        %% but the case must be exhaustive). Binds a fresh throwaway
        %% variable, not `Type`/`Error` again — those are already bound by
        %% the enclosing `catch` clause and stay in scope for `raw_raise`
        %% below without needing to be re-destructured here, mirroring
        %% `on_do_catch_preamble`'s own catch-all arm.
        <OtherPair> when 'true' ->
            primop 'raw_raise'(Type, Error, Stack)
    end
end

Spike-verified (see Implementation § De-risking spike results): the try/catch/is_number/raw_raise shape — including the mandatory try ... of ... catch ... end form (Core Erlang, unlike Erlang source, requires the of clause explicitly; its absence is a syntax error) and the mandatory when guard on every case clause — was hand-compiled with erlc and confirmed to core_lint cleanly, then functionally exercised end-to-end for all three branches (happy-path add, non-numeric-right dispatch, and numeric-right re-raise via float overflow). That spike used a bare send/3 stub, predating the send_number_coercion/4 wrapper added below — the outer try/catch shape shown above is unchanged by that addition (still spike-verified), but send_number_coercion/4 itself is a new, not-yet-compiled function and should go through the same hand-compile- and-verify treatment before implementation, not be assumed correct by extension.

+ - * / can each raise badarith between two genuine numbers, not only when the right operand isn't a number: / on a zero divisor, and — empirically verified against the BEAM runtime, since Erlang floats have no IEEE infinity/NaN representation — + - * on float overflow (1.0e308 + 1.0e308 raises badarith rather than returning an infinite float). Naively catching every badarith and dispatching unconditionally would misroute 5 / 0 (divisor 0 is a number) to beamtalk_message_dispatch:send(0, 'divFromNumber:', [5]), which fails as does_not_understand: Integer does not understand 'divFromNumber:' — a regression of the existing, documented badarithTypeError "bad arithmetic operation" classification (beamtalk_exception_handler:wrap_raw/2, ADR 0028), which correctly explains it as an arithmetic error today. The is_number(BinRight) check inside the catch handler distinguishes the two badarith causes generically, independent of which specific numeric failure triggered it: a non-numeric BinRight is a genuine coercion miss and dispatches to the reflected method; a numeric BinRight means the failure is a real numeric error unrelated to coercion (division by zero or float overflow), and primop 'raw_raise' re-raises it with its original class, reason, and stacktrace — identical to today's uncaught propagation — so it reaches the same classification layer unchanged regardless of which of the two numeric causes produced it. The catch block itself only ever runs on an actual badarith, so its cost is paid exclusively on the already-failing path; the try wrapper's cost on the non-failing path, restricted to call sites where the right operand's type is genuinely unknown was measured by the de-risking spike (§ Implementation) and found comparable to the already-accepted is_number guard's cost on those same call sites — not the zero-cost claim an unqualified reading of "happy path" might suggest, but the correct comparison given the compile-time skip already removes the true zero-cost call sites from this mechanism's reach entirely.

This is deliberately the one place BT-2709 avoided (catch-on-failure instead of a guard) — correct here specifically because the happy path (number + number) can never raise, unlike the general receiver-dispatch case where a non-numeric receiver is the common case a guard has to cover cheaply, and because the refined trigger condition above already removes the statically-known-numeric-right-operand cases a guard would otherwise have to cover for free.

Compiler-generated exception handling on Beamtalk's own state-threading constructs (loops, on:do:/ensure:, NLR boundaries, class-var shadow-writes) is required to lower through ThreadedIr and its verify() (CLAUDE.md, ADR 0111). This try/catch doesn't: it threads no state across the boundary — no class variable, no loop accumulator, no NLR relay — and lives entirely inside a single expression's evaluation, the same "general expression codegen stays AST-directed" category the existing Phase 1 is_number guard (guarded_op_doc, a plain case Document) is already in. It is built directly as a Document from the AST and generator state, with no ThreadedIr involvement, consistent with that existing guard and outside ThreadedIr's scope.

Hinting the DNU when the reflected method is missing

A plain beamtalk_message_dispatch:send(BinRight, 'plusFromNumber:', [BinLeft]) that finds no plusFromNumber: on BinRight's class produces an ordinary does_not_understand: "<Class> does not understand 'plusFromNumber:'". That's a real gap for a selector the caller never typed — unlike a normal DNU (foo bar"Foo does not understand 'bar'", directly traceable to the caller's own source), plusFromNumber: is synthesized by this mechanism; someone who wrote 5 + aVector has no reason to already know that name.

Rather than teach the general DNU-formatting path (beamtalk_error.erl's generate_message/3 / maybe_enrich_dnu_hint/1) about this one synthetic selector family — those stay generic, by design, and have no way to recover "this was tried because of the + on line N" once the failure reaches them — the fix lives at the one place that still has that context: the coercion-dispatch call site itself. A new runtime helper, beamtalk_message_dispatch:send_number_coercion/4 (Right, Selector, Args, OrigOp), wraps the existing send/3:

send_number_coercion(Right, Selector, Args, OrigOp) ->
    RightClass = beamtalk_primitive:class_of(Right),
    try
        send(Right, Selector, Args)
    catch
        error:#{'$beamtalk_class' := _,
                error := #beamtalk_error{kind = does_not_understand,
                                          class = RightClass,
                                          selector = Selector} = Error}:Stack ->
            %% RightClass and Selector are already bound above/as this
            %% function's argument — the catch pattern re-uses them, so this
            %% only matches a does_not_understand for *this exact* class and
            %% selector, not any DNU that happens to propagate through.
            Hint = iolist_to_binary(io_lib:format(
                "~s has no '~s' — implement it to support 'number ~s ~s' arithmetic",
                [RightClass, Selector, OrigOp, RightClass])),
            HintedError = beamtalk_error:with_hint(Error, Hint),
            erlang:raise(error, beamtalk_exception_handler:wrap(HintedError), Stack)
    end.

This mirrors the real shape a DNU actually propagates as: beamtalk_error:raise/1 (the path every production DNU already goes through — e.g. raise_class_dnu/raise_class_self_dnu in beamtalk_class_dispatch.erl) wraps the #beamtalk_error{} via beamtalk_exception_handler:wrap/1 into a #{'$beamtalk_class' := ..., error := #beamtalk_error{...}} tagged map before calling erlang:error/1 — so a catch here has to match that wrapped shape, not the bare record, and the re-raise has to re-wrap the hinted error the same way to stay consistent with every other exception in the system.

Why match on both fields, not just the selector: if plusFromNumber: does exist on Right's class but its body calls something else that itself DNUs (an unrelated bug inside the user's own method), that inner failure has a different selector and must propagate unchanged — not get relabeled as "coercion hook missing" when the hook was actually present and something else broke. Same discipline § Dispatch mechanism already applies to badarith: narrow matching on the exact failure this call site can actually produce, not a broad catch-and-relabel of anything that happens to propagate through.

kind stays does_not_understand (RuntimeError) — this only adds a hint, the same field format/1 already renders for every DNU (§ REPL example below). No new exception class, no change to how on: RuntimeError do: or on: Error do: catch it.

REPL example

Using the project's .btscript // => assertion convention (the verified substring the REPL/eval error message actually contains — beamtalk_error:generate_message/3 for the DNU case, beamtalk_exception_handler:wrap_raw/2 for the badarith case; see tests/repl-protocol/cases/errors.btscript):

5 + aVector
// => Vector(6, 7, 8)

5 + "not a vector"
// => ERROR: String does not understand 'plusFromNumber:'
// => ERROR: String has no 'plusFromNumber:' — implement it to support 'number + String' arithmetic

aVector + 5
// => Vector(6, 7, 8)

5 / 0
// => ERROR: bad arithmetic operation

(Two // => lines shown for 5 + "not a vector" deliberately — the first is the bare DNU message every other DNU in this doc uses, the second is the added hint send_number_coercion/4 attaches to it, on the same format/1 line every DNU's hint already renders on: "<message>\nHint: <hint>". A real .btscript test asserts against the substring it needs, not both.)

What this ADR does not do

Prior Art

LanguageMechanismVerdict
Pharo/Squeak Smalltalkgenerality-ranked retryRelationalOp:coercing: / aNumber adaptToInteger: self andSend: #+, dispatched via perform:Rejected wholesale — the generality ladder assumes a total-ordered scalar tower we don't have, and non-tower types (Vector, Money) have no meaningful rank. The perform: retry is opaque to Beamtalk's type checker, which Phase 1 was specifically built to avoid.
Python__radd__/__rmul__ reflected dunder methods, tried automatically when the left operand's __add__ returns NotImplementedAdopted as the closest model — one statically-dispatchable method per operator, owned by the type that wants to participate, with no shared coercion ladder. Beamtalk's difference: the trigger is a caught badarith from the numeric BIF rather than a sentinel return value, since the numeric fast path never returns a sentinel — it's a bare BIF call.
GleamNo operator overloading at all; +/+. are fixed to Int/Float, mixed-type or custom-type arithmetic requires named functionsRejects the whole problem by design. Not viable for Beamtalk, which already committed to overloadable operators in BT-2709/2710 for value-type ergonomics (Money + Money, Vector + Vector).
Elixir/ErlangNo user-defined operator overloading; + is fixed to numbers, structs use named functions (Vector.add/2) or protocols (Numeric behaviour, via libraries)Confirms BEAM itself has no operator-dispatch story to inherit — Beamtalk's message-based operators are already a deliberate departure the epic (BT-2708) took on.
NewspeakNo numeric coercion protocol distinct from ordinary message dispatch — arithmetic is just messages, and mixed-type numeric towers are avoided by keeping the numeric class hierarchy shallowReinforces "keep it as ordinary dispatch" — supports treating plusFromNumber: as just another message rather than inventing new machinery.

User Impact

Steelman Analysis

Alternative: Generality/coercion tower

Alternative: perform:-based reflective retry (adaptTo:andSend:)

Alternative: Always-on runtime guard on the right operand's type

Tension points

Alternatives Considered

Generality/coercion tower (generality-ranked retry)

Rank every numeric-participating type on a total order and retry the operator after coercing the lower-ranked operand to the higher-ranked type's representation, à la Pharo Number>>retryRelationalOp:coercing:. Rejected: the tower this exists to order (SmallInteger < Fraction < Float < …) barely exists in Beamtalk (BEAM already fuses int/float at the BIF level, and there is no Fraction/ScaledDecimal/Rational), and the types this ADR actually needs to support (Vector, Money, Matrix) have no meaningful rank relative to Number5 + aVector isn't "promote 5 to a vector," it's "broadcast 5 across the vector," which a coerce-to-common-type model can't express.

perform:-based reflected dispatch (adaptToInteger:andSend:)

Re-invoke the operator dynamically via anObject adaptTo: self andSend: #+, internally using perform: aSymbol with: anArgument. Rejected: this is opaque to the type checker — Phase 1's entire design (BT-2709) exists to keep a static fast path and covariant-return inference (ADR 0068) working for arithmetic, and a perform:-based retry throws that guarantee away for exactly the call sites this ADR touches.

Always-on runtime guard on the right operand's type

Instead of catching badarith, emit a proactive runtime check on the right operand (e.g. is_number(Right)) alongside the existing left-operand guard unconditionally — every call site with a numeric left operand pays a right-operand check, regardless of whether the right operand's type is already known at compile time. Rejected in this unconditional form: the common case is total + delta — a numeric literal or known-numeric receiver on the left, and a right operand whose type is already visible to the compiler (a :: Number-family param, another numeric literal, a numeric/untyped field — the identical rule the left operand already uses). Guarding that right operand at runtime would regress it from a zero-cost bare BIF to an always-checked one, for information the compiler already has for free.

Not fully rejected, though — adopted as a compile-time skip instead of a runtime guard. The chosen design reuses exactly this idea (receiver_is_statically_numeric applied to the right operand), but as a codegen-time condition that removes the try/catch from total + delta-shaped call sites entirely, rather than as a runtime is_number check both operands pay on every call. Only a right operand whose type is genuinely unknown at compile time (5 + aVector) reaches the try/catch mechanism at all — see § Dispatch mechanism's "Trigger condition, refined." The remaining design question is narrower than the original framing suggests: not "guard vs. catch, always," but "guard vs. catch, only for the already-dynamically-dispatched residual" — and for that residual, the catch-on-badarith approach still only pays a cost when the fallback actually fires, which an always-on runtime guard would not.

Status quo — leave 5 + aVector unhandled

Do nothing: keep the receiver-dispatch-only behavior from BT-2709/2710 and document number-on-the-left arithmetic as unsupported. Rejected: it leaves the value-type ergonomics the epic (BT-2708) promised half-finished — aVector + 5 working but 5 + aVector crashing with a raw badarith is an asymmetry with no principled justification a user could discover short of hitting the crash, and "arithmetic operators are overloadable" becomes a claim with an asterisk. The cost of the fix (one codegen change, opt-in per type) is low enough that "leave it broken" doesn't clear the bar of a genuine alternative, only a fallback if the chosen design had turned out to be infeasible.

Extend reflection to comparison operators now

Apply the same <verb>FromNumber: pattern to < > <= >= in this ADR, since they share the guard machinery. Deferred, not rejected: comparison's failure mode (silent wrong term-ordering, since Erlang < never raises) has no badarith-style signal to catch, so it needs its own mechanism (likely a proactive is_object-style check on the right operand, mirroring BT-2710's existing left-operand guard) — different enough to warrant its own follow-up rather than folding it into this arithmetic-specific design.

Consequences

Positive

Negative

Neutral

Implementation

Affected components: codegen, plus one small runtime addition (no parser, type-checker, or new runtime protocol/exception-class changes) — the type checker's existing check_binary_operand_types warning is left as-is, including its now-known false positive on newly-valid number-on-the-left code (§ Consequences, Negative), rather than folded into this ADR's scope. The runtime addition is beamtalk_message_dispatch:send_number_coercion/4 (§ Dispatch mechanism's DNU-hinting subsection) — a thin wrapper around the existing send/3, not a new dispatch mechanism or exception class.

De-risking spike (done — results below)

Mirroring BT-2709's own precedent for this exact call site, run before writing this ADR's Implementation section into real codegen:

1. Hand-written Core Erlang, compiled with erlc. The exact shape in § Dispatch mechanism was hand-written as a standalone .core module and compiled directly. Two real issues surfaced that the illustrative Erlang in earlier drafts of this ADR got wrong, both fixed in that section now:

With both fixed, the module compiles cleanly. core_lint passes; the only diagnostic is the expected one — erlc's own note that the inner is_number case's defensive third arm can't match, confirmed by removing that arm and observing the warning disappear entirely. This is the exact situation guarded_op_doc's doc comment already describes for the identical pattern (an is_number-driven boolean case the compiler can prove exhaustive without a wildcard, defensively given one anyway) — not a new problem, a confirmed instance of an already-accepted one.

2. Functional verification, end to end. The compiled module (with a one-function stub standing in for beamtalk_message_dispatch:send/3) was exercised directly for all three branches: a plain numeric add returns the correct sum; a non-numeric right operand produces the expected plusFromNumber: dispatch call with operands in the documented order; and 1.0e308 + 1.0e308 (float overflow, both operands genuinely numeric) re-raises badarith with its original stacktrace showing erlang:'+' as the failing call — bit-for-bit the same shape as today's uncaught propagation, confirming the re-raise path doesn't fabricate or lose information.

3. Benchmark, with an important caveat. Extending the BT-2709 methodology (same tight-loop shape, same N/Reps, same min_us best-of sampling as bench_guard/0) to compare bare erlang:'+' against this ADR's full try/catch/is_number shape gave a ratio in the same ballpark as the already-shipped is_number guard measured the identical way, on the identical hardware, in the same run — not dramatically worse, and isolating the try/catch wrapper alone (no is_number re-check) showed it contributes a small fraction of that cost; the is_number check itself, not the try/catch, is where most of the overhead already lives in the existing, accepted mechanism this ADR reuses that check from.

The one thing this spike does not give: a trustworthy absolute ns/add number. Reproducing bench_guard/0 completely unmodified, on this sandbox, gave a ratio well outside the ~2.7–3.0× this repository's own docs/development/benchmarks.md records for that exact same benchmark — confirming the doc's own "(run-dependent)" caveat rather than contradicting it. The relative comparison (this ADR's mechanism vs. the guard it's layered next to, same run, same hardware) is sound; the absolute number is not portable off this sandbox and needs re-measuring wherever the project's real benchmark numbers get recorded (docs/development/benchmarks.md) as part of implementation, not asserted from this spike.

Conclusion: the mechanism is real, syntactically valid once corrected, functionally correct on all three branches, and costs roughly what the already-accepted is_number guard costs on the same call sites — which, thanks to the compile-time skip (§ Dispatch mechanism), are exactly the call sites that already pay a guard-shaped cost today. It does not touch the true zero-cost fast path (total + delta, 2 + 2) at all — that remains a structural guarantee (no try emitted there), not a benchmark claim. Safe to proceed to the codegen wiring below.

No number.bt changes — reflected methods are declared only on the types that opt in, not as an abstract protocol entry, since there is no receiver to declare subclassResponsibility against (the hook is looked up dynamically on whatever the right operand turns out to be).

Migration Path

No changes required for existing code — this only changes behavior for expressions that currently crash with a raw badarith (number-on-the-left arithmetic against a non-numeric right operand). No previously-successful expression's result changes.

One narrow, existing-code-visible effect is worth naming rather than glossing over: code that wraps such an expression in on: TypeError do: (or a broader on: Error do:) specifically to recover from today's badarith will observe a different outcome after this ADR ships, split by whether the right operand's type gained the reflected hook —

Addendum (2026-08-25): BT-3266 — right-operand untyped self.<field> gap, accepted

receiver_is_statically_numeric (BT-2709) treats an untyped self.<field> as statically numeric to keep self.count := self.count + 1-shaped counter arithmetic on the bare-BIF fast path. § Dispatch mechanism's "Trigger condition, refined" deliberately reuses that same predicate, unmodified, to gate this ADR's right-operand coercion check — so the identical leniency reproduces on the right side: self.total + self.extra with extra untyped stays a bare erlang:'+', no guard and no try/catch, and raw- crashes on badarith if extra ever holds a non-number. BT-3263 code review (PR #3515) flagged this as a real, if narrow, gap and filed it as BT-3266 rather than folding a fix into that PR.

Decision: accept the gap as specified; do not diverge the right-operand check from the left-operand one. Concretely, this addendum closes BT-3266 without a code change to the trigger condition — the codegen already matches what's decided here (see "What already shipped" below).

Rationale:

What already shipped (landed in BT-3263, commit 871eac5, ahead of this addendum): receiver_is_statically_numeric's own doc comment (operators.rs) names this exact asymmetry and cross-references BT-3266; test_number_coercion_untyped_self_field_right_operand_stays_bare (codegen/core_erlang/tests/expressions.rs) pins the current, accepted behavior down as a regression test, and a second test using self.<field> on both operands (self.total + self.extra, this issue's literal example) was added closing out BT-3266's coverage criterion.

If untyped-field arithmetic's silent-badarith risk ever becomes a real problem in practice, revisit both operands together as one design pass — not the right operand alone.

Implementation Tracking

Parent: BT-2712 (Phase 4 of BT-2708, already tracked this ADR's spec — reused as the parent for the issues below rather than creating a duplicate Epic) Issues:

Status: Planned

References