Skip to content

Run schema-aligned parsing as VM natives - #5119

Merged
aaronvg merged 6 commits into
aaron/istype-fast-refutefrom
aaron/sap-rust-function
Oct 3, 2026
Merged

aaronvg merged 6 commits into
aaron/istype-fast-refutefrom
aaron/sap-rust-function

Conversation

@aaronvg

@aaronvg aaronvg commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Issue Reference

No issue filed. Stacked on #5113 (which is stacked on #5111): it removes TypeCtx::for_target from #5111 and extends llm_call_memory.rs. Retarget to canary once those merge.

Changes

Schema-aligned parsing (SAP) was three sys ops ($rust_io_function) although it does no IO: before spawn, sys ops were the only way to get concurrency. As sys ops, each parse:

  • received T as an owned copy (SapTy), and the engine copied the class and enum tables into the per-call SysOpContext (twice more for runtime-built types);
  • built the parse model from those copies (TypeCtx::for_target);
  • returned a BexExternalValue tree that the engine landed on the heap by looking every class and enum up again by name.

The SAP ops already returned SysOpOutput::Ready, so they ran inline on the VM task under the heap permit. Moving them into the VM changes nothing about what they block.

This PR makes them VM natives ($rust_function, bex_vm/src/package_baml/sap.rs):

  • _new_parse_cache<T> reads T from the call's type arguments, walks the declarations it reaches (reachable::all_declarations), reads each with the shared definition reader, and builds the same CompiledSapModel through the unchanged TypeCtx::new / build_db / convert_ty. The model stays in the _ParseCache<T> RustData (one model per cache, only the types T reaches); it holds tagged names, never heap pointers, because RustData is a GC leaf. The instance now carries its real T.
  • _parse_final / _parse_partial run jsonish and the coercer as before and allocate the result directly on the heap, mapping the model's tagged names back to the declarations the target reaches. Error behavior is unchanged: a final failure is baml.errors.LlmClient with the same messages, a partial that does not parse yet is _NoYield.

Supporting changes:

  • One definition reader (bex_vm::definitions): class_definition / enum_definition replace the engine's three class readers and two enum readers, which disagreed on @skip (the runtime-type overlay dropped skipped fields; the program table and the runtime-package overlay kept them flagged). Every consumer (output-format rendering, json.schema, SAP) already filters on the flag, so the reader keeps skipped fields flagged and drops skipped enum variants.
  • reachable::all_declarations: every declaration a type reaches, aliases included; all_nominals now filters it, and the walk uses a HashSet instead of Vec::contains.
  • BexVm::rust_data_field: one helper for "clone the Arc out of a $rust_type field", replacing the copies in regex.rs and csv.rs (which also reported a misleading MissingNativeFunction).
  • Deleted: sys_ops/src/sap.rs and the SAP sys-op impls, CompiledSapModel::from_sys_op_context, TypeCtx::for_target / reachable_definitions, and the value half of bex_sap::to_external (the type half remains, renamed to_baml_ty). bex_sap no longer depends on bex_external_types; sys_ops no longer depends on bex_sap.

Bug fixed: @skip fields

Parsing into a class with a @skip field failed: SAP drops the field (the prompt never shows it), and the heap landing required every field (Missing field \age` in external Instance for class `user.Person`). The native fills a skipped field with its type's empty value: nullfor nullable types,0/0.0/0n, false, ""`, an empty list or map, a literal's own value, an enum's first variant, and a class of its fields' empty values. A skipped field whose type has none (media, functions, a self-containing class) panics with a message naming the field.

Memory

Measured with the counting allocator in llm_call_memory.rs (debug build). The gain grows with the size of the parsed output, because the parse no longer builds an external copy of the value and lands it by name.

Parse of a 13 KB JSON reply with 200 items (Item { id, name, tags }[]) #5113 this PR
Final parse (baml.sap.parse<Item[]>) 1,149,933 bytes 664,622 bytes (−42%)
Same reply streamed in 52 batches of 256 characters, each reparsed through one _ParseCache (as ai.stream.Stream does) 45,427,554 bytes 32,301,944 bytes (−29%)

(SAP's own bytes: the totals minus a baseline function doing the same work without parsing, about 4-6 KB.)

Small LLM call (a one-line reply) #5113 this PR
Bytes allocated per call 699,060 687,584
Extra bytes per call from 400 unrelated classes 21,918 0
Extra bytes per call per prompt message 35,023 34,967

The streamed case is still large because every batch reparses the whole text so far; incremental parsing is not in this PR.

Testing

  • bex_sap unit tests unchanged and passing (they build TypeRefDb directly).
  • New bex_vm unit tests: definitions keeps skipped fields flagged and drops skipped variants.
  • New corpus test sap_parse_fills_skipped_fields_with_empty_values (failed before with the missing-field error).
  • baml_builtins2_codegen: test_sap_parse_is_a_vm_builtin replaces the IO-builtin assertion for _parse_final.
  • New memory guards in llm_call_memory.rs: large_parse_allocates_little (limit 900 KiB) and streamed_parse_allocates_little (limit 40 MiB). Both limits sit between the numbers above, so a return to the external-copy path fails them.
  • Locally, the tests this touches: bex_sap, bex_vm, baml_builtins2_codegen, sys_ops, and the baml_tests library plus llm_call_memory and reflect_call_any (1616 passed); the corpus namespaces that parse (parse_companions, generic_union_returns, streaming_partial_parse, stdlib_runner_stream_batching, runtime_type_bindings_phase1_wrapped, runtime_identity_seams, provider_stdlib, and every llm_* namespace: 498 passed). CI runs the rest.
  • prek passes (cargo fmt, cargo clippy, cargo stow, baml fmt).

Additional Notes

  • Not in this PR: yielding to BAML code for custom coercion (the YieldToCall continuation path baml.json.to<T> uses), and moving ctx.output_format() / baml.json.schema, the last users of the SysOpContext tables, to natives.
  • StreamStateTy still has no production constructor; left as is.

🤖 Generated with Claude Code


Note

Medium Risk
Touches core LLM output parsing and heap allocation paths; behavior is intended to match prior SAP errors but execution moved from sys ops to VM with definition-walk semantics changes.

Overview
Schema-aligned parsing (SAP) moves from async sys ops to VM natives (baml.sap uses $rust_function with //baml:mut_vm). Parsing no longer copies definition tables into SysOpContext, builds models via TypeCtx::for_target, or returns BexExternalValue for a second heap landing—_new_parse_cache<T> walks only declarations T reaches on the heap, and _parse_final / _parse_partial allocate results directly.

Supporting cleanup: shared bex_vm::definitions for class/enum tables (skipped fields stay flagged), reachable::declarations_reached, BexVm::rust_data_field, removal of sys_ops SAP and bex_sap::to_external (types live in to_baml_ty).

@skip fields are filled with type empty values instead of failing parse/landing. New corpus and allocation guards in llm_call_memory.rs lock in lower parse memory (~42% on large final parses).

Reviewed by Cursor Bugbot for commit e82af14. Bugbot is set up for automated code reviews on this repo. Configure here.

SAP's three ops were sys ops although they do no IO. As sys ops each parse received T as an owned copy, built its model from definition tables the engine copied into the per-call SysOpContext, and returned a BexExternalValue tree the engine landed on the heap by looking every class and enum up by name. They returned SysOpOutput::Ready, so they already ran inline under the heap permit.

_new_parse_cache<T>, _parse_final, and _parse_partial are now $rust_function natives (bex_vm/src/package_baml/sap.rs). The model is built from the declarations T reaches on the heap (reachable::all_declarations) through the unchanged TypeCtx::new; it holds tagged names only, since RustData is a GC leaf. Results are allocated directly on the heap. Error behavior is unchanged.

A @Skip field no longer breaks a parse: the parser drops it and the heap landing required every field. The native fills it with its type's empty value.

Supporting changes: bex_vm::definitions is the one class/enum definition reader (the engine's five readers disagreed on @Skip); BexVm::rust_data_field replaces copies in regex.rs and csv.rs; reachable::all_nominals filters the new all_declarations. Deleted: the SAP sys ops, CompiledSapModel::from_sys_op_context, TypeCtx::for_target, and the value half of to_external (now to_baml_ty).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@vercel

vercel Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
developer-docs Ready Ready Preview Oct 3, 2026 6:29am UTC

Request Review

@coderabbitai

coderabbitai Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 23acff27-a867-435f-9572-7b91e7c6341b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown

⏭️ Performance benchmarks were skipped

Perf benchmarks (CodSpeed) are opt-in on pull requests — they no longer run on every push. They always run automatically after merge to canary/main.

To run them on this PR, do any of the following, then push a commit (or re-run CI):

  • Add RUN_CODSPEED=1 to the PR description, or
  • Include run-perf or /perf in the PR title or any commit message.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 7293ce3. Configure here.

# Conflicts:
#	baml_language/crates/bex_sap/src/sap_model/convert.rs
A 13 KB reply of 200 items: a final parse allocates about 665 KB (1.15 MB before the natives), and the same reply streamed in 52 batches allocates about 32.3 MB (45.4 MB before), since the parse no longer builds an external copy of each value and lands it by name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@blacksmith-sh

blacksmith-sh Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Found 3 test failures on Blacksmith runners:

Failures

Test View Logs
baml_cli/describe_command_tests::render_builtin_package_listing View Logs
baml_cli/describe_command_tests::render_builtin_package_listing View Logs
baml_cli/describe_command_tests::render_builtin_package_listing View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

# Conflicts:
#	baml_language/crates/bex_sap/src/sap_model/convert.rs
@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Binary size checks failed

❌ 2 violations · ✅ 1 passed

⚠️ Please fix the size gate issues or acknowledge them by updating baselines.

Artifact Platform File Gzip Gated on Baseline Delta Status
✅ baml-cli Linux 🔒 59.9 MB 25.4 MB file 73.1 MB -13.2 MB (-18.1%) OK
❌ packed-program Linux 🔒 38.5 MB 15.6 MB file 29.2 MB +9.3 MB (+31.9%) FAIL
❌ bridge_wasm WASM 26.1 MB 🔒 7.4 MB gzip 5.7 MB +1.7 MB (+30.7%) FAIL

🔒 = the size this artifact is GATED on (ceiling + delta). Binaries gate on file size (installed binary); WASM gates on gzip (download size). The other size is shown for information only.

Details & how to fix

Violations:

  • packed-program (Linux) file_bytes: 38.5 MB exceeds limit of 30.1 MB (exceeded by +8.4 MB, policy: max_file_bytes)
  • packed-program (Linux) file_delta_pct: +31.9% exceeds limit of 3.0% (exceeded by +28.9pp, policy: max_delta_pct)
  • bridge_wasm (WASM) gzip_bytes: 7.4 MB exceeds limit of 5.9 MB (exceeded by +1.5 MB, policy: max_gzip_bytes)
  • bridge_wasm (WASM) gzip_delta_pct: +30.7% exceeds limit of 3.0% (exceeded by +27.7pp, policy: max_delta_pct)

Add/update baselines:

.ci/size-gate/wasm32-unknown-unknown.toml:

[artifacts.bridge_wasm]
file_bytes = 26074945
gzip_bytes = 7403159

.ci/size-gate/x86_64-unknown-linux-gnu.toml:

[artifacts.packed-program]
file_bytes = 38460792
gzip_bytes = 15616477

Generated by cargo size-gate · workflow run

The parse model and the per-parse declaration map followed every head a declaration holds, including @Skip fields, which the parser drops, and field templates. Types only a skipped field names now cost a parse nothing; with 400 such classes the walk added 8.65 MB per call. reachable::declarations_reached takes the heads to follow, and all_declarations is it with every head.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The $rust_function directives in sap.baml moved baml.sap.parse and parse_type down four lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@aaronvg
aaronvg merged commit b5ba31c into aaron/istype-fast-refute Oct 3, 2026
83 of 84 checks passed
@aaronvg
aaronvg deleted the aaron/sap-rust-function branch October 3, 2026 06:46
meefs pushed a commit to meefs/baml that referenced this pull request Oct 3, 2026
… match arms (BoundaryML#5113)

## Issue Reference
No issue filed. Found by heap-profiling an LLM call after BoundaryML#5111 (now
merged).

**This branch also carries BoundaryML#5119** ("Run schema-aligned parsing as VM
natives"), which was merged into it: SAP as `$rust_function` natives,
the shared definition reader, the `@skip` fix, and the parse memory
guards. See BoundaryML#5119 for its description and measurements.

## Changes
After BoundaryML#5111, one small LLM call still allocated 3.4 MB, and **each
prompt message added 1.56 MB** (debug build). A heap profile (dhat) put
77% of it in runtime `match` type tests (`IsType` / `NarrowBind` →
`value_matches_template` → `normalize::is_subtype`):

- `openai.internal._prune_nulls` (and the Bedrock copy) runs once per
node of the request JSON and tests `map<string, baml.json.json>`, then
`baml.json.json[]`.
- A hit is cheap (the `sub == sup` fast path). A **miss** is not:
`heads_definitely_differ` decided only same-variant nominal pairs, so a
string or a list tested against `map<string, json>` canonicalized both
sides and expanded the recursive `json` alias through the μ automaton:
about 187 KB per string, 377 KB per list.

Two fixes:

1. **Cross-category refutation in the type algebra**
(`baml_type/src/normalize.rs`). `heads_definitely_differ` and its
interned twin now also return true when both heads have a `Category` and
the categories differ, so all five entry points
(`is_subtype`/`equivalent`, the interned free functions,
`InternedCanonicalCache`) reject those pairs before canonicalizing.
Heads that canonicalization can rewrite or that a context-driven rule
relates across kinds — alias, union, projection, interface, type
variable, inference variable, `unknown`, `never`, error — have no
category and are never refuted. A literal and its base, and an enum
variant and its enum, share a category. This follows TYPE_SYSTEM.md
§Concrete Types (no concrete type is a subtype of another); interface
membership, associated types, and type variables stay with the full
algebra. The `Ty` and interned category tables come from one
`macro_rules!` table.
2. **Tag test for narrowing container arms**
(`baml_compiler2_mir/src/lower.rs`). MIR already emits a coarse
`IsTypeTag` when the scrutinee's static type proves it suffices
(`parametric_arm_tag_sufficient`), and a `json` parameter is already
unfolded to `… | json[] | map<string, json>`. But a `let x: T` arm tests
a *snapshot* of the scrutinee, and the guard compared against the match
local only, so the shortcut never fired for narrowing arms.
`MatchScrutinee` now records snapshot copies, and both the container-arm
gate and the switch gate ask whether the tested local holds the
scrutinee. An element-specific arm (`let xs: int[]` on a `json`
scrutinee) stays structural.

| Measure (debug build) | BoundaryML#5111 | this PR |
| --- | --- | --- |
| Bytes allocated per small LLM call | about 3.4 MB | about 0.70 MB |
| Extra bytes per call per prompt message | about 1.56 MB | about 35 KB
|

## Testing
- [x] `baml_type` unit tests: an oracle over a 37-kind corpus (every
`Ty` variant, plus facts that make the interface, type-variable,
projection, and recursive-alias rules fire) checks that `is_subtype` and
`equivalent` give the canonical relation's verdict for every pair, on
the plain and the interned paths. Cost tests count the context's
`alias_def` lookups and assert that cross-category pairs never ask for
one (they failed before the change). A test pins that rewritable and
context-driven kinds are not refuted.
- [x] MIR tests: `json_container_arms_test_only_the_runtime_tag` (failed
before) and `json_scrutinee_keeps_element_specific_arms_structural`.
- [x] Corpus tests in `ns_json_alias/json_alias.baml`: every json kind
reaches its own arm, and a recursive null-pruning function over nested
json gives the right result.
- [x] Regression test: `llm_call_memory.rs`
`call_cost_does_not_grow_with_conversation_length` compares a 1-message
and a 21-message prompt and bounds the bytes per extra message at 128
KiB (it failed at 1,559,878). The per-call bound drops from 8 MiB to 2
MiB.
- [x] CI suites, locally: the general job passed 4,891 tests. The
snapshot job passed 1,595; its 12 CLI-driven tests (`baml_corpus`,
`pack_e2e`, `type_quiz`) stopped only at the local agent-skill check and
pass with `BAML_AGENT_SKILL_CHECK=off` (14 of 14, including
`type_quiz_conformance` and the new corpus tests). No snapshot changed.
- [x] `cargo clippy` on the changed crates is clean.

## Additional Notes
- Not done: caching the canonical form of constant type templates in the
VM. After these two fixes the type tests cost about nothing in an LLM
call, and a cache would hold `NormalTy<TypeHead>` (no GC head walk)
built from facts that runtime impl registration can change.
- Soundness of fix 2 rests on the same invariant the existing gate uses:
a value's runtime container type matches its static type. `let j: json =
xs` with `xs: int[]` is a type error, so a list in a `json` slot is
stamped `json[]`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes type subtyping fast paths and MIR match lowering
(soundness-sensitive) plus the SAP/LLM parse pipeline; broad test and
allocation guards reduce regression risk.
> 
> **Overview**
> Cuts **LLM-call allocation** by fixing two hot paths and moving
**schema-aligned parsing (SAP)** onto the VM.
> 
> **Type tests on `json`:** Failed runtime `match` checks used to
**canonicalize the recursive `json` alias** on every miss (e.g. when
pruning request JSON). `heads_definitely_differ` now **rejects
cross-category pairs** before canonicalization. MIR **`MatchScrutinee`**
tracks narrowing-arm snapshots so **`json[]` / `map<string, json>`
arms** can use coarse **LIST/MAP tag tests** when the scrutinee is
statically `json`, while arms like `int[]` stay structural.
> 
> **SAP:** Internal SAP entry points are **`$rust_function` VM natives**
(not sys ops). Parsing builds a model from **declarations the target
reaches**, writes results **directly on the heap** (no external value
tree), and fills **`@skip` fields** with type empty values. Definition
extraction is centralized in **`bex_vm::definitions`**.
> 
> **Tests:** Corpus and MIR coverage for json container matching; SAP
skip-field behavior; **`llm_call_memory`** bounds per-call bytes,
per-message growth, large/streamed parses, and types only referenced by
skipped fields.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
3691a2f. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added schema-aligned parsing that fills missing skipped fields with
appropriate empty values.
* **Bug Fixes**
* Improved JSON matching so list and map patterns distinguish containers
from scalars while preserving structural checks for typed collections.
* Improved type comparisons to reject incompatible concrete types
without disrupting checks that depend on aliases or context.
* **Performance**
* Tightened the per-call allocation limit for LLM calls and added limits
for allocation growth as conversations get longer.
* **Tests**
* Added coverage for nested removal of null-valued JSON entries, JSON
parsing and stringifying, skipped fields, and allocation limits.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>

This branch was successfully deployed

1 active deployment
Preview – developer-docs — e82af144 Deployed Oct 3, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant