Aggregates, dispatch, and the literals money needs

Milestone log, v0.0.11 – v0.0.20. The design these versions serve is in DESIGN.md; the whole log is indexed here.

The aggregate ABI, receiver methods, interfaces and dyn, the logical operators, string length and equality, interpolation, money and percent literals, mixed-scale multiplication, and sum types with exhaustive matching.

v0.0.11: honest numbers, unary minus, human errors

A 97-program adversarial sweep found six open issues; all fixed. The numeric three shared one root cause worth stating plainly: the scaled-i64 representation’s cost is not precision, it is that INTERMEDIATES need more headroom than values do.

v0.0.12: the aggregate ABI (A4.5)

How multi-field values cross function boundaries. Unglamorous plumbing, but it is the substrate the object model sits on, so it is settled BEFORE the OOP grammar rather than rewritten underneath it.

The principle that decides everything else: semantics are defined at the value level; the ABI is a mechanism that must be invisible to them. Passing, returning and assigning an aggregate are value-copy operations on every target. Whether the machine uses registers or a pointer to a temporary is a target detail the program can never observe — if a program could tell the difference, the ABI is wrong, not the program.

What A4.6 may assume, and no more: layout is the declared fields; dispatch data lives OUTSIDE the value (fat pointer / dictionary); pass/return/assign is a value copy on every target; field N denotes the same field everywhere. Needing to violate one of those is a signal to revisit this milestone deliberately and record it — not to bolt a hidden header onto the layout.

v0.0.13: receiver methods — the first slice of A4.6

struct Account { balance: Decimal<2> }

fn (self: Account) balance_of() -> Decimal<2> {
    return self.balance;
}
fn (mut self: Account) deposit(amount: Decimal<2>) -> Decimal<2> {
    self.balance = self.balance + amount;
    return self.balance;
}

let mut acct: Account = Account { balance: 100.00 };
acct.deposit(25.50);           // side effect kept, result discarded
print(acct.balance_of());      // 125.50

A method is a plain function in the receiver’s namespace — fn (self: T) name(...), mangled bx.<T>.<name> — not an impl block, so there is no hidden this and no new nesting form. self is bound exactly like a parameter, with the SAME exact-type rules everywhere else.

Also landed: expression statements. Methods exposed a real gap — there was no way to call anything purely for its side effect; every call had to be wrapped in print(...) or let. f(); and acct.deposit(10.00); are now statements: Stmt::ExprStmt, evaluated for effect, result discarded.

v0.0.14: interfaces and dispatch (A4.6)

trait HasBalance {
    fn balance_of(self) -> Decimal<2>
}

impl HasBalance for Account {
    fn (self: Account) balance_of() -> Decimal<2> { return self.balance; }
}

print(acct.balance_of());          // static: a direct call, no vtable exists
let any: dyn HasBalance = acct;    // `dyn` is the ONLY thing that asks for
print(any.balance_of());           // runtime dispatch

A trait is a named set of method signatures a type can promise to satisfy. That is the whole concept: no fields, no state, no bodies.

A4.6 deferred-features ledger

Each of these is a real feature other languages have, and each is deferred with a trigger rather than silently added — because in a design phase with no physics to enforce discipline, scope is the thing being tested.

Feature Why deferred Earns its place when…
Default methods (bodies in traits) Pulls in override-resolution: “which body runs” A required program needs shared default behavior across many impls
Trait inheritance (trait A : B) Biggest complexity multiplier — satisfaction becomes a graph problem A real hierarchy genuinely cannot be modeled by small separate traits
Generics / trait bounds A whole milestone: inference, monomorphization Static polymorphism over type parameters is concretely needed
Associated types / constants Compounds generics complexity Only alongside generics, if ever
Blanket / overlapping impls Coherence and overlap rules A required pattern cannot be expressed one impl at a time
Operator traits (Add) Backdoors operator overloading into the numeric core Never, unless the numeric stance is deliberately revisited
Downcasting / reflection Large surface, breaks the abstraction A required program genuinely needs runtime type identity
Multiple dispatch Dispatch is on the single receiver only —
Mutating methods through dyn Needs to know the borrowed value is itself mutable — that is a borrow checker Borrow tracking exists
Returning / storing a dyn Borrows would outlive their storage Borrow tracking exists

interface remains a reserved word (v0.0.8) but trait is the chosen keyword; the North Star’s struct X is Priceable sketch is superseded by impl Trait for Type, which gives the trait’s methods one definite home and a place to enumerate them for a vtable.

v0.0.15: &&, ||, ! — closing A5.0

The last gap in the control-flow milestone. Bool-only, no truthiness: the left and right of &&/|| must each be a Bool, and ! refuses anything else, each with an error that says why.

if balance > 0.00 && balance < 1000.00 { ... }
if n != 0 && d / n > 1.00 { ... }        // safe: the division never runs

Short-circuit is part of the language, not an optimization, because skipping the right side is observable. It lowers to real basic blocks with a phi at the join, never a bitwise and of both sides. Two tests pin this down: one where the right side is a function that prints (its number is absent when skipped), and one where the right side would divide by zero — it prints instead of trapping, which is only possible if the right side genuinely does not execute.

& and | alone are errors pointing at &&/||; Burxt has no bitwise operators, so the single forms are free to be advice instead of silently meaning something else.

With this, A5.0’s own acceptance program passes: fib(20) prints 6765. Still deliberately deferred from that spec: for (needs iterators), match (needs sum types), break/continue (nothing has needed them yet), and any form of ternary.

v0.0.16: string length and equality (A4.4, unblocked half)

print(len("hello"));        // 5
print("abc" == "abc");      // true

The audit in spec/README.md found that A4.4 bundled length, equality and concatenation as one heap-blocked group, but only concatenation actually needs the heap. Length is a byte scan; equality is a byte loop. Both shipped here; concatenation stays refused until the memory model (M1).

v0.0.17: string interpolation, and the syntax-change law

print("Account of {owner}: {balance}");    // Account of Andre: 1234.56
print("literal braces: \{like this\}");

{expr} splices a value’s exact display form; any expression works, obeying exactly the grammar and type rules it would outside a string.

The law this milestone established, because a bare { was previously an ordinary character:

A feature that changes what currently-valid syntax MEANS must make the old form a compile error, never silently reinterpret it. The breaking error, pointing at the exact fix, is the feature working correctly.

So print("hi {name}") — which used to print the braces literally — is now a compile error naming \{ as the fix, rather than quietly becoming interpolation. Letting it change meaning silently was rejected on principle: a language that reinterprets valid syntax once will do it again, and that is the whole trust proposition. All brace handling lives in the lexer’s string scanner, so {, }, \{, \} are settled at tokenization and the parser has no ambiguity left to resolve.

Interpolation prints; it does not build a String. Producing a String value would mean building new bytes — the same allocation wall concatenation hits — so let s: String = "x {n}"; is refused with that reason, while print("x {n}") emits the pieces in order and allocates nothing. This keeps the milestone on the safe side of the heap boundary; it lifts with M1.

Literal pieces remain printf ARGUMENTS, never format strings, so a %s or %n in user text still prints literally.

v0.0.18: money and percent literals (A4.7, slice 2)

let price: Decimal<2> = $19.99;      // sugar for Decimal<2>, not a new type
let rate:  Decimal<4> = 8.25%;       // exactly 0.0825

Open decision this surfaced (recorded in the A4.7 spec): the spec’s own flagship price + price * 8.25% at Decimal<2> does NOT compile, because a percent literal is Decimal<4> and Decimal * Decimal requires matching scales. Percent-of-money works at a matching scale today; making the mixed form work means deciding whether * may take mixed scales when a rounding contract says how to land — a change to a core thesis rule, so it is left as a judgment call rather than smuggled in.

v0.0.19: mixed-scale multiplication — percent-of-money works

let price: Decimal<2, RoundHalfEven> = $19.99;
let total: Decimal<2, RoundHalfEven> = price + price * 8.25%;   // 21.64

A refined rule, not a silent swap. * now permits operands of DIFFERENT scales when the result binding supplies a rounding contract. + and - remain strict. The asymmetry is principled and states in one sentence:

Addition combines like quantities, so scales must match. Multiplication combines a quantity with a rate, so scales differ by nature.

$1.00 + $0.001 is almost always a bug — differing scales suggest the operands are not the same kind of thing. But money × rate is inherently between two different kinds: a price is scale-2, a tax or interest rate is finer. Forcing those to match was forcing a fiction.

The mandatory contract is the safety line and must never become optional. A mixed-scale product’s natural scale is the SUM of the operand scales, so landing it on the declared scale drops digits, and someone must say how. Without a contract on the result it is a compile error naming the fix. If a mixed-scale product could be silently rounded, the thesis would be broken.

This is more on-thesis than staying strict, not a loosening: the strict form already “worked” by making the author widen money to the rate’s scale by hand, multiply, and narrow back — a human performing rounding and rescaling manually, which is exactly what Burxt exists to prevent.

Operation Scales Rule
+, - must match unchanged
* same legal; contract still required (the scale doubles)
* mixed legal only with a rounding contract on the result
/ must match unchanged — this decision covered * only

Codegen shifts the exact product by lhs_scale + rhs_scale - result_scale, which subsumes the same-scale case (s + s - s = s), so mixed and matching scales share one path instead of being special-cased against each other. A result wider than the exact product widens losslessly and rounds nothing. The i128 intermediate reaches 10^36, so pow10 now builds its constant from a u128 rather than one 64-bit word.

Tested to the numeric core’s bar: exact ties at mixed scales in both modes and both signs, checked against an independent exact-decimal implementation; the scale-18 × scale-18 extreme where the intermediate needs 36 decimal places; the widening direction; and a result that genuinely cannot fit a scaled i64, which still traps loudly.

v0.0.20: sum types and exhaustive matching (A6.0)

The milestone that makes a compiler expressible in Burxt — a Token, an Expr, a Type are all sum types — and that delivers exhaustiveness, the correctness-family feature committed long ago.

enum Token { Plus, Number(Int), End }

fn describe(t: Token) -> Int {
    match t {
        Plus      => { return 1; }
        Number(n) => { return n; }
        End       => { return 0; }
    }
}

The self-hosting consequence, and it is the useful finding here: payloads are scalars only, because an enum containing an enum has no finite size without heap indirection. A lexer’s Token is flat, so the lexer is expressible today — but an AST node is recursive, so the parser is M1-blocked. That sharpens the self-hosting path: the partial self-host can begin now and stops precisely at the parser.