bridge-cpp: order-canonical baml::Union, baml::match, typed error unions - #4071
Conversation
BAML unions are sets (string | int == int | string), but variant<A, B> and variant<B, A> are distinct C++ types. baml::Union<Ts...> closes the gap: a std::variant ALIAS whose alternatives are sorted at compile time by a constexpr per-type name, so every spelling of one alternative set resolves to the same instantiation. It stays a plain std::variant (std::get/visit/holds_alternative all work); baml::match(u, arms...) is the reading companion (type-dispatched, exhaustiveness-checked by std::visit, const auto& as the explicit catch-all). - emitter re-enables multi-member unions as ::baml::Union<...>; T | null stays std::optional<T> and A | B | null stays optional<Union<A, B>> (null is never an alternative) - Codec<std::variant<Ts...>>: encode visits the active alternative (no union wrapper inbound); decode is order-independent by construction - strict arm matching first (int never satisfies a double alternative), engine int->float coercion admitted in a second pass only when no int arm matched; enum decode now FQN-checks like classes so two enums in one union dispatch precisely - typed error unions: generated bindings pass the declared throws set to CallSync as a Union; the error arm decodes into it and throws BamlThrown<Union<...>> (derives BamlError, so untyped catch sites and is<T>/get<T> keep working; undeclared throws fall back untyped). BamlThrown<Union<A, B>> and BamlThrown<Union<B, A>> are the same catchable type - un-skipped by this: RecList/RecListWithOther recursive aliases, baml.json.json (ParseJson), ComplexProfile/Invoice, unions fixture namespace, baml.panics.Panic alias - tests: full port of test_unions.py + test_complex_models.py + the RecList cases of test_aliases.py + the union subset of test_errors.py (typed catch + match + class_name parity + untyped compatibility), plus C++-only canonicalization static_asserts and std::variant-interop cases; type_shapes 77 cases, function_calls 11, suite 10/10
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
⏭️ Performance benchmarks were skippedPerf benchmarks (CodSpeed) are opt-in on pull requests — they no longer run on every push. They always run automatically after merge to To run them on this PR, do any of the following, then push a commit (or re-run CI):
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthroughThe PR adds canonical C++ union types, variant serialization, typed declared-error propagation, generator support for union and throws signatures, stricter enum decoding, and tests covering unions, aliases, complex models, and typed errors. ChangesC++ union and typed-error support
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant GeneratedFunction
participant CallSync
participant DecodeResult
participant VariantCodec
participant BamlThrown
GeneratedFunction->>CallSync: call with return and declared throws types
CallSync->>DecodeResult: decode response envelope
DecodeResult->>VariantCodec: decode declared thrown union payload
VariantCodec-->>DecodeResult: typed union value
DecodeResult->>BamlThrown: throw typed error with metadata and payload
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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/sdks/cpp/bridge_cpp/include/baml/union.h`:
- Around line 46-76: Update the canonicalization flow centered on
detail::CanonSort and Union to remove duplicate alternative types before forming
the std::variant. Ensure repeated types such as Union<int64_t, int64_t> produce
the same variant type as Union<int64_t>, while preserving the existing
name-based canonical ordering for distinct alternatives and avoiding ambiguous
type-based construction or access.
🪄 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
Run ID: f3eb8616-b1c2-4a19-8234-b8957be62f9e
📒 Files selected for processing (10)
baml_language/sdk_tests/crates/cpp/function_calls/customizable/tests/test_errors.ccbaml_language/sdk_tests/crates/cpp/type_shapes/customizable/tests/test_aliases.ccbaml_language/sdk_tests/crates/cpp/type_shapes/customizable/tests/test_complex_models.ccbaml_language/sdk_tests/crates/cpp/type_shapes/customizable/tests/test_unions.ccbaml_language/sdks/cpp/bridge_cpp/include/baml/baml.hbaml_language/sdks/cpp/bridge_cpp/include/baml/codec.hbaml_language/sdks/cpp/bridge_cpp/include/baml/detail/call.hbaml_language/sdks/cpp/bridge_cpp/include/baml/errors.hbaml_language/sdks/cpp/bridge_cpp/include/baml/union.hbaml_language/sdks/cpp/sdkgen_cpp/src/lib.rs
Binary size checks failed❌ 4 violations · ✅ 3 passed
Details & how to fixViolations:
Add/update baselines:
[artifacts.baml-cli]
file_bytes = 19528560
stripped_bytes = 19528608
gzip_bytes = 9324057
[artifacts.bridge_wasm]
file_bytes = 16160508
gzip_bytes = 4402886
[artifacts.baml-cli]
file_bytes = 21048320
stripped_bytes = 21048320
gzip_bytes = 9534145
[artifacts.baml-cli]
file_bytes = 25244432
stripped_bytes = 25244424
gzip_bytes = 10720500Generated by |
A completeness-critic pass over the whole python suite caught union coverage living OUTSIDE the dedicated union files that the union-disable had removed and the re-land did not restore: - test_lists.cc: round_trip_union_list ((int|string)[]) and round_trip_list_container (union_list field) - test_optional.cc: round_trip_optional_union ((int|string)?) and round_trip_optional_container (optional_union field) - test_forward_refs.cc: round_trip_rec_list (forward_refs' union-bodied recursive alias) and round_trip_rec_list_with_other (the only union-bodied recursive alias with a class alternative) - test_raises.cc (new): the free-function subset of test_raises.py -- union throws list every member unqualified, single/inferred contracts, summary-precedes-Raises, and no Raises line on non-throwing functions - unions_static.cc (new): compile-time contract pins -- canonicalization under optional/vector nesting, Union-is-a-variant, and negative is_invocable probes that a match missing an arm cannot compile while a const auto& catch-all restores invocability type_shapes 83 cases, function_calls 16; suite 10/10.
baml::Union is regular Google style (type aliases are PascalCase, and lowercase union is a keyword regardless); match is the deliberate std-mimicking deviation and belongs in carve-out 2.
Full lowercase stdlib mirroring is impossible (union is a C++ keyword, so baml::union cannot exist); with Union necessarily PascalCase, Match follows regular Google function naming instead of carrying a carve-out.
…ing) The union surface now mirrors std vocabulary end to end: baml::variant<Ts...> (order-canonical std::variant alias) read with baml::match. It would be baml::union, but union is a C++ keyword, so variant keeps the honest name for what it is. union.h -> variant.h; STYLE.md carve-out 2 covers both spellings.
baml::Union (type alias), baml::Match (function), baml::Unset/kUnset (sentinel tag + constant, replacing unset_t/unset). Lowercase stdlib mirroring was ruled out because baml::union cannot exist (keyword); with Union necessarily PascalCase, the rest follows the guide instead of carrying carve-outs. variant.h returns to union.h.
Arg::IsSet/IsUnset/Value, Box/OptionalBox::HasValue, BamlError::Is<T>/Get<T>. What stays snake_case is what the Google guide itself prescribes: stored-state accessors (message, class_name, ...), set_<param> mutators on generated opts structs, type traits, and extern-C symbols. std::optional call sites are untouched (std API).
Structure is Google-cased, vocabulary is std-cased -- exactly Abseil's practice (absl::optional::has_value, absl::visit, absl::nullopt beside absl::StatusOr). Types stay PascalCase (Union, Box, Arg, Unset), but optional/variant-shaped API keeps std spelling so it reads uniformly next to std types and works in duck-typed generic code: - baml::Match -> baml::match (mirrors std::visit) - baml::kUnset -> baml::unset (mirrors std::nullopt) - Arg::IsSet/IsUnset/Value -> is_set/is_unset/value - Box/OptionalBox::HasValue -> has_value (uniform with std::optional at adjacent call sites) - BamlError::Is<T>/Get<T> -> is<T>/get<T> (mirror std::holds_alternative/std::get) The rule is written down in STYLE.md; naming is now frozen.
BAML unions are sets, so Union<A, A> must be the same type as Union<A>; previously it produced std::variant<A, A>, a distinct type with ambiguous type-based access. The canonical index computation now drops adjacent duplicate names after the sort.
…cellation, co_await (BoundaryML#4078) Bridge-week step 10 for C++ (async half; error/panic behavior landed in BoundaryML#4071). ## What Every generated function gains an **Async sibling** returning `baml::Future<T, ThrownU>`: ```cpp auto fut = b::ExtractResumeAsync(text); // returns immediately fut.Cancel(); // optional engine-side cancellation Resume r = fut.get(); // blocks + decodes; typed throws as BamlThrown<Union<...>> ``` - `get()`/`wait()`/`wait_for()`/`wait_until()`/`valid()` mirror `std::future` (STYLE.md carve-out 2); `Cancel()` and the sibling's `Async` suffix are our vocabulary (Pascal). The suffix follows the opts-struct convention: verbatim BAML spelling + suffix (`probe` -> `probeAsync`), allocated through the naming pool. - **Cancellation**: `Cancel()` calls the v1 ABI's `cancel_function_call`; the envelope still arrives as a `baml.panics.Cancelled` panic and `get()` throws `BamlCancelled`. Destruction detaches (never blocks, never cancels) - Python task-model parity. No Rust changes. - **co_await (C++20 only)**: the registry's per-call cell is now a custom `CallState` (mutex + condvar + continuation slot) instead of `std::promise`, so the dispatcher thread can resume a suspended coroutine when the envelope lands. The awaiter is feature-gated (`__cpp_impl_coroutine` + `__cpp_lib_coroutine`); the header stays C++17-clean. - **One call path**: `CallSync` is now literally `StartCall().get()`. ## Tests - `test_cancellation.cc`: port of python's `test_cancellation.py` (portable core: null-return baseline + engine-side cancel; asyncio/BamlCallContext idioms documented as deviations) + C++-specific future semantics (consume-once, `wait_for` timeout, detach-on-destroy). - Async cases added to `test_optional_args.cc` (python parity), `test_raises.cc` (async sibling repeats the doc block), `test_errors.cc` (typed throw through `get()`). - `futures_static.cc`: move-only + ThrownU order-canonicality static asserts. - `tests/cxx20/test_coawait.cc`: 6 live co_await cases (pending resume, value, fast path, typed throw into the coroutine, cancellation, escape-to-join) driven by a minimal completion-latch Task. The harness builds `tests/cxx20/*.cc` as a second executable only when the toolchain has C++20, so the main binary keeps proving the SDK compiles as plain C++17. All 12 `sdk_test_cpp` fixture tests pass locally (26 C++17 + 6 C++20 cases in function_calls). Note: the size gate is expected to fail until the stale baselines are refreshed - canary has drifted ~600 KB since BoundaryML#4057 and every open PR is at the 3% ceiling (see BoundaryML#4071's report). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added dual async C++ SDK bindings (`*Async`) alongside sync calls. * Introduced `baml::Future` for in-flight calls, including cancellation and optional C++20 `co_await` support. * Added typed singleton literal support via `BAML_LIT(...)` for more precise literal and union typing. * **Bug Fixes** * Improved literal encoding/decoding and union arm selection to preserve exact typed literal values. * Enhanced async sibling error propagation and cancellation behavior. * **Tests** * Expanded C++ coverage for futures (including C++20), cancellation, typed throws, optional args, and literal/type-shape checks. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
BAML unions are sets (
string | int==int | string), butstd::variant<A, B>andstd::variant<B, A>are distinct C++ types. This closes the gap and re-lands unions in the C++ bridge (bridge-week step 7, parked at the #4004 slim pending representation work).baml::Union
A
std::variantalias (not a wrapper class) whose alternatives are sorted at compile time by a constexpr per-type name, so every spelling of one alternative set resolves to the same instantiation:Because it IS a
std::variant,std::get,std::holds_alternative, andstd::visitall work unchanged.baml::Match(u, arms...)is the reading companion: one callable per alternative, dispatched by type, exhaustiveness enforced bystd::visitat compile time,const auto&as the explicit catch-all.Nullability stays std vocabulary:
T | nullisstd::optional<T>,A | B | nullisstd::optional<Union<A, B>>; null is never an alternative.Codec
Encode visits the active alternative (no union wrapper inbound; engine/Python parity). Decode receives the union-unwrapped value and picks an alternative from the wire arm alone — canonical ordering means selection must be (and is) order-independent: strict arm matching first (an int wire value never satisfies a
doublealternative), the engine's int→float coercion admitted in a second pass only when no int arm matched. Enum decode now FQN-checks like classes so two enums in one union dispatch precisely.Typed error unions
Generated bindings pass the declared
throwsset toCallSyncas aUnion; the error arm decodes into it and throwsBamlThrown<Union<...>>carrying the typed payload:BamlThrownderivesBamlError, so untyped catch sites andis<T>()/get<T>()keep working; a thrown value outside the declared set falls back to plainBamlError.Un-skipped by this
RecList/RecListWithOther recursive aliases,
baml.json.json(ParseJson), ComplexProfile/Invoice, the unions fixture namespace, and thebaml.panics.Panicalias all emit again.Tests
Full ports of
test_unions.py,test_complex_models.py, the RecList cases oftest_aliases.py, and the union subset oftest_errors.py(typed catch + match + class_name parity for single-vs-multi-member throws + untyped compatibility), plus C++-only canonicalizationstatic_asserts andstd::variant-interop cases. type_shapes: 77 cases, function_calls: 11; fixture suite 10/10; bridge smoke green.Summary by CodeRabbit
baml::Unionplus type-basedbaml::match.Unset).