Skip to content

Preserve structural VM values in diagnostics and JSON - #4143

Merged
aaronvg merged 5 commits into
canaryfrom
codex/upstream-vm-value-fidelity
Jul 23, 2026
Merged

aaronvg merged 5 commits into
canaryfrom
codex/upstream-vm-value-fidelity

Conversation

@aaronvg

@aaronvg aaronvg commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Root cause

Two VM rendering paths used incomplete fallbacks for values whose runtime shape carried more information than the fallback understood:

  • ErrorContext.to_string() executes inside a native call and cannot yield to user ToString implementations. It therefore rendered class errors as only their class name and replaced other non-string values with <error>, losing the values needed to diagnose caught failures.
  • typed-union JSON serialization delegated to a runtime-shape walker instead of selecting a union member. That lost the member’s declared-type behavior, so class/media values could be dropped and non-JSON members such as uint8array could behave differently inside versus outside a union.

Scope

  • add a VM-heap-allocation-free structural value renderer for native diagnostic paths with depth, node, and active-cycle guards
  • render ErrorContext class errors with consistent qualified names, brace spacing, and recursively rendered fields
  • structurally render non-class thrown values instead of <error>
  • keep ErrorContext rendering non-dispatching, so user ToString overrides are not invoked from the native call
  • select the first union member containing the runtime value with the VM’s canonical ordered membership check, then serialize through that member’s typed path
  • raise JsonSerializationError when a value matches no member, or when the selected member itself is not JSON-serializable (including uint8array without explicit encoding)
  • add focused regressions for ErrorContext class/non-class values and unions containing class, enum, and media values

User impact

Caught errors retain actionable structural detail without risking unbounded diagnostic recursion. baml.json.to_string<T>() now preserves typed-member behavior inside unions; consequently, union serialization can fail when the runtime value matches no member or the selected member is not JSON-serializable.

Validation

  • cargo nextest run -p baml_tests --test errorcontext --test json_typed_union (19 passed)
  • cargo nextest run -p bex_vm (61 passed)
  • cargo clippy -p bex_vm --all-targets --all-features -- -D warnings
  • cargo fmt --all --check
  • git diff --check

Note

Medium Risk
Touches core VM error rendering and typed JSON serialization paths used in diagnostics and baml.json.to_string<T>(), with behavior changes for union members and non-class thrown values.

Overview
Improves how thrown values appear in ErrorContext cause chains and stack traces, and fixes typed union JSON serialization so it follows declared member types instead of a loose runtime-shape walk.

ErrorContext / diagnostics: Non-string errors no longer collapse to a class name or <error>. Native rendering uses a new bounded structural printer (render_value_structural) with qualified class names, recursive fields, depth/node limits, and cycle truncation—without calling user ToString overrides (native paths cannot yield). Panic snapshots now include payload fields (e.g. DivisionByZero { dividend: 42 }).

JSON: baml.json.to_string<T>() for unions picks the first declared member that contains the value via value_matches_template (same as is / match), then serializes through that member’s typed path. Class, enum, and media values are preserved; values outside the union or non-JSON members (e.g. uint8array) raise JsonSerializationError consistently inside and outside unions.

Regression tests cover ErrorContext rendering, truncation budgets, and typed union JSON (including equivalence with direct class serialization).

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

Summary by CodeRabbit

  • Bug Fixes
    • Improved thrown-error rendering so class-identity and structural fields are preserved in error cause chains, including safe handling for non-string throws and suppression of user ToString overrides for class-thrown values.
    • Structural string rendering now safely truncates cyclic and deeply nested structures with clear ellipsis/limit markers.
    • Panic output now includes the panic payload, not just the variant name.
    • Typed JSON union serialization now selects the first compatible union member; invalid values fail with a JsonSerializationError.
  • Tests
    • Added regression coverage for error context rendering, truncation behavior (including cyclic/deep and large arrays), and typed union JSON serialization (success and failure). Updated the panic snapshot.

@vercel

vercel Bot commented Jul 23, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
beps Ready Ready Preview, Comment Jul 23, 2026 3:22am
promptfiddle Ready Ready Preview, Comment Jul 23, 2026 3:22am
promptfiddle2 Ready Ready Preview, Comment Jul 23, 2026 3:22am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Structural error rendering now uses bounded, cycle-aware diagnostics and preserves qualified class identities. Typed union JSON serialization selects a matching declared member before serialization and reports mismatches. Regression tests cover rendering, truncation, supported union values, and serialization errors.

Changes

Structural error rendering

Layer / File(s) Summary
Bounded structural rendering
baml_language/crates/bex_vm/src/package_baml/root.rs, baml_language/crates/bex_vm/src/package_baml/error_context.rs
Rendering tracks overrides, depth, node count, cycles, and qualified class names; non-string error values render structurally.
Error rendering regression coverage
baml_language/crates/baml_tests/tests/errorcontext.rs, baml_language/crates/baml_tests/tests/exceptions.rs
Tests cover class and non-class errors, cycles, truncation, and panic payload snapshots.

Typed union JSON serialization

Layer / File(s) Summary
Type-directed union serialization
baml_language/crates/bex_vm/src/package_baml/json.rs
Union serialization selects the first matching declared member and reports an error when none matches.
Typed union serialization tests
baml_language/crates/baml_tests/tests/json_typed_union.rs
Tests cover class, enum, media, equivalent class/union, and unsupported byte-array values.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ErrorContext
  participant render_value_structural
  participant render_to_string
  ErrorContext->>render_value_structural: render non-string thrown value
  render_value_structural->>render_to_string: render with diagnostic state
  render_to_string->>render_to_string: apply depth, node, and cycle limits
Loading
sequenceDiagram
  participant TypedSerializer
  participant TypeMatcher
  participant MemberSerializer
  TypedSerializer->>TypeMatcher: match value against declared union members
  TypeMatcher-->>TypedSerializer: return first matching member
  TypedSerializer->>MemberSerializer: serialize using selected member type
Loading

Possibly related PRs

Poem

I’m a rabbit, and errors now show what they hold,
With class names and fields, neatly told.
Cycles meet an ellipsis, deep paths do too,
While unions find the type that is true.
JSON hops safely from branch into branch—
A tidy trail through the codebase expanse.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: preserving structural VM values in diagnostics and typed JSON.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/upstream-vm-value-fidelity

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

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.

@vercel
vercel Bot temporarily deployed to Preview – beps July 23, 2026 01:05 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 July 23, 2026 01:14 Inactive
@github-actions

github-actions Bot commented Jul 23, 2026 •

Copy link
Copy Markdown

Binary size checks passed

✅ 7 passed

Artifact Platform File Gzip Gated on Baseline Delta Status
✅ baml-cli Linux 🔒 25.7 MB 10.9 MB file 25.3 MB +445.3 KB (+1.8%) OK
✅ packed-program Linux 🔒 17.2 MB 7.1 MB file 17.0 MB +189.4 KB (+1.1%) OK
✅ baml-cli macOS 🔒 19.9 MB 9.5 MB file 19.6 MB +347.6 KB (+1.8%) OK
✅ packed-program macOS 🔒 13.4 MB 6.2 MB file 13.2 MB +214.9 KB (+1.6%) OK
✅ baml-cli Windows 🔒 21.5 MB 9.7 MB file 21.1 MB +384.5 KB (+1.8%) OK
✅ packed-program Windows 🔒 14.3 MB 6.3 MB file 14.2 MB +177.1 KB (+1.3%) OK
✅ bridge_wasm WASM 16.2 MB 🔒 4.4 MB gzip 4.4 MB +22.3 KB (+0.5%) OK

🔒 = 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.


Generated by cargo size-gate · workflow run

@vercel
vercel Bot temporarily deployed to Preview – beps July 23, 2026 01:17 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 July 23, 2026 01:24 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle July 23, 2026 01:38 Inactive

@aaronvg aaronvg left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both fixes are real fidelity improvements and strictly better than the old behavior (null union members, <error> placeholders). Leaving a few comments on where the fixpoint is — none of these block the patch.

Behavior change worth documenting: the union arm is now fallible where it was total. value_to_serde never raised (unrepresentable → null); render_to_serde raises JsonSerializationError for uint8arrays without encoding, functions, futures, etc. So a union member value that previously serialized (badly, as null) can now throw from baml.json.to_string<T>(). That's arguably the right semantics, but it deserves a line in the PR description and a regression test (e.g. a union member with a uint8array field).

Test suggestion: an assertion that to_string<Record>(r) == to_string<Record | Phase>(r) for a class with a nested typed field would lock in the intended equivalence between the typed-class path and the union path (see inline comment on the union arm for why they can diverge).

// Empty override tables preserve typed `to_string`'s canonical
// behavior: custom `ToJson` implementations are not invoked.
let mut counter = 0;
render_to_serde(vm, value, &[], &[], &mut counter, path)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is the right patch, but I think the fixpoint is type-directed union serialization via membership testing, not a shape-directed structural walk:

RealizedTy::Union(members, _) =>
    // first member m where runtime value ∈ m (same ordered, decidable
    // check BEP-015 match uses — not speculative try-and-backtrack)
    match first_matching_member(vm, value, members) {
        Some(m) => ty_value_to_serde(vm, value, m, path),
        None => Err(raise_serialize(vm, "value is not a member of the union", path, "union")),
    }

Why this is more correct than routing through render_to_serde:

  1. Coherence by construction: to_string<Record>(r) == to_string<Record | Phase>(r) always, because both go through the same RealizedTy::Class arm. As written, the typed-class path (serialize_class_instance, declared-type-driven) and the union path (runtime-shape-driven) can diverge for the same value wherever the class path applies type-directed treatment to fields.
  2. Type-directed behavior composes: media tagging, uint8array encoding rules, alias handling, and any future per-field attributes automatically apply inside unions; the structural walker has to re-implement or silently miss each of these forever.
  3. Honest errors: a value outside every member is a type-contract violation — raising beats serializing it as something.

The existing comment's objection ("dispatch on runtime shape rather than trying each member") conflates speculative try-and-backtrack (bad: error-swallowing, non-deterministic) with membership testing, which is the same ordered, decidable check match lowering already uses. Overlapping members resolve first-match, exactly like match arms — a semantics users already hold. TYPE_SYSTEM.md's subset-based unions give you this primitive for free.

The json alias correctly stays on value_to_serde — it's defined as untyped. Fine as a follow-up; this patch is strictly better than what it replaces.

return format!("{} {{{}}}", class.name, fields.join(", "));
}
"<error>".to_string()
super::root::render_value_structural(vm, value, false)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

For this diagnostic path the governing principle should be totality over fidelity: it must never throw, never dispatch user code, and never diverge. The no-user-dispatch invariant is handled (empty override tables — and the test asserting the override is not invoked is exactly right), but render_to_string has no depth/cycle guard, and class fields are mutable (AtomicValueSlot), so a self-referential thrown value would stack-overflow while rendering an error context. The old class-name-only rendering was total by construction.

Suggested follow-up: a depth/node budget on render_value_structural that degrades to … truncation. (This weakness pre-exists in user-facing to_string, but ErrorContext is exactly the path that should be crash-proof.)

Two cosmetic notes while here: the top level renders as user.AppError {message: ...} (qualified, unpadded braces) while nested instances render as Detail { label: ... } (short name, padded braces) — both spellings now appear in one output line and are asserted verbatim in the tests. A qualified: bool knob on the walker would remove both the duplication of the instance-rendering loop and the spacing mismatch.

@vercel
vercel Bot temporarily deployed to Preview – beps July 23, 2026 02:51 Inactive

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
baml_language/crates/bex_vm/src/package_baml/root.rs (1)

622-650: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Make diagnostic collection stop at the budget.

The node guard only makes each over-budget child return …; these loops still visit every remaining element and append one ellipsis per item. Also, depth rejection occurs before decrementing the node budget, so a wide container at the depth boundary remains unbounded. DisplaySnap::Seq/Entries already clone the full container before this loop, too.

A thrown huge array/map can therefore still allocate and emit O(n) diagnostic output. Limit snapshots and sibling iteration to the remaining budget, then append one truncation marker. Extend the wide-value test to assert a bounded result length/count.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@baml_language/crates/bex_vm/src/package_baml/root.rs` around lines 622 - 650,
Bound diagnostic traversal in render_to_string and the DisplaySnap::Seq,
DisplaySnap::Entries, and Instance branches by consuming only the remaining node
budget, then append a single truncation marker when additional siblings remain.
Ensure the node budget is decremented before depth-based rejection so wide
containers at the depth boundary are also bounded. Avoid materializing full
snapshots where possible, and extend the wide-value test to verify bounded
output length/count.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@baml_language/crates/baml_tests/tests/exceptions.rs`:
- Line 243: Update the expected DivisionByZero snapshot in the exceptions test
to include spaces inside the braces, matching render_to_string’s structural
instance formatting.

---

Outside diff comments:
In `@baml_language/crates/bex_vm/src/package_baml/root.rs`:
- Around line 622-650: Bound diagnostic traversal in render_to_string and the
DisplaySnap::Seq, DisplaySnap::Entries, and Instance branches by consuming only
the remaining node budget, then append a single truncation marker when
additional siblings remain. Ensure the node budget is decremented before
depth-based rejection so wide containers at the depth boundary are also bounded.
Avoid materializing full snapshots where possible, and extend the wide-value
test to verify bounded output length/count.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d2e0c29f-71b5-46bf-b4c4-6965ad9b9e11

📥 Commits

Reviewing files that changed from the base of the PR and between 5b9d45e and 29e0a2d.

📒 Files selected for processing (6)
  • baml_language/crates/baml_tests/tests/errorcontext.rs
  • baml_language/crates/baml_tests/tests/exceptions.rs
  • baml_language/crates/baml_tests/tests/json_typed_union.rs
  • baml_language/crates/bex_vm/src/package_baml/error_context.rs
  • baml_language/crates/bex_vm/src/package_baml/json.rs
  • baml_language/crates/bex_vm/src/package_baml/root.rs

@vercel
vercel Bot temporarily deployed to Preview – beps July 23, 2026 02:58 Inactive
@vercel
vercel Bot temporarily deployed to Preview – beps July 23, 2026 03:01 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 July 23, 2026 03:09 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle July 23, 2026 03:22 Inactive
@aaronvg
aaronvg added this pull request to the merge queue Jul 23, 2026
Merged via the queue into canary with commit 50ec92f Jul 23, 2026
69 checks passed
@aaronvg
aaronvg deleted the codex/upstream-vm-value-fidelity branch July 23, 2026 03:59

This branch was previously deployed

3 inactive deployments
Preview – promptfiddle — 94b78bd8 Deployed Jul 23, 2026 by vercel[bot]
Preview – promptfiddle2 — 94b78bd8 Deployed Jul 23, 2026 by vercel[bot]
Preview – beps — 94b78bd8 Deployed Jul 23, 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