This repository was archived by the owner on Aug 20, 2026. It is now read-only.
docs(python): clarify editable profile scope - #25
Draft
Sam Lijin (sxlijin) wants to merge 9 commits into
Draft
Sam Lijin (sxlijin) wants to merge 9 commits into
Sam Lijin (sxlijin) wants to merge 9 commits into
Conversation
## Summary - bump all legacy BAML v0 release surfaces from 0.226.0 to 0.226.1 using the repository version-bump configuration - release the OpenAI token-detail compatibility fix from #4503 - synchronize the new 0.226.1 sections in the top-level and Fern changelogs - regenerate all nine integration clients and refresh the engine, Rust SDK, and generated Rust integration lockfile versions ## Release boundary - previous release: 0.226.0 / merge commit 2fde4f7 - sole shipped engine change: #4503 / merge commit 378cdcc - the reviewed #4503 head and final merge diff have the same stable patch ID - this branch is based exactly on the #4503 merge commit in canary - `gh stack link` was attempted while preparing the dependency, but #4503 merged before submission and the extension does not allow merged PRs in a new stack; #4524 is therefore a one-layer gh-stack rooted on the fulfilled dependency in canary ## Validation - TypeScript runtime build and nine-client generation - Python runtime build/install and nine-client generation - deterministic regeneration: complete binary diff hash unchanged on repeat - `cargo fmt --manifest-path engine/Cargo.toml --package baml-runtime -- --check` - `cargo test --manifest-path engine/Cargo.toml -p baml-runtime --lib`: 185 passed - locked Cargo metadata for engine, Rust SDK, and generated Rust integration workspaces - generated Rust integration library build: `cargo test --manifest-path integ-tests/rust/Cargo.toml --lib --locked` - release-owned stale 0.226.0 audit clean - exact 38-file match to the 0.226.0 release workflow; all 36 non-changelog files are pure 0.226.0 to 0.226.1 substitutions ## Status - all applicable GitHub Actions checks passed - repository CodeRabbit review completed with no findings; hosted CodeRabbit status is green and there are no review threads - current with canary and ready to merge
Follows #4501, which carried runtime *definitions* through interface dispatch and closed by naming what it did **not** fix: > **Identity does not cross dispatch, only definitions do.** `type.of<T>()` inside an > interface-impl method re-mints […] Nothing here depends on mint equality; making > identity survive dispatch means carrying exact values through `realize_frame`, which is > a separate change. This is that change. Antonio ratified carrying the exact values through, on the same discipline as the defs carry. ## The hole `VirtualCall` resolves the impl from the receiver's realized `Self` and seeds the callee frame from `resolver.realize_frame`, which produces **realized types only**. The exact `TypeValue`s the caller minted reach the callee frame's `FrameTypeMetadata.values` from `append_virtual_method_type_args` alone — i.e. from *method-level* type arguments. The receiver's class-level slots got definitions but no values, so `LoadType(TypeArgRef)` in the body fell through to `alloc_static_type_with_defs` and derived a **fresh static digest**. The result was a type value that named the same definition and rendered, parsed and reflected identically, but was `==`-distinct from the one the caller passed: ```baml class Holder<T> { implements Probe<T> { function same(self, t: type) -> bool { type.of<T>() == t } // false } } ``` Structural checks all passed, so the failure was silent: a map keyed by type missed, an `==` against a stored type went the wrong way, and nothing reported an error. The direct paths were already correct — a generic function call and a generic-class instance method both thread the call site's type-argument operands, values included. Only the resolver path dropped them. ## The fix A runtime definition records the mint it was created with (`RuntimeTypeProvenance`), so the caller's identity can be **read back off the definition** instead of derived afresh — never a new mint, which is what BEP-066 I-1 requires. The interface operand already carries the definitions the receiver's class-level slots name (that is #4501's overlay), so those two facts together reconstruct the exact value: - `BexVm::runtime_declaration_identity(definition_ptr)` rebuilds the minted value for a runtime class or enum, adding the definition's own pointer back to its provenance defs. `type.of_value` was already doing exactly this inline; both now share the one construction. - `BexVm::minted_declaration_value(ty, defs)` resolves a frame slot's realized type against the operand's overlay, **only for a mint-unique name** — see below. The reconstructed value must also describe the same type or it is refused, so a decorated or parameterized spelling can never borrow a definition's mint. - `VirtualCall` fills the owner slots with what that recovers and hands them to the callee frame ahead of any method-level slots, which keep their existing positions. ### Why recovery is restricted to `$dyn` names A `DynTypeDefs` is keyed by `QualifiedTypeName`, and **only a `runtime_local` name carries its mint in the name** (`user.$dyn.N.Foo` — what `reflect.class.new` and `reflect.enum.new` produce). A static declaration and a compiled package's declaration are both plain `user.Foo`. An overlay also reaches a frame whether or not the spelling being recovered is the one that pulled it in, because `LoadType` staples the whole frame overlay onto anything materialized there. Matching an ordinary name against the overlay therefore answers from a *different* definition. Both shapes are now regressions in this PR, and both were verified failing without the `is_runtime_minted` gate: | shape | ungated | gated | | --- | --- | --- | | static `Holder<Item>` in a frame that also bound a compiled package's `Item` | `type.of<T>() == type.of<Item>()` is **false**, `== package_item` is **true** | `true`, `false` | | two compiled packages each declaring `Item`, dispatching on `Holder<B>` | `== b_item` is **false**, `== a_item` is **true** | `false`, `false` | The first is a regression against canary, which got it right by never recovering at all. The second is `==` lying about a type it is not, which is worse than not knowing. So anything but a mint-unique name declines and the body re-derives normally. ## Cost The added work is gated on the interface operand carrying definitions at all. A static interface operand leaves `iface_defs` empty, so the owner-slot walk never runs and the values vec never allocates; the existing no-type-args fast path is entered on exactly the same condition as before. The four `interfaces/*` speedtest workloads are static-interface dispatch and are structurally untouched by that reasoning — but note honestly that **no bench covers the runtime-definitions dispatch path**, and CodSpeed was not run for this branch, so the "definitions present" case is argued, not measured. When the operand does carry definitions, the walk is `O(owner slots)` (0–2 in practice) map lookups, and each *recovered* slot clones the definition's provenance defs — the same `O(defs)` shape as the overlay clone #4501's F4 note flagged. Note the frames this runs in are exactly the ones where `LoadType`'s static cache is already disabled (a non-empty overlay disables it), so the recovery does not add an allocation that was previously avoided. `Arc<DynTypeDefs>` remains the lever for both; it is not a drop-in, because GC forwarding rewrites the pointers *inside* a `DynTypeDefs` in place, so sharing would have to be unshared again exactly where it pays off. That reasoning is now a comment at the clone rather than only in a PR body. ## Documented gaps Both are the same shape — the receiver is the only thing that could carry the identity, and `Instance` drops its class type-argument *values* at construction (it stores realized types only; `Object`'s 64-byte assert and its Borsh wire form make a values lane a change to the object model, not an implementation detail). **Derived runtime types.** `t.array()`, `t.optional()` and `type.meta(…)` mint a fresh runtime id per evaluation and attach it to no definition, so there is nothing to read back: `Holder<RuntimeOutput[]>`'s impl body still sees a re-derived value. The alternative — deriving a derived type's mint from its parts — would make `t.array() == t.array()` true in ordinary code too, and contradicts I-1's "one per constructor evaluation". **Declarations from a compiled `reflect.Package`.** Their names are not mint-unique, which is exactly the restriction above. Their *definitions* still travel (#4501), so an impl body can read, render and parse the type; it just does not hold the caller's identity token for it. `runtime_package_declarations_keep_definitions_but_not_identity` pins that contract rather than leaving it silent. The honest futures are to give compiled-package declarations mint-unique names, or to carry the values on the receiver — the same lever as the derived case. Neither is a ruling this PR should make; written up separately. ## Follow-up noted in code, not fixed `execute_call_from_locals_offset_with_type_args` restores `pending_call_type_values` (the rooted copy) before reading `options.type_values`, which borrows an unrooted caller local. A collection in between would leave those pointers stale. It is unreachable as written — the callee-entry helper pushes a frame and sizes the eval stack with no TLAB allocation, and the native path that can allocate pushes no bytecode frame, so the write guard declines — and it predates this PR (#4501 introduced the lane). Recorded as a comment because this lane now carries recovered identities too. ## Tests Runtime-output oracles in `runtime_type_bindings.rs`: | Test | Covers | | --- | --- | | `minted_type_identity_survives_interface_dispatch` | implements-block method, inherited default method, two-hop dispatch out of an impl body | | `interface_impl_methods_look_up_a_type_keyed_registry` | the registry pattern — entries keyed by the call site's type value, matched by `==` inside the impl, with a same-shape same-name entry as the in-test miss | | `dispatch_identity_separates_distinct_mints_and_leaves_static_generics_alone` | negative control (two mints of the same shape stay unequal) and static generics (`Holder<string>`) unchanged in both directions | | `dispatch_identity_covers_owner_and_method_slots_together` | a generic method on a generic impl: owner slot recovered, method slot supplied, neither crossing | | `dispatch_identity_covers_a_runtime_enum_slot` | the enum arm of the recovery, which nothing else reaches | | `static_class_slots_are_not_answered_from_a_same_named_runtime_definition` | the static-vs-package collision above | | `same_named_declarations_from_two_packages_do_not_cross_match` | the two-packages collision above | | `runtime_package_declarations_keep_definitions_but_not_identity` | the documented gap, pinned | Unit tests in `vm.rs` pin the owner/method slot alignment: recovered owner values precede method-level slots, and a non-generic method still receives them. ## Verification Focused: `runtime_type_bindings` (18/18), `bex_vm` unit + integration. The two collision regressions were re-verified failing with the name gate removed (`false|true` in both cases). Full pinned gate below. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Preserved runtime-minted type identity across interface, inherited default-method, and multi-hop dispatch. * Improved type comparisons and registry lookups for runtime-generated classes and enums. * Maintained correct runtime package ownership during reflection. * Fixed type propagation through sparse owner slots and non-generic method dispatch. * Corrected truthiness handling for supported values and negation. * **Tests** * Added coverage for runtime classes, enums, separate packages, same-named declarations, and dispatch scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary - replace `arduino/setup-protoc` with a local cross-platform installer pinned to protoc 23.4 - download official release assets directly instead of querying the GitHub Releases API - verify each platform archive against a pinned SHA-256 checksum - route the Rust SDK workflow and shared Rust setup action through the local installer ## Root cause Both 0.226.1 release runs failed their Windows ARM64 TypeScript build while `arduino/setup-protoc@v3` paginated the GitHub Releases API. The installation token had exhausted its API quota, so protoc setup failed before compilation. ## Validation - `actionlint .github/workflows/test-rust-sdk.yml` - YAML parse validation for both composite actions and the workflow - downloaded and checksum-verified the official macOS ARM64 asset - installed the archive locally and verified `libprotoc 23.4` - independently downloaded and recorded SHA-256 checksums for all six selected protobuf 23.4 release assets <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Chores** - Added a repository-managed setup process for Protocol Buffers compiler installation on Unix and Windows environments. - Added checksum verification and explicit platform validation for compiler downloads. - Updated Rust setup to optionally skip compiler installation. - Updated Rust SDK validation workflows to use the shared setup process and respond to related configuration changes. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
Implements the reserved half of [B-1582](https://linear.app/boundaryml2/issue/B-1582) item 3 — the ratified specialization API. #4501 fixed everything about runtime types that did not need a new surface; this is the surface. ## What it looks like ```baml let descriptor = pkg.functions().get("root.Extract$render_prompt") ?? throw "not listed" descriptor.is_generic() // true descriptor.generic_params() // [ GenericParam { name: "T" } ] let specialized = descriptor.specialize([record.as_type()]) let render = specialized.get<PromptFn>() ?? throw "no callable" render("records").text() // embeds the runtime class's schema ``` `baml.reflect.function.Type` gains: | method | contract | |---|---| | `is_generic(self) -> bool throws never` | still expects type arguments | | `generic_params(self) -> GenericParam[] throws never` | names + count, declaration order | | `specialize(self, args: type[]) -> Type throws CompilationError` | arity + bounds checked | | `get<F>(self) -> F? throws CompilationError` | the callable, through an `F` contract | `Package.functions()` now lists **every** declared function, generics included. ## Why the listing changed The omission was never a decision: `functions()` `filter_map`ped over `function_type`, which returned `None` whenever `callable_signature` failed — which is exactly what an unspecialized generic does (`TyTemplate::substitute` hits `TypeArgRefOutOfRange`, and `.ok()` erases it). "Listed but not extractable" already existed on canary for generic companions since #4501. This PR makes both states first-class and actionable instead of a dead end. ## How it works A reflection kind view *is* the `Object::Type` value (`as_type` returns the receiver), so a descriptor has to be a `type` value that also remembers its callable. Two additive, provenance-only payload fields: - **`TypeValue.callable: HeapPtr`** — the `Object::GenericFunction` a descriptor was reflected from, null everywhere else. Outside the identity tuple: `==`/`Hash` stay mint-only, so two descriptors of equal type remain equal type values. GC traces it exactly like `owner`. - **`GenericFunction.exact_type_values: Option<Box<[Option<TypeValue>]>>`** (`#[borsh(skip)]`, `None` for every compile-time instantiation, so pooled interned objects stay byte-identical) — the exact `type` values behind `type_args`. `execute_call_from_locals_offset` seeds the callee's `FrameTypeMetadata` from it, which is how `LoadType` hands the body's `type.of<T>()` back the caller's own minted value with its `DynTypeDefs` overlay attached. Without that lane the specialized `$render_prompt` companion would render a bare unresolvable name instead of the runtime class's schema. Everything else is assembly of parts that already existed: - **arity/genericity** — `type_args.len() < generic_param_bounds.len()`, the same question `unspecialized_generic_callable_name` asks (`vm.rs:2636`). - **bounds** — the proof `validate_runtime_generic_bounds` runs before entering a runtime-checked generic call: substitute the bound's `args`/`assoc` against the completed frame, then `ImplResolver::type_implements`. Rooted at the *supplied value's* dynamic world (`for_value`) so a runtime-minted type's impls are visible, and reported as a typed diagnostic instead of a bare "mismatched types". `baml.AnyClass` keeps its #4493 carve-out for free. - **`specialize`** — build a `GenericFunction` with the completed `type_args` plus the exact values; `callable_signature` then reconstructs. Specialize ≈ "make `callable_signature` succeed". - **`get<F>`** — `Package.get_function`'s contract check, factored into `check_function_contract` and shared verbatim. ## Contract changes to existing pins Each of these is a deliberate change to something previously pinned: 1. **`Package.functions()` lists unspecialized generics.** `function_listing_omits_unspecialized_generics` → `function_listing_includes_unspecialized_generics`, now also asserting `is_generic()` on the generic entry and its absence on the concrete one. The stdlib docstring's "unspecialized generic functions are omitted" claim is deleted. 2. **`function.Type.params()` / `return_type()` gained a throws channel** (`throws never` → `throws baml.reflect.errors.CompilationError`). An unspecialized generic descriptor has no realized function type to decompose; reading one was an `unreachable!` before it was reachable, and is now E0165. `type_kinds.rs`'s `read_views` helper declares the channel accordingly. 3. **E0165's two messages changed.** Both said reflection "cannot supply type arguments yet". It can now, so both name the route that works: `Package.functions()` → `specialize`. Extraction *by name* is still refused — a name lookup has nowhere to put type arguments — so `unspecialized_generic_get_function_reports_reflection_limit` and the four `reflect_call_any` message pins keep their shape and take the new text. `generic_function_companion_extraction_reports_reflection_limit` is unchanged in behaviour; its doc comment now points at the route that does work. 4. **New E0169 `ReflectSpecializationFailed`**, appended at the end of `DiagnosticId` (borsh discriminants are declaration-ordered), with six shared factories and their oracle rows in `runtime_type.rs`'s message table: arity mismatch, bound violation, not-generic, already-specialized, not-a-descriptor, and the unreconstructible-signature backstop. (E0167 went to #4498's always-constant-condition lint and E0168 to #4518's escaping-`unreflect` diagnostic while this branch was open; E0169 is the next free code at the rebase head.) ## Review round **GC edges are structural now (blocker).** `TypeValue` carries three heap pointers — its owning package, its definition overlay, and (new here) a descriptor's callable — and six sites walked that set by hand: the collector's major/forwarding/young arms, a frame's exact type arguments, the pending-call lane, and the `runtime_type` provenance on classes and enums. Adding a field meant editing all six, and missing one leaves a dangling pointer that `get_object`'s unchecked deref turns into UB rather than a panic — which is exactly what the first cut of this PR did. The walk now lives on the payload as `TypeValue::gc_edges()` / `forward_gc_edges()` (with the same pair on `DynTypeDefs` and `RuntimeTypeProvenance`, and a `young_edges` filter for the minor-collection arms), and every site calls it. The six-copy pattern is gone, so the next pointer-bearing field cannot repeat this. Two pre-existing gaps fell out: a runtime class field's type value never had its `owner` forwarded, and the class/enum provenance arms duplicated the same walk a third time. Covered by a unit test that roots and forwards a callable-bearing frame value, plus two BAML tests that collect between every step — one holding a descriptor across collections and then specializing/extracting/calling it, one specializing with a runtime-minted class and reading `type.of<T>()` back out of the callee after a collection. **Bounds resolve in the descriptor's world, not the caller's.** `lookup_interface` goes through `package_for_type`, which roots at the *executing frame's* runtime package. A bound declared inside a `Package.compile`d package is `Local` to that package, so proving it from a host call site found no interface, no rules, and rejected every argument — including conforming ones. `ImplResolver` now resolves the interface name against its `root_package` first and falls back to the lexical lookup (the name-resolution half of what `for_package` already did for rules), and `specialize` roots at the descriptor's package. The fallback matters: a goal can equally name an interface the inspected package *borrowed* from a mounted dependency, which is local to that dependency and only resolvable the lexical way — rooting alone broke `scenario_6`'s `PlanThenAct implements app.AgentAction`. Pinned by a test that compiles a package declaring the interface, a conforming class, and a non-conforming one: before the fix the *conforming* type was rejected. **Exact values are positional, not structural.** `params()`/`return_type()` matched the supplied values against the reconstructed types by structural equality, so a parameter the author wrote as a concrete type could be handed the caller's type argument whenever the two happened to coincide. The mapping is now read off the callable's own `TyTemplate`s — a position reports an exact value only when it is written as exactly that type parameter (`TyTemplate::TypeArgRef`); a nested occurrence (`T[]`) decomposes normally and keeps the overlay. Pinned by a test whose second parameter is declared as the very class supplied for `T`. **Also:** re-specializing an already-bound descriptor gets its own message (telling the caller it "is not generic" pointed at the wrong mistake); a fully supplied frame that still fails to reconstruct now throws instead of silently producing an `unknown` descriptor that denies being generic; the call hot path reads a `GenericFunction`'s two carried lanes from one deref; and `is_generic` no longer allocates a name and an argument vector to answer an arity question. ## Notes - A descriptor is still a `type` value, so descriptor **equality is type equality** — mint-only, and a specialization's mint is the static digest of its reconstructed function type. Two descriptors specialized from different runtime classes therefore compare equal when their signatures do not mention the type parameter (a `$render_prompt` companion is exactly that shape), even though their `return_type()`s differ. That is the BEP-066 rule working as designed — the descriptor denotes a function type, not an instantiation — but it is worth knowing before anyone keys a cache on one. - The arity message is phrased against the parameters *still* awaiting arguments. Specialization is all-at-once today, so that is always the full count; if partial specialization ever lands, the message already says the right thing. ## Deferred - **Bounds in `generic_params()`.** A bound's args are `TyTemplate`s over the callee's own frame, so `T extends Comparable<T>` has no `type` value to report and every workaround is a policy choice (drop silently / substitute `unknown` / render a string / introduce a `Bound` row). Shipped names + count; the ruling is written up separately. `specialize` enforces every bound regardless. - **Static sugar `specialize<T1, …>()`.** Did not fall out cheaply — it needs a turbofish-to-`type.of<T>()` desugaring at the call site rather than a native. - **Family specialize.** Companions are specialized individually, as ratified (`GenericList` and `GenericList$render_prompt` are separate entries). ## Tests New `crates/baml_tests/tests/reflect_specialize.rs`, 13 cases: the item-3 flow end to end with a runtime-minted type (asserting the rendered prompt carries the runtime class's fields), the same shape with a static type, the `is_generic` truth table over all four cells, `generic_params` names/count, specialized signature readback, mint identity on both the descriptor and the callable side, arity mismatch, an interface-bound violation, an `AnyClass`-bound violation, specialize-on-non-generic, the unspecialized signature read, a non-descriptor function type, and contract enforcement on extraction. ## Snapshot churn Eight files — **6 modified, 1 added, 1 deleted** — every one a consequence of adding one class and four methods to the stdlib: 1. `baml_cli__…__describe_package_functions_documents_unspecialized_generic_omission.snap` — **deleted with its test.** It existed to pin the omission contract in `baml describe`, and that contract is gone. Replaced by `…__describe_package_functions_documents_the_generic_listing_contract.snap`, asserting the new docstring instead. 2. `baml_cli__…__render_builtin_package_listing.snap` — one added row, `class baml.reflect.function.GenericParam`, plus the line-number shifts in `reflect.baml` from the docstring edits. 3. `baml_tests__compiler2_tir__phase5__snapshot_baml_package_items.snap` — `baml.reflect.function` gains `class GenericParam { methods: [] }`, and `class Type`'s method list gains `is_generic, generic_params, specialize, get`. 4-6. `__baml_std__` `03_ppir`, `04_5_mir`, `06_codegen` — the same class (with its generated `GenericParam$stream` companion) and the same four builtin methods, at each stage. `05_diagnostics` is unchanged: the stdlib still compiles clean. 7-8. `bytecode_format__bytecode_display_expanded{,_unoptimized}` — global slot indices shift by exactly +4 (`call 917` → `call 921` and so on), the four new builtin functions in the global table. No instruction changes. Regenerated after the rebase rather than hand-merged, since the base numbers moved too. The reflection test suites themselves are `assert_eq!` on engine values, so they contribute nothing here. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added reflection support for generic functions, including parameter inspection, type specialization, signature access, and callable retrieval. * Generic functions and generated companions now appear in package function listings. * Added validation for specialization arguments, including count and type-bound checks. * Expanded truthiness behavior for the `!` operator beyond boolean values. * **Bug Fixes** * Improved reflection errors and guidance for incomplete, invalid, or unavailable generic signatures. * Improved runtime type diagnostics with clearer messages and suggested corrections. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
…ding (#4529) Two defects from MIG_BRIEF Fix 4(b), shipped together because they are one story — *a `let` binding that lives in a global should behave like an ordinary binding* — and because splitting them would leave a catchable→uncatchable regression on canary in between. ## 1 — a method call on such a binding reached the VM with no receiver ```baml client MyClient = openai.ResponsesClient.new(model = "gpt-4o-mini", …) function main() -> string { MyClient.id() } // VM internal: expected instance, got any ``` **This is ordinary BAML, no Session anywhere** — a `client` declaration lowers to a top-level `let`, so it took the same defect. That is the largest behavioral surface of this PR and it is verified by A/B against canary's lowering. The cause: a top-level `let` is an initialized **global**, not a lexical local, so MIR's `place_for_path` correctly finds nothing. Several roads read that absence as *"no receiver"* rather than *"not a local"* — a single-segment receiver became `Constant::Null`, a member-access base failed `base_is_value` so the receiver was dropped from the call, and the container/interface dispatch block was gated on `local_for_path`. The null **is** the `any` in the message: `Type::of` maps `ValueKind::Null` to `ObjectType::Any`. Field access and indexing were never affected, because those roads already loaded the global — which is why the bug presented as "method dispatch on a binding, specifically". All roads now go through one `load_top_level_let_root`, factored out of the field-chain road that already did this inline. ### The `.length()` holdout, and why my first diagnosis was wrong An earlier revision of this PR shipped with `v.length()` still broken and blamed "container methods whose owner is generic" / `builtin_kind: Some(Vm)` / emit's inline opcodes. **All three were wrong discriminators.** `v.join(…)`, `m.keys()` and every other container method already worked off the loaded global — A/B verified. The real cause is one line of asymmetry in MIR: the `.length()` special case emits `Rvalue::Len(place)`, and `Len` is the **only** consumer that takes a `Place` where every other takes an `Operand`. The temp `lower_item_ref` defines is `Use(Constant::GlobalItem)`, which emit's analysis classifies as a pure constant and *virtualizes* — re-emitted at each use rather than stored. Operand consumers re-emit it happily; the Place road read a slot nothing had written. Handing out a materialized copy makes the place a real defined local. `v.length()` and `m.length()` are fixed, with regressions. Note-only, spotted there: that same match tests `"baml.string.length"` lowercase against a class named `baml.String` — a stale dead branch. ## 2 — a literal binding took the literal's type `s.eval("let n = 5")` bound `n` at the type `5`. A `let` item has no declaration signature, so a reference recovers its type from the initializer's inference result — `type_of_expr[root_expr]`, the **expression's** type. Ordinary `let` never binds that: it applies `widen_fresh` first. This road skipped that step. Unconditional here because a Session binding cannot opt out: `lower_session_let` refuses any pattern ascription, so `let n: int = 5` is not a legal submission at all (pinned as a test, since it is the reason there is no annotation branch). ### Consequences of the widening, all deliberate and all pinned - **An eval contract naming the literal no longer accepts the binding.** `s.eval<5>("n")` was accepted and is now `submission result has type int, which is not a subtype of requested contract 5`. This is the PR's cleanest oracle — it flips exactly at the change. - **Match exhaustiveness moves to the base type.** `match (n) { 5 => … }` on a session binding is now `non-exhaustive match on type int; missing: _`. The mirror also holds: a complete `true`/`false` match on a bool binding is now legal where it previously matched the literal `true` alone. - The eval **result contract** itself still reads the unwidened initializer type (`let_initializer_type`), so `s.eval<5>("5")` is unchanged. ## Correction: this PR does have one regressing spelling An earlier revision claimed "there is no spelling that regresses". **That is false**, and here is the case: an interface-dispatched method on such a binding — `n.compare(m)`, or a session-local `implements` on a primitive — reaches a **pre-existing broken road** and fails with an uncatchable `InvalidArgumentCount { expected: 2, got: 1 }`, where before the widening the same spelling was a catchable `E0007: type \`5\` has no member \`compare\``. Verified identical before and after this PR's MIR change, so the receiver fix does not cause it; what the widening changes is **reachability** — a literal-typed binding had no members at all, so users never got there. Filed as MIG_BRIEF Fix 11 with the evidence. Stated here rather than papered over, because it is the honest cost of the widening. ## What this does not fix - **Interface dispatch on a top-level-let receiver** — above; MIG_BRIEF Fix 11. - **A union-of-fresh-literals initializer does not widen.** `let picked = if (c) { 1 } else { 2 }` binds `1 | 2` where ordinary `let` binds `int`. `widen_fresh` keys on freshness, and union canonicalization at `finish()` drops it, so by the time a reference reads the recorded initializer type there is nothing left to widen. Closing it means recording the let's *binding* type as its own product of the let's inference — which then also has to avoid changing the result contract above. - **`to_string` reports as absent** on these bindings (`type \`int\` has no member \`to_string\``), because the universal sugar road and the member walk these paths use are two different tiers and only the former carries universal members. Cosmetic today — both spellings error — but filed as MIG_BRIEF Fix 9, and the test that pins one of these messages says so in its comment. - **Mounted-package class methods inside a Session** (E0099 shadowing, then "no member") — pre-existing, notebook-relevant, filed as MIG_BRIEF Fix 10. ## Related, filed not fixed A Session assignment does not typecheck against its binding (`n = "seven"` on an `int` binding compiles), because an assignment lowers to a fresh `let __gen = (value)` plus a `commit_global` with no check. MIG_BRIEF Fix 8, with a recommendation appended: check through `let_initializer_type` while leaving `let` free to re-bind. Its urgency rose with this PR — an unchecked assignment used to be mostly inert on a `5`-typed binding, and now produces uncatchable crashes downstream. ## Cost `load_top_level_let_root` runs a linear scope scan (`resolve_name_at_in_scope`) on three more roads than before. Measured in review: **+1.8% debug-compile time**. The lever if that ever matters is memoizing the resolution per `(expr, name)` — deliberately not done here, because caching the *loaded local* (rather than just the resolution) would have to prove the first load dominates the second use, and the two can land in different blocks. Relatedly, the dispatch-block `or_else` emits a dead duplicate global read when that block declines the call; both reads are pure constant fetches, and there is now a comment saying so. ## Tests `crates/baml_tests/tests/runtime_session.rs`, 27/27: | Test | Covers | | --- | --- | | `session_top_level_lets_widen_literal_initializers` | the contract oracle — `s.eval<5>("n")` refused post-widening | | `session_let_widening_is_visible_through_member_resolution` | int, string and bool each name their **base** type (annotated re: the `to_string` gap) | | `session_let_widening_moves_match_exhaustiveness_to_the_base_type` | both directions of the exhaustiveness flip | | `session_let_rebinding_across_submissions_is_unaffected` | widening changes the binding, not the values | | `session_let_annotations_are_still_rejected` | why widening is unconditional | | `session_let_narrowing_still_sees_the_literal` | `if (n is 5)` still narrows | | `method_calls_on_session_let_bindings_dispatch` | every road the fix touches: primitive companion, container-as-`Call`, container-as-`Rvalue::Len`, a session-declared class's own method, a reflection handle's `fields()` | | `method_calls_on_a_session_binding_work_in_its_own_submission` | the defect never needed two submissions | | `session_binding_field_access_and_indexing_still_work` | the controls that always worked | | `client_declaration_methods_dispatch` | `MyClient.id()` in ordinary BAML | ## Verification Focused: `runtime_session` 27/27. A/B against canary's lowering for the client case, the `join`/`keys`-vs-`length` split, and the pre-widening `E0007` for the interface-dispatch regression. Full pinned gate below. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Fixed Session `let` and `client` bindings so literal values correctly widen to their base types. * Fixed method calls on bindings, including primitive, container, user-defined class, reflection, and client values. * Improved member access, indexing, rebinding, match exhaustiveness, and evaluation behavior. * Preserved constructed type identity through interface dispatch and inherited default methods. * Improved failure handling for unsupported interface dispatch. * **Tests** * Added regression coverage for binding behavior, method dispatch, type identity, and related Session operations. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
Two small changes that cost nothing on your runners and unbreak CI for anyone running this repo's workflows outside it. **sccache log path.** `.envrc` wrote the sccache error log to a hardcoded `/tmp`, which fails on any runner with an isolated or read-only `/tmp`. It now respects `TMPDIR` and behaves exactly as before everywhere else. **benchmarks queue-wedge.** `benchmarks build (baml)` targets an ARM runner label that only exists in this repository. On a fork the job queues for 24 hours holding the whole run open, which also blocks re-running any other job in that run. It is now gated on `github.repository`. Found while running this repo's full CI end to end on our own runner fleet (context in the PRs that follow). Both fixes have been live on the fork since 2026-08-16. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Chores** * Updated development environment setup to support explicit Nix configuration. * Preserved temporary-directory handling for build cache error logs, with `/tmp` used as a fallback. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Sam Lijin <sam@boundaryml.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Remove the verbose explanatory comments introduced by BoundaryML#4482 and retain only a short clarification that
editable-profileapplies to PEP 660 editable builds such asuv sync, notmaturin buildor the Python release workflow.This is stacked directly on BoundaryML#4482, so its diff contains only the documentation clarification.
Validation
pyproject.tomlwith Python'stomllibgit diff --check