From b82b418d151d37816624917226e1cd82a6df52a3 Mon Sep 17 00:00:00 2001 From: Antonio Sarosi Date: Thu, 2 Jul 2026 01:32:10 +0200 Subject: [PATCH 1/2] Honor subtyping in generic class match arms' reified type-arg check (B-634) A generic `match` arm `let s: Opt` (where `T` is the enclosing function's type var) lowered its runtime type-test to an EXACT reified type-arg comparison. When inference pins `T` to a supertype union of the scrutinee's actual type arg -- which happens whenever a `default: T` argument subtypes, so `T` reifies to the un-subsumed join `Shape | Sq` while the value is a plain `Opt` -- the exact `Shape | Sq == Shape` check failed and the arm silently missed, falling through to the default. Concretely, `unwrap_or(mk(Sq{s:5}), Sq{s:0}) .area()` returned 0 instead of 25; widening the default to `Shape` (so `T` pins to `Shape`) already returned 25. Fix (MIR layer): in `lower_pattern_test`, the typevar-carrying class-pattern arm now builds its `IsType` template with `tir2_to_dispatch_guard_template` (frame `TypeVar` -> `TypeArgRefOrWildcard`, subtype-or-wildcard) instead of `ty_to_template` (frame `TypeVar` -> `TypeArgRef`, exact). The VM then compares the reified frame arg with `is_subtype_of` rather than `==`, which is the correct covariant "does this value belong to type `Opt`" semantics and matches the interface class-dispatch guard path (`emit_interface_class_guard_ branch`), which already uses the same template. This keeps `TypeArgRef`'s exact-materialization meaning intact for other uses. Directionality is preserved: a strictly wider runtime type-arg still fails to match a narrower pinned `T`. Concrete parametric arms (`Foo` vs `Foo`) are unaffected -- they carry no enclosing type var and take the exact concrete path. Tests: - bex_vm unit tests on `guard_template_matches` locking the directional subtype semantics (under-match fix, exact `TypeArgRef` stays exact, wider arg does not over-match, unknown-slot wildcard, concrete discrimination). - ns_generic_match_subtype e2e: narrow-default -> 25, widened-default -> 25, non-Opt scrutinee falls through, concrete `Foo`/`Foo` still discriminate. - projects/compiles/generic_match_typevar_arm locks the emitted template as `TypeArgRefOrWildcard` (`Opt<#0?>`) in the MIR/codegen snapshots. --- .../crates/baml_compiler2_mir/src/lower.rs | 24 +- .../generic_match_subtype.baml | 122 ++++++++++ .../generic_match_typevar_arm.baml | 21 ++ .../baml_tests/snapshots/baml_src/_root.snap | 3 + .../baml_src/generic_match_subtype.snap | 213 ++++++++++++++++++ ...__01_lexer__generic_match_typevar_arm.snap | 201 +++++++++++++++++ ..._02_parser__generic_match_typevar_arm.snap | 100 ++++++++ ...es__generic_match_typevar_arm__03_hir.snap | 12 + ...__generic_match_typevar_arm__04_5_mir.snap | 32 +++ ...es__generic_match_typevar_arm__04_tir.snap | 23 ++ ...ric_match_typevar_arm__05_diagnostics.snap | 5 + ...generic_match_typevar_arm__06_codegen.snap | 29 +++ ..._formatter__generic_match_typevar_arm.snap | 25 ++ baml_language/crates/bex_vm/src/vm.rs | 91 ++++++++ 14 files changed, 899 insertions(+), 2 deletions(-) create mode 100644 baml_language/crates/baml_tests/baml_src/ns_generic_match_subtype/generic_match_subtype.baml create mode 100644 baml_language/crates/baml_tests/projects/compiles/generic_match_typevar_arm/generic_match_typevar_arm.baml create mode 100644 baml_language/crates/baml_tests/snapshots/baml_src/generic_match_subtype.snap create mode 100644 baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__01_lexer__generic_match_typevar_arm.snap create mode 100644 baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__02_parser__generic_match_typevar_arm.snap create mode 100644 baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__03_hir.snap create mode 100644 baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__04_5_mir.snap create mode 100644 baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__04_tir.snap create mode 100644 baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__05_diagnostics.snap create mode 100644 baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__06_codegen.snap create mode 100644 baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__10_formatter__generic_match_typevar_arm.snap diff --git a/baml_language/crates/baml_compiler2_mir/src/lower.rs b/baml_language/crates/baml_compiler2_mir/src/lower.rs index 29d375c5d4a..551e934b6d5 100644 --- a/baml_language/crates/baml_compiler2_mir/src/lower.rs +++ b/baml_language/crates/baml_compiler2_mir/src/lower.rs @@ -15190,12 +15190,32 @@ impl LoweringContext<'_> { // `any`) must NOT go through `convert_tir_ty_for_runtime` — // that erases TypeVar → Void and the test becomes // constant-false. Build a template instead so the args - // lower to `TypeArgRef` resolved against the frame. + // resolve against the frame. + // + // Use the dispatch-guard template (frame TypeVar → + // `TypeArgRefOrWildcard`, subtype-or-wildcard) rather than the + // exact `TypeArgRef` one. A pattern type-test is covariant — + // it asks "does this value belong to type `Opt`" — so the + // reified frame arg must be compared with `is_subtype_of`, not + // `==`. Otherwise, when inference pins `T` to a supertype union + // of the value's actual type arg (e.g. a `default: T` arg + // subtypes, so `T` reifies to the un-subsumed join `Shape | Sq` + // while the value is `Opt`), the exact check + // `Shape | Sq == Shape` fails and the arm silently misses. This + // matches the interface class-dispatch guard path + // (`emit_interface_class_guard_branch`), which already builds + // its typevar args with `tir2_to_dispatch_guard_template`. + // Directionality is preserved: a strictly wider runtime arg + // still fails to match a narrower pinned `T`. if matches!(&pat_tir_ty, Tir2Ty::Class(..)) && baml_compiler2_tir::generics::contains_typevar(&pat_tir_ty) { let generic_params = self.enclosing_generic_params(); - let template = self.ty_to_template(&pat_tir_ty, &generic_params); + let template = tir2_to_dispatch_guard_template( + &pat_tir_ty, + self.resolved_aliases, + &generic_params, + ); self.emit_is_type_template_branch(scrutinee, template, success, failure); return; } diff --git a/baml_language/crates/baml_tests/baml_src/ns_generic_match_subtype/generic_match_subtype.baml b/baml_language/crates/baml_tests/baml_src/ns_generic_match_subtype/generic_match_subtype.baml new file mode 100644 index 00000000000..72150c69b28 --- /dev/null +++ b/baml_language/crates/baml_tests/baml_src/ns_generic_match_subtype/generic_match_subtype.baml @@ -0,0 +1,122 @@ +// End-to-end tests for B-634. +// +// A generic `match` arm `let s: Opt` (where `T` is the enclosing function's +// type var) used to lower its runtime type-test to an EXACT reified type-arg +// comparison. When inference pins `T` to a supertype UNION of the scrutinee's +// actual type arg — which happens whenever a `default: T` argument subtypes, +// so `T` reifies to the un-subsumed join `Shape | Sq` while the value is a +// plain `Opt` — the exact `Shape | Sq == Shape` check failed and the arm +// silently missed, falling through to the default. +// +// Fixed in `baml_compiler2_mir::lower::lower_pattern_test`: the typevar-carrying +// class-pattern arm now builds its `IsType` template with +// `tir2_to_dispatch_guard_template` (frame `TypeVar` -> `TypeArgRefOrWildcard`, +// subtype-or-wildcard) instead of `ty_to_template` (frame `TypeVar` -> +// `TypeArgRef`, exact). This matches the interface class-dispatch guard path and +// gives the correct covariant "does this value belong to type `Opt`" +// semantics. +// +// NOTE: test bodies live in top-level helper functions — locals holding class +// instances misbehave when compared/passed inside `test { ... }` blocks (the +// same VM local-boxing caveat documented in `ns_class_type_args_at_runtime`). + +interface Shape { + function area(self) -> int +} + +class Sq { + s: int, + implements Shape { + function area(self) -> int { + self.s * self.s + } + } +} + +class Opt { + value: T, +} + +class Non {} + +function mk(v: T) -> Opt | Non { + Opt { value: v } +} + +function unwrap_or(o: Opt | Non, default: T) -> T { + match (o) { + let s: Opt => s.value, + _ => default, + } +} + +// ─── 1. Narrow default: T infers to the un-subsumed join `Shape | Sq` ───────── +// The scrutinee is a plain `Opt`; before the fix the reified frame `T` +// (`Shape | Sq`) failed the exact type-arg check and the arm returned the +// default `Sq { s: 0 }` (area 0). With subtype-or-wildcard, `Shape <: Shape|Sq` +// matches, so we get the wrapped `Sq { s: 5 }` back (area 25). +function generic_match_subtype_narrow_default_fn() -> int { + let o: Opt | Non = mk(Sq { s: 5 }); + unwrap_or(o, Sq { s: 0 }).area() +} + +test "generic_match_subtype_narrow_default" { + assert.equal(generic_match_subtype_narrow_default_fn(), 25) +} + +// ─── 2. Widened default: T pinned to `Shape` directly ───────────────────────── +// This path already worked before the fix (exact `Shape == Shape`); kept as a +// control to guard against regressing it. +function generic_match_widened_default_fn() -> int { + let o: Opt | Non = mk(Sq { s: 5 }); + let d: Shape = Sq { s: 0 }; + unwrap_or(o, d).area() +} + +test "generic_match_widened_default" { + assert.equal(generic_match_widened_default_fn(), 25) +} + +// ─── 3. The widened arm still discriminates `Opt` from the non-Opt member ── +// The subtype-or-wildcard template must not blindly match every value: a `Non` +// scrutinee must still fall through to the default (`_` arm). Confirms the fix +// does not collapse the class-name discrimination in the pattern. +function generic_match_falls_through_on_non_fn() -> int { + let o: Opt | Non = Non {}; + unwrap_or(o, Sq { s: 7 }).area() +} + +test "generic_match_falls_through_on_non" { + assert.equal(generic_match_falls_through_on_non_fn(), 49) +} + +// ─── 4. Concrete parametric arms still discriminate (unchanged path) ────────── +// `let s: Foo` / `let s: Foo` are concrete (no enclosing type var), +// so they take the exact concrete path, untouched by the fix. They must keep +// discriminating `Foo` from `Foo`. +class Foo { + value: T, +} + +function pick_concrete(x: Foo | Foo) -> string { + match (x) { + let a: Foo => "int", + let b: Foo => "string", + } +} + +function concrete_discriminates_int_fn() -> string { + pick_concrete(Foo { value: 1 }) +} + +test "generic_match_concrete_discriminates_int" { + assert.equal(concrete_discriminates_int_fn(), "int") +} + +function concrete_discriminates_string_fn() -> string { + pick_concrete(Foo { value: "x" }) +} + +test "generic_match_concrete_discriminates_string" { + assert.equal(concrete_discriminates_string_fn(), "string") +} diff --git a/baml_language/crates/baml_tests/projects/compiles/generic_match_typevar_arm/generic_match_typevar_arm.baml b/baml_language/crates/baml_tests/projects/compiles/generic_match_typevar_arm/generic_match_typevar_arm.baml new file mode 100644 index 00000000000..293b609c08e --- /dev/null +++ b/baml_language/crates/baml_tests/projects/compiles/generic_match_typevar_arm/generic_match_typevar_arm.baml @@ -0,0 +1,21 @@ +// B-634: a generic `match` arm `let s: Opt` (where `T` is the enclosing +// function's type var) must lower its runtime type-test through the +// subtype-or-wildcard dispatch-guard template, so the reified frame type-arg is +// compared with `is_subtype_of` rather than exact `==`. +// +// This project locks the emitted template: the `Opt` arm's IsType constant +// must carry `TypeArgRefOrWildcard(0)` (rendered `#0?`), not the exact +// `TypeArgRef(0)` (rendered `#0`), in the *_04_5_mir / *_06_codegen snapshots. + +class Opt { + value: T, +} + +class Non {} + +function unwrap_or(o: Opt | Non, default: T) -> T { + match (o) { + let s: Opt => s.value, + _ => default, + } +} diff --git a/baml_language/crates/baml_tests/snapshots/baml_src/_root.snap b/baml_language/crates/baml_tests/snapshots/baml_src/_root.snap index 915e30f2104..d809f31783a 100644 --- a/baml_language/crates/baml_tests/snapshots/baml_src/_root.snap +++ b/baml_language/crates/baml_tests/snapshots/baml_src/_root.snap @@ -210,6 +210,9 @@ function $init_test(registry: unknown) -> null { load_var ?1 call ? pop 1 + load_var ?1 + call ? + pop 1 load_const ? return } diff --git a/baml_language/crates/baml_tests/snapshots/baml_src/generic_match_subtype.snap b/baml_language/crates/baml_tests/snapshots/baml_src/generic_match_subtype.snap new file mode 100644 index 00000000000..f1684063dd8 --- /dev/null +++ b/baml_language/crates/baml_tests/snapshots/baml_src/generic_match_subtype.snap @@ -0,0 +1,213 @@ +--- +source: crates/baml_tests/tests/baml_src.rs +--- +function generic_match_subtype.$init_test_ns_generic_match_subtype_generic_match_subtype(registry: testing.TestCollector) -> null { + load_var registry + load_const "generic_match_subtype_narrow_default" + make_closure ., 0 + load_const null + call testing.TestCollector.register_test + pop 1 + load_var registry + load_const "generic_match_widened_default" + make_closure ., 0 + load_const null + call testing.TestCollector.register_test + pop 1 + load_var registry + load_const "generic_match_falls_through_on_non" + make_closure ., 0 + load_const null + call testing.TestCollector.register_test + pop 1 + load_var registry + load_const "generic_match_concrete_discriminates_int" + make_closure ., 0 + load_const null + call testing.TestCollector.register_test + pop 1 + load_var registry + load_const "generic_match_concrete_discriminates_string" + make_closure ., 0 + load_const null + call testing.TestCollector.register_test + pop 1 + load_const null + return +} + +function generic_match_subtype.Sq$stream.Shape.area(self: generic_match_subtype.Sq$stream) -> int { + load_var self + load_field .0 + load_var self + load_field .0 + mul_int + return +} + +function generic_match_subtype.Sq.Shape.area(self: generic_match_subtype.Sq) -> int { + load_var self + load_field .0 + load_var self + load_field .0 + mul_int + return +} + +function generic_match_subtype.concrete_discriminates_int_fn() -> string { + load_const 1 + load_type int + init_instance<1> user.generic_match_subtype.Foo .value + call user.generic_match_subtype.pick_concrete + return +} + +function generic_match_subtype.concrete_discriminates_string_fn() -> string { + load_const "x" + load_type string + init_instance<1> user.generic_match_subtype.Foo .value + call user.generic_match_subtype.pick_concrete + return +} + +function generic_match_subtype.generic_match_falls_through_on_non_fn() -> int { + alloc_instance user.generic_match_subtype.Non + store_var o + load_type generic_match_subtype.Shape | generic_match_subtype.Sq + load_var o + load_const 7 + init_instance user.generic_match_subtype.Sq .s + call user.generic_match_subtype.unwrap_or + pop 1 + load_type generic_match_subtype.Shape | generic_match_subtype.Sq + load_var o + load_const 7 + init_instance user.generic_match_subtype.Sq .s + call user.generic_match_subtype.unwrap_or + store_var _6 + load_var _6 + is_type generic_match_subtype.Sq + pop_jump_if_false L0 + jump L1 + + L0: + load_var _6 + is_type generic_match_subtype.Sq + pop_jump_if_false L3 + load_var _6 + call user.generic_match_subtype.Sq.Shape.area + jump L2 + + L1: + load_var _6 + call user.generic_match_subtype.Sq.Shape.area + + L2: + return + + L3: + unreachable +} + +function generic_match_subtype.generic_match_subtype_narrow_default_fn() -> int { + load_type generic_match_subtype.Shape + load_const 5 + init_instance user.generic_match_subtype.Sq .s + call user.generic_match_subtype.mk + store_var o + load_type generic_match_subtype.Shape | generic_match_subtype.Sq + load_var o + load_const 0 + init_instance user.generic_match_subtype.Sq .s + call user.generic_match_subtype.unwrap_or + pop 1 + load_type generic_match_subtype.Shape | generic_match_subtype.Sq + load_var o + load_const 0 + init_instance user.generic_match_subtype.Sq .s + call user.generic_match_subtype.unwrap_or + store_var _8 + load_var _8 + is_type generic_match_subtype.Sq + pop_jump_if_false L0 + jump L1 + + L0: + load_var _8 + is_type generic_match_subtype.Sq + pop_jump_if_false L3 + load_var _8 + call user.generic_match_subtype.Sq.Shape.area + jump L2 + + L1: + load_var _8 + call user.generic_match_subtype.Sq.Shape.area + + L2: + return + + L3: + unreachable +} + +function generic_match_subtype.generic_match_widened_default_fn() -> int { + load_type generic_match_subtype.Shape + load_const 5 + init_instance user.generic_match_subtype.Sq .s + call user.generic_match_subtype.mk + store_var o + load_type generic_match_subtype.Shape + load_var o + load_const 0 + init_instance user.generic_match_subtype.Sq .s + call user.generic_match_subtype.unwrap_or + load_type generic_match_subtype.Shape + load_const "area" + virtual_call nargs=1 ntypeargs=0 + store_var _0 + load_var _0 + return +} + +function generic_match_subtype.mk(v: T) -> generic_match_subtype.Opt | generic_match_subtype.Non { + load_var v + load_type #0 + init_instance<1> user.generic_match_subtype.Opt .value + return +} + +function generic_match_subtype.pick_concrete(x: generic_match_subtype.Foo | generic_match_subtype.Foo) -> string { + load_var x + is_type generic_match_subtype.Foo<...> + pop_jump_if_false L0 + jump L1 + + L0: + load_const "string" + jump L2 + + L1: + load_const "int" + + L2: + return +} + +function generic_match_subtype.unwrap_or(o: generic_match_subtype.Opt | generic_match_subtype.Non, default: T) -> T { + load_var o + is_type generic_match_subtype.Opt<...> + pop_jump_if_false L0 + jump L1 + + L0: + load_var default + jump L2 + + L1: + load_var o + load_field .value + + L2: + return +} diff --git a/baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__01_lexer__generic_match_typevar_arm.snap b/baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__01_lexer__generic_match_typevar_arm.snap new file mode 100644 index 00000000000..19f680f54df --- /dev/null +++ b/baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__01_lexer__generic_match_typevar_arm.snap @@ -0,0 +1,201 @@ +--- +source: crates/baml_tests/src/generated_tests.rs +--- +Slash "/" +Slash "/" +Word "B-634" +Colon ":" +Word "a" +Word "generic" +Backtick "`" +Match "match" +Backtick "`" +Word "arm" +Backtick "`" +Let "let" +Word "s" +Colon ":" +Word "Opt" +Less "<" +Word "T" +Greater ">" +Backtick "`" +LParen "(" +Word "where" +Backtick "`" +Word "T" +Backtick "`" +Is "is" +Word "the" +Word "enclosing" +Slash "/" +Slash "/" +Function "function" +Error "'" +Word "s" +Word "type" +Word "var" +RParen ")" +Word "must" +Word "lower" +Word "its" +Word "runtime" +Word "type-test" +Word "through" +Word "the" +Slash "/" +Slash "/" +Word "subtype-or-wildcard" +Word "dispatch-guard" +Word "template" +Comma "," +Word "so" +Word "the" +Word "reified" +Word "frame" +Word "type-arg" +Is "is" +Slash "/" +Slash "/" +Word "compared" +Word "with" +Backtick "`" +Word "is_subtype_of" +Backtick "`" +Word "rather" +Word "than" +Word "exact" +Backtick "`" +EqualsEquals "==" +Backtick "`" +Dot "." +Slash "/" +Slash "/" +Slash "/" +Slash "/" +Word "This" +Word "project" +Word "locks" +Word "the" +Word "emitted" +Word "template" +Colon ":" +Word "the" +Backtick "`" +Word "Opt" +Less "<" +Word "T" +Greater ">" +Backtick "`" +Word "arm" +Error "'" +Word "s" +Word "IsType" +Word "constant" +Slash "/" +Slash "/" +Word "must" +Word "carry" +Backtick "`" +Word "TypeArgRefOrWildcard" +LParen "(" +IntegerLiteral "0" +RParen ")" +Backtick "`" +LParen "(" +Word "rendered" +Backtick "`" +Hash "#" +IntegerLiteral "0" +Question "?" +Backtick "`" +RParen ")" +Comma "," +Word "not" +Word "the" +Word "exact" +Slash "/" +Slash "/" +Backtick "`" +Word "TypeArgRef" +LParen "(" +IntegerLiteral "0" +RParen ")" +Backtick "`" +LParen "(" +Word "rendered" +Backtick "`" +Hash "#" +IntegerLiteral "0" +Backtick "`" +RParen ")" +Comma "," +In "in" +Word "the" +Star "*" +Word "_04_5_mir" +Slash "/" +Star "*" +Word "_06_codegen" +Word "snapshots" +Dot "." +Class "class" +Word "Opt" +Less "<" +Word "T" +Greater ">" +LBrace "{" +Word "value" +Colon ":" +Word "T" +Comma "," +RBrace "}" +Class "class" +Word "Non" +LBrace "{" +RBrace "}" +Function "function" +Word "unwrap_or" +Less "<" +Word "T" +Greater ">" +LParen "(" +Word "o" +Colon ":" +Word "Opt" +Less "<" +Word "T" +Greater ">" +Pipe "|" +Word "Non" +Comma "," +Word "default" +Colon ":" +Word "T" +RParen ")" +Arrow "->" +Word "T" +LBrace "{" +Match "match" +LParen "(" +Word "o" +RParen ")" +LBrace "{" +Let "let" +Word "s" +Colon ":" +Word "Opt" +Less "<" +Word "T" +Greater ">" +FatArrow "=>" +Word "s" +Dot "." +Word "value" +Comma "," +Word "_" +FatArrow "=>" +Word "default" +Comma "," +RBrace "}" +RBrace "}" diff --git a/baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__02_parser__generic_match_typevar_arm.snap b/baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__02_parser__generic_match_typevar_arm.snap new file mode 100644 index 00000000000..f4a8d0e5553 --- /dev/null +++ b/baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__02_parser__generic_match_typevar_arm.snap @@ -0,0 +1,100 @@ +--- +source: crates/baml_tests/src/generated_tests.rs +--- +=== SYNTAX TREE === +SOURCE_FILE + CLASS_DEF + KW_CLASS "class" + WORD "Opt" + GENERIC_PARAM_LIST + LESS "<" + GENERIC_PARAM "T" + WORD "T" + GREATER ">" + L_BRACE "{" + FIELD + WORD "value" + COLON ":" + TYPE_EXPR "T" + WORD "T" + COMMA "," + R_BRACE "}" + CLASS_DEF "class Non {}" + KW_CLASS "class" + WORD "Non" + L_BRACE "{" + R_BRACE "}" + FUNCTION_DEF + KW_FUNCTION "function" + WORD "unwrap_or" + GENERIC_PARAM_LIST + LESS "<" + GENERIC_PARAM "T" + WORD "T" + GREATER ">" + PARAMETER_LIST + L_PAREN "(" + PARAMETER + WORD "o" + COLON ":" + TYPE_EXPR + WORD "Opt" + TYPE_ARGS + LESS "<" + TYPE_EXPR "T" + WORD "T" + GREATER ">" + PIPE "|" + WORD "Non" + COMMA "," + PARAMETER + WORD "default" + COLON ":" + TYPE_EXPR "T" + WORD "T" + R_PAREN ")" + ARROW "->" + TYPE_EXPR "T" + WORD "T" + EXPR_FUNCTION_BODY + BLOCK_EXPR + L_BRACE "{" + MATCH_EXPR + KW_MATCH "match" + L_PAREN "(" + WORD "o" + R_PAREN ")" + L_BRACE "{" + MATCH_ARM + PATTERN + BINDING_PATTERN + KW_LET "let" + WORD "s" + COLON ":" + PATTERN + TYPE_PATTERN + TYPE_EXPR + WORD "Opt" + TYPE_ARGS + LESS "<" + TYPE_EXPR "T" + WORD "T" + GREATER ">" + FAT_ARROW "=>" + PATH_EXPR "s.value" + WORD "s" + DOT "." + WORD "value" + COMMA "," + MATCH_ARM + PATTERN + WILDCARD_PATTERN "_" + WORD "_" + FAT_ARROW "=>" + WORD "default" + COMMA "," + R_BRACE "}" + R_BRACE "}" + +=== ERRORS === +None diff --git a/baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__03_hir.snap b/baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__03_hir.snap new file mode 100644 index 00000000000..0ff8b846f65 --- /dev/null +++ b/baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__03_hir.snap @@ -0,0 +1,12 @@ +--- +source: crates/baml_tests/src/generated_tests.rs +--- +=== HIR2 === +class user.Non { +} +class user.Opt { + value: T +} +function user.unwrap_or(o: user.Opt | user.Non, default: T) -> T [expr] { + { } match (o) { s: user.Opt => s.value, _ => default } +} diff --git a/baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__04_5_mir.snap b/baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__04_5_mir.snap new file mode 100644 index 00000000000..d9e215877d5 --- /dev/null +++ b/baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__04_5_mir.snap @@ -0,0 +1,32 @@ +--- +source: crates/baml_tests/src/generated_tests.rs +--- +=== MIR2 === +fn user.unwrap_or(o: Opt | Non, default: T) -> T { + // Locals: + let _0: T // _0 // return + let _1: Opt | Non // o // param + let _2: T // default // param + let _3: bool + let _4: Opt // s + + bb0: { + _3 = is_type(copy _1, Opt<#0?>); + branch copy _3 -> [bb2, bb1]; + } + + bb1: { + _0 = copy _2; + goto -> bb3; + } + + bb2: { + _4 = copy _1; + _0 = copy _4.0; + goto -> bb3; + } + + bb3: { + return; + } +} diff --git a/baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__04_tir.snap b/baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__04_tir.snap new file mode 100644 index 00000000000..cb3156b0628 --- /dev/null +++ b/baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__04_tir.snap @@ -0,0 +1,23 @@ +--- +source: crates/baml_tests/src/generated_tests.rs +--- +=== TIR2 === +class user.Opt { + value: T +} +class user.Non { +} +function user.unwrap_or(o: user.Opt | user.Non, default: T) -> T throws never { + { : T + match (o : user.Opt | user.Non) : T + s: Opt => + s.value : T + _ => + default : T + } +} +class user.Opt$stream { + value: T | null +} +class user.Non$stream { +} diff --git a/baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__05_diagnostics.snap b/baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__05_diagnostics.snap new file mode 100644 index 00000000000..40cf564409c --- /dev/null +++ b/baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__05_diagnostics.snap @@ -0,0 +1,5 @@ +--- +source: crates/baml_tests/src/generated_tests.rs +--- +=== COMPILER2 DIAGNOSTICS === +No errors found. diff --git a/baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__06_codegen.snap b/baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__06_codegen.snap new file mode 100644 index 00000000000..3103b180935 --- /dev/null +++ b/baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__06_codegen.snap @@ -0,0 +1,29 @@ +--- +source: crates/baml_tests/src/generated_tests.rs +--- +function boundary.LocalId.capture(self: boundary.LocalId, inputs: bool | null, output: bool | null, error: bool | null) -> boundary.LocalId { +} + +function boundary.id() -> boundary.LocalId { +} + +function boundary.id.current() -> string { +} + +function user.unwrap_or(o: Opt | Non, default: T) -> T { + load_var o + is_type Opt<...> + pop_jump_if_false L0 + jump L1 + + L0: + load_var default + jump L2 + + L1: + load_var o + load_field .value + + L2: + return +} diff --git a/baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__10_formatter__generic_match_typevar_arm.snap b/baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__10_formatter__generic_match_typevar_arm.snap new file mode 100644 index 00000000000..8f530d472b6 --- /dev/null +++ b/baml_language/crates/baml_tests/snapshots/compiles/generic_match_typevar_arm/baml_tests__compiles__generic_match_typevar_arm__10_formatter__generic_match_typevar_arm.snap @@ -0,0 +1,25 @@ +--- +source: crates/baml_tests/src/generated_tests.rs +--- +// B-634: a generic `match` arm `let s: Opt` (where `T` is the enclosing +// function's type var) must lower its runtime type-test through the +// subtype-or-wildcard dispatch-guard template, so the reified frame type-arg is +// compared with `is_subtype_of` rather than exact `==`. +// +// This project locks the emitted template: the `Opt` arm's IsType constant +// must carry `TypeArgRefOrWildcard(0)` (rendered `#0?`), not the exact +// `TypeArgRef(0)` (rendered `#0`), in the *_04_5_mir / *_06_codegen snapshots. + +class Opt { + value: T, +} + +class Non { +} + +function unwrap_or(o: Opt | Non, default: T) -> T { + match (o) { + let s: Opt => s.value, + _ => default, + } +} diff --git a/baml_language/crates/bex_vm/src/vm.rs b/baml_language/crates/bex_vm/src/vm.rs index 0ecf93c5740..2dee3944d26 100644 --- a/baml_language/crates/bex_vm/src/vm.rs +++ b/baml_language/crates/bex_vm/src/vm.rs @@ -626,6 +626,97 @@ mod tests { "unrooted trampoline function must not be forwarded after return" ); } + + // ── B-634: reified type-arg comparison in generic class match arms ────── + // + // A generic `match` arm `let s: Opt` lowers its runtime type-test to a + // template whose frame type-arg is checked against the scrutinee instance's + // `class_type_args`. When inference pins `T` to a supertype union of the + // value's actual arg (a `default: T` arg subtypes, so `T` reifies to the + // un-subsumed join `Shape | Sq`), an EXACT (`==`) comparison wrongly misses. + // The fix lowers the arm through the dispatch-guard template so the frame + // arg becomes `TypeArgRefOrWildcard` (subtype-or-wildcard), matching the + // interface class-dispatch guard path. These tests lock the directional + // semantics of `guard_template_matches` — the property that is awkward to + // observe from surface BAML because static typing + covariance guarantee a + // well-typed scrutinee's runtime arg is always `<:` the frame `T`. + #[test] + fn guard_template_type_arg_ref_or_wildcard_honors_subtyping() { + use super::guard_template_matches; + + let shape = RuntimeTy::class("Shape"); + let sq = RuntimeTy::class("Sq"); + // The reified frame `T` from the B-634 repro: the un-subsumed join. + let widened_t = RuntimeTy::union([shape.clone(), sq.clone()]); + + // Under-match fix: the value's actual arg (`Shape`) is a member of the + // widened frame `T` (`Shape | Sq`), so the subtype-or-wildcard template + // now matches. This is the exact B-634 scenario (was `false`). + #[expect(deprecated)] + let subtype_template = TyTemplate::TypeArgRefOrWildcard(0); + assert!( + guard_template_matches(&subtype_template, &[widened_t.clone()], &shape), + "Shape must match a Shape|Sq frame T under subtype-or-wildcard" + ); + + // The exact `TypeArgRef` template (kept exact on purpose — this is why + // the fix is at the MIR layer, swapping which template the arm emits, + // rather than broadening every `TypeArgRef` comparison in the VM). + let exact_template = TyTemplate::TypeArgRef(0); + assert!( + !guard_template_matches(&exact_template, &[widened_t.clone()], &shape), + "exact TypeArgRef must still reject Shape != Shape|Sq" + ); + + // Directionality / soundness: a strictly WIDER runtime arg must NOT + // match a narrower frame `T`. Here `T` is pinned to `Sq` and the value + // carries the wider `Shape | Sq`; `(Shape|Sq) <: Sq` is false, so the + // arm is skipped (falls to the default). The fix does not over-match. + assert!( + !guard_template_matches(&subtype_template, &[sq.clone()], &widened_t), + "a wider runtime arg (Shape|Sq) must not match a narrower frame T (Sq)" + ); + // Same directionality with two unrelated classes. + assert!( + !guard_template_matches(&subtype_template, &[sq.clone()], &shape), + "an unrelated runtime arg must not match the frame T" + ); + + // A proper subtype (union member) matches (covariant "belongs to"). + assert!( + guard_template_matches(&subtype_template, &[widened_t.clone()], &sq), + "Sq must match a Shape|Sq frame T under subtype-or-wildcard" + ); + + // An unconcretized (all-`unknown`) frame slot matches any instance. + assert!( + guard_template_matches(&subtype_template, &[RuntimeTy::unknown()], &shape), + "an unknown frame slot must match any runtime arg" + ); + } + + #[test] + fn guard_template_concrete_type_args_still_discriminate() { + use super::guard_template_matches; + + // Concrete parametric arms (`Foo` vs `Foo`) take the + // concrete path (`contains_typevar == false`) and must keep exact + // discrimination — unaffected by the B-634 subtype fix. + let foo = TypeName::local(Name::new("Foo")); + let foo_int_template = + TyTemplate::Class(foo.clone(), vec![TyTemplate::Concrete(RuntimeTy::int())]); + let foo_int_value = RuntimeTy::class_with_args(foo.clone(), vec![RuntimeTy::int()]); + let foo_string_value = RuntimeTy::class_with_args(foo.clone(), vec![RuntimeTy::string()]); + + assert!( + guard_template_matches(&foo_int_template, &[], &foo_int_value), + "Foo template must match a Foo value" + ); + assert!( + !guard_template_matches(&foo_int_template, &[], &foo_string_value), + "Foo template must not match a Foo value" + ); + } } impl RootHaver for Frame { From 57cc6c139eb41628982769db16fac5c823f61165 Mon Sep 17 00:00:00 2001 From: Antonio Sarosi Date: Thu, 2 Jul 2026 01:43:44 +0200 Subject: [PATCH 2/2] Address clippy lints in B-634 vm.rs type-arg guard tests --- baml_language/crates/bex_vm/src/vm.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/baml_language/crates/bex_vm/src/vm.rs b/baml_language/crates/bex_vm/src/vm.rs index 2dee3944d26..03b8cfe7c85 100644 --- a/baml_language/crates/bex_vm/src/vm.rs +++ b/baml_language/crates/bex_vm/src/vm.rs @@ -655,7 +655,7 @@ mod tests { #[expect(deprecated)] let subtype_template = TyTemplate::TypeArgRefOrWildcard(0); assert!( - guard_template_matches(&subtype_template, &[widened_t.clone()], &shape), + guard_template_matches(&subtype_template, std::slice::from_ref(&widened_t), &shape), "Shape must match a Shape|Sq frame T under subtype-or-wildcard" ); @@ -664,7 +664,7 @@ mod tests { // rather than broadening every `TypeArgRef` comparison in the VM). let exact_template = TyTemplate::TypeArgRef(0); assert!( - !guard_template_matches(&exact_template, &[widened_t.clone()], &shape), + !guard_template_matches(&exact_template, std::slice::from_ref(&widened_t), &shape), "exact TypeArgRef must still reject Shape != Shape|Sq" ); @@ -673,18 +673,18 @@ mod tests { // carries the wider `Shape | Sq`; `(Shape|Sq) <: Sq` is false, so the // arm is skipped (falls to the default). The fix does not over-match. assert!( - !guard_template_matches(&subtype_template, &[sq.clone()], &widened_t), + !guard_template_matches(&subtype_template, std::slice::from_ref(&sq), &widened_t), "a wider runtime arg (Shape|Sq) must not match a narrower frame T (Sq)" ); // Same directionality with two unrelated classes. assert!( - !guard_template_matches(&subtype_template, &[sq.clone()], &shape), + !guard_template_matches(&subtype_template, std::slice::from_ref(&sq), &shape), "an unrelated runtime arg must not match the frame T" ); // A proper subtype (union member) matches (covariant "belongs to"). assert!( - guard_template_matches(&subtype_template, &[widened_t.clone()], &sq), + guard_template_matches(&subtype_template, std::slice::from_ref(&widened_t), &sq), "Sq must match a Shape|Sq frame T under subtype-or-wildcard" ); @@ -706,7 +706,7 @@ mod tests { let foo_int_template = TyTemplate::Class(foo.clone(), vec![TyTemplate::Concrete(RuntimeTy::int())]); let foo_int_value = RuntimeTy::class_with_args(foo.clone(), vec![RuntimeTy::int()]); - let foo_string_value = RuntimeTy::class_with_args(foo.clone(), vec![RuntimeTy::string()]); + let foo_string_value = RuntimeTy::class_with_args(foo, vec![RuntimeTy::string()]); assert!( guard_template_matches(&foo_int_template, &[], &foo_int_value),