From 607ec85ec531f9a6e53cef02823ee65cec87e04d Mon Sep 17 00:00:00 2001 From: 2kai2kai2 Date: Thu, 30 Jul 2026 17:05:06 -0700 Subject: [PATCH] Emit virtual call for comparison operators --- .../crates/baml_compiler2_mir/src/lower.rs | 236 ++++++- .../baml_src/ns_operators/operators.baml | 256 +++++++ .../snapshots/baml_src/operators.snap | 646 ++++++++++++++++++ .../crates/baml_tests/tests/interfaces.rs | 104 +++ .../crates/bex_vm/tests/comparison_driver.rs | 161 +++++ 5 files changed, 1383 insertions(+), 20 deletions(-) diff --git a/baml_language/crates/baml_compiler2_mir/src/lower.rs b/baml_language/crates/baml_compiler2_mir/src/lower.rs index 0401b2598ea..bdee7348e10 100644 --- a/baml_language/crates/baml_compiler2_mir/src/lower.rs +++ b/baml_language/crates/baml_compiler2_mir/src/lower.rs @@ -1494,6 +1494,11 @@ impl<'db> LoweringContext<'db> { Self::baml_iter_qtn(name) } + /// A `baml.ops.` interface name (`Equals`, `Compare`, …). + fn baml_ops_qtn(name: &str) -> QualifiedTypeName { + QualifiedTypeName::new(Name::new("baml"), vec![Name::new("ops")], Name::new(name)) + } + fn baml_iter_done_ty() -> RuntimeTy { RuntimeTy::Class(Self::baml_iter_type_name("Done"), vec![], TyAttr::default()) } @@ -6232,6 +6237,19 @@ impl LoweringContext<'_> { return; } + // Ordering over operands the comparison opcodes cannot order (`bool`, an + // enum or class implementing `Compare`, or a type variable / `Self` / + // projection that realizes to one) dispatches through `baml.ops.Compare`, + // resolved at runtime from the receiver's concrete type. Unlike the + // arithmetic operators this needs no `__union_*` driver: `Compare` is + // *single* dispatch (`other: Self`), so the receiver alone picks the impl. + if let Some(method) = Self::ordering_method(op) + && !self.ordering_uses_primitive_opcode(lhs, rhs) + { + self.lower_ordering_via_virtual_call(method, lhs, rhs, dest); + return; + } + // Mixed `int OP bigint` (or `bigint OP int`) operators resolve the // `int` operand to a small local `BigInt` in the VM (the specialized // `*Bigint`/`CmpBigint` opcodes accept a lone `int` operand), without @@ -6282,6 +6300,14 @@ impl LoweringContext<'_> { /// case (concrete-type comparison + custom `Equals` dispatch). The driver may /// yield (it can call a user `eq`), so the call splits the block. `!=` negates /// the `==` result. + // + // BUG: `!=` never dispatches `Equals.neq`, so a type that overrides `neq` + // inconsistently with `eq` sees the override ignored by the operator (it is + // only reachable as `a.neq(b)`). Unlike ordering — which is gated on a single + // concrete `Compare` type and so can dispatch the interface method directly — + // `==`/`!=` accept arbitrary operand pairs, which have no shared `Equals` to + // dispatch through. Fixing it therefore means deciding what `!=` should mean + // across type boundaries, not just changing the lowering. fn lower_equality_via_driver( &mut self, op: AstBinaryOp, @@ -6352,17 +6378,25 @@ impl LoweringContext<'_> { } } - /// Whether `ty` is a primitive the specialized arithmetic opcodes / - /// `exec_binop` handle directly — int/bigint/float, plus `string` when - /// `include_string` (binary `+` concatenates; unary `-` has no string form). + /// Whether `ty` is a primitive the specialized opcodes handle directly — + /// int/bigint/float, plus `string` when `include_string` (binary `+` + /// concatenates; unary `-` has no string form). /// A literal counts as its base; a union counts only when every member is /// the SAME primitive kind (`int | 3`): a mixed-kind union (`int | float`) /// would let emit pick a single-kind opcode for a value of the other kind — - /// UB in the specialized handlers — so it goes through the `__union_*` - /// interface driver, as does anything else (a user type, or a union / - /// existential / type variable involving one). TIR has already validated - /// the operation through the interface registry; this only chooses the - /// lowering route. + /// UB in the specialized handlers — so it takes the interface route instead, + /// as does anything else (a user type, or a union / existential / type + /// variable involving one). TIR has already validated the operation through + /// the interface registry; this only chooses the lowering route. + /// + /// Shared by three operator families, which reach different interface routes + /// when it says no: arithmetic and unary negation go to the `baml.ops` + /// `__union_*` drivers ([`Self::arithmetic_uses_primitive_opcode`], + /// [`Self::negate_uses_primitive_opcode`]), while ordering dispatches + /// `baml.ops.Compare` directly ([`Self::ordering_uses_primitive_opcode`], + /// which additionally rejects `null`). `exec_binop` and `exec_cmpop` are the + /// runtime counterparts; ordering is the narrower of the two, so a change to + /// either handler's supported set has to be reflected here. fn arith_primitive(ty: &RuntimeTy, include_string: bool) -> bool { /// The primitive kind of a non-union member, literal widened to base. /// The builtin wrapper classes (`baml.Float`, etc.) count as their @@ -6441,6 +6475,129 @@ impl LoweringContext<'_> { self.lower_via_ops_driver(driver, vec![lhs_op, rhs_op], result_ty, dest); } + /// Whether both ordering operands are primitives the comparison opcodes can + /// *order*: int, bigint, float, string. That reduces to + /// [`Self::arith_primitive`] with `include_string` — which also admits the + /// spellings of those four (a literal, a same-kind union like `int | 3`, and + /// the builtin companion classes) — so the predicate is shared rather than + /// duplicated. `exec_cmpop` orders exactly those four and treats every other + /// pair (`bool`, `uint8array`, enum variants, class instances, …) as + /// equality-only. `bool` falls out on its own — `PrimitiveType::Bool` is not + /// one of the arithmetic kinds — which is what routes it to the `Compare` + /// impl the stdlib declares for it. + /// + /// Preconditions, both owed by TIR's ordering check and *not* re-derived + /// here: the two operands have the same type, and that type implements + /// `baml.ops.Compare`. The second is what makes the interface route correct + /// for everything this predicate rejects. Note the predicate tests each + /// operand independently, so it leans on the first precondition — a mixed + /// pair such as `int < string` would take the opcode path and fault, but TIR + /// rejects it before lowering. + /// + /// The `null` guard is defense in depth rather than a fix: `null` has no + /// `Compare` impl, so neither route can order it and TIR rejects it outright. + /// It is here because [`Self::arith_primitive`] deliberately treats a `null` + /// union member as *transparent* — a carve-out for chain-narrowed + /// compound-assign targets, which ordering has no form of — and inheriting + /// that silently would make `int | null` look opcode-orderable. + /// + /// Reads `expr_ty` (TIR types), where a `Self`-annotated parameter in a + /// concrete `implements` block has already been resolved to the block's + /// subject — the *MIR local* type keeps the unresolved `Self` (see + /// `lower_signature_runtime_ty`), and reading that instead would deoptimize + /// `baml.Comparable$for$int.compare` and friends off the opcode path. + fn ordering_uses_primitive_opcode(&self, lhs: AstExprId, rhs: AstExprId) -> bool { + /// `arith_primitive`, minus the `null`-transparency carve-out. + fn orderable(ty: &RuntimeTy) -> bool { + let has_null = match ty { + RuntimeTy::Union(members, _) => { + members.iter().any(|m| matches!(m, RuntimeTy::Null { .. })) + } + RuntimeTy::Null { .. } => true, + _ => false, + }; + !has_null && LoweringContext::arith_primitive(ty, true) + } + orderable(&self.expr_ty(lhs)) && orderable(&self.expr_ty(rhs)) + } + + /// The `baml.ops.Compare` method an ordering operator dispatches, or `None` + /// for any other operator. Single source for both the route test in + /// [`Self::lower_binary`] and the dispatched method name, so the two cannot + /// disagree about which operators are orderings. + fn ordering_method(op: AstBinaryOp) -> Option<&'static str> { + match op { + AstBinaryOp::Lt => Some("lt"), + AstBinaryOp::Le => Some("le"), + AstBinaryOp::Gt => Some("gt"), + AstBinaryOp::Ge => Some("ge"), + _ => None, + } + } + + /// Lower `a OP b` for `<`/`<=`/`>`/`>=` through `baml.ops.Compare`, resolving + /// the impl at runtime from the receiver's concrete type. Mirrors + /// [`Self::lower_arithmetic_via_driver`], but dispatches directly instead of + /// through a `baml.ops` driver function. + /// + /// A driver earns its keep when the compiler cannot *name* the interface to + /// dispatch on: `equals_equals` because `==` spans operand pairs that share + /// no interface at all, and `__union_add` and friends because `Add` is + /// generic in an `Rhs` that may be statically erased. `Compare` is neither — + /// it is single dispatch (`other: Self`), non-generic, and its methods return + /// plain `bool` rather than an associated type — so the interface, the + /// method, and the result type are all statically known and the receiver + /// alone picks the impl. (Single dispatch is necessary but not sufficient: + /// `Negate` is single dispatch too, yet returns `Self.Output`, which is why + /// it still goes through `__union_neg`.) + /// + /// Each operator dispatches its *own* method rather than deriving the other + /// three from `lt`. `implement Compare for float` overrides all four + /// natively so that NaN is unordered in every direction, which `ge = !lt` + /// would break; and rewriting `a > b` as `b.lt(a)` would ignore a user's + /// `gt` override. The interface's defaults still supply whichever methods an + /// impl leaves out — they are merged into the impl's method table when the + /// program is baked. + fn lower_ordering_via_virtual_call( + &mut self, + method: &str, + lhs: AstExprId, + rhs: AstExprId, + dest: Place, + ) { + let lhs_op = self.lower_to_operand(lhs); + let rhs_op = self.lower_to_operand(rhs); + // `Compare` is non-generic and declares no associated types, so the + // template carries neither; the receiver supplies `Self` at runtime. + // With no args or associated types to map onto frame slots, the + // enclosing generic params would never be consulted — pass none. + let iface = tir2_interface_to_template( + &Self::baml_ops_qtn("Compare"), + &[], + &[], + self.resolved_aliases, + &[], + ); + // Ordering always produces `bool`: TIR's ordering arm types it that way, + // and the literal pairs `try_fold_binary` would fold instead are all + // opcode-orderable, so they never reach here. Name it directly rather + // than reading it back out of `expr_ty`. + let bool_ty = RuntimeTy::Bool { + attr: TyAttr::default(), + }; + let unwind = self.catch_context.as_ref().map(|c| c.unwind_target); + self.emit_virtual_call_with_operands( + iface, + method, + vec![lhs_op, rhs_op], + /* ntypeargs */ 0, + /* runtime_id */ None, + bool_ty, + unwind, + dest, + ); + } + /// Whether the negation operand is [`Self::arith_primitive`] (the `Neg` /// opcode has no string form). fn negate_uses_primitive_opcode(&self, operand: AstExprId) -> bool { @@ -10014,25 +10171,65 @@ impl<'db> LoweringContext<'db> { ); let unwind = self.catch_context.as_ref().map(|c| c.unwind_target); let runtime_id_operand = self.lower_runtime_id_operand(runtime_id); + let result_ty = self.expr_ty(expr_id); + self.emit_virtual_call_with_operands( + iface_template, + method.as_str(), + all_args, + ntypeargs, + runtime_id_operand, + result_ty, + unwind, + dest.clone(), + ); + true + } + + /// Emit the `VirtualCall` terminator itself, given operands that are already + /// lowered. Shared by the method-call funnel ([`Self::emit_virtual_call`], + /// which builds its operands from an AST call) and by operator lowering, + /// which has no call expression to read arguments from. + /// + /// `args` must be laid out as `[method_type_args… ++ receiver ++ value_args…]` + /// with exactly `ntypeargs` leading type args, mirroring `Call`. **The + /// receiver is `args[ntypeargs]`** — the VM reads its runtime concrete type + /// as `Self` and resolves the impl off that, so passing the operands in the + /// wrong order silently dispatches on the wrong value. `Self` is taken from + /// the value, not the operand form, so a receiver may be any `Operand` + /// (`emit_virtual_call` always passes a local; operator lowering may pass a + /// constant, as in `true < false`). + /// + /// The call splits the block — the resolved impl may be user bytecode — so + /// lowering resumes in a fresh one. `VirtualCall`'s destination must be a + /// `Place::Local`; a projection (field/index) or capture is dispatched into + /// a `result_ty`-typed temp and assigned through in the resume block, + /// mirroring how `lower_call`/`lower_await` normalize their destinations. + #[expect(clippy::too_many_arguments)] + fn emit_virtual_call_with_operands( + &mut self, + iface: TyTemplateInterface, + method: &str, + args: Vec, + ntypeargs: usize, + runtime_id: Option, + result_ty: RuntimeTy, + unwind: Option, + dest: Place, + ) { let resume = self.builder.create_block(); - // `VirtualCall`'s destination must be a `Place::Local`. If the caller - // handed us a projection (field/index) or capture, dispatch into a temp - // local and assign through to the projection in the resume block — - // mirrors how `lower_call`/`lower_await` normalize their destinations. let (call_dest, projection_dest) = match dest { - Place::Local(_) => (dest.clone(), None), + Place::Local(_) => (dest, None), projection => { - let call_ty = self.expr_ty(expr_id); - let tmp = self.builder.temp(call_ty); - (Place::local(tmp), Some(projection.clone())) + let tmp = self.builder.temp(result_ty); + (Place::local(tmp), Some(projection)) } }; self.builder.virtual_call_with_runtime_id( - iface_template, + iface, method.to_string(), - all_args, + args, ntypeargs, - runtime_id_operand, + runtime_id, call_dest.clone(), resume, unwind, @@ -10042,7 +10239,6 @@ impl<'db> LoweringContext<'db> { self.builder .assign(projection, Rvalue::Use(Operand::Copy(call_dest))); } - true } /// Emit an [`Rvalue::MakeVirtualBoundMethod`] binding `method` of the interface diff --git a/baml_language/crates/baml_tests/baml_src/ns_operators/operators.baml b/baml_language/crates/baml_tests/baml_src/ns_operators/operators.baml index 18306128e17..8693263539c 100644 --- a/baml_language/crates/baml_tests/baml_src/ns_operators/operators.baml +++ b/baml_language/crates/baml_tests/baml_src/ns_operators/operators.baml @@ -525,3 +525,259 @@ test "driver_panics_match_opcodes" { assert.is_true(baml.deep_equals(drv_catch_float_div_zero(), -1.0)) assert.is_true(baml.deep_equals(drv_catch_bigint_div_zero(), -1n)) } + +// ─── Ordering dispatch through baml.ops.Compare ─────────────────────────────── +// `<` `<=` `>` `>=` on operands the comparison opcodes cannot order lower to a +// virtual call on baml.ops.Compare. `bool` needs no user code to reach this: +// the stdlib declares `implement Compare for bool`, and before the interface +// lowering existed `true < false` aborted with an uncatchable VM internal error. + +function ord_bool_lt(a: bool, b: bool) -> bool { a < b } +function ord_bool_le(a: bool, b: bool) -> bool { a <= b } +function ord_bool_gt(a: bool, b: bool) -> bool { a > b } +function ord_bool_ge(a: bool, b: bool) -> bool { a >= b } + +test "ordering_on_bool_dispatches_compare" { + assert.is_true(ord_bool_lt(false, true)) + assert.is_true(!ord_bool_lt(true, false)) + assert.is_true(!ord_bool_lt(true, true)) + assert.is_true(ord_bool_gt(true, false)) + assert.is_true(!ord_bool_gt(false, true)) + // `le`/`ge` come from the Compare defaults, so they are reflexive. + assert.is_true(ord_bool_le(false, false)) + assert.is_true(ord_bool_ge(true, true)) + assert.is_true(ord_bool_le(false, true)) + assert.is_true(!ord_bool_ge(false, true)) +} + +// A user class implementing only the required `lt`; `le`/`gt`/`ge` resolve to the +// interface defaults, which are merged into the impl's method table at bake time. +class Ranked { + rank int + implements baml.ops.Equals { + function eq(self, other: Self) -> bool throws never { self.rank == other.rank } + } + implements baml.ops.Compare { + function lt(self, other: Self) -> bool throws never { self.rank < other.rank } + } +} + +function ord_rank_lt(a: Ranked, b: Ranked) -> bool { a < b } +function ord_rank_le(a: Ranked, b: Ranked) -> bool { a <= b } +function ord_rank_gt(a: Ranked, b: Ranked) -> bool { a > b } +function ord_rank_ge(a: Ranked, b: Ranked) -> bool { a >= b } + +test "ordering_on_user_class_dispatches_compare" { + assert.is_true(ord_rank_lt(Ranked { rank: 1 }, Ranked { rank: 2 })) + assert.is_true(!ord_rank_lt(Ranked { rank: 2 }, Ranked { rank: 1 })) + assert.is_true(ord_rank_le(Ranked { rank: 1 }, Ranked { rank: 1 })) + assert.is_true(ord_rank_gt(Ranked { rank: 2 }, Ranked { rank: 1 })) + assert.is_true(ord_rank_ge(Ranked { rank: 1 }, Ranked { rank: 1 })) + assert.is_true(!ord_rank_ge(Ranked { rank: 1 }, Ranked { rank: 2 })) +} + +// Each operator dispatches its own method, so an override beats the default it +// replaces. `gt` here is deliberately inconsistent with `!le`: the default would +// make `a > a` false, the override makes it true. This fails if `>` is lowered as +// `!(a <= b)` or as a swapped `b.lt(a)`. +class OddOrder { + n int + implements baml.ops.Equals { + function eq(self, other: Self) -> bool throws never { self.n == other.n } + } + implements baml.ops.Compare { + function lt(self, other: Self) -> bool throws never { self.n < other.n } + function gt(self, other: Self) -> bool throws never { true } + } +} + +function ord_odd_gt(a: OddOrder, b: OddOrder) -> bool { a > b } +function ord_odd_le(a: OddOrder, b: OddOrder) -> bool { a <= b } + +test "ordering_override_beats_default" { + assert.is_true(ord_odd_gt(OddOrder { n: 1 }, OddOrder { n: 1 })) + // The un-overridden `le` still follows the default (`lt || eq`). + assert.is_true(ord_odd_le(OddOrder { n: 1 }, OddOrder { n: 1 })) +} + +// A bounded-generic operand keeps MIR off the specialized opcodes, like the +// arithmetic drv_* block above — though the route differs: arithmetic reaches a +// baml.ops.__union_* driver function, while ordering dispatches baml.ops.Compare +// directly. Either way the impl can only come from the runtime instantiation. +// Monomorphic wrappers pin T (literal args could otherwise infer a literal-typed +// T, which no interface bound admits). +function ord_lt(a: T, b: T) -> bool { a < b } +function ord_le(a: T, b: T) -> bool { a <= b } +function ord_gt(a: T, b: T) -> bool { a > b } +function ord_ge(a: T, b: T) -> bool { a >= b } + +function ord_int_lt(a: int, b: int) -> bool { ord_lt(a, b) } +function ord_int_le(a: int, b: int) -> bool { ord_le(a, b) } +function ord_int_gt(a: int, b: int) -> bool { ord_gt(a, b) } +function ord_int_ge(a: int, b: int) -> bool { ord_ge(a, b) } +function ord_float_lt(a: float, b: float) -> bool { ord_lt(a, b) } +function ord_float_ge(a: float, b: float) -> bool { ord_ge(a, b) } +function ord_string_lt(a: string, b: string) -> bool { ord_lt(a, b) } +function ord_bigint_lt(a: bigint, b: bigint) -> bool { ord_lt(a, b) } +function ord_rank_generic_lt(a: Ranked, b: Ranked) -> bool { ord_lt(a, b) } + +// Named for the generic (interface) route rather than a driver: unlike the +// arithmetic block above, ordering reaches no baml.ops driver function. +test "generic_ordering_matches_opcodes" { + assert.is_true(baml.deep_equals(ord_int_lt(1, 2), 1 < 2)) + assert.is_true(baml.deep_equals(ord_int_lt(2, 1), 2 < 1)) + assert.is_true(baml.deep_equals(ord_int_le(1, 1), 1 <= 1)) + assert.is_true(baml.deep_equals(ord_int_gt(2, 1), 2 > 1)) + assert.is_true(baml.deep_equals(ord_int_ge(1, 2), 1 >= 2)) + assert.is_true(baml.deep_equals(ord_float_lt(1.5, 2.5), 1.5 < 2.5)) + assert.is_true(baml.deep_equals(ord_string_lt("a", "b"), "a" < "b")) + assert.is_true(baml.deep_equals(ord_bigint_lt(1n, 2n), 1n < 2n)) + // NaN is unordered in every direction on both paths. + assert.is_true(!ord_float_lt(float.nan(), 1.0)) + assert.is_true(!ord_float_ge(float.nan(), 1.0)) + assert.is_true(!(float.nan() < 1.0)) + assert.is_true(!(float.nan() >= 1.0)) + // The generic path reaches a user class too. + assert.is_true(ord_rank_generic_lt(Ranked { rank: 1 }, Ranked { rank: 2 })) +} + +// ─── Ordering: panics propagate and unwind, like the arithmetic drivers ─────── +// The virtual call carries the enclosing catch's unwind edge. `Compare` methods +// are `throws never`, so the only thing that can escape a user `lt` is a panic — +// which must still reach an enclosing catch arm, including through an inherited +// default that nests another dispatch (`le` = `lt || eq`). + +class Boom { + n int + implements baml.ops.Equals { + function eq(self, other: Self) -> bool throws never { self.n == other.n } + } + implements baml.ops.Compare { + function lt(self, other: Self) -> bool throws never { (1 / self.n) < other.n } + } +} + +function ord_catch_lt() -> bool { + (Boom { n: 0 } < Boom { n: 1 }) catch (e) { baml.panics.DivisionByZero => true } +} +function ord_catch_le() -> bool { + (Boom { n: 0 } <= Boom { n: 1 }) catch (e) { baml.panics.DivisionByZero => true } +} +function ord_catch_gt() -> bool { + (Boom { n: 0 } > Boom { n: 1 }) catch (e) { baml.panics.DivisionByZero => true } +} +function ord_catch_ge() -> bool { + (Boom { n: 0 } >= Boom { n: 1 }) catch (e) { baml.panics.DivisionByZero => true } +} + +test "ordering_panics_unwind_to_catch" { + assert.is_true(ord_catch_lt()) + assert.is_true(ord_catch_le()) + assert.is_true(ord_catch_gt()) + assert.is_true(ord_catch_ge()) +} + +// ─── Ordering into non-local destinations ──────────────────────────────────── +// The call terminator's destination must be a local, so a field / index / +// captured target is routed through a temp and assigned through on resume. + +class FlagBox { flag bool } + +function ord_into_field() -> bool { + let b = FlagBox { flag: false } + b.flag = (Ranked { rank: 1 } < Ranked { rank: 2 }) + b.flag +} +function ord_into_index() -> bool { + let xs = [false, false] + xs[0] = (Ranked { rank: 1 } < Ranked { rank: 2 }) + xs[0] +} +function ord_into_capture() -> bool { + let out = false + let set = () -> null throws never { out = (false < true); null } + set() + out +} + +test "ordering_into_projection_destinations" { + assert.is_true(ord_into_field()) + assert.is_true(ord_into_index()) + // Each target starts `false` and is written `true`, so a dropped write fails. + assert.is_true(ord_into_capture()) +} + +// ─── Ordering in condition and short-circuit position ──────────────────────── +// The dispatch splits the block, so the branch terminator lands in the resume +// block rather than the one the operands were lowered into. + +// The class literals are bound first: a bare `Ranked { … }` in condition position +// would have its brace parsed as the `if` body. +function ord_as_if_cond() -> int { + let lo = Ranked { rank: 1 } + let hi = Ranked { rank: 2 } + if lo < hi { 10 } else { 20 } +} +function ord_as_while_cond() -> int { + let n = 0 + let a = false + while a < true { n += 1; a = true } + n +} +function ord_in_short_circuit() -> bool { + (Ranked { rank: 1 } < Ranked { rank: 2 }) && (true > false) +} + +test "ordering_in_condition_position" { + assert.is_true(baml.deep_equals(ord_as_if_cond(), 10)) + assert.is_true(baml.deep_equals(ord_as_while_cond(), 1)) + assert.is_true(ord_in_short_circuit()) +} + +// ─── Ordering with a `Self` receiver and on generic instantiations ─────────── +// `Self` inside an interface default body and a generic class are two of the +// three receiver shapes the virtual-call terminator exists for. + +interface Ordered requires baml.ops.Compare { + function precedes(self, other: Self) -> bool throws never { self < other } +} +class Tier { + level int + implements baml.ops.Equals { + function eq(self, other: Self) -> bool throws never { self.level == other.level } + } + implements baml.ops.Compare { + function lt(self, other: Self) -> bool throws never { self.level < other.level } + } + implements Ordered {} +} + +class Wrapped { + weight int + implements baml.ops.Equals { + function eq(self, other: Self) -> bool throws never { self.weight == other.weight } + } + implements baml.ops.Compare { + function lt(self, other: Self) -> bool throws never { self.weight < other.weight } + } +} + +function ord_self_in_default(a: Tier, b: Tier) -> bool { a.precedes(b) } +function ord_generic_int(a: Wrapped, b: Wrapped) -> bool { a < b } +function ord_generic_string(a: Wrapped, b: Wrapped) -> bool { a < b } + +test "ordering_on_self_and_generic_receivers" { + assert.is_true(ord_self_in_default(Tier { level: 1 }, Tier { level: 2 })) + assert.is_true(!ord_self_in_default(Tier { level: 2 }, Tier { level: 1 })) + assert.is_true(ord_generic_int(Wrapped { weight: 1 }, Wrapped { weight: 2 })) + assert.is_true(ord_generic_string(Wrapped { weight: 1 }, Wrapped { weight: 2 })) +} + +// The literal form named in the lowering's own comments: `true < false` compiles +// to a dispatch whose receiver operand is a constant, not a local. +test "ordering_on_bool_literals" { + assert.is_true(!(true < false)) + assert.is_true(false < true) + assert.is_true(true >= true) + assert.is_true(false <= false) +} diff --git a/baml_language/crates/baml_tests/snapshots/baml_src/operators.snap b/baml_language/crates/baml_tests/snapshots/baml_src/operators.snap index 49907350126..1f557fa1586 100644 --- a/baml_language/crates/baml_tests/snapshots/baml_src/operators.snap +++ b/baml_language/crates/baml_tests/snapshots/baml_src/operators.snap @@ -366,7 +366,90 @@ function operators.$init_test_ns_operators_operators(registry: testing.TestColle load_const null call testing.TestCollector.register_test_at pop 1 + load_var registry + load_const "root.operators" + load_const "ordering_on_bool_dispatches_compare" + make_closure ., 0 + load_const null + call testing.TestCollector.register_test_at + pop 1 + load_var registry + load_const "root.operators" + load_const "ordering_on_user_class_dispatches_compare" + make_closure ., 0 + load_const null + call testing.TestCollector.register_test_at + pop 1 + load_var registry + load_const "root.operators" + load_const "ordering_override_beats_default" + make_closure ., 0 + load_const null + call testing.TestCollector.register_test_at + pop 1 + load_var registry + load_const "root.operators" + load_const "generic_ordering_matches_opcodes" + make_closure ., 0 + load_const null + call testing.TestCollector.register_test_at + pop 1 + load_var registry + load_const "root.operators" + load_const "ordering_panics_unwind_to_catch" + make_closure ., 0 load_const null + call testing.TestCollector.register_test_at + pop 1 + load_var registry + load_const "root.operators" + load_const "ordering_into_projection_destinations" + make_closure ., 0 + load_const null + call testing.TestCollector.register_test_at + pop 1 + load_var registry + load_const "root.operators" + load_const "ordering_in_condition_position" + make_closure ., 0 + load_const null + call testing.TestCollector.register_test_at + pop 1 + load_var registry + load_const "root.operators" + load_const "ordering_on_self_and_generic_receivers" + make_closure ., 0 + load_const null + call testing.TestCollector.register_test_at + pop 1 + load_var registry + load_const "root.operators" + load_const "ordering_on_bool_literals" + make_closure ., 0 + load_const null + call testing.TestCollector.register_test_at + pop 1 + load_const null + return +} + +function operators.Boom.baml.ops.Compare.lt(self: operators.Boom, other: operators.Boom) -> bool { + load_const 1 + load_var self + load_field .n + div_int + load_var other + load_field .0 + cmp_int_op < + return +} + +function operators.Boom.baml.ops.Equals.eq(self: operators.Boom, other: operators.Boom) -> bool { + load_var self + load_field .n + load_var other + load_field .0 + cmp_int_op == return } @@ -404,6 +487,39 @@ function operators.Meters.baml.ops.Add.add(self: operators.Meters, rhs: int return } +function operators.OddOrder.baml.ops.Compare.gt(self: operators.OddOrder, other: operators.OddOrder) -> bool { + load_const true + return +} + +function operators.OddOrder.baml.ops.Compare.lt(self: operators.OddOrder, other: operators.OddOrder) -> bool { + load_var self + load_field .n + load_var other + load_field .0 + cmp_int_op < + return +} + +function operators.OddOrder.baml.ops.Equals.eq(self: operators.OddOrder, other: operators.OddOrder) -> bool { + load_var self + load_field .n + load_var other + load_field .0 + cmp_int_op == + return +} + +function operators.Ordered.precedes(self: operators.Ordered, other: #0) -> bool { + load_var2 1 2 + load_type baml.ops.Compare + load_const "lt" + virtual_call nargs=2 ntypeargs=0 + store_var _0 + load_var _0 + return +} + function operators.Poly.baml.ops.Add.add(self: operators.Poly, rhs: float) -> operators.Poly { load_var self load_field .v @@ -422,6 +538,24 @@ function operators.Poly.baml.ops.Add.add(self: operators.Poly, rhs: int) -> return } +function operators.Ranked.baml.ops.Compare.lt(self: operators.Ranked, other: operators.Ranked) -> bool { + load_var self + load_field .rank + load_var other + load_field .0 + cmp_int_op < + return +} + +function operators.Ranked.baml.ops.Equals.eq(self: operators.Ranked, other: operators.Ranked) -> bool { + load_var self + load_field .rank + load_var other + load_field .0 + cmp_int_op == + return +} + function operators.Scaled.baml.ops.Divide.div(self: operators.Scaled, rhs: operators.Scaled) -> operators.Scaled { load_var self load_field .v @@ -462,6 +596,24 @@ function operators.Scaled.baml.ops.Subtract.sub(self: operators.Scaled, return } +function operators.Tier.baml.ops.Compare.lt(self: operators.Tier, other: operators.Tier) -> bool { + load_var self + load_field .level + load_var other + load_field .0 + cmp_int_op < + return +} + +function operators.Tier.baml.ops.Equals.eq(self: operators.Tier, other: operators.Tier) -> bool { + load_var self + load_field .level + load_var other + load_field .0 + cmp_int_op == + return +} + function operators.Vec2.baml.ops.Add.add(self: operators.Vec2, rhs: operators.Vec2) -> operators.Vec2 { load_var self load_field .x @@ -488,6 +640,24 @@ function operators.Vec2.baml.ops.Negate.neg(self: operators.Vec2) -> operators.V return } +function operators.Wrapped.baml.ops.Compare.lt(self: operators.Wrapped<#0>, other: operators.Wrapped<#0>) -> bool { + load_var self + load_field .weight + load_var other + load_field .0 + cmp_int_op < + return +} + +function operators.Wrapped.baml.ops.Equals.eq(self: operators.Wrapped<#0>, other: operators.Wrapped<#0>) -> bool { + load_var self + load_field .weight + load_var other + load_field .0 + cmp_int_op == + return +} + function operators.drv_add(a: #0, b: #0) -> #0 { load_var2 1 2 call baml.ops.__union_add @@ -929,3 +1099,479 @@ function operators.ops_scaled_sub(a: operators.Scaled, b: operators.Scaled) -> o call baml.ops.__union_sub return } + +function operators.ord_as_if_cond() -> int { + load_const 1 + init_instance user.operators.Ranked .rank + load_const 2 + init_instance user.operators.Ranked .rank + load_type baml.ops.Compare + load_const "lt" + virtual_call nargs=2 ntypeargs=0 + store_var _3 + load_var _3 + pop_jump_if_false L0 + jump L1 + + L0: + load_const 20 + jump L2 + + L1: + load_const 10 + + L2: + return +} + +function operators.ord_as_while_cond() -> int { + load_const 0 + store_var n + load_const false + + L0: + load_const true + load_type baml.ops.Compare + load_const "lt" + virtual_call nargs=2 ntypeargs=0 + store_var _3 + load_var _3 + pop_jump_if_false L1 + jump L2 + + L1: + load_var n + return + + L2: + load_var n + load_const 1 + add_int + store_var n + load_const true + jump L0 +} + +function operators.ord_bigint_lt(a: bigint, b: bigint) -> bool { + load_type bigint + load_var2 1 2 + call user.operators.ord_lt + return +} + +function operators.ord_bool_ge(a: bool, b: bool) -> bool { + load_var2 1 2 + load_type baml.ops.Compare + load_const "ge" + virtual_call nargs=2 ntypeargs=0 + store_var _0 + load_var _0 + return +} + +function operators.ord_bool_gt(a: bool, b: bool) -> bool { + load_var2 1 2 + load_type baml.ops.Compare + load_const "gt" + virtual_call nargs=2 ntypeargs=0 + store_var _0 + load_var _0 + return +} + +function operators.ord_bool_le(a: bool, b: bool) -> bool { + load_var2 1 2 + load_type baml.ops.Compare + load_const "le" + virtual_call nargs=2 ntypeargs=0 + store_var _0 + load_var _0 + return +} + +function operators.ord_bool_lt(a: bool, b: bool) -> bool { + load_var2 1 2 + load_type baml.ops.Compare + load_const "lt" + virtual_call nargs=2 ntypeargs=0 + store_var _0 + load_var _0 + return +} + +function operators.ord_catch_ge() -> bool { + load_const 0 + init_instance user.operators.Boom .n + load_const 1 + init_instance user.operators.Boom .n + load_type baml.ops.Compare + load_const "ge" + virtual_call nargs=2 ntypeargs=0 + store_var _0 + jump L2 + load_var e + is_type baml.panics.DivisionByZero + pop_jump_if_false L0 + jump L1 + + L0: + load_var e + rethrow + + L1: + load_const true + store_var _0 + + L2: + load_var _0 + return +} + +function operators.ord_catch_gt() -> bool { + load_const 0 + init_instance user.operators.Boom .n + load_const 1 + init_instance user.operators.Boom .n + load_type baml.ops.Compare + load_const "gt" + virtual_call nargs=2 ntypeargs=0 + store_var _0 + jump L2 + load_var e + is_type baml.panics.DivisionByZero + pop_jump_if_false L0 + jump L1 + + L0: + load_var e + rethrow + + L1: + load_const true + store_var _0 + + L2: + load_var _0 + return +} + +function operators.ord_catch_le() -> bool { + load_const 0 + init_instance user.operators.Boom .n + load_const 1 + init_instance user.operators.Boom .n + load_type baml.ops.Compare + load_const "le" + virtual_call nargs=2 ntypeargs=0 + store_var _0 + jump L2 + load_var e + is_type baml.panics.DivisionByZero + pop_jump_if_false L0 + jump L1 + + L0: + load_var e + rethrow + + L1: + load_const true + store_var _0 + + L2: + load_var _0 + return +} + +function operators.ord_catch_lt() -> bool { + load_const 0 + init_instance user.operators.Boom .n + load_const 1 + init_instance user.operators.Boom .n + load_type baml.ops.Compare + load_const "lt" + virtual_call nargs=2 ntypeargs=0 + store_var _0 + jump L2 + load_var e + is_type baml.panics.DivisionByZero + pop_jump_if_false L0 + jump L1 + + L0: + load_var e + rethrow + + L1: + load_const true + store_var _0 + + L2: + load_var _0 + return +} + +function operators.ord_float_ge(a: float, b: float) -> bool { + load_type float + load_var2 1 2 + call user.operators.ord_ge + return +} + +function operators.ord_float_lt(a: float, b: float) -> bool { + load_type float + load_var2 1 2 + call user.operators.ord_lt + return +} + +function operators.ord_ge(a: #0, b: #0) -> bool { + load_var2 1 2 + load_type baml.ops.Compare + load_const "ge" + virtual_call nargs=2 ntypeargs=0 + store_var _0 + load_var _0 + return +} + +function operators.ord_generic_int(a: operators.Wrapped, b: operators.Wrapped) -> bool { + load_var2 1 2 + load_type baml.ops.Compare + load_const "lt" + virtual_call nargs=2 ntypeargs=0 + store_var _0 + load_var _0 + return +} + +function operators.ord_generic_string(a: operators.Wrapped, b: operators.Wrapped) -> bool { + load_var2 1 2 + load_type baml.ops.Compare + load_const "lt" + virtual_call nargs=2 ntypeargs=0 + store_var _0 + load_var _0 + return +} + +function operators.ord_gt(a: #0, b: #0) -> bool { + load_var2 1 2 + load_type baml.ops.Compare + load_const "gt" + virtual_call nargs=2 ntypeargs=0 + store_var _0 + load_var _0 + return +} + +function operators.ord_in_short_circuit() -> bool { + load_const 1 + init_instance user.operators.Ranked .rank + load_const 2 + init_instance user.operators.Ranked .rank + load_type baml.ops.Compare + load_const "lt" + virtual_call nargs=2 ntypeargs=0 + store_var _1 + load_var _1 + jump_if_false L0 + pop 1 + load_const true + load_const false + load_type baml.ops.Compare + load_const "gt" + virtual_call nargs=2 ntypeargs=0 + + L0: + return +} + +function operators.ord_int_ge(a: int, b: int) -> bool { + load_type int + load_var2 1 2 + call user.operators.ord_ge + return +} + +function operators.ord_int_gt(a: int, b: int) -> bool { + load_type int + load_var2 1 2 + call user.operators.ord_gt + return +} + +function operators.ord_int_le(a: int, b: int) -> bool { + load_type int + load_var2 1 2 + call user.operators.ord_le + return +} + +function operators.ord_int_lt(a: int, b: int) -> bool { + load_type int + load_var2 1 2 + call user.operators.ord_lt + return +} + +function operators.ord_into_capture() -> bool { + load_var ?1 + make_cell + store_var ?1 + load_const false + store_deref ?1 + load_var out + make_closure ., 1 + call_indirect + pop 1 + load_deref ?1 + return +} + +function operators.ord_into_field() -> bool { + load_const false + init_instance user.operators.FlagBox .flag + store_var b + load_const 1 + init_instance user.operators.Ranked .rank + load_const 2 + init_instance user.operators.Ranked .rank + load_type baml.ops.Compare + load_const "lt" + virtual_call nargs=2 ntypeargs=0 + store_var _4 + load_var2 1 2 + store_field .flag + load_var b + load_field .flag + return +} + +function operators.ord_into_index() -> bool { + load_const false + load_const false + load_type bool + alloc_array 2 + store_var xs + load_const 1 + init_instance user.operators.Ranked .rank + load_const 2 + init_instance user.operators.Ranked .rank + load_type baml.ops.Compare + load_const "lt" + virtual_call nargs=2 ntypeargs=0 + store_var _5 + load_var xs + load_const 0 + load_var _5 + store_array_element + load_var xs + load_const 0 + load_array_element + return +} + +function operators.ord_le(a: #0, b: #0) -> bool { + load_var2 1 2 + load_type baml.ops.Compare + load_const "le" + virtual_call nargs=2 ntypeargs=0 + store_var _0 + load_var _0 + return +} + +function operators.ord_lt(a: #0, b: #0) -> bool { + load_var2 1 2 + load_type baml.ops.Compare + load_const "lt" + virtual_call nargs=2 ntypeargs=0 + store_var _0 + load_var _0 + return +} + +function operators.ord_odd_gt(a: operators.OddOrder, b: operators.OddOrder) -> bool { + load_var2 1 2 + load_type baml.ops.Compare + load_const "gt" + virtual_call nargs=2 ntypeargs=0 + store_var _0 + load_var _0 + return +} + +function operators.ord_odd_le(a: operators.OddOrder, b: operators.OddOrder) -> bool { + load_var2 1 2 + load_type baml.ops.Compare + load_const "le" + virtual_call nargs=2 ntypeargs=0 + store_var _0 + load_var _0 + return +} + +function operators.ord_rank_ge(a: operators.Ranked, b: operators.Ranked) -> bool { + load_var2 1 2 + load_type baml.ops.Compare + load_const "ge" + virtual_call nargs=2 ntypeargs=0 + store_var _0 + load_var _0 + return +} + +function operators.ord_rank_generic_lt(a: operators.Ranked, b: operators.Ranked) -> bool { + load_type operators.Ranked + load_var2 1 2 + call user.operators.ord_lt + return +} + +function operators.ord_rank_gt(a: operators.Ranked, b: operators.Ranked) -> bool { + load_var2 1 2 + load_type baml.ops.Compare + load_const "gt" + virtual_call nargs=2 ntypeargs=0 + store_var _0 + load_var _0 + return +} + +function operators.ord_rank_le(a: operators.Ranked, b: operators.Ranked) -> bool { + load_var2 1 2 + load_type baml.ops.Compare + load_const "le" + virtual_call nargs=2 ntypeargs=0 + store_var _0 + load_var _0 + return +} + +function operators.ord_rank_lt(a: operators.Ranked, b: operators.Ranked) -> bool { + load_var2 1 2 + load_type baml.ops.Compare + load_const "lt" + virtual_call nargs=2 ntypeargs=0 + store_var _0 + load_var _0 + return +} + +function operators.ord_self_in_default(a: operators.Tier, b: operators.Tier) -> bool { + load_var2 1 2 + load_type operators.Ordered + load_const "precedes" + virtual_call nargs=2 ntypeargs=0 + store_var _0 + load_var _0 + return +} + +function operators.ord_string_lt(a: string, b: string) -> bool { + load_type string + load_var2 1 2 + call user.operators.ord_lt + return +} diff --git a/baml_language/crates/baml_tests/tests/interfaces.rs b/baml_language/crates/baml_tests/tests/interfaces.rs index 0f57a0abed2..dfa5f047925 100644 --- a/baml_language/crates/baml_tests/tests/interfaces.rs +++ b/baml_language/crates/baml_tests/tests/interfaces.rs @@ -12044,6 +12044,11 @@ fn duplicate_implements_differing_only_in_assoc_bindings_is_compile_error() { // Ordering (`<` `<=` `>` `>=`) is valid only when both operands are the *same // concrete type* implementing `baml.ops.Compare` (or the same bounded type-var). // A union, an interface-existential, or two different types is a compile error. +// +// That restriction is load-bearing, not merely conservative: a valid ordering over +// a non-primitive lowers to a `baml.ops.Compare` virtual call resolved from the +// *receiver's* concrete type alone (`lower_binary`). Single dispatch is only sound +// because both operands are guaranteed to be the same concrete type at runtime. #[test] fn ordering_on_union_operands_is_rejected() { @@ -12115,6 +12120,105 @@ fn ordering_on_same_concrete_primitive_is_ok() { // its single concrete base `int` before the union rejection — is exercised at // runtime by `baml_src/ns_arrays/sort_comparable.baml`.) +// The tests below are TIR-only: these helpers collect diagnostics and never run +// MIR lowering, so they pin the *accepted set* rather than the dispatch. They are +// the guard that the shapes `lower_ordering_via_virtual_call` exists to serve stay +// accepted, and that the shapes its soundness depends on stay rejected. Runtime +// behavior of the lowering lives in `bex_vm/tests/comparison_driver.rs` and +// `baml_src/ns_operators/operators.baml`. + +#[test] +fn ordering_on_user_class_implementing_compare_is_ok() { + // Guards against over-rejection: a class implementing `Compare` may be + // ordered. Only the required `lt` is defined — `<=`/`>`/`>=` reach the + // interface's defaults. + assert_no_compile_errors( + r#" + class Money { + cents: int + implements baml.ops.Equals { + function eq(self, other: Self) -> bool throws never { self.cents == other.cents } + } + implements baml.ops.Compare { + function lt(self, other: Self) -> bool throws never { self.cents < other.cents } + } + } + function f(a: Money, b: Money) -> bool throws never { + (a < b) && (a <= b) && (a > b) && (a >= b) + } + "#, + ); +} + +#[test] +fn ordering_on_bounded_type_var_is_ok() { + // `T extends Compare` is a single concrete type per instantiation, so ordering + // is exact-type. The impl can only come from the runtime instantiation. + assert_no_compile_errors( + r#" + function max(a: T, b: T) -> T throws never { + if a < b { b } else { a } + } + "#, + ); +} + +#[test] +fn compare_bound_rejects_abstract_type_argument() { + // The counterpart to `ordering_on_union_operands_is_rejected`: a union cannot + // sneak in through a type argument either. This is what makes the operand of a + // `Compare`-bounded ordering a single concrete type at runtime, and hence what + // makes single-dispatch (`Compare.lt` resolved on the receiver alone, in + // `lower_ordering_via_virtual_call`) sound. + assert_compile_error_code( + r#" + function max(a: T, b: T) -> T throws never { + if a < b { b } else { a } + } + function f(a: int | string, b: int | string) -> int | string throws never { + max(a, b) + } + "#, + "E0001", + ); +} + +#[test] +fn ordering_on_interface_existential_type_argument_is_rejected() { + // Same premise via the other abstract spelling: an interface-existential type + // argument has no single runtime type to dispatch on either. + assert_compile_error_code( + r#" + function max(a: T, b: T) -> T throws never { + if a < b { b } else { a } + } + function f(a: baml.ops.Compare, b: baml.ops.Compare) -> baml.ops.Compare throws never { + max(a, b) + } + "#, + "E0001", + ); +} + +#[test] +fn compare_without_equals_is_rejected() { + // `Compare requires Equals`, and the inherited `le` default is literally + // `self.lt(other) || self.eq(other)`. If a type could implement `Compare` + // without `Equals`, `a <= b` would lower to a virtual call whose `eq` has no + // impl to resolve — an uncatchable internal error. E0125 is what prevents it. + assert_compile_error_code( + r#" + class NoEq { + v: int + implements baml.ops.Compare { + function lt(self, other: Self) -> bool throws never { self.v < other.v } + } + } + "#, + "E0125", + ); +} + // ── Group AI: arithmetic operators dispatch through the `baml.ops` interfaces ── // `+ - * / %` (and unary `-`) are valid iff the operand types implement the // matching `baml.ops` interface for the right operand; the result is the impl's diff --git a/baml_language/crates/bex_vm/tests/comparison_driver.rs b/baml_language/crates/bex_vm/tests/comparison_driver.rs index ae8893951fc..51dc805c78f 100644 --- a/baml_language/crates/bex_vm/tests/comparison_driver.rs +++ b/baml_language/crates/bex_vm/tests/comparison_driver.rs @@ -9,6 +9,10 @@ //! dispatch to a user class's custom `Equals.eq` (resolved against the baked impl //! registry and called via `YieldToCall`), including generic classes and the //! structural fallback when a class has no `Equals` impl. +//! +//! It also covers the sibling comparison surface: `baml.ops.Compare`, reached +//! both by method call (`bool_compare_is_reflexive`) and — since ordering +//! operators gained an interface lowering — by `<` `<=` `>` `>=` themselves. use std::sync::{Arc, atomic::AtomicBool}; @@ -272,6 +276,163 @@ fn driver_dispatches_custom_enum_equals() { assert!(run_bool(SRC, "user.eq_diff_variants")); } +// ── Ordering operators dispatch `baml.ops.Compare` ────────────────────────── +// +// `<` `<=` `>` `>=` on operands the comparison opcodes cannot order lower to a +// `VirtualCall` on `baml.ops.Compare`. Before that route existed they reached +// `exec_cmpop` and aborted with the uncatchable `VmInternalError::CannotApplyCmpOp` +// ("cannot apply comparison operation: bool < bool") — for `bool` that needed no +// user code at all, since the stdlib declares `implement Compare for bool`. + +// The operator form must agree with the method form pinned by +// `bool_compare_is_reflexive` above, value for value — same impl, same defaults. +#[test] +fn bool_ordering_operators_dispatch_compare() { + const SRC: &str = r#" + function lt_b(a: bool, b: bool) -> bool { a < b } + function le_b(a: bool, b: bool) -> bool { a <= b } + function gt_b(a: bool, b: bool) -> bool { a > b } + function ge_b(a: bool, b: bool) -> bool { a >= b } + + function lt_false_true() -> bool { lt_b(false, true) } + function lt_true_false() -> bool { lt_b(true, false) } + function lt_same() -> bool { lt_b(true, true) } + function le_same() -> bool { le_b(false, false) } + function le_false_true() -> bool { le_b(false, true) } + function le_true_false() -> bool { le_b(true, false) } + function gt_true_false() -> bool { gt_b(true, false) } + function gt_same() -> bool { gt_b(true, true) } + function ge_same() -> bool { ge_b(true, true) } + function ge_false_true() -> bool { ge_b(false, true) } + "#; + // `false < true` is the only strict-less pair. + assert!(run_bool(SRC, "user.lt_false_true")); + assert!(!run_bool(SRC, "user.lt_true_false")); + assert!(!run_bool(SRC, "user.lt_same")); + // `le`/`ge` come from the `Compare` defaults and are reflexive. + assert!(run_bool(SRC, "user.le_same")); + assert!(run_bool(SRC, "user.ge_same")); + assert!(run_bool(SRC, "user.le_false_true")); + assert!(!run_bool(SRC, "user.le_true_false")); + // `gt` is `bool`'s own override. + assert!(run_bool(SRC, "user.gt_true_false")); + assert!(!run_bool(SRC, "user.gt_same")); + assert!(!run_bool(SRC, "user.ge_false_true")); +} + +// A class implementing only the required `lt`: `le`/`gt`/`ge` must resolve to the +// interface defaults, which are merged into the impl's method table at bake time. +#[test] +fn user_class_ordering_dispatches_compare() { + const SRC: &str = r#" + class Money { + cents: int + implements baml.ops.Equals { + function eq(self, other: Self) -> bool throws never { self.cents == other.cents } + } + implements baml.ops.Compare { + function lt(self, other: Self) -> bool throws never { self.cents < other.cents } + } + } + function money(c: int) -> Money { Money { cents: c } } + + function cheaper() -> bool { money(1) < money(2) } + function not_cheaper() -> bool { money(2) < money(1) } + function le_equal() -> bool { money(1) <= money(1) } + function gt_bigger() -> bool { money(2) > money(1) } + function ge_equal() -> bool { money(1) >= money(1) } + function ge_smaller() -> bool { money(1) >= money(2) } + "#; + assert!(run_bool(SRC, "user.cheaper")); + assert!(!run_bool(SRC, "user.not_cheaper")); + assert!(run_bool(SRC, "user.le_equal")); + assert!(run_bool(SRC, "user.gt_bigger")); + assert!(run_bool(SRC, "user.ge_equal")); + assert!(!run_bool(SRC, "user.ge_smaller")); +} + +// Each operator dispatches its own method, so an override wins over the default it +// replaces. `gt` here is deliberately inconsistent with `!le` — the default would +// make `a > a` false, the override makes it true. Pins that `>` is not lowered as +// `!(a <= b)` or as a swapped `b.lt(a)`. +#[test] +fn user_class_ordering_honors_gt_override() { + const SRC: &str = r#" + class Odd { + n: int + implements baml.ops.Equals { + function eq(self, other: Self) -> bool throws never { self.n == other.n } + } + implements baml.ops.Compare { + function lt(self, other: Self) -> bool throws never { self.n < other.n } + function gt(self, other: Self) -> bool throws never { true } + } + } + function odd(n: int) -> Odd { Odd { n: n } } + function gt_self() -> bool { odd(1) > odd(1) } + function le_self() -> bool { odd(1) <= odd(1) } + "#; + assert!(run_bool(SRC, "user.gt_self")); + // The un-overridden `le` still follows the default (`lt || eq`). + assert!(run_bool(SRC, "user.le_self")); +} + +// Enums can implement `Compare` too; before the route existed `exec_cmpop`'s +// `(Variant, Variant)` arm handled only `Eq`/`Ne` and aborted on ordering. +#[test] +fn enum_ordering_dispatches_compare() { + const SRC: &str = r#" + enum E { A B } + implement baml.ops.Equals for E { + function eq(self, other: Self) -> bool throws never { false } + } + implement baml.ops.Compare for E { + function lt(self, other: Self) -> bool throws never { true } + } + function lt_variants() -> bool { E.A < E.B } + function gt_variants() -> bool { E.A > E.B } + "#; + assert!(run_bool(SRC, "user.lt_variants")); + // `gt` defaults to `!le` = `!(lt || eq)` = `!(true || false)`. + assert!(!run_bool(SRC, "user.gt_variants")); +} + +// Inside a `T extends Compare` body the operand type is a type variable, so the impl +// can only come from the runtime instantiation. This shape silently worked for +// primitives before (the VM's opcode arms) and fatally aborted for classes — the +// body looks tested while half its instantiations abort. +// Monomorphic wrappers are required because literal arguments would otherwise infer a +// literal union for `T`, which no `Compare` bound admits. +#[test] +fn bounded_typevar_ordering_dispatches_at_runtime() { + const SRC: &str = r#" + class Money { + cents: int + implements baml.ops.Equals { + function eq(self, other: Self) -> bool throws never { self.cents == other.cents } + } + implements baml.ops.Compare { + function lt(self, other: Self) -> bool throws never { self.cents < other.cents } + } + } + function money(c: int) -> Money { Money { cents: c } } + + function lt_g(a: T, b: T) -> bool { a < b } + function lt_int(a: int, b: int) -> bool { lt_g(a, b) } + function lt_bool(a: bool, b: bool) -> bool { lt_g(a, b) } + function lt_money(a: Money, b: Money) -> bool { lt_g(a, b) } + + function generic_int() -> bool { lt_int(1, 2) } + function generic_bool() -> bool { lt_bool(false, true) } + function generic_money() -> bool { lt_money(money(1), money(2)) } + function generic_money_rev() -> bool { lt_money(money(2), money(1)) } + "#; + assert!(run_bool(SRC, "user.generic_int")); + assert!(run_bool(SRC, "user.generic_bool")); + assert!(run_bool(SRC, "user.generic_money")); + assert!(!run_bool(SRC, "user.generic_money_rev")); +} + // Union type args are order-insensitive: `Box` and `Box` are // the same `Self`, so two such instances with equal contents compare equal — the driver // compares `class_type_args` semantically (`ty_args_equivalent`), not structurally, the