Skip to content

Commit 8edd37d

Browse files
sxlijinclaude
andcommitted
feat(generics): inbound value-inference + baml_type_runtime + union-TypeVar fix
Let Python call generic BAML functions without explicit type args: the engine solves each TypeVar from argument values, round-tripping through the Python<->BAML FFI. Extract the shared Ty-walking generic inference into a new baml_type_runtime crate that depends only on baml_type (not the compiler frontend, not BEX), so the TIR (compile-time) and the Python bridge (runtime) share ONE inference implementation. Verified: no bex_engine -> baml_compiler2_tir dependency edge. Fix the union-with-concrete-sibling TypeVar bug (reverses 00b3 G5), e.g. tag_or_value<T>(x: T | string | null). Inference: a new arm subtracts the concrete siblings an actual already satisfies and routes the residual to the lone TypeVar member (coercion-free; >1 TypeVar => ambiguous => unbound). Introspection: union_targets_for_pattern is made atom-aware so a concrete pattern no longer over-claims an open TypeVar union member (fixing a spurious E0063 unreachable arm) without regressing the standard library's nullable-TypeVar matches. Tested at the baml_type_runtime, bex_engine, baml_tests, and python_pydantic2 layers; full Python->BAML round-trip verified (tag_or_value(5) == "int"). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 85247f4 commit 8edd37d

16 files changed

Lines changed: 1812 additions & 318 deletions

File tree

baml_language/Cargo.lock

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

baml_language/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ version = "0.0.0-beta"
5151
bex_str = { path = "crates/bex_str" }
5252
baml_base = { path = "crates/baml_base" }
5353
baml_type = { path = "crates/baml_type" }
54+
baml_type_runtime = { path = "crates/baml_type_runtime" }
5455
baml_type_macros = { path = "crates/baml_type_macros" }
5556
baml_builtins2 = { path = "crates/baml_builtins2" }
5657
baml_builtins2_codegen = { path = "crates/baml_builtins2_codegen" }

baml_language/crates/baml_compiler2_tir/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ baml_compiler2_ast = { workspace = true }
1212
baml_compiler2_hir = { workspace = true }
1313
baml_compiler2_ppir = { workspace = true }
1414
baml_type = { workspace = true }
15+
baml_type_runtime = { workspace = true }
1516
baml_workspace = { workspace = true }
1617
indexmap = { workspace = true }
1718
num-bigint = { workspace = true }

baml_language/crates/baml_compiler2_tir/src/builder.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17070,9 +17070,41 @@ impl TypeInferenceBuilder<'_> {
1707017070
// structural overlap check that respects runtime tag identity:
1707117071
// primitives must agree on head, classes must agree on qtn AND
1707217072
// overlap pairwise on their generic args.
17073+
// A `TypeVar` scrutinee member (e.g. the `T` in `T | string | null`)
17074+
// is an *open* type: at runtime it stands for whatever concrete type
17075+
// `T` is instantiated to. `atoms_overlap` deliberately reports a
17076+
// `TypeVar` as overlapping *everything* (a `let v: T` pattern can
17077+
// match any value). That symmetry is correct for a `TypeVar`
17078+
// *pattern* but wrong for a `TypeVar` *member*: a purely-concrete
17079+
// pattern (`string`, `null`, a bare class) must not over-claim the
17080+
// open `T` member, or it shadows the dedicated `let v: T` arm and
17081+
// that arm is then reported unreachable (the `tag_or_value<T>` bug).
17082+
//
17083+
// So make the overlap directional for `TypeVar` members: claim a
17084+
// `TypeVar` member only when the pattern itself can genuinely match
17085+
// open-`T` values — i.e. some atom of the pattern's natural type is a
17086+
// `TypeVar` (a `let v: T` pattern), or an `Unknown`/`Error` recovery
17087+
// atom. A pattern whose natural type is a union that *includes* a
17088+
// `TypeVar` (e.g. a `let p: T | Concrete` binding, as in the
17089+
// streaming `TStream | StreamNoYield` case) still claims it, because
17090+
// one of its atoms is a `TypeVar`. Concrete patterns skip the member.
17091+
let natural_atoms = {
17092+
let mut atoms = Vec::new();
17093+
self.collect_overlap_atoms(&natural, &mut atoms);
17094+
atoms
17095+
};
17096+
let pattern_claims_typevar = natural_atoms
17097+
.iter()
17098+
.any(|a| matches!(a, Ty::TypeVar(..) | Ty::Unknown { .. } | Ty::Error { .. }));
1707317099
union_members
1707417100
.iter()
17075-
.filter(|m| self.types_overlap(&natural, m))
17101+
.filter(|m| {
17102+
if matches!(m, Ty::TypeVar(..)) {
17103+
pattern_claims_typevar
17104+
} else {
17105+
self.types_overlap(&natural, m)
17106+
}
17107+
})
1707617108
.cloned()
1707717109
.collect()
1707817110
}

baml_language/crates/baml_compiler2_tir/src/generics.rs

Lines changed: 11 additions & 262 deletions
Original file line numberDiff line numberDiff line change
@@ -532,268 +532,17 @@ pub fn contains_typevar_where(ty: &Ty, pred: &dyn Fn(&Name) -> bool) -> bool {
532532
}
533533
}
534534

535-
/// Infer type variable bindings by walking formal and actual types in parallel.
536-
///
537-
/// When `formal` is `Ty::TypeVar("T", TyAttr::default())` and `actual` is `Ty::Int { attr: TyAttr::default() }`,
538-
/// records `T → int` in `bindings`. For structural types, recurses into
539-
/// matching structures. Conflicting inferences are merged via `union_ty`.
540-
fn infer_bindings_inner(
541-
formal: &Ty,
542-
actual: &Ty,
543-
bindings: &mut FxHashMap<Name, Ty>,
544-
allow_typevar_actuals: bool,
545-
// A *rigid* type variable that must never be bound from an argument — the
546-
// pinned `Self` of an interface method call (mirrors rustc's `ty::Param`,
547-
// which unification never instantiates). `None` = no rigid variable (the
548-
// historical behavior). This is the only thing that distinguishes a
549-
// Self-pinned call from any other; ordinary calls pass `None`, so their
550-
// inference is completely unchanged.
551-
rigid: Option<&Name>,
552-
) {
553-
fn nullable_non_null_part(ty: &Ty) -> Option<Ty> {
554-
let Ty::Union(members, attr) = ty else {
555-
return None;
556-
};
557-
if !members.iter().any(Ty::is_null) {
558-
return None;
559-
}
560-
let non_null: Vec<Ty> = members
561-
.iter()
562-
.filter(|member| !member.is_null())
563-
.cloned()
564-
.collect();
565-
match non_null.as_slice() {
566-
[] => None,
567-
[single] => Some(single.clone()),
568-
_ => Some(Ty::Union(non_null, attr.clone())),
569-
}
570-
}
571-
572-
match (formal, actual) {
573-
(Ty::TypeVar(name, _), actual_ty) => {
574-
if rigid == Some(name) {
575-
return;
576-
}
577-
// Skip TypeVar-to-TypeVar bindings by default — they usually provide
578-
// no information for ordinary call inference. Some higher-order
579-
// callable-summary paths opt into preserving them explicitly.
580-
if !allow_typevar_actuals && matches!(actual_ty, Ty::TypeVar(_, _)) {
581-
return;
582-
}
583-
// An `Unknown` actual carries NO information: binding it (or
584-
// unioning it into an existing binding) only poisons the result —
585-
// e.g. an expected return of `SpawnParams<unknown, unknown>`
586-
// driving phase-0 must not turn a param-bound `T = int` into
587-
// `int | unknown`.
588-
if matches!(actual_ty, Ty::Unknown { .. }) {
589-
return;
590-
}
591-
bindings
592-
.entry(name.clone())
593-
.and_modify(|existing| *existing = union_ty(existing, actual_ty))
594-
.or_insert_with(|| actual_ty.clone());
595-
}
596-
(Ty::List(f, _), Ty::List(a, _)) => {
597-
infer_bindings_inner(f, a, bindings, allow_typevar_actuals, rigid);
598-
}
599-
(
600-
Ty::Map {
601-
key: fk, value: fv, ..
602-
},
603-
Ty::Map {
604-
key: ak, value: av, ..
605-
},
606-
) => {
607-
infer_bindings_inner(fk, ak, bindings, allow_typevar_actuals, rigid);
608-
infer_bindings_inner(fv, av, bindings, allow_typevar_actuals, rigid);
609-
}
610-
(Ty::Union(_, _), _) if nullable_non_null_part(formal).is_some() => {
611-
let formal_inner = nullable_non_null_part(formal).expect("checked above");
612-
let actual_inner = nullable_non_null_part(actual).unwrap_or_else(|| actual.clone());
613-
infer_bindings_inner(
614-
&formal_inner,
615-
&actual_inner,
616-
bindings,
617-
allow_typevar_actuals,
618-
rigid,
619-
);
620-
}
621-
(Ty::Union(f_members, _), Ty::Union(a_members, _))
622-
if f_members.len() == a_members.len() =>
623-
{
624-
for (formal_member, actual_member) in f_members.iter().zip(a_members.iter()) {
625-
infer_bindings_inner(
626-
formal_member,
627-
actual_member,
628-
bindings,
629-
allow_typevar_actuals,
630-
rigid,
631-
);
632-
}
633-
}
634-
(
635-
Ty::Function {
636-
params: fp,
637-
ret: fr,
638-
throws: fth,
639-
..
640-
},
641-
Ty::Function {
642-
params: ap,
643-
ret: ar,
644-
throws: ath,
645-
..
646-
},
647-
) => {
648-
for (fp, ap) in fp.iter().zip(ap.iter()) {
649-
infer_bindings_inner(&fp.ty, &ap.ty, bindings, allow_typevar_actuals, rigid);
650-
}
651-
infer_bindings_inner(fr, ar, bindings, allow_typevar_actuals, rigid);
652-
infer_bindings_inner(fth, ath, bindings, allow_typevar_actuals, rigid);
653-
}
654-
(Ty::Class(fn_name, f_args, _), Ty::Class(an_name, a_args, _)) if fn_name == an_name => {
655-
for (ft, at) in f_args.iter().zip(a_args.iter()) {
656-
infer_bindings_inner(ft, at, bindings, allow_typevar_actuals, rigid);
657-
}
658-
}
659-
// `Future<T, E>` is its own variant — descend into both params so the
660-
// future combinators can infer `<T, E>` from a `Future<T, E>[]` arg.
661-
(Ty::Future(f_value, f_error, _), Ty::Future(a_value, a_error, _)) => {
662-
infer_bindings_inner(f_value, a_value, bindings, allow_typevar_actuals, rigid);
663-
infer_bindings_inner(f_error, a_error, bindings, allow_typevar_actuals, rigid);
664-
}
665-
// A heterogeneous future array — e.g. `[spawn { 1 }, spawn { 2 }]` —
666-
// types as `(Future<A, EA> | Future<B, EB>)[]` because `Future` is
667-
// invariant. Match the `Future<T, E>` formal against each union member
668-
// so `T`/`E` bind to the union of the member value/error types (the
669-
// TypeVar arm merges the per-member bindings via `union_ty`).
670-
(Ty::Future(_, _, _), Ty::Union(members, _)) => {
671-
for member in members {
672-
infer_bindings_inner(formal, member, bindings, allow_typevar_actuals, rigid);
673-
}
674-
}
675-
(
676-
Ty::Interface(fn_name, f_args, f_assoc, _),
677-
Ty::Interface(an_name, a_args, a_assoc, _),
678-
) if fn_name == an_name => {
679-
for (ft, at) in f_args.iter().zip(a_args.iter()) {
680-
infer_bindings_inner(ft, at, bindings, allow_typevar_actuals, rigid);
681-
}
682-
for (formal_name, formal_ty) in f_assoc {
683-
if let Some((_, actual_ty)) = a_assoc
684-
.iter()
685-
.find(|(actual_name, _)| actual_name == formal_name)
686-
{
687-
infer_bindings_inner(
688-
formal_ty,
689-
actual_ty,
690-
bindings,
691-
allow_typevar_actuals,
692-
rigid,
693-
);
694-
}
695-
}
696-
}
697-
// Builtin container bridging: Array<T> ↔ List(T), Map<K,V> ↔ Map(K,V)
698-
// This enables UFCS calls like `Array.length(arr)` where the formal self
699-
// type is Class(Array, [T]) and the actual is List(int).
700-
(Ty::Class(class_name, f_args, _), Ty::List(actual_inner, _))
701-
if class_name.is_builtin_root_type("Array") && f_args.len() == 1 =>
702-
{
703-
infer_bindings_inner(
704-
&f_args[0],
705-
actual_inner,
706-
bindings,
707-
allow_typevar_actuals,
708-
rigid,
709-
);
710-
}
711-
(
712-
Ty::Class(class_name, f_args, _),
713-
Ty::Map {
714-
key: actual_key,
715-
value: actual_val,
716-
..
717-
},
718-
) if class_name.is_builtin_root_type("Map") && f_args.len() == 2 => {
719-
infer_bindings_inner(
720-
&f_args[0],
721-
actual_key,
722-
bindings,
723-
allow_typevar_actuals,
724-
rigid,
725-
);
726-
infer_bindings_inner(
727-
&f_args[1],
728-
actual_val,
729-
bindings,
730-
allow_typevar_actuals,
731-
rigid,
732-
);
733-
}
734-
_ => {} // Concrete types: nothing to infer
735-
}
736-
}
737-
738-
pub fn infer_bindings(formal: &Ty, actual: &Ty, bindings: &mut FxHashMap<Name, Ty>) {
739-
infer_bindings_inner(formal, actual, bindings, false, None);
740-
}
741-
742-
pub fn infer_bindings_allow_typevars(formal: &Ty, actual: &Ty, bindings: &mut FxHashMap<Name, Ty>) {
743-
infer_bindings_inner(formal, actual, bindings, true, None);
744-
}
745-
746-
/// Like [`infer_bindings`] but treats `rigid` (when `Some`) as a rigid type
747-
/// variable that is never bound from an argument — the pinned `Self` of an
748-
/// interface method call. Every other variable infers exactly as before.
749-
pub fn infer_bindings_rigid_self(
750-
formal: &Ty,
751-
actual: &Ty,
752-
bindings: &mut FxHashMap<Name, Ty>,
753-
rigid: Option<&Name>,
754-
) {
755-
infer_bindings_inner(formal, actual, bindings, false, rigid);
756-
}
757-
758-
/// Combine two types into a union, deduplicating members.
759-
///
760-
/// Used when the same type variable is inferred from multiple arguments
761-
/// (e.g., `deep_equals(myInt, myString)` → `T` gets `int` then `string`).
762-
pub fn union_ty(a: &Ty, b: &Ty) -> Ty {
763-
normalize_union_members([a.clone(), b.clone()], TyAttr::default())
764-
}
765-
766-
fn normalize_union_members(members: impl IntoIterator<Item = Ty>, attr: TyAttr) -> Ty {
767-
let mut normalized = Vec::new();
768-
for member in members {
769-
match member {
770-
Ty::Never { .. } => {}
771-
Ty::Union(inner, _) => {
772-
for inner_member in inner {
773-
if !matches!(inner_member, Ty::Never { .. })
774-
&& !normalized.contains(&inner_member)
775-
{
776-
normalized.push(inner_member);
777-
}
778-
}
779-
}
780-
other if !normalized.contains(&other) => normalized.push(other),
781-
_ => {}
782-
}
783-
}
784-
785-
match normalized.len() {
786-
0 => Ty::Never { attr },
787-
1 => normalized.pop().expect("length checked"),
788-
_ => {
789-
// TODO(TyAttr): This union is synthesized from multiple input types — there's no
790-
// single "original attr" to preserve. If inputs carry different attrs, which one
791-
// wins? May need a merge/lattice operation on TyAttr, or default may be correct if
792-
// attrs describe declaration sites rather than computed types.
793-
Ty::Union(normalized, attr)
794-
}
795-
}
796-
}
535+
// ── Type variable inference & union normalization ──────────────────────────────
536+
//
537+
// The pure `Ty`-walking inference/union primitives now live in `baml_type` so the
538+
// runtime engine can share the single algorithm (it widens `RuntimeTy` → `Ty`,
539+
// runs the unifier, narrows back) without a runtime → compiler dependency. They
540+
// are re-exported here so every existing `crate::generics::…` caller is
541+
// unchanged. See `01c-inbound-inference-reuse.md`.
542+
pub use baml_type_runtime::{
543+
infer_bindings, infer_bindings_allow_typevars, infer_bindings_rigid_self,
544+
normalize_union_members, union_ty,
545+
};
797546

798547
/// Replace any remaining `Ty::TypeVar` with `Ty::Unknown` and emit diagnostics.
799548
///

0 commit comments

Comments
 (0)