From 21092751970afbb9a69e8d3cf31c020d7a56dd8e Mon Sep 17 00:00:00 2001 From: Ryan Mazzolini Date: Mon, 2 Nov 2026 14:59:50 +0000 Subject: [PATCH 1/7] feat(ruby): generate a Sorbet-typed Ruby SDK that calls BAML through the C bridge Ruby had a V1 bridge loader but no way to generate an SDK or make calls on ABI revision 5. This adds the foundation every later Ruby change builds on. `baml-cli generate` gains a `ruby/sorbet` output type. The generator emits the whole symbol pool, standard library included, as T::Struct classes, T::Enum enums and Sorbet-signed functions and methods, each with an `_async` sibling. Values cover null, booleans, integers, floats, strings, single literals, lists, string-keyed maps, optionals, classes and enums; declarations that need anything else are skipped until later changes. Names that collide in Ruby are escaped with a trailing underscore, in Python's allocation order. Bundled provider packages are written under baml_sdk/__vendor/, because Tapioca's default Sorbet config ignores every vendor/ directory. The package registers its bytecode, embedded metadata, types and parameter descriptors once when it loads. The runtime returns a Baml::Call promise for every call; sync methods wait on it. Results own their engine handles until decoded, abandoned calls are released, and callback registration is safe against interrupts. One program runs per process, a forked child using an inherited runtime raises, unhandled spawn errors end the process as in Python, and the runtime shuts down at exit. The runtime's Sorbet contract is rbi/baml.rbi. Under ABI 5 the engine converts nothing: it builds the value the SDK states and checks it against the declared type. So the runtime encodes each argument against its declared type in three steps. It reads the Ruby value, plans the wire value without side effects, choosing union arms and making exact conversions such as 5 to 5.0 for a float, then commits it, stating the type of every list, map and class. Depth, node and planning-step budgets refuse cyclic or pathological values with TypeError, and every engine reference a refused or abandoned result carries is released. Also adds the committed Ruby protobuf clients and their CI drift check, Ruby support in the SDK parity lint, and the shared harness gates (minitest and Sorbet per fixture). Tests: adds encoder tests for the read, plan and commit rules; 108 nextest targets pass and Ruby declares 27 shared SDK tests. Developed with AI assistance. --- .github/workflows/ci.yaml | 11 +- baml_language/Cargo.lock | 11 + baml_language/Cargo.toml | 2 + baml_language/crates/baml_cli/Cargo.toml | 1 + baml_language/crates/baml_cli/src/generate.rs | 16 +- .../baml_sdkgen_types/src/generator_fields.rs | 9 +- baml_language/crates/bridge_ctypes/README.md | 6 +- baml_language/crates/bridge_ctypes/build.rs | 20 + .../crates/bridge_ctypes/build_support.rs | 25 + baml_language/crates/bridge_ctypes/src/lib.rs | 4 + baml_language/sdk_tests/codegen/Cargo.toml | 1 + baml_language/sdk_tests/codegen/src/lib.rs | 1 + baml_language/sdk_tests/codegen/src/main.rs | 1 + .../sdk_tests/codegen/src/ruby_sorbet.rs | 29 + .../sdk_tests/crates/parity_analysis.md | 1974 +++++++++-------- .../sdk_tests/crates/ruby_sorbet/Gemfile.lock | 48 + .../function_calls/customizable/test_main.rb | 19 + .../customizable/test_methods_on_classes.rb | 64 + .../customizable/test_stdlib_entrypoints.rb | 33 + .../customizable/typed_consumer.rb | 19 + .../customizable/replay_harness.rb | 76 + .../llm_functions/customizable/test_main.rb | 39 + .../customizable/test_streaming_e2e.rb | 29 + .../customizable/typed_consumer.rb | 15 + .../sdk_tests/crates/ruby_sorbet/setup.ps1 | 16 +- .../sdk_tests/crates/ruby_sorbet/setup.sh | 32 +- .../sdk_tests/crates/ruby_sorbet/src/lib.rs | 54 +- .../ruby_sorbet/test/bridge_call_test.rb | 359 +++ .../ruby_sorbet/test/bridge_encode_test.rb | 411 ++++ .../ruby_sorbet/test/bridge_loader_test.rb | 110 +- .../ruby_sorbet/test/bridge_scenario.rb | 500 +---- .../ruby_sorbet/test/native/bridge_fixture.c | 341 +-- .../ruby_sorbet/test/native/missing_getter.c | 9 - .../crates/ruby_sorbet/test/static/Gemfile | 4 + .../ruby_sorbet/test/static/Gemfile.lock | 27 + .../ruby_sorbet/test/static/minitest.rbi | 6 + .../ruby_sorbet/test/static/wrong_argument.rb | 4 + .../type_shapes/customizable/test_literals.rb | 41 + .../customizable/test_primitives.rb | 11 + .../type_shapes/customizable/test_void.rb | 11 + .../ns_methods_on_classes/ruby_hooks.baml | 15 + .../sdk_tests/harness_runner/src/lib.rs | 1 + .../harness_runner/src/ruby_sorbet.rs | 117 + .../ref-ruby-state-of-completeness.md | 116 + baml_language/sdks/ruby/bridge_ruby/Gemfile | 2 + baml_language/sdks/ruby/bridge_ruby/README.md | 42 +- .../sdks/ruby/bridge_ruby/lib/baml/bridge.rb | 28 +- .../ruby/bridge_ruby/lib/baml/bridge/call.rb | 82 + .../lib/baml/bridge/encode/commit.rb | 58 + .../lib/baml/bridge/encode/plan.rb | 258 +++ .../lib/baml/bridge/encode/read.rb | 64 + .../lib/baml/bridge/encode/rules.rb | 38 + .../lib/baml/bridge/encode/slot.rb | 148 ++ .../bridge_ruby/lib/baml/bridge/errors.rb | 27 +- .../bridge_ruby/lib/baml/bridge/handle.rb | 82 + .../bridge_ruby/lib/baml/bridge/native.rb | 176 +- .../lib/baml/bridge/process_runtime.rb | 263 ++- .../bridge_ruby/lib/baml/bridge/protocol.rb | 270 +++ .../bridge_ruby/lib/baml/value_equality.rb | 20 + .../lib/baml_bridge/cffi/v1/baml_handle_pb.rb | 20 + .../baml_bridge/cffi/v1/baml_inbound_pb.rb | 38 + .../baml_bridge/cffi/v1/baml_outbound_pb.rb | 45 + .../lib/baml_bridge/cffi/v1/baml_type_pb.rb | 46 + .../sdks/ruby/bridge_ruby/rbi/baml.rbi | 133 ++ .../sdks/ruby/sdkgen_ruby_sorbet/Cargo.toml | 22 + .../sdkgen_ruby_sorbet/src/descriptors.rs | 88 + .../sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs | 218 ++ .../sdks/ruby/sdkgen_ruby_sorbet/src/lib.rs | 135 ++ .../sdks/ruby/sdkgen_ruby_sorbet/src/names.rs | 609 +++++ .../ruby/sdkgen_ruby_sorbet/src/package.rs | 107 + .../sdkgen_ruby_sorbet/src/review_tests.rs | 178 ++ .../sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs | 443 ++++ .../sdks/ruby/sdkgen_ruby_sorbet/src/types.rs | 40 + tools/sdk-parity-lint/README.md | 2 +- tools/sdk-parity-lint/ast-grep-rules/ruby.yml | 23 + .../sdk-parity-lint/baml_src/annotations.baml | 9 + tools/sdk-parity-lint/baml_src/ast_grep.baml | 6 +- tools/sdk-parity-lint/baml_src/config.baml | 12 + tools/sdk-parity-lint/baml_src/normalize.baml | 1 + tools/sdk-parity-lint/baml_src/tests.baml | 82 +- .../fixture/customizable/test_layout.rb | 6 + .../fixture/customizable/test_unsupported.rb | 11 + .../ruby/fixture/customizable/test_sample.rb | 14 + 83 files changed, 6476 insertions(+), 2008 deletions(-) create mode 100644 baml_language/crates/bridge_ctypes/build_support.rs create mode 100644 baml_language/sdk_tests/codegen/src/ruby_sorbet.rs create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_main.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_methods_on_classes.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_stdlib_entrypoints.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/typed_consumer.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/replay_harness.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_main.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_streaming_e2e.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/typed_consumer.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_call_test.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_encode_test.rb delete mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/test/native/missing_getter.c create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/test/static/Gemfile create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/test/static/Gemfile.lock create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/test/static/minitest.rbi create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/test/static/wrong_argument.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_literals.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_primitives.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_void.rb create mode 100644 baml_language/sdk_tests/fixtures/function_calls/baml_src/ns_methods_on_classes/ruby_hooks.baml create mode 100644 baml_language/sdk_tests/harness_runner/src/ruby_sorbet.rs create mode 100644 baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md create mode 100644 baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/call.rb create mode 100644 baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/commit.rb create mode 100644 baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/plan.rb create mode 100644 baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/read.rb create mode 100644 baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/rules.rb create mode 100644 baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/slot.rb create mode 100644 baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/handle.rb create mode 100644 baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/protocol.rb create mode 100644 baml_language/sdks/ruby/bridge_ruby/lib/baml/value_equality.rb create mode 100644 baml_language/sdks/ruby/bridge_ruby/lib/baml_bridge/cffi/v1/baml_handle_pb.rb create mode 100644 baml_language/sdks/ruby/bridge_ruby/lib/baml_bridge/cffi/v1/baml_inbound_pb.rb create mode 100644 baml_language/sdks/ruby/bridge_ruby/lib/baml_bridge/cffi/v1/baml_outbound_pb.rb create mode 100644 baml_language/sdks/ruby/bridge_ruby/lib/baml_bridge/cffi/v1/baml_type_pb.rb create mode 100644 baml_language/sdks/ruby/bridge_ruby/rbi/baml.rbi create mode 100644 baml_language/sdks/ruby/sdkgen_ruby_sorbet/Cargo.toml create mode 100644 baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/descriptors.rs create mode 100644 baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs create mode 100644 baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/lib.rs create mode 100644 baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/names.rs create mode 100644 baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/package.rs create mode 100644 baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/review_tests.rs create mode 100644 baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs create mode 100644 baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/types.rs create mode 100644 tools/sdk-parity-lint/ast-grep-rules/ruby.yml create mode 100644 tools/sdk-parity-lint/test-fixtures/ruby-adjacency/fixture/customizable/test_layout.rb create mode 100644 tools/sdk-parity-lint/test-fixtures/ruby-audit/fixture/customizable/test_unsupported.rb create mode 100644 tools/sdk-parity-lint/test-fixtures/ruby/fixture/customizable/test_sample.rb diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1d9921348c3..f4a9dfab9df 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -346,7 +346,9 @@ jobs: run: | if git diff --quiet "${MERGE_BASE}...HEAD" -- \ ':baml_language/crates/bridge_ctypes/**/*.proto' \ + ':baml_language/crates/bridge_ctypes/build.rs' \ ':baml_language/crates/bridge_cffi/include/**' \ + ':baml_language/sdks/ruby/bridge_ruby/lib/baml_bridge/cffi/v1/**' \ ':baml_language/sdks/swift/Sources/BamlBridge/Proto/**' \ ':baml_language/sdks/swift/Sources/CBamlBridge/include/**' \ ; then @@ -865,7 +867,7 @@ jobs: save-if: false shared-key: "linux-cargo" - # bridge_ctypes/build.rs needs `protoc`; baml_go's generate.sh needs + # bridge_ctypes/build.rs uses vendored protoc; baml_go's generate.sh needs # `protoc` + `protoc-gen-go`; bridge_typescript needs node + pnpm. # The `cargo build -p bridge_ctypes` step uses cargo from rustup # (not mise), so we don't need `go` itself here either. @@ -879,9 +881,9 @@ jobs: run: cargo fetch working-directory: baml_language - # Rust (prost) + Python (protoc) — both driven by bridge_ctypes/build.rs. - # Writes Python pb2/pyi into baml_language/sdks/python/src/baml_bridge/cffi/v1/. - - name: "Generate Rust + Python proto bindings (bridge_ctypes)" + # Rust (prost), Python, and Ruby (protoc) — all driven by bridge_ctypes/build.rs. + # Writes Python pb2/pyi and committed Ruby clients into their SDK trees. + - name: "Generate Rust + Python + Ruby proto bindings (bridge_ctypes)" run: cargo build -p bridge_ctypes working-directory: baml_language @@ -934,6 +936,7 @@ jobs: baml_language/crates/bridge_ctypes baml_language/sdks/go/baml_go/internal/cffi baml_language/sdks/python/src/baml_bridge/cffi + baml_language/sdks/ruby/bridge_ruby/lib/baml_bridge/cffi/v1 baml_language/sdks/rust/bridge_rust/src/wire baml_language/sdks/swift/Sources/BamlBridge/Proto baml_language/sdks/swift/Sources/CBamlBridge/include diff --git a/baml_language/Cargo.lock b/baml_language/Cargo.lock index 9fcfb72c116..61c8efdc37f 100644 --- a/baml_language/Cargo.lock +++ b/baml_language/Cargo.lock @@ -495,6 +495,7 @@ dependencies = [ "sdkgen_go", "sdkgen_java", "sdkgen_python_pydantic2", + "sdkgen_ruby_sorbet", "sdkgen_rust", "sdkgen_swift", "sdkgen_typescript_shared", @@ -5877,6 +5878,7 @@ dependencies = [ "sdkgen_go", "sdkgen_java", "sdkgen_python_pydantic2", + "sdkgen_ruby_sorbet", "sdkgen_rust", "sdkgen_swift", "sdkgen_typescript_shared", @@ -6027,6 +6029,15 @@ dependencies = [ "pretty_assertions", ] +[[package]] +name = "sdkgen_ruby_sorbet" +version = "0.0.0-beta" +dependencies = [ + "baml_base", + "baml_sdkgen_types", + "baml_type", +] + [[package]] name = "sdkgen_rust" version = "0.0.0-beta" diff --git a/baml_language/Cargo.toml b/baml_language/Cargo.toml index a78a69b0c0a..15c5df7cef2 100644 --- a/baml_language/Cargo.toml +++ b/baml_language/Cargo.toml @@ -5,6 +5,7 @@ members = [ "sdks/cpp/sdkgen_cpp", "sdks/csharp/sdkgen_csharp", "sdks/go/sdkgen_go", + "sdks/ruby/sdkgen_ruby_sorbet", "sdks/java/bridge_java", "sdks/java/sdkgen_java", "sdks/typescript/bridge_typescript", @@ -91,6 +92,7 @@ sdkgen_java = { path = "sdks/java/sdkgen_java" } bridge_java = { path = "sdks/java/bridge_java" } sdkgen_swift = { path = "sdks/swift/rust/sdkgen_swift" } sdkgen_go = { path = "sdks/go/sdkgen_go" } +sdkgen_ruby_sorbet = { path = "sdks/ruby/sdkgen_ruby_sorbet" } sdkgen_rust = { path = "sdks/rust/sdkgen_rust" } baml_sdkgen_types = { path = "crates/baml_sdkgen_types" } bridge_encode = { path = "crates/bridge_encode" } diff --git a/baml_language/crates/baml_cli/Cargo.toml b/baml_language/crates/baml_cli/Cargo.toml index ae857b9a825..1fdd5cb4d34 100644 --- a/baml_language/crates/baml_cli/Cargo.toml +++ b/baml_language/crates/baml_cli/Cargo.toml @@ -44,6 +44,7 @@ sdkgen_csharp = { workspace = true } sdkgen_go = { workspace = true } sdkgen_java = { workspace = true } sdkgen_python_pydantic2 = { workspace = true } +sdkgen_ruby_sorbet = { workspace = true } sdkgen_rust = { workspace = true } sdkgen_swift = { workspace = true } sdkgen_typescript_shared = { workspace = true } diff --git a/baml_language/crates/baml_cli/src/generate.rs b/baml_language/crates/baml_cli/src/generate.rs index 10ad2b3cdf7..769480adf0f 100644 --- a/baml_language/crates/baml_cli/src/generate.rs +++ b/baml_language/crates/baml_cli/src/generate.rs @@ -604,6 +604,20 @@ impl GenerateArgs { .map(|(path, content)| (path, content.into_bytes())) .collect() } + OutputType::RubySorbet => { + let (files, warnings) = + sdkgen_ruby_sorbet::to_source_code_with_bytecode_and_warnings( + &pool, + &baml_bytecode, + generator.naming_convention, + Some(&embedded_baml_toml), + ); + report_skipped(&reporter, "Ruby", &warnings, self.show_all_skips); + files + .into_iter() + .map(|(path, content)| (path, content.into_bytes())) + .collect() + } OutputType::Java => sdkgen_java::to_source_code_with_bytecode_and_metadata( &pool, &baml_bytecode, @@ -769,7 +783,7 @@ fn discover_generators(root: &Path) -> (Vec, Vec) { name, "output_type", generator.output_type.as_ref(), - r#"one of: "python/pydantic", "python/pydantic/v1", "typescript/node", "typescript/web", "swift", "go", "rust", "java", "cpp", "csharp""#, + r#"one of: "python/pydantic", "python/pydantic/v1", "typescript/node", "typescript/web", "swift", "go", "rust", "java", "ruby/sorbet", "cpp", "csharp""#, table_range, &mut diags, ); diff --git a/baml_language/crates/baml_sdkgen_types/src/generator_fields.rs b/baml_language/crates/baml_sdkgen_types/src/generator_fields.rs index 13fcd366906..7880819e2c7 100644 --- a/baml_language/crates/baml_sdkgen_types/src/generator_fields.rs +++ b/baml_language/crates/baml_sdkgen_types/src/generator_fields.rs @@ -32,6 +32,9 @@ pub enum OutputType { /// Java SDK (`com.boundaryml:baml-bridge` runtime). #[strum(serialize = "java")] Java, + /// Ruby SDK with Sorbet types (`baml-bridge` gem runtime). + #[strum(serialize = "ruby/sorbet")] + RubySorbet, /// C++17 SDK (self-contained source tree; dlopens the shared runtime). #[strum(serialize = "cpp")] Cpp, @@ -57,6 +60,7 @@ impl OutputType { Self::Go => "go", Self::Rust => "rust", Self::Java => "java", + Self::RubySorbet => "ruby/sorbet", Self::Cpp => "cpp", Self::CSharp => "csharp", } @@ -70,7 +74,9 @@ impl OutputType { /// The naming conventions this generator implements. pub const fn naming_support(self) -> NamingSupport { match self { - Self::Go | Self::CSharp => NamingSupport::Only(NamingConvention::Language), + Self::Go | Self::CSharp | Self::RubySorbet => { + NamingSupport::Only(NamingConvention::Language) + } Self::Rust | Self::PythonPydantic | Self::PythonPydanticV1 => NamingSupport::Either { default: NamingConvention::Language, }, @@ -167,6 +173,7 @@ mod tests { for output_type in [ OutputType::Go, OutputType::CSharp, + OutputType::RubySorbet, OutputType::Rust, OutputType::PythonPydantic, OutputType::PythonPydanticV1, diff --git a/baml_language/crates/bridge_ctypes/README.md b/baml_language/crates/bridge_ctypes/README.md index 47875e842a8..0fcdaea1959 100644 --- a/baml_language/crates/bridge_ctypes/README.md +++ b/baml_language/crates/bridge_ctypes/README.md @@ -9,6 +9,10 @@ cargo build -p bridge_ctypes # -> sdks/rust/bridge_rust/src/wire/baml_bridge.cffi.v1.rs (committed) # -> sdks/python/src/baml_bridge/cffi/v1/*_pb2.py(i) +# Ruby (protoc) — also driven by bridge_ctypes/build.rs with vendored protoc. +# The generated clients are COMMITTED so consumers do not need protoc. +# -> sdks/ruby/bridge_ruby/lib/baml_bridge/cffi/v1/*_pb.rb + # Node / TypeScript (protobufjs + napi loader) cd sdks/typescript/bridge_typescript && pnpm build:debug # -> sdks/typescript/bridge_typescript/typescript_src/proto/baml_cffi.{js,d.ts} @@ -42,4 +46,4 @@ sdks/swift/scripts/generate-protos.sh Other consumers (`bridge_cffi`, `bridge_wasm`, `sdks/python/rust/bridge_python`) use the Rust prost types via `bridge_ctypes` — nothing extra to regenerate. `sdks/rust/bridge_rust` is the exception: it vendors the generated file (see above) because it publishes to crates.io and must not depend on this engine-coupled crate. -No generated clients exist for Ruby, Java/Kotlin, C#/.NET, or PHP. The Java and C# bridges read and write the wire by hand (`sdks/java/baml_bridge` `ProtoReader`, `sdks/csharp/bridge_csharp` `BamlGeneratedCanonicalCodecs.cs`, and the C# generator's metadata encoder), as does the engine's trace encoder (`crates/bex_engine/src/trace_value_encode.rs`): a change to a message's fields is mirrored there by hand. +No generated clients exist for Java/Kotlin, C#/.NET, or PHP. The Java and C# bridges read and write the wire by hand (`sdks/java/baml_bridge` `ProtoReader`, `sdks/csharp/bridge_csharp` `BamlGeneratedCanonicalCodecs.cs`, and the C# generator's metadata encoder), as does the engine's trace encoder (`crates/bex_engine/src/trace_value_encode.rs`): a change to a message's fields is mirrored there by hand. diff --git a/baml_language/crates/bridge_ctypes/build.rs b/baml_language/crates/bridge_ctypes/build.rs index de416fd8ab0..13dfcc0f4db 100644 --- a/baml_language/crates/bridge_ctypes/build.rs +++ b/baml_language/crates/bridge_ctypes/build.rs @@ -1,7 +1,10 @@ use std::path::PathBuf; +mod build_support; + fn main() -> std::io::Result<()> { println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=build_support.rs"); let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); let proto_dir = manifest_dir.join("types"); @@ -72,6 +75,23 @@ fn main() -> std::io::Result<()> { let status = cmd.status().expect("failed to run protoc"); assert!(status.success(), "protoc (python) failed with {status}"); + // Generate Ruby clients into the bridge's load path. These files are + // committed so Ruby consumers do not need protoc at runtime. Remove the + // generated package first so deleted or renamed schemas cannot leave stale + // clients behind. + let ruby_out = manifest_dir.join("../../sdks/ruby/bridge_ruby/lib"); + let ruby_package_out = ruby_out.join("baml_bridge/cffi/v1"); + build_support::allow_missing(std::fs::remove_dir_all(&ruby_package_out))?; + std::fs::create_dir_all(&ruby_out)?; + let mut cmd = std::process::Command::new(&protoc); + cmd.arg(format!("--proto_path={}", proto_dir.display())); + cmd.arg(format!("--ruby_out={}", ruby_out.display())); + for proto in &protos { + cmd.arg(proto); + } + let status = cmd.status().expect("failed to run protoc"); + assert!(status.success(), "protoc (Ruby) failed with {status}"); + Ok(()) } diff --git a/baml_language/crates/bridge_ctypes/build_support.rs b/baml_language/crates/bridge_ctypes/build_support.rs new file mode 100644 index 00000000000..7462d0cafeb --- /dev/null +++ b/baml_language/crates/bridge_ctypes/build_support.rs @@ -0,0 +1,25 @@ +pub fn allow_missing(result: std::io::Result<()>) -> std::io::Result<()> { + match result { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + result => result, + } +} + +#[cfg(test)] +mod tests { + use super::allow_missing; + use std::io::{Error, ErrorKind}; + + #[test] + fn cleanup_tolerates_missing_directories_but_preserves_other_errors() { + assert!(allow_missing(Ok(())).is_ok()); + assert!(allow_missing(Err(Error::from(ErrorKind::NotFound))).is_ok()); + let error = allow_missing(Err(Error::new( + ErrorKind::PermissionDenied, + "cleanup refused", + ))) + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::PermissionDenied); + assert_eq!(error.to_string(), "cleanup refused"); + } +} diff --git a/baml_language/crates/bridge_ctypes/src/lib.rs b/baml_language/crates/bridge_ctypes/src/lib.rs index fa527c215b8..8f940fe3615 100644 --- a/baml_language/crates/bridge_ctypes/src/lib.rs +++ b/baml_language/crates/bridge_ctypes/src/lib.rs @@ -4,6 +4,10 @@ //! between host values and `BexExternalValue`, used by both `bridge_cffi` //! and `bridge_wasm`. +#[cfg(test)] +#[path = "../build_support.rs"] +mod build_support; + mod error; mod handle_table; mod wire_depth; diff --git a/baml_language/sdk_tests/codegen/Cargo.toml b/baml_language/sdk_tests/codegen/Cargo.toml index 388de401b31..efbb35b6226 100644 --- a/baml_language/sdk_tests/codegen/Cargo.toml +++ b/baml_language/sdk_tests/codegen/Cargo.toml @@ -47,6 +47,7 @@ sdkgen_go = { workspace = true } sdkgen_typescript_shared = { workspace = true } sdkgen_java = { workspace = true } sdkgen_rust = { workspace = true } +sdkgen_ruby_sorbet = { workspace = true } baml_sdkgen_types = { workspace = true } baml_base = { workspace = true } baml_db = { workspace = true } diff --git a/baml_language/sdk_tests/codegen/src/lib.rs b/baml_language/sdk_tests/codegen/src/lib.rs index 9ea261767b3..8bf22057ba6 100644 --- a/baml_language/sdk_tests/codegen/src/lib.rs +++ b/baml_language/sdk_tests/codegen/src/lib.rs @@ -47,6 +47,7 @@ pub mod csharp; pub mod go; pub mod java; pub mod python_pydantic2; +pub mod ruby_sorbet; pub mod rust; pub mod swift; pub mod typescript; diff --git a/baml_language/sdk_tests/codegen/src/main.rs b/baml_language/sdk_tests/codegen/src/main.rs index 38c7708c60d..f8d18980f6d 100644 --- a/baml_language/sdk_tests/codegen/src/main.rs +++ b/baml_language/sdk_tests/codegen/src/main.rs @@ -31,6 +31,7 @@ const GENERATORS: &[(&str, RunAll)] = &[ "python_pydantic2", sdk_test_codegen::python_pydantic2::run_all, ), + ("ruby_sorbet", sdk_test_codegen::ruby_sorbet::run_all), ("rust", sdk_test_codegen::rust::run_all), ("swift", sdk_test_codegen::swift::run_all), ("typescript", sdk_test_codegen::typescript::run_all), diff --git a/baml_language/sdk_tests/codegen/src/ruby_sorbet.rs b/baml_language/sdk_tests/codegen/src/ruby_sorbet.rs new file mode 100644 index 00000000000..aba1109143f --- /dev/null +++ b/baml_language/sdk_tests/codegen/src/ruby_sorbet.rs @@ -0,0 +1,29 @@ +//! Stage the Ruby SDK fixtures. +use std::fs; + +use baml_sdkgen_types::NamingConvention; +use sdk_test_harness_runner::fixtures; + +use crate::{CodegenCtx, Overlay, load_fixture, write_codegen_output}; + +pub fn run_all(ctx: &CodegenCtx) { + for fixture in fixtures::checked_shared(&ctx.fixtures_root) { + let loaded = load_fixture(&ctx.fixtures_root, fixture); + let (files, skipped) = sdkgen_ruby_sorbet::to_source_code_with_bytecode_and_skipped( + &loaded.pool, + &loaded.baml_bytecode, + NamingConvention::Language, + None, + ); + eprintln!("Ruby {fixture} skipped: {}", skipped.join(", ")); + let root = ctx.crate_dir.join(fixture); + let generated = root.join("generated"); + if generated.exists() { + fs::remove_dir_all(&generated).unwrap(); + } + write_codegen_output(&generated, files, fixture); + let mut overlay = Overlay::new(&root); + overlay.copy_tree(&root.join("customizable"), ""); + overlay.install(); + } +} diff --git a/baml_language/sdk_tests/crates/parity_analysis.md b/baml_language/sdk_tests/crates/parity_analysis.md index 937b1f76a2f..35965594e5b 100644 --- a/baml_language/sdk_tests/crates/parity_analysis.md +++ b/baml_language/sdk_tests/crates/parity_analysis.md @@ -2,9 +2,9 @@ This report inventories checked-in test declarations. It does not report whether tests passed. -Distinct exact test IDs: 981. IDs with complete required parity: 232. Required gaps: 5143. +Distinct exact test IDs: 983. IDs with complete required parity: 234. Required gaps: 5865. -Baseline ratchet: BETTER. Required gaps: 5143 (baseline: 5143). Present declarations: 2610 (baseline: 2608). Newly missing required pairs: 0. Resolved baseline gaps: 0. Weakened requirements: 0. +Baseline ratchet: UNCHANGED. Required gaps: 5865 (baseline: 5865). Present declarations: 2639 (baseline: 2639). Newly missing required pairs: 0. Resolved baseline gaps: 0. Weakened requirements: 0. ## Python-baselined parity @@ -22,992 +22,994 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | go | 21 / 457 | 4.6% | | java | 307 / 457 | 67.2% | | swift | 191 / 457 | 41.8% | +| ruby_sorbet | 27 / 457 | 5.9% | -| Test case | python_pydantic2 | typescript_node | typescript_web_chromium | typescript_web_cloudflare_workers | cpp | csharp | rust | go | java | swift | Required in | Reason | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| docstrings_etc/class_doc_summary_and_attributes | - | - | - | - | - | - | - | y | - | - | all | | -| docstrings_etc/enum_doc_summary_and_members | - | - | - | - | - | - | - | y | - | - | all | | -| docstrings_etc/enum_summary_only_omits_member_comments | - | - | - | - | - | - | - | y | - | - | all | | -| docstrings_etc/go_codegen_function_doc_comment | - | - | - | - | - | - | - | y | - | - | all | | -| docstrings_etc/imports | - | - | - | - | - | - | - | y | - | - | all | | -| docstrings_etc/main_class_doc_summary_and_attributes_section | y | y | - | - | y | - | y | - | y | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| docstrings_etc/main_class_doc_summary_present | - | - | - | - | - | - | - | - | - | y | all | | -| docstrings_etc/main_enum_and_variant_docs_attached | - | - | - | - | - | - | - | - | - | y | all | | -| docstrings_etc/main_enum_doc_summary_and_members_section | y | y | - | - | y | - | y | - | y | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| docstrings_etc/main_enum_summary_only_omits_members_section | y | - | - | - | y | - | y | - | y | - | all | | -| docstrings_etc/main_field_docs_attached | - | - | - | - | - | - | - | - | - | y | all | | -| docstrings_etc/main_imports_symbols_reachable | y | y | y | y | - | - | y | - | y | y | all | | -| docstrings_etc/main_multi_line_class_doc_preserved | - | - | - | - | - | - | - | - | - | y | all | | -| docstrings_etc/main_no_inline_field_or_variant_doc_artifacts | y | y | - | - | y | - | y | - | y | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| docstrings_etc/main_undocumented_field_listed_as_bare_name_under_attributes | y | - | - | - | y | - | y | - | y | - | all | | -| docstrings_etc/no_inline_field_or_variant_doc_artifacts | - | - | - | - | - | - | - | y | - | - | all | | -| docstrings_etc/undocumented_field_has_no_doc_artifact | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/ambient_context_survives_await_rust_only | - | - | - | - | - | - | y | - | - | - | rust | Rust task scopes and lazy future cancellation are specific to Rust | -| function_calls/async_callback_can_use_originating_loop_resources_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | -| function_calls/async_callback_can_use_originating_loop_resources_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/async_callback_inherits_application_context_across_suspension_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | -| function_calls/async_callback_preserves_ambient_frame_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node async carrier, native signals, and entry-time snapshots are specific to TypeScript | -| function_calls/async_callback_reenters_async_baml_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python loop/ContextVar and Node AsyncLocalStorage assertions | -| function_calls/async_callback_reenters_sync_baml_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | sync callback re-entry differs between Python and Node | -| function_calls/async_entry_sync_callback_inherits_application_context_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | -| function_calls/async_host_runs_in_calling_task_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | covers Python asyncio task and event-loop identity | -| function_calls/async_reentry_preserves_async_local_storage_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/async_stream_schema_failure_is_parse_failed | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | exercises the Python asyncio streaming bridge | -| function_calls/baml_closure_decodes_multiple_args_and_structured_return_values | y | y | y | y | y | - | y | y | y | y | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, rust, go, java, swift | C# covers this canonical behavior in its native integration harness | -| function_calls/baml_closure_is_a_native_callable_with_host_language_arguments | y | y | y | y | y | - | y | y | y | y | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, rust, go, java, swift | C# covers this canonical behavior in its native integration harness | -| function_calls/baml_closure_is_reusable_and_retains_mutable_captures | y | y | y | y | y | - | y | y | y | y | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, rust, go, java, swift | C# covers this canonical behavior in its native integration harness | -| function_calls/baml_error_carries_baml_trace | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/baml_time_class_and_field_wire_names_are_exact | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/baml_time_constructors_and_parsers_return_lossless_internal_values | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/baml_time_go_constructed_values_round_trip_at_numeric_boundaries | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/baml_time_malformed_values_fail_at_the_earliest_typed_boundary | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/baml_time_nested_containers_and_baml_field_inspection | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/baml_time_nullable_and_defaulted_positions | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/baml_time_raw_class_transport_does_not_enforce_semantic_invariants | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/baml_trace_is_embedded_in_go_error_string | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/boolean_timeout_rejected_python_only | y | - | - | - | - | - | - | - | - | - | all | | -| function_calls/callable_entry_invokes_callback | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/callable_entry_waits_for_callback_completion | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/callback_bound_method_adoption_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | covers Python bound method identity via its exact SDK function | -| function_calls/callback_captures_internal_baml_context | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/callback_frame_and_reentry | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/callback_frame_is_installed_and_restored | - | - | - | - | - | - | y | - | - | - | all | | -| function_calls/callback_marker_adopts_once_during_recursion | y | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| function_calls/callback_marker_adopts_sync_body | y | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| function_calls/callback_marker_concurrent_reuse_and_later_direct_call | y | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| function_calls/callback_marker_context_precedence | y | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| function_calls/callback_marker_defaults_override_inherited_context | y | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| function_calls/callback_marker_only_outer_wrapper_adopts | y | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| function_calls/callback_marker_sync_entry_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | covers Python synchronous BAML entry and inline callback dispatch | -| function_calls/callback_reenters_baml | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/callback_reuse_across_event_loop_turns_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/callback_reuse_after_previous_application_loop_is_closed_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | -| function_calls/callback_suppresses_task_cancellation_and_returns_late_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python callback suppression of cancellation and late results | -| function_calls/callback_task_cancelled_before_first_execution_completes_call_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python task factory cancellation before callback execution | -| function_calls/callback_third_party_wrapper_is_not_adopted | y | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| function_calls/callback_throws_caught_and_replaced_makes_the_function_infallible | - | - | - | - | - | - | y | - | - | - | rust | validates Rust-specific inferred callback error unions | -| function_calls/callback_throws_caught_then_rethrown_value_is_the_replacement_union | - | - | - | - | - | - | y | - | - | - | rust | validates Rust-specific inferred callback error unions | -| function_calls/callback_throws_rethrown_carries_the_effect_param_into_the_error_union | - | - | - | - | - | - | y | - | - | - | rust | validates Rust-specific inferred callback error unions | -| function_calls/cancel_controls_with_cancelled_context_go_only | - | - | - | - | - | - | - | y | - | - | go | Go cancellation controls remain usable with a canceled context | -| function_calls/cancellation_async_call_returns_none | y | - | - | - | y | - | y | - | y | y | all | | -| function_calls/cancellation_async_cancel_skips_later_step | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | drives Python asyncio cancellation (task.cancel / wait_for) | -| function_calls/cancellation_async_cancel_via_asyncio_timeout | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/cancellation_async_cancel_via_call_context | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/cancellation_async_cancel_via_future_cancel | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/cancellation_async_cancel_via_task_cancel | y | - | - | - | - | - | y | - | y | y | all | | -| function_calls/cancellation_async_cancel_via_task_group_sibling | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/cancellation_async_cancel_via_timeout_race | - | - | - | - | - | - | - | - | - | y | all | | -| function_calls/cancellation_can_pre_abort_async_baml_cancellation | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/cancellation_cancels_an_async_generated_free_function | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/cancellation_cancels_an_async_generated_instance_method | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/cancellation_detaches_a_completed_call_before_aborting_the_remaining_call | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/cancellation_future_destruction_detaches | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/cancellation_future_second_get_throws_future_error | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/cancellation_future_wait_for_times_out_while_in_flight | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/cancellation_future_wait_then_get | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/cancellation_immediately_cancels_every_call_attached_after_abort | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/cancellation_pre_aborts_a_generated_synchronous_call | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/cancellation_surfaces_a_reused_call_context_as_abort_error_with_baml_reason | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/cancellation_surfaces_async_cancellation_as_abort_error_with_baml_reason | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/cancellation_surfaces_cancellation_through_promise_all | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/cancellation_surfaces_sync_pre_aborted_cancellation_as_abort_error | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/cancellation_sync_call_returns_none | y | - | - | - | y | - | y | - | y | y | all | | -| function_calls/cancellation_sync_cancel_via_call_context | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/cancelled_waiter_does_not_end_host_execution | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/cancelled_waiter_keeps_host_context_through_cleanup_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python task unwinding and ContextVar semantics | -| function_calls/cancelled_waiter_keeps_host_resource_until_promise_exit_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node AsyncResource destruction and AsyncLocalStorage | -| function_calls/cancelled_waiter_preserves_callback_context | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/cancelled_waiter_stays_cancelled_when_callback_returns_late | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/cancelling_call_delivers_cancellation_and_allows_callback_cleanup_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python callback task and event loop ownership | -| function_calls/canonical_json_class_union_uses_declared_field_codecs | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/canonical_json_composes_through_containers_and_classes | - | - | - | - | - | - | y | y | - | - | all | | -| function_calls/canonical_json_defaults_and_callbacks | - | - | - | - | - | - | y | y | - | - | all | | -| function_calls/canonical_json_dynamic_union | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/canonical_json_rejects_extensions_before_dispatch | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/canonical_json_round_trips_at_top_level_and_through_alias | - | - | - | - | - | - | y | y | - | - | all | | -| function_calls/child_cancellation_does_not_cancel_input | y | y | y | y | - | - | y | - | - | - | all | | -| function_calls/clean_exit_helper_process | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/clean_exit_terminates_process_with_code | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/closure_call_async_can_be_reused_after_creation_loop_closes_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python returned closure reuse across application loops | -| function_calls/closure_call_async_cancels_its_retained_callback_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python closure async entry cancellation delivery | -| function_calls/closure_call_async_preserves_binding_and_exception_identity_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python closure async entry argument and error semantics | -| function_calls/closure_call_async_uses_invoking_loop_and_context_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python explicit async closure entry and context capture | -| function_calls/coawait_co_await_cancelled_call_throws_cancelled | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/coawait_co_await_completed_future_fast_path | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/coawait_co_await_pending_future_resumes | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/coawait_co_await_throws_typed_into_the_coroutine | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/coawait_co_await_yields_the_decoded_value | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/coawait_uncaught_coroutine_exception_reaches_join | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/completed_call_can_reuse_callback | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/completed_call_does_not_pin_reused_callback_to_old_context_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | -| function_calls/composite_token_observes_every_source | y | y | y | y | - | - | y | - | y | - | all | | -| function_calls/concurrent_calls_isolate_async_local_storage_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/concurrent_calls_keep_callback_results_independent | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/concurrent_calls_share_callback_without_sharing_context_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | -| function_calls/concurrent_host_invocations_isolate_context | y | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| function_calls/concurrent_invocations_isolate_context | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/concurrent_reservation_attaches_once | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/configuration_snapshot_is_not_live | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/cooperative_abort_allows_callback_cleanup_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/cooperative_abort_cleans_up_retained_callback_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/coroutine_entry_uses_execution_context_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | covers Python coroutine creation versus first execution | -| function_calls/current_context_returns_detached_snapshot | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/deadline_reentry_does_not_reset_budget | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/dropping_started_future_cancels_rust_only | - | - | - | - | - | - | y | - | - | - | rust | Rust task scopes and lazy future cancellation are specific to Rust | -| function_calls/dynamic_application_map_preserves_control_like_keys | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/dynamic_call_accepts_controls | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/dynamic_call_async_accepts_controls | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/dynamic_name_and_handle_accept_controls | - | - | - | - | - | - | y | - | y | - | all | | -| function_calls/dynamic_name_and_handle_accept_controls_async | - | - | - | - | - | - | y | - | - | - | all | | -| function_calls/dynamic_type_bindings_async_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python dynamic type bindings accept Python classes and reflected BamlType handles | -| function_calls/empty_class_self_round_trip | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/empty_controls | y | y | y | y | - | - | y | - | y | - | all | | -| function_calls/empty_json_containers_round_trip | - | - | - | - | - | - | - | - | y | - | all | | -| function_calls/encoding_time_counts_against_deadline_python_only | y | - | - | - | - | - | - | - | - | - | all | | -| function_calls/error_call_cancellation_preserves_context_identity | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/error_string_is_non_empty | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/errors_async_sibling_throws_typed | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/errors_baml_error_carries_baml_trace | y | - | - | - | - | - | y | - | y | y | all | | -| function_calls/errors_baml_trace_spliced_into_python_traceback | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/errors_cancellation_surfaces_as_baml_panic | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/errors_clean_exit_terminates_process_with_code | - | - | - | - | - | - | y | - | - | - | all | | -| function_calls/errors_clean_exit_terminates_process_with_code_0 | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/errors_clean_exit_terminates_process_with_code_7 | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/errors_host_invalid_argument_wraps_baml_errors_invalid_argument | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/errors_stdlib_error_surfaces_as_baml_error | y | - | - | - | - | - | y | - | y | y | all | | -| function_calls/errors_stdlib_error_surfaces_typed | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/errors_str_is_non_empty | y | - | - | - | - | - | y | - | y | y | all | | -| function_calls/errors_typed_throw_is_still_a_baml_error | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/errors_union_throws_preserves_class_name | y | y | y | y | y | - | y | - | y | y | all | | -| function_calls/errors_user_panic_surfaces_as_baml_panic | y | - | - | - | - | - | y | - | y | y | all | | -| function_calls/errors_user_throw_surfaces_declared_instance | y | - | - | - | y | - | y | - | y | y | all | | -| function_calls/explicit_controls_are_applied | - | - | - | - | - | - | - | - | - | y | all | | -| function_calls/explicit_controls_are_applied_async | - | - | - | - | - | - | - | - | - | y | all | | -| function_calls/failed_admission_does_not_consume_reservation | - | - | - | - | - | - | y | - | y | - | all | | -| function_calls/four_call_forms | y | y | y | y | - | - | y | - | y | y | all | | -| function_calls/four_call_forms_async | y | y | y | y | - | - | y | - | y | y | all | | -| function_calls/generic_calls_choose_explicit | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/generic_calls_consume_int_wrapper_baseline | y | - | - | - | - | - | y | - | y | y | all | | -| function_calls/generic_calls_extract_explicit | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/generic_calls_generic_free_fn_requires_binding | y | - | - | - | - | - | y | - | y | y | all | | -| function_calls/generic_calls_generic_static_infers_binding | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/generic_calls_genericbox_get_explicit | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/generic_calls_genericbox_new_static_explicit | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/generic_calls_genericbox_pair_with_explicit | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/generic_calls_identity_async_explicit | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/generic_calls_identity_explicit | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/generic_calls_identity_explicit_class_cannot_restate_an_instance | - | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_calls_instance_method_unparameterized_receiver_raises | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/generic_calls_list_head_explicit | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/generic_calls_make_int_box_reified | y | - | - | - | - | - | y | - | y | y | all | | -| function_calls/generic_calls_make_int_container_reified | y | - | - | - | - | - | y | - | y | y | all | | -| function_calls/generic_calls_make_int_str_bool_triple_reified | y | - | - | - | - | - | y | - | y | y | all | | -| function_calls/generic_calls_make_nested_box_reified | y | - | - | - | - | - | y | - | y | y | all | | -| function_calls/generic_calls_make_triple_explicit | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/generic_calls_named_static_distinct_typevar_names | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/generic_calls_one_type_arg_explicit | y | - | - | - | - | - | y | - | y | y | all | | -| function_calls/generic_calls_optional_only_unset_names_its_type_arg | - | - | - | - | - | - | - | - | - | y | all | | -| function_calls/generic_calls_parse_as_explicit | y | - | - | - | - | - | y | - | y | y | all | | -| function_calls/generic_calls_read_items_explicit | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/generic_calls_second_of_explicit | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/generic_calls_subscript_wrong_arity_raises | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/generic_calls_tag_or_value_explicit | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/generic_calls_two_type_args_explicit | y | - | - | - | - | - | y | - | y | y | all | | -| function_calls/generic_calls_wrap_explicit | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/generic_class_instantiated_at_int | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_class_with_a_json_field_round_trips | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_classes_containers_and_concrete_outputs | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_defaulted_parameters_use_option_setters | - | - | - | - | - | - | - | y | - | - | go | pins Go's functional-option setters, generic when the defaulted value mentions a type parameter | -| function_calls/generic_host_callbacks_share_the_function_type_parameters | - | - | - | - | - | - | - | y | - | - | go | pins Go's generic host-callback adapters | -| function_calls/generic_identity_accepts_nested_dynamic_containers | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_identity_inference_and_explicit_types | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_apply_closure_poisons_typevars_must_specify | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_apply_closure_typevars_specified_succeeds | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_body_only_var_still_requires_binding | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_choose_divergent_generic_instances_union | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_choose_infers_divergent_union | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_choose_infers_unified_typevar | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_choose_union_outside_container_is_sound | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_combine_invariant_class_arg_conflict_rejects | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_default_only_value_position | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | validates Python default-argument stubs and runtime TypeVar inference | -| function_calls/generic_inference_elem_type_heterogeneous_array_unifies | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_elem_type_homogeneous_array_is_single_type | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_elem_type_three_way_heterogeneous_array_unifies | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_extract_fully_unbound_nested_pair_recovers_all_vars | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_extract_infers_four_typevars_from_nesting | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_first_or_empty_list_requires_binding | y | - | - | - | - | - | - | - | y | - | python_pydantic2, java | a dynamic host has no static element type for an empty container; the typed SDKs state theirs and round-trip | -| function_calls/generic_inference_first_or_empty_list_round_trips_none | - | - | - | - | - | - | - | - | - | y | all | | -| function_calls/generic_inference_first_or_empty_list_with_explicit_type_round_trips | - | - | - | - | - | - | - | - | - | y | swift | Swift names `T` statically, so an empty list cannot leave it unbound; this pins the explicit binding round trip | -| function_calls/generic_inference_first_or_nonempty_infers_element | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_generic_static_infers_own_typevar | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_genericbox_get_infers_class_var_from_receiver | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_genericbox_pair_with_infers_method_typevar | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_genericbox_pair_with_unbound_receiver_recovers_class_var | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_glue_invariant_and_covariant_agree_binds | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_glue_invariant_vs_covariant_conflict_rejects | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_host_only_object_not_encodable_from_python | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_identity_async_infers | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_identity_enum_round_trips | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_identity_infers_generic_instance | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_identity_infers_primitives | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_identity_infers_user_class | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_identity_nested_unbound_round_trips | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_identity_null_binds_null | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_identity_null_with_explicit_optional_type_round_trips | - | - | - | - | - | - | - | - | - | y | swift | Swift names `T` statically, so a null argument infers nothing here; this pins the explicit binding round trip | -| function_calls/generic_inference_identity_unbound_generic_instance_round_trips | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_list_head_infers_from_recursive_generic | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_list_head_unbound_recursive_recovers_t_from_fields | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_make_triple_full_subscript_contradicted_by_actual_rejects | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_make_triple_heterogeneous_list_element_unions | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_make_triple_infers_multiple_typevars | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_make_triple_partial_explicit_then_infer | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_make_triple_partial_subscript_requires_full_arity | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_make_triple_subscript_fully_bound | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_make_triple_types_kwarg_contradicted_by_actual_rejects | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_maybe_id_null_round_trips | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_maybe_id_present_value_infers | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_merge_invariant_map_value_conflict_rejects | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_named_static_infers_distinct_typevars | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_one_type_arg_explicit_types_succeeds | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_pair_invariant_list_agree_binds | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_pair_invariant_list_conflict_rejects | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_parse_as_explicit_types_succeeds | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_read_items_infers_from_instance_wire_args | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_read_items_unbound_container_recovers_t_from_fields | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_return_only_var_still_requires_binding | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_second_of_infers_from_nested_generic | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_second_of_unbound_instance_recovers_field_type | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_tag_or_value_binds_generic_instance | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_triple_choose_join_includes_concrete_class | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_triple_choose_join_includes_enum_variant | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_triple_choose_three_covariant_join | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_two_typevar_union_is_uninferrable_rejects | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_union_concrete_sibling_absorbs_value_binds_never | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_union_concrete_sibling_keeps_the_static_type_arg | - | - | - | - | - | - | - | - | - | y | all | | -| function_calls/generic_inference_union_null_actual_binds_never | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_union_null_actual_keeps_the_static_type_arg | - | - | - | - | - | - | - | - | - | y | all | | -| function_calls/generic_inference_union_with_concrete_sibling_infers_typevar | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_values_of_empty_map_requires_binding | y | - | - | - | - | - | - | - | y | - | python_pydantic2, java | a dynamic host has no static value type for an empty map; the typed SDKs state theirs and round-trip | -| function_calls/generic_inference_values_of_empty_map_round_trips_empty_list | - | - | - | - | - | - | - | - | - | y | all | | -| function_calls/generic_inference_values_of_nonempty_returns_values | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_inference_wrap_infers_and_returns_bound_generic | y | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_wrap_infers_and_returns_generic | y | - | - | - | - | - | - | - | y | y | all | | -| function_calls/generic_nullable_type_variables_preserve_every_pointer_boundary | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_receiver_and_static_helpers | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_return_only_type_arguments | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_union_input_and_engine_validation | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/go_codegen_context_deadline | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/go_codegen_default_argument_serialization_error_names_argument | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/go_codegen_defaulted_argument_type_matrix | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/go_codegen_defaulted_void | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/go_codegen_option_name_collisions | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/go_codegen_optional_arg_last_value_wins | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/go_codegen_person_round_trip | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/hello_world_returns_literal | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/hidden_mode_does_not_inherit | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/host_baml_cancellation_remains_live_after_exit | y | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| function_calls/host_baml_trace_override_and_callback_context_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | explicit Python call controls override instrumentation defaults | -| function_calls/host_callable_cancellation_while_dispatched | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/host_callable_class_argument | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/host_callable_closed_union_containers_and_nominal_arms | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/host_callable_closed_union_literal_optional_and_selected_empty_container_arms | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/host_callable_closed_union_round_trips | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/host_callable_declared_throw_is_catchable | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/host_callable_failure_under_open_throws_is_catchable | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/host_callable_hof_receives_a_baml_closure | y | y | y | y | y | - | y | - | y | y | all | | -| function_calls/host_callable_late_and_uncaught_failures_release_native_identity | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/host_callable_media_round_trip | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/host_callable_nullable_optional_distinguishes_all_three_states | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/host_callable_optional_arguments | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/host_callable_optional_names_avoid_generated_and_projection_collisions | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/host_callable_panic_does_not_cross_cgo_boundary | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/host_callable_primitive_and_multiple_arguments | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/host_callable_reentrant_call_does_not_deadlock | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/host_callable_repeated_and_concurrent_reuse | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/host_callable_structured_round_trips | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/host_callable_two_cbs_callbacks_named_f_and_f_returns_both_run | y | y | y | y | y | - | y | y | y | y | all | | -| function_calls/host_callable_void_signatures | y | y | y | y | y | - | y | y | y | - | all | | -| function_calls/host_callables_adopts_a_custom_thenable_exactly_once | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/host_callables_adopts_a_promise_from_a_separate_browser_realm | - | - | y | - | - | - | - | - | - | - | python_pydantic2, typescript_web_chromium, cpp, csharp, rust, go, java, swift | | -| function_calls/host_callables_async_callable_future_completing_exceptionally_round_trips_original | - | - | - | - | - | - | - | - | y | - | all | | -| function_calls/host_callables_async_callable_returning_future_is_awaited_by_bridge | - | - | - | - | - | - | - | - | y | - | all | | -| function_calls/host_callables_async_callable_runs_to_completion | y | - | - | - | - | - | y | - | y | y | all | | -| function_calls/host_callables_awaits_a_promise_returning_callback | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/host_callables_baml_error_panicked_with_is_thrown_on | - | - | - | - | - | - | - | y | - | - | go | Go's completion classification of a BAML error a callback forwards by panicking with it | -| function_calls/host_callables_call_repeatedly_invokes_callback_n_times | y | y | y | y | y | - | y | - | y | y | all | | -| function_calls/host_callables_call_repeatedly_with_zero_n_returns_empty_list | y | y | y | y | y | - | y | - | y | y | all | | -| function_calls/host_callables_call_with_throwing_in_baml_catches_host_callable_error | y | y | y | y | y | - | y | - | y | y | all | | -| function_calls/host_callables_cancels_through_the_originating_runtime_after_runtime_replacement | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/host_callables_class_callback_round_trips_class_value | - | - | - | - | y | - | - | - | - | y | all | | -| function_calls/host_callables_class_callback_round_trips_pydantic_model | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/host_callables_completes_a_pending_host_call_after_runtime_replacement | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/host_callables_completes_through_the_metadata_fallback_for_a_hostile_thrown_object | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/host_callables_concurrent_throws_in_flight_rehydrate_to_their_own_object | - | - | - | - | - | - | - | - | y | - | all | | -| function_calls/host_callables_delivers_a_single_supplied_optional_by_name_defaulting_the_rest | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/host_callables_delivers_both_supplied_optionals_in_one_opts_object | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/host_callables_forwarded_async_cancellation_stays_a_panic | - | - | - | - | - | - | - | - | y | y | java, swift | pins a typed SDK's completion of a nested call's cancellation, which it surfaces as its native cancellation | -| function_calls/host_callables_forwarded_baml_error_is_held_to_the_declared_throws | - | - | - | - | - | - | - | y | - | - | go | Go's completion classification of a forwarded BAML error held to the declared throws type | -| function_calls/host_callables_forwarded_baml_error_is_thrown_as_itself | - | - | - | - | y | - | - | - | - | y | cpp, swift | pins a forwarded BAML error completed as thrown | -| function_calls/host_callables_forwarded_cancellation_stays_a_panic_under_throws | - | - | - | - | y | - | - | - | - | - | cpp | C++ surfaces BAML cancellation as baml::cancelled. | -| function_calls/host_callables_forwarded_panic_stays_a_panic_under_a_declared_throws_class | - | - | - | - | - | - | - | y | y | y | go, java, swift | pins a typed SDK's completion of a forwarded panic under a single-class throws | -| function_calls/host_callables_forwarded_type_mismatch_crosses_as_the_baml_error_it_is | - | - | - | - | - | - | - | - | y | - | java | pins the Java completion of a forwarded TypeMismatch, which Java surfaces as an IllegalArgumentException | -| function_calls/host_callables_ignores_a_host_promise_settlement_after_its_outer_call_is_cancelled | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/host_callables_int_return_callable_round_trip | y | y | y | y | y | - | y | - | y | y | all | | -| function_calls/host_callables_lambda_round_trip | y | - | - | - | y | - | y | - | y | y | all | | -| function_calls/host_callables_literal_union_callback_states_its_literal_types | - | - | - | - | - | - | - | - | - | y | all | | -| function_calls/host_callables_media_callback_states_the_media_type | - | - | - | - | - | - | - | - | - | y | all | | -| function_calls/host_callables_multiple_callable_keys_are_distinct | y | y | y | y | y | - | y | - | y | y | all | | -| function_calls/host_callables_multiple_throws_in_flight_do_not_collide_in_registry | y | - | - | - | y | - | y | - | y | y | all | | -| function_calls/host_callables_non_finite_json_result_is_a_contract_violation | - | - | - | - | - | - | - | y | - | - | go | Go's completion classification of a returned value that does not fit its declared type | -| function_calls/host_callables_omits_both_optionals_so_the_callback_s_own_defaults_apply | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/host_callables_optional_args_all_set_deliver_both | y | - | - | - | y | - | y | - | y | y | all | | -| function_calls/host_callables_optional_args_all_unset_apply_host_defaults | y | - | - | - | y | - | y | - | y | y | all | | -| function_calls/host_callables_optional_args_partially_set_deliver_by_name | y | - | - | - | y | - | y | - | y | y | all | | -| function_calls/host_callables_overlapping_container_union_callback_states_the_selected_list | - | - | - | - | - | - | - | - | - | y | all | | -| function_calls/host_callables_passes_a_generated_class_instance_into_the_callback | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/host_callables_preserves_a_rejected_promise_reason_by_identity | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/host_callables_preserves_an_error_whose_stack_is_not_a_string | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/host_callables_preserves_arbitrary_thrown_js_values_without_hanging | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/host_callables_preserves_number_kind_for_float_bigint_union | - | y | y | y | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | exercises JavaScript's distinct number and bigint host representations | -| function_calls/host_callables_preserves_same_realm_thrown_object_identity | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/host_callables_rejects_callable_args_on_the_generated_sync_path_instead_of_hanging | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/host_callables_release_fires_on_drop_of_callable | y | y | y | y | - | - | y | - | y | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, rust, java | callable release coverage depends on host weak-reference support and remains nondeterministic | -| function_calls/host_callables_resolves_integral_number_for_float_class_field | - | y | y | y | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | exercises the Node bridge's JavaScript number encoding in generated classes | -| function_calls/host_callables_resolves_integral_number_for_float_return | - | y | y | y | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | exercises the Node bridge's JavaScript number encoding for float host returns | -| function_calls/host_callables_returns_and_invokes_a_host_callable_nested_in_a_list | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/host_callables_returns_and_invokes_a_nested_host_callable | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/host_callables_round_trips_a_typed_baml_error_through_typed_catch_and_propagation | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/host_callables_round_trips_an_arrow_function_callback | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/host_callables_simple_sync_callable_returns_string | y | y | y | y | y | - | y | - | y | y | all | | -| function_calls/host_callables_surfaces_a_throwing_callback_as_a_baml_error | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/host_callables_surfaces_a_wrong_callback_return_type_as_host_contract_violation | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/host_callables_throwing_async_callable_round_trips_original_error | - | - | - | - | - | - | - | - | - | y | all | | -| function_calls/host_callables_throwing_async_callable_round_trips_original_python_exception | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/host_callables_throwing_callable_bamlerror_propagates_back_with_typed_fields | y | - | - | - | - | - | y | y | y | - | all | | -| function_calls/host_callables_throwing_callable_bamlerror_wrapping_codegenned_class_is_caught_in_baml | y | - | - | - | - | - | y | y | y | - | all | | -| function_calls/host_callables_throwing_callable_custom_host_exception_round_trips_with_identity | - | - | - | - | y | - | - | - | - | y | all | | -| function_calls/host_callables_throwing_callable_custom_python_exception_round_trips_with_identity | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/host_callables_throwing_callable_hostthrow_codegenned_class_is_caught_in_baml | - | - | - | - | y | - | - | - | - | y | all | | -| function_calls/host_callables_throwing_callable_hostthrow_propagates_back_with_typed_fields | - | - | - | - | y | - | - | - | - | y | all | | -| function_calls/host_callables_throwing_callable_keyerror_round_trips_with_identity | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/host_callables_throwing_callable_out_of_range_round_trips_with_identity | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/host_callables_throwing_callable_round_trips_original_host_exception | - | - | - | - | y | - | - | - | - | y | all | | -| function_calls/host_callables_throwing_callable_round_trips_original_python_exception | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/host_callables_thrown_value_of_another_go_type_is_a_contract_violation | - | - | - | - | - | - | - | y | - | - | go | Go's completion classification of a value thrown as a Go type other than the declared one | -| function_calls/host_callables_two_arg_callable_unpacks_positional_args | y | y | y | y | y | - | y | - | y | y | all | | -| function_calls/host_callables_typed_throw_too_deep_is_a_bridge_failure | - | - | - | - | y | - | - | - | - | - | cpp | C++ throws a BAML value with baml::host_throw. | -| function_calls/host_callables_unfit_callback_result_is_a_contract_violation | - | - | - | - | y | - | - | - | - | - | cpp | C++ can return only a malformed bigint as unfit. | -| function_calls/host_callback_cleanup_retains_context | y | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| function_calls/host_callback_reentry_context | y | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| function_calls/host_cancellation_waits_for_cleanup_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | covers Python task cancellation and asynchronous finally cleanup | -| function_calls/host_capture_avoids_getters_and_proxies_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | covers JS getters, Proxy traps, and custom thenables | -| function_calls/host_capture_preserves_application_values | y | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| function_calls/host_capture_wire_stays_decodable_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | checks the Python capture adapter's wire output directly | -| function_calls/host_capture_wire_stays_decodable_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | checks the Node capture adapter's wire output directly. | -| function_calls/host_child_outlives_parent | y | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| function_calls/host_common_types_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node observations are checked alongside Python by local SQL. | -| function_calls/host_context_inherits_and_restores | y | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| function_calls/host_current_exposes_generated_cancel_token | y | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| function_calls/host_defaults_baml_span_and_error_capture_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python instrumentation defaults Python-to-BAML span/capture requests | -| function_calls/host_errors_preserve_identity_and_restore_context | y | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| function_calls/host_preserves_call_shape_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | covers JS receivers, variadic arguments, and native Promise results | -| function_calls/host_recording_audit_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | runs the Node half of the independent local SQL audit | -| function_calls/host_rejects_unsupported_configuration_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | covers unsupported Python generator/decorator shapes | -| function_calls/host_rejects_unsupported_configuration_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | covers JS generator and marker configuration rejection | -| function_calls/host_supplied_empty_and_null_bearing_json_containers_state_the_alias | - | - | - | - | - | - | - | - | - | y | all | | -| function_calls/host_supplied_json_supports_typed_narrowing | y | y | y | y | y | - | y | y | y | y | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, rust, go, java, swift | C# declares no function_calls suite (its native coverage is Rust-wrapped integration tests) | -| function_calls/host_thread_context_handoff_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | covers Python ContextVar handoff to a thread pool | -| function_calls/instance_method_cancellation_returns_exact_context_error | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/instance_method_media_receiver_default_and_ownership_round_trip | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/instance_method_optional_arguments | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/instance_method_throw_preserves_current_go_error_contract | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/instance_methods_on_classes_round_trip | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/instance_never_method_has_error_only_signature_and_returns_panic | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/instrument_decorator_forms_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | covers Python decorator forms | -| function_calls/instrument_preserves_sync_execution_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | covers Python signatures and positional argument binding | -| function_calls/invalid_controls_rejected | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/invalid_function_arguments_surface_baml_error | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/invalid_response_preserves_wire_diagnostics | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | exercises Python BamlError and generated Pydantic payloads | -| function_calls/invalid_timeout_rejected | y | y | y | y | - | - | y | - | y | - | all | | -| function_calls/invocation_inheritance | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/invocation_inheritance_multi_layer_context_patch_inherits_and_restores | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/invocation_lifecycle | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/invocation_lifecycle_failed_admission_does_not_consume_reservation | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/invocation_lifecycle_pre_cancelled_call_does_not_enter_callback | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/invocation_lifecycle_reservation_is_single_use | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/invocation_lifecycle_retained_effective_token_observes_late_parent_cancellation | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/invocation_lifecycle_zero_timeout_does_not_enter_callback | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/invocation_options | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/invocation_options_four_call_forms | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/invocation_options_four_call_forms_async | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/invocation_options_invalid_timeout_rejected | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/invocation_surfaces | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/invocation_surfaces_returned_callable_accepts_controls | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/json_bigint_round_trips_as_bigint | - | - | - | - | - | - | - | - | y | - | all | | -| function_calls/json_nested_at_the_wire_limit_crosses_and_one_level_more_is_refused | - | - | - | - | - | - | - | - | y | y | java, swift | pins a typed SDK encoder's exact message count at the wire's limit | -| function_calls/json_nested_deeper_than_the_wire_carries_is_refused_as_a_callback_result | y | y | y | y | y | - | y | y | y | y | all | | -| function_calls/json_nested_deeper_than_the_wire_carries_is_refused_as_an_argument | y | y | y | y | y | - | y | y | y | y | all | | -| function_calls/json_nested_to_the_wire_limit_crosses_and_one_level_more_does_not | - | - | - | - | y | - | - | - | - | - | cpp | pins the C++ encoder's exact wire measure at its cut. | -| function_calls/json_returned_from_host_callback_supports_typed_narrowing | y | y | y | y | y | - | y | y | y | y | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, rust, go, java, swift | C# declares no function_calls suite (its native coverage is Rust-wrapped integration tests) | -| function_calls/late_callback_keeps_async_local_storage_after_abort_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node callback Promises can outlive an aborted SDK waiter | -| function_calls/layered_callback_context_inheritance_and_restoration_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python sync/async re-entry and ContextVar propagation | -| function_calls/layered_callback_context_inheritance_and_restoration_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node async carrier, native signals, and entry-time snapshots are specific to TypeScript | -| function_calls/layered_callback_context_restores_after_returned_callable | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/legacy_controls_rejected | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/linking_the_sdk_leaves_serde_json_features_alone | - | - | - | - | - | - | y | - | - | - | rust | cargo feature unification is Rust-only | -| function_calls/live_token_cancels_after_admission | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/main_hello_world_returns_literal | y | - | - | - | y | - | y | - | y | y | all | | -| function_calls/main_rejects_mistyped_int_argument | y | y | y | y | - | - | - | - | - | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | statically typed SDKs cannot pass a mistyped argument | -| function_calls/main_returns_the_literal_async | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/main_returns_the_literal_sync | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/main_round_trips_a_single_positional_argument | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/main_round_trips_ints_bools_strings_and_floats | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/main_single_required_arg_round_trips | y | - | - | - | y | - | y | - | y | y | all | | -| function_calls/method_generated_name_collisions_stay_callable | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/method_self_all_supported_positions_round_trip | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/methods_accept_controls | y | y | y | y | - | - | y | - | y | - | all | | -| function_calls/methods_accept_controls_async | y | y | y | y | - | - | y | - | - | - | all | | -| function_calls/methods_on_classes_create_constructs_a_greeter_async_plus_sync | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/methods_on_classes_exposes_sync_plus_async_bindings_for_both_flavors | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/methods_on_classes_greet_arg_echoes_a_non_self_argument_async_plus_sync | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/methods_on_classes_instance_greet_async_with_arg_round_trips | y | - | - | - | y | - | y | - | y | y | all | | -| function_calls/methods_on_classes_instance_greet_with_arg_round_trips | y | - | - | - | y | - | y | - | y | y | all | | -| function_calls/methods_on_classes_instance_who_async_round_trips | y | - | - | - | y | - | y | - | y | y | all | | -| function_calls/methods_on_classes_instance_who_round_trips | y | - | - | - | y | - | y | - | y | y | all | | -| function_calls/methods_on_classes_method_bindings_exist | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/methods_on_classes_static_create_async_round_trips | y | - | - | - | y | - | y | - | y | y | all | | -| function_calls/methods_on_classes_static_create_round_trips | y | - | - | - | y | - | y | - | y | y | all | | -| function_calls/methods_on_classes_who_returns_a_field_off_self_async_plus_sync | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/multi_layer_context_patch_inherits_and_restores | - | - | - | - | - | - | y | - | y | - | all | | -| function_calls/native_closure_can_cross_sync_and_async_entries_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node sync calls support native closures whose captures need no host dispatch | -| function_calls/native_signal_cancels_without_cancelling_input_token_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node async carrier, native signals, and entry-time snapshots are specific to TypeScript | -| function_calls/nil_host_callable_fails_before_dispatch | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/null_controls_preserve_inherited_context | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/omitted_argument_is_not_null | y | y | y | y | - | - | y | - | y | y | all | | -| function_calls/optional_args_async_samples | y | y | y | y | y | - | y | - | y | y | all | | -| function_calls/optional_args_bigint_in_i64_range | - | - | - | - | - | - | - | - | y | - | all | | -| function_calls/optional_args_covers_static_and_instance_optional_args | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/optional_args_covers_the_runtime_matrix | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/optional_args_negative_runtime_cases_reject | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/optional_args_opt_box_method_matrix | y | - | - | - | y | - | y | - | y | y | all | | -| function_calls/optional_args_python_unset_and_none_differ_in_one_call | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/optional_args_rejects_invalid_runtime_calls_that_bypass_types | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/optional_args_runtime_matrix | y | - | - | - | y | - | y | y | y | y | all | | -| function_calls/optional_args_treats_undefined_as_omitted_and_keeps_null_distinct | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/optional_args_unset_and_null_differ_in_one_call | - | - | - | - | y | - | - | - | - | y | all | | -| function_calls/options_snapshot_at_async_entry_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node async carrier, native signals, and entry-time snapshots are specific to TypeScript | -| function_calls/options_snapshot_when_coroutine_starts_python_only | y | - | - | - | - | - | - | - | - | - | all | | -| function_calls/parse_json_successful_value_uses_generated_json_projection | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/pre_aborted_call_does_not_dispatch_callback_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/pre_aborted_native_signal_does_not_enter_callback_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node async carrier, native signals, and entry-time snapshots are specific to TypeScript | -| function_calls/pre_cancelled_call_does_not_enter_callback | y | y | y | y | - | - | y | - | y | - | all | | -| function_calls/promise_all_failure_with_explicit_sibling_abort_preserves_error_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/promise_callback_preserves_async_local_storage_across_suspension_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/promise_callback_sync_reentry_rejects_host_callback_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/provider_error_preserves_native_error_metadata | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | exercises Python BamlError and generated Pydantic payloads | -| function_calls/raises_async_sibling_also_has_raises | y | - | - | - | y | - | y | - | y | - | all | | -| function_calls/raises_imports | - | - | - | - | - | - | y | - | - | - | all | | -| function_calls/raises_imports_symbols_reachable | y | - | - | - | y | - | - | - | y | - | all | | -| function_calls/raises_inferred_contract_without_clause_still_raises | y | - | - | - | y | - | y | - | y | - | all | | -| function_calls/raises_method_raises_block_in_pyi | y | - | - | - | - | - | y | - | y | - | all | | -| function_calls/raises_method_raises_blocks | - | - | - | - | y | - | - | - | - | - | all | | -| function_calls/raises_non_throwing_function_has_no_raises_block | y | - | - | - | y | - | y | - | y | - | all | | -| function_calls/raises_single_throws | y | - | - | - | y | - | y | - | y | - | all | | -| function_calls/raises_summary_precedes_raises_block | y | - | - | - | y | - | y | - | y | - | all | | -| function_calls/raises_union_throws_lists_all_names | y | - | - | - | y | - | y | - | y | - | all | | -| function_calls/reflect_facade_builds_runtime_declarations_and_decodes_their_instances | - | - | - | - | - | - | - | y | - | - | go | Go's hand-written reflect facade; the host_reflect fixture that would cover it does not compile | -| function_calls/reflected_type_closed_dynamic_unions_and_callback | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/reflected_type_composes_through_optional_containers_and_classes | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/reflected_type_primitive_literal_and_nominal_descriptors | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/reflected_type_top_level_and_runtime_produced_values | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/rejected_admission_does_not_consume_reservation | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/repeated_abort_does_not_interrupt_async_callback_cleanup_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python callback cancellation under an explicit controller | -| function_calls/repeated_cooperative_abort_does_not_interrupt_callback_cleanup_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/repeated_dispatches_each_start_with_entry_async_local_storage_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/repeated_dispatches_each_start_with_entry_context_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python ContextVar isolation between dispatch tasks | -| function_calls/repeated_dispatches_invoke_callback_in_order | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/reservation_does_not_inherit | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/reservation_is_single_use | - | - | - | - | - | - | y | - | y | - | all | | -| function_calls/retained_callback_uses_invocation_async_local_storage_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/retained_context_survives_parent_completion | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/retained_effective_token_observes_late_parent_cancellation | - | - | - | - | - | - | y | - | y | - | all | | -| function_calls/retained_effective_token_stays_live_after_callback | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/retained_host_callback_uses_later_invocations_application_context_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python callback task and event loop ownership | -| function_calls/retained_invocation_resolves_token_after_callback | y | - | - | - | - | - | - | - | - | - | all | | -| function_calls/returned_callable_accepts_controls | y | y | y | y | - | - | y | - | y | - | all | | -| function_calls/returned_callable_accepts_controls_async | - | - | - | - | - | - | y | - | - | - | all | | -| function_calls/returned_closure_preserves_callback_error_and_remains_reusable | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/returned_closure_retains_host_callback | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/returned_function_is_accepted_as_an_argument | - | - | - | - | - | - | y | - | - | - | all | | -| function_calls/reused_callback_uses_current_async_local_storage_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/runtime_executes_the_generated_sdk_in_a_browser | - | - | y | - | - | - | - | - | - | - | python_pydantic2, typescript_web_chromium, cpp, csharp, rust, go, java, swift | | -| function_calls/runtime_executes_the_generated_sdk_in_node | - | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| function_calls/runtime_executes_the_generated_sdk_in_workerd | - | - | - | y | - | - | - | - | - | - | python_pydantic2, typescript_web_cloudflare_workers, cpp, csharp, rust, go, java, swift | | -| function_calls/runtime_imports_the_generated_sdk_in_the_configured_runtime | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/same_callback_recurses_through_baml | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/same_callback_recurses_through_baml_without_reusing_an_entry_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | sync callback re-entry differs between Python and Node | -| function_calls/same_named_throws_ok_passes_through | - | - | - | - | - | - | y | - | - | - | rust | validates Rust-specific throws variant naming | -| function_calls/same_named_throws_selects_the_json_parse_error | - | - | - | - | - | - | y | - | - | - | rust | validates Rust-specific throws variant naming | -| function_calls/same_named_throws_selects_the_stdlib_parse_error | - | - | - | - | - | - | y | - | - | - | rust | validates Rust-specific throws variant naming | -| function_calls/single_required_arg_round_trips | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/specialized_callable_rejects_type_bindings | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/static_method_errors_never_cancellation_and_collision_names | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/static_method_media_json_type_and_rust_type_round_trips | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/static_method_required_default_and_structured_round_trips | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/stdlib_entrypoints_compiler_intrinsics_are_not_emitted_as_entry_points | y | y | - | - | y | - | y | - | y | y | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| function_calls/stdlib_entrypoints_native_argv_callable_as_entry_point | y | - | - | - | y | - | y | - | y | y | all | | -| function_calls/stdlib_entrypoints_native_baml_sys_argv_is_callable_as_an_entry_point | - | y | y | y | - | - | - | - | - | - | all | | -| function_calls/stdlib_entrypoints_sysop_fs_exists_callable_as_entry_point | y | y | - | - | y | - | y | - | y | y | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| function_calls/stdlib_error_surfaces_as_go_error | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/stream_schema_failure_is_parse_failed | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | exercises Python BamlError and generated Pydantic payloads | -| function_calls/suspended_callback_survives_gc_until_cancellation_cleanup_finishes_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | asyncio tasks retain callback bodies until actual completion | -| function_calls/sync_call_returns_null | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/sync_callback_can_run_async_baml_with_its_own_application_loop_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Node cannot synchronously drive an application loop | -| function_calls/sync_callback_inherits_call_time_async_local_storage_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/sync_callback_inherits_call_time_context_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | -| function_calls/sync_callback_of_async_entry_reenters_sync_baml_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | sync callback re-entry differs between Python and Node | -| function_calls/sync_callback_of_async_entry_runs_on_originating_loop_thread_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | -| function_calls/sync_callback_of_async_entry_runs_on_originating_loop_thread_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/sync_callback_reenters_sync_baml_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | sync callback re-entry differs between Python and Node | -| function_calls/sync_callback_runs_on_sync_callers_thread_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | -| function_calls/sync_callback_sync_reentry_rejects_host_callback_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/sync_cancel_via_context | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/sync_entry_async_callback_copies_context_across_suspension_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python ContextVar semantics with a bridge-owned loop | -| function_calls/sync_entry_async_callback_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | -| function_calls/sync_entry_cancellation_reaches_async_callback_on_worker_loop_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python cancellation on a bridge-owned callback loop | -| function_calls/sync_entry_in_running_loop_with_self_contained_async_callback_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | -| function_calls/sync_entry_in_running_loop_with_sync_callback_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | -| function_calls/sync_entry_rejection_does_not_start_promise_callback_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/sync_entry_rejection_preserves_application_context_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/sync_entry_rejects_before_dispatch_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/sync_entry_rejects_callback_before_async_reentry_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/sync_entry_rejects_callback_before_recursive_reentry_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/sync_entry_rejects_callback_before_sync_reentry_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/sync_entry_rejects_promise_callback_between_event_loop_turns_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/sync_entry_rejects_promise_callback_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/sync_entry_rejects_retained_callback_and_allows_async_reuse_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node sync entries must reject callbacks hidden in native closure captures | -| function_calls/sync_entry_rejects_sync_callback_between_event_loop_turns_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/sync_entry_rejects_sync_callback_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | -| function_calls/sync_entry_sync_callback_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | -| function_calls/sync_retained_closure_rejects_before_dispatch_typescript_only | - | y | - | - | - | - | - | - | - | - | typescript_node | Node cannot run JS callbacks while a sync native call blocks its event loop | -| function_calls/task_factory_failure_completes_dispatch_and_preserves_exception_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python asyncio task factory failures during dispatch | -| function_calls/taskgroup_failure_cancels_sibling_baml_call_and_preserves_error_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python callback task and event loop ownership | -| function_calls/timeout_upper_bound_accepted | y | y | y | y | - | - | y | - | y | - | all | | -| function_calls/to_thread_sync_entry_inherits_copied_application_context_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | -| function_calls/two_application_loops_on_two_threads_share_callback_without_rerouting_python_only | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | -| function_calls/unhandled_spawn_error_uses_host_default | - | - | - | - | y | - | - | y | y | y | cpp, go, java, swift | requires subprocess-level SDK harness support | -| function_calls/union_throws_preserves_concrete_class_identity | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/unknown_control_rejected | y | y | y | y | - | - | - | - | - | - | all | | -| function_calls/unmarked_callback_explicit_context_and_reentry | y | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| function_calls/unpolled_future_starts_nothing_rust_only | - | - | - | - | - | - | y | - | - | - | rust | Rust task scopes and lazy future cancellation are specific to Rust | -| function_calls/unset_and_none_differ_in_one_call | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/user_panic_surfaces_as_go_error_without_panicking | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/user_throw_surfaces_declared_class_identity | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/web_sysops_maps_fetch_failures_and_timeouts_into_declared_baml_errors | - | - | y | y | - | - | - | - | - | - | python_pydantic2, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, csharp, rust, go, java, swift | | -| function_calls/web_sysops_rejects_http_streaming_and_unrelated_sysops | - | - | y | y | - | - | - | - | - | - | python_pydantic2, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, csharp, rust, go, java, swift | | -| function_calls/web_sysops_rejects_sync_and_async_baml_fs_read_promptly | - | - | y | - | - | - | - | - | - | - | python_pydantic2, typescript_web_chromium, cpp, csharp, rust, go, java, swift | | -| function_calls/web_sysops_rejects_sync_http_before_dispatching_fetch | - | - | y | y | - | - | - | - | - | - | python_pydantic2, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, csharp, rust, go, java, swift | | -| function_calls/web_sysops_rejects_unsupported_filesystem_operations | - | - | y | y | - | - | - | - | - | - | python_pydantic2, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, csharp, rust, go, java, swift | | -| function_calls/web_sysops_supports_sync_and_async_baml_fs_read_through_node_fs_read_file_sync | - | - | - | y | - | - | - | - | - | - | python_pydantic2, typescript_web_cloudflare_workers, cpp, csharp, rust, go, java, swift | | -| function_calls/web_sysops_trampolines_baml_http_fetch_to_global_fetch_and_buffers_the_response | - | - | y | y | - | - | - | - | - | - | python_pydantic2, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, csharp, rust, go, java, swift | | -| function_calls/web_sysops_trampolines_baml_http_send_with_method_headers_and_body | - | - | y | y | - | - | - | - | - | - | python_pydantic2, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, csharp, rust, go, java, swift | | -| function_calls/zero_timeout_does_not_enter_callback | y | y | y | y | - | - | y | - | y | - | all | | -| host_reflect/a_runtime_type_is_one_declaration_and_rejects_serialization | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | BEP-066 host reflection is currently exposed only by Python, TypeScript, and Go | -| host_reflect/compiled_package_returns_class_graph | y | y | y | y | - | - | - | y | - | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, go | BEP-066 host reflection is currently exposed only by Python, TypeScript, and Go | -| host_reflect/generated_class_subclasses_resolve_to_declared_type | y | y | y | y | - | - | - | - | - | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | Python and TypeScript have generated-class subclass tokens; Go has no subclass construct | -| host_reflect/host_handles_expose_composition_only | y | y | y | y | - | - | - | y | - | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, go | BEP-066 host reflection is currently exposed only by Python, TypeScript, and Go | -| host_reflect/known_type_tokens_compose_and_reject_unknowns | y | y | y | y | - | - | - | y | - | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, go | BEP-066 host reflection is currently exposed only by Python, TypeScript, and Go | -| host_reflect/reflection_compile_errors_are_typed | y | y | y | y | - | - | - | y | - | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, go | BEP-066 host reflection is currently exposed only by Python, TypeScript, and Go | -| host_reflect/runtime_class_definition_preserves_nested_metadata | y | y | y | y | - | - | - | y | - | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, go | BEP-066 host reflection is currently exposed only by Python, TypeScript, and Go | -| host_reflect/runtime_enum_definition_decodes_alias | y | y | y | y | - | - | - | y | - | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, go | BEP-066 host reflection is currently exposed only by Python, TypeScript, and Go | -| host_reflect/wire_occurrences_are_fresh_and_handles_reject_serialization | - | y | y | y | - | - | - | y | - | - | all | | -| integration/baml_closures_execute_host_arguments_structured_returns_and_mutable_captures | - | - | - | - | - | y | - | - | - | - | csharp | C# canonical coverage executes through its native integration harness | -| integration/basic_calls_executes_sync_and_async | - | - | - | - | - | y | - | - | - | - | csharp | exercises C#-specific native SDK integration coverage | -| integration/cancel_token_any_propagates_native_cancellation | - | - | - | - | - | y | - | - | - | - | csharp | isolates the flaky native cancellation propagation check | -| integration/canonical_documentation_consumer_compiles_and_executes | - | - | - | - | - | y | - | - | - | - | csharp | validates the C#-specific documentation consumer | -| integration/checked_in_union_runtime_source_matches_generator | - | - | - | - | - | y | - | - | - | - | csharp | validates C#-specific generated union runtime source | -| integration/dynamic_values_executes_native_dynamic_value_parity | - | - | - | - | - | y | - | - | - | - | csharp | exercises C#-specific native SDK integration coverage | -| integration/dynamic_values_type_naming_a_runtime_declaration_twice_round_trips | - | - | - | - | - | y | - | - | - | - | csharp | exercises C#-specific ownership of a runtime declaration's repeated key | -| integration/failures_and_cancellation_executes_typed_failures_cancellation_and_exit | - | - | - | - | - | y | - | - | - | - | csharp | exercises C#-specific native SDK integration coverage | -| integration/generated_baml_clients_are_not_tracked | - | - | - | - | - | y | - | - | - | - | csharp | validates C#-specific generated-client repository hygiene | -| integration/generics_executes_inferred_and_explicit_generics | - | - | - | - | - | y | - | - | - | - | csharp | exercises C#-specific native SDK integration coverage | -| integration/generics_generated_surface_rejects_ambiguous_generic_calls | - | - | - | - | - | y | - | - | - | - | csharp | exercises C#-specific generated-surface compile coverage | -| integration/host_callable_hof_receives_a_baml_closure | - | - | - | - | - | y | - | - | - | - | csharp | C# canonical coverage executes through its native integration harness | -| integration/host_callables_callback_faults_complete_by_whose_they_are | - | - | - | - | - | y | - | - | - | - | csharp | C# completion-contract coverage executes through its native integration harness | -| integration/host_callables_json_nested_deeper_than_the_wire_carries_is_refused_as_a_callback_result | - | - | - | - | - | y | - | - | - | - | csharp | C# measures typed lists and class chains at the wire's exact limit | -| integration/host_callables_json_nested_deeper_than_the_wire_carries_is_refused_as_an_argument | - | - | - | - | - | y | - | - | - | - | csharp | C# measures typed lists and class chains at the wire's exact limit | -| integration/media_executes_media_in_both_directions | - | - | - | - | - | y | - | - | - | - | csharp | exercises C#-specific native SDK integration coverage | -| integration/primitive_edges_executes_native_primitive_and_nullable_edges | - | - | - | - | - | y | - | - | - | - | csharp | exercises C#-specific native SDK integration coverage | -| integration/release_abi_lifetimes_match_current_native_library | - | - | - | - | - | y | - | - | - | - | csharp | exercises the C# release ABI probe before publication | -| integration/stdlib_resources_executes_native_typed_resource_apis_lifetimes_and_state | - | - | - | - | - | y | - | - | - | - | csharp | exercises C#-specific native SDK integration coverage | -| integration/stdlib_structurals_executes_native_stdlib_structural_roundtrips | - | - | - | - | - | y | - | - | - | - | csharp | exercises C#-specific native SDK integration coverage | -| integration/streaming_executes_generated_native_stream_and_request_failure | - | - | - | - | - | y | - | - | - | - | csharp | exercises C#-specific native SDK integration coverage | -| integration/type_roundtrips_executes_nominals_collections_defaults_and_unions | - | - | - | - | - | y | - | - | - | - | csharp | exercises C#-specific native SDK integration coverage | -| llm_functions/a_runtime_declarations_type_value_holds_it_and_states_it_again | - | - | - | - | - | - | - | - | y | - | java | pins the Java BamlType holding a runtime declaration's engine reference | -| llm_functions/constructs_a_live_spec_without_a_synthetic_fqn | - | y | y | y | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | validates TypeScript's live FunctionSpec metadata surface | -| llm_functions/dynamic_runtime_stream_identity_and_flat_projection_parity | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | validates Python BamlRuntimeValue identity through the generated sync stream surface | -| llm_functions/dynamic_runtime_stream_is_an_elegant_async_iterable | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | validates Python's generated async-iterable stream surface | -| llm_functions/flat_stream_calls_exact_companion_fqn | - | - | - | - | y | - | - | - | - | - | cpp | C++-only exact flat-stream companion dispatch check. | -| llm_functions/flat_stream_controls_are_typed_options | - | - | - | - | - | - | - | y | - | - | go | pins the Go generator's typed option surface for flat stream controls. | -| llm_functions/function_spec_helpers_accept_invocation_controls | y | - | - | - | - | - | - | - | - | - | all | | -| llm_functions/function_spec_helpers_accept_invocation_controls_async | y | - | - | - | - | - | - | - | - | - | all | | -| llm_functions/function_spec_parse_replaces_the_parse_companion | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | validates Python's generated FunctionSpec parse surface | -| llm_functions/function_spec_parse_returns_closed_enum | - | - | - | - | - | - | - | y | - | - | go | pins Go's closed-enum FunctionSpec decoder surface. | -| llm_functions/function_spec_parse_returns_runtime_error_for_invalid_output | - | - | - | - | - | - | - | y | - | - | go | pins Go's FunctionSpec parse-error translation. | -| llm_functions/function_spec_parse_returns_typed_class_and_fills_missing_nullable_field | - | - | - | - | - | - | - | y | - | - | go | pins Go's typed FunctionSpec class decoder surface. | -| llm_functions/function_spec_prompt_is_accepted_as_an_argument | - | - | - | - | y | - | - | - | - | - | cpp | pins that a C++ prompt crosses back as an argument. | -| llm_functions/function_spec_prompt_uses_generated_ai_prompt_type_and_is_reusable | - | - | - | - | - | - | - | y | - | - | go | pins the Go FunctionSpec prompt facade and reusable context behavior. | -| llm_functions/keeps_a_portable_prompt_reusable_across_engine_reentry | - | y | y | y | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | validates TypeScript's portable Prompt and Image wrappers | -| llm_functions/legacy_function_companion_wire_bindings_are_absent | - | - | - | - | - | - | - | y | - | - | go | pins the Go generator's emitted wire-binding names. | -| llm_functions/main_baml_sdk_lorem_and_baml_sdk_ipsum_are_reachable | - | y | y | y | - | - | - | - | - | - | all | | -| llm_functions/main_classify_sentiment_factory_bindings | y | - | - | - | - | - | y | - | y | - | all | | -| llm_functions/main_extract_resume_factory_bindings | y | - | - | - | - | - | y | - | y | - | all | | -| llm_functions/main_extract_resume_operation_bindings | y | - | - | - | - | - | - | - | y | - | python_pydantic2, java | pins Python and Java flat operation binding spellings | -| llm_functions/main_extract_resume_spec_and_stream_bindings | - | - | - | - | - | - | y | - | - | - | rust | pins Rust generated spec and stream binding spellings | -| llm_functions/main_flat_stream_controls_live_on_the_stream_options | - | - | - | - | - | - | - | - | y | - | all | | -| llm_functions/main_function_spec_prompt_is_portable_and_reusable | - | - | - | - | - | - | - | - | y | - | all | | -| llm_functions/main_ipsum_classify_sentiment_sync_plus_async_factories_are_callable | - | y | y | y | - | - | - | - | - | - | all | | -| llm_functions/main_ipsum_sentiment_enum_has_positive_negative_neutral_members | - | y | y | y | - | - | - | - | - | - | all | | -| llm_functions/main_ipsum_sentiment_enum_shape | y | - | - | - | - | - | y | - | y | y | all | | -| llm_functions/main_lorem_extract_resume_exposes_flat_spec_and_stream_bindings | - | y | y | y | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | pins TypeScript's dollar-preserving flat stream binding names | -| llm_functions/main_lorem_extract_resume_sync_plus_async_factories_are_callable | - | y | y | y | - | - | - | - | - | - | all | | -| llm_functions/main_lorem_resume_class_shape | y | - | - | - | - | - | y | - | y | - | all | | -| llm_functions/main_lorem_resume_is_reachable | - | y | y | y | - | - | - | - | - | - | all | | -| llm_functions/main_lorem_streaming_doc_class_shape | y | - | - | - | - | - | y | - | y | - | all | | -| llm_functions/main_lorem_streaming_doc_is_reachable | - | y | y | y | - | - | - | - | - | - | all | | -| llm_functions/main_lorem_streaming_extract_exposes_flat_spec_and_stream_bindings | - | y | y | y | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | pins TypeScript's dollar-preserving flat streaming binding names | -| llm_functions/main_lorem_streaming_extract_sync_plus_async_factories_are_callable | - | y | y | y | - | - | - | - | - | - | all | | -| llm_functions/main_namespaces_reachable_via_explicit_import | y | - | - | - | - | - | y | - | y | - | all | | -| llm_functions/main_nullable_model_field_can_be_omitted | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | pins the Python generator's nullable-field `= None` default | -| llm_functions/main_replay_server_namespace_bindings | y | - | - | - | - | - | y | - | y | - | all | | -| llm_functions/main_root_imports_cleanly | y | y | y | y | - | - | y | - | y | - | all | | -| llm_functions/main_spec_replaces_prompt_parse_and_request_companions | - | - | - | - | - | - | y | - | - | - | rust | validates Rust's generated FunctionSpec prompt, parse, and request surface | -| llm_functions/main_streaming_extract_factory_bindings | y | - | - | - | - | - | y | - | y | - | all | | -| llm_functions/main_streaming_extract_operation_bindings | y | - | - | - | - | - | - | - | y | - | python_pydantic2, java | pins Python and Java flat streaming operation binding spellings | -| llm_functions/main_streaming_extract_spec_and_stream_bindings | - | - | - | - | - | - | y | - | - | - | rust | pins Rust generated streaming spec and stream binding spellings | -| llm_functions/main_types_and_bindings_reachable | - | - | - | - | - | - | - | - | - | y | all | | -| llm_functions/on_event_plain_call_delivers_events | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | host on_event listener coverage lands Python-first; other SDKs port separately | -| llm_functions/on_event_plain_call_delivers_model_call_model | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | host on_event listener coverage lands Python-first; other SDKs port separately | -| llm_functions/on_event_plain_call_raising_listener_does_not_fail | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | host on_event listener coverage lands Python-first; other SDKs port separately | -| llm_functions/on_event_stream_delivers_settle_events | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | host on_event listener coverage lands Python-first; other SDKs port separately | -| llm_functions/on_event_stream_raising_listener_does_not_fail | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | host on_event listener coverage lands Python-first; other SDKs port separately | -| llm_functions/projects_static_and_instance_llm_methods_without_invoking_a_provider | - | y | y | y | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | validates TypeScript static and instance operation member spellings | -| llm_functions/prompt_is_reusable_and_media_survives_request_preview | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | validates Python's portable Prompt and media wrapper surface | -| llm_functions/recording_lands_in_cwd_project | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | native Python recording lands where `baml query --local` reads | -| llm_functions/spec_projection_honors_cancellation | - | - | - | - | - | - | - | y | - | - | go | pins Go context cancellation through the Spec projection. | -| llm_functions/stream_async_for_keeps_context_after_creator_returns | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python stream lifetime/context regression | -| llm_functions/stream_contexts_are_isolated_during_concurrent_iteration | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python concurrent stream context isolation | -| llm_functions/stream_final_caches_only_settled_errors | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python final caching must distinguish results from control flow | -| llm_functions/stream_final_keeps_context_and_allows_a_step_patch | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python sync stream defaults and explicit step patches | -| llm_functions/stream_final_parse_failure_is_preserved_after_eof | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python EOF settlement retains final parse errors | -| llm_functions/stream_final_timeout_is_not_cached | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python task cancellation must not poison later final awaits | -| llm_functions/stream_keeps_creation_cancel_token | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python stream cancellation survives constructor return | -| llm_functions/stream_precancelled_admission_preserves_reservation | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python stream reservations must survive rejected admission | -| llm_functions/stream_recording_parent_chain | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | native Python recording ancestry, duration, values and step-count audit | -| llm_functions/streaming_e2e_async_class_typed_next_async_yields_10_partials | - | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| llm_functions/streaming_e2e_async_next_async_yields_10_partials | - | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| llm_functions/streaming_e2e_baml_driven_collect_keeps_the_s_stream_finished_union_engine_side | - | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| llm_functions/streaming_e2e_baml_driven_collect_returns_the_final_doc | - | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| llm_functions/streaming_e2e_next_yields_10_doc_partials_final_is_a_typed_streaming_doc | - | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| llm_functions/streaming_e2e_next_yields_10_partials_and_drains_to_stream_finished | - | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| llm_functions/streaming_e2e_stream | y | - | - | - | - | - | y | - | y | y | all | | -| llm_functions/streaming_e2e_stream_async | y | - | - | - | - | - | y | - | y | y | all | | -| llm_functions/streaming_e2e_stream_collect_in_baml | y | - | - | - | - | - | y | - | y | y | all | | -| llm_functions/streaming_e2e_stream_doc | y | - | - | - | - | - | y | - | y | y | all | | -| llm_functions/streaming_e2e_stream_doc_async | y | - | - | - | - | - | y | - | y | y | all | | -| llm_functions/streaming_e2e_stream_doc_collect_in_baml | y | - | - | - | - | - | y | - | y | y | all | | -| package_edges/compile_cross_package_types_compile | - | - | - | - | - | - | - | y | - | - | all | | -| package_edges/compile_llm_projection_default_overrides | - | - | - | - | - | - | - | y | - | - | go | pins Go generator options for authored Spec and Stream defaults. | -| type_shapes/alias_container_composition_and_defaults | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/alias_package_scope_collisions_compile_and_run | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/aliases_round_trip_alias_container | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/aliases_round_trip_maybe_rec | - | - | - | - | y | - | - | - | - | - | all | | -| type_shapes/aliases_round_trip_rec_list | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/aliases_round_trip_string_list | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/bridge_handles_media_clones_ordinary_owners_and_wire_keys_independently | - | y | y | y | - | - | - | - | - | - | all | | -| type_shapes/bridge_handles_media_clones_ownership_through_to_handle_and_from_handle | - | y | y | y | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | exercises TypeScript bridge media handle ownership APIs | -| type_shapes/bridge_handles_media_constructs_url_file_and_base64_descriptors | - | y | y | y | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | exercises TypeScript bridge media descriptor APIs | -| type_shapes/bridge_handles_media_exposes_stable_seed_tags | - | y | y | y | - | - | - | - | - | - | all | | -| type_shapes/bridge_handles_media_keeps_host_value_tags_outside_ordinary_clone_and_release | - | y | y | y | - | - | - | - | - | - | all | | -| type_shapes/bridge_handles_media_normalizes_key_halves_losslessly_and_returns_defensive_key_objects | - | y | y | y | - | - | - | - | - | - | all | | -| type_shapes/bridge_handles_media_rejects_an_invalid_ordinary_key_only_when_an_operation_resolves_it | - | y | y | y | - | - | - | - | - | - | all | | -| type_shapes/bridge_surface_exports_the_same_runtime_values_in_node_browsers_and_workers | - | y | y | y | - | - | - | - | - | - | all | | -| type_shapes/bridge_surface_preserves_the_public_constructor_names | - | y | y | y | - | - | - | - | - | - | all | | -| type_shapes/class_refs_make_outer | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/class_refs_round_trip_async_by_value_or_reference_rust_only | - | - | - | - | - | - | y | - | - | - | rust | Rust class parameters take `impl Borrow`, so callers pass a value or a reference | -| type_shapes/class_refs_round_trip_by_value_or_reference_rust_only | - | - | - | - | - | - | y | - | - | - | rust | Rust class parameters take `impl Borrow`, so callers pass a value or a reference | -| type_shapes/class_refs_round_trip_inner | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/class_refs_round_trip_outer | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/closed_union_zero_value_returns_an_input_error | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/complex_models_round_trip_complex_profile_accepts_plain_object_literals_no_class_constructors | - | y | y | y | - | - | - | - | - | - | all | | -| type_shapes/complex_models_round_trip_complex_profile_preserves_deeply_nested_mixed_shape_class | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/container_recursive_class_round_trip | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/declared_enum_functions_and_class_round_trip | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/deep_namespace_thing_reachable | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/defaulted_enum_argument_and_invalid_values | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/dynamic_union_accepts_natural_go_integers | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/dynamic_union_candidates_round_trip_as_concrete_go_values | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/dynamic_union_delegates_semantic_validation_to_baml | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/dynamic_union_nested_containers_round_trip | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/dynamic_union_of_containers_uses_selected_type_metadata | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/dynamic_union_rejects_unserializable_go_values_in_bridge | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/enum_composition_matrix_round_trip | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/enum_package_scope_collisions_compile_and_run | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/enums_pick_positive | y | y | y | y | y | - | y | - | y | - | all | | -| type_shapes/enums_pick_sentiment | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/enums_round_trip_enums | y | y | y | y | y | - | y | - | y | - | all | | -| type_shapes/enums_round_trip_sentiment | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/enums_round_trip_sentiment_positive | y | y | y | y | y | - | y | - | y | - | all | | -| type_shapes/every_supported_leaf_through_lists_and_maps | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/forward_refs_round_trip_g_node_int | y | y | y | y | - | - | y | - | y | - | all | | -| type_shapes/forward_refs_round_trip_node_symbol_exists | - | - | - | - | y | - | - | - | - | y | all | | -| type_shapes/forward_refs_round_trip_other | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/forward_refs_round_trip_rec_list | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/forward_refs_round_trip_rec_list_with_other | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/generic_generic | y | y | y | y | - | - | y | - | y | y | all | | -| type_shapes/generic_generic_wrapper_get_value | y | - | - | - | - | - | y | - | y | y | all | | -| type_shapes/generic_wrapper_get_value | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/generics_round_trip_box_int | y | y | y | y | - | - | y | - | y | y | all | | -| type_shapes/generics_round_trip_differing_instantiation | y | y | y | y | - | - | y | - | y | y | all | | -| type_shapes/generics_round_trip_generic_binary_tree_int | y | y | y | y | - | - | y | - | y | y | all | | -| type_shapes/generics_round_trip_generic_linked_list_int | y | y | y | y | - | - | y | - | y | y | all | | -| type_shapes/generics_round_trip_nested_generics | y | y | y | y | - | - | y | - | y | y | all | | -| type_shapes/generics_round_trip_wrapper_int | y | y | y | y | - | - | y | - | y | y | all | | -| type_shapes/handles_baml_fs_open_returns_a_typed_file_handle | - | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| type_shapes/handles_file_cursor_state_persists_across_calls | y | y | - | - | - | - | y | - | y | y | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| type_shapes/handles_http_get_response_fields_and_methods | y | y | - | - | - | - | y | - | y | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift | | -| type_shapes/handles_image_from_base64_roundtrips_payload | y | y | y | y | - | - | y | - | y | y | all | | -| type_shapes/handles_open_file_returns_file_handle | y | - | - | - | - | - | y | - | y | y | all | | -| type_shapes/host_created_media_round_trips_through_optional_list | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | validates Python host-created media wrapper encoding | -| type_shapes/host_from_file_reads_now_and_keeps_the_base_name | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | validates the Python media wrapper's from_file, which is BAML's from_file | -| type_shapes/json_interchange_both_reject_what_baml_rejects | - | - | - | - | - | - | y | - | - | - | rust | validates the Rust SDK's optional serde impls | -| type_shapes/json_interchange_deserialize_matches_baml | - | - | - | - | - | - | y | - | - | - | rust | validates the Rust SDK's optional serde impls | -| type_shapes/json_interchange_json_fields_match_baml | - | - | - | - | - | - | y | - | - | - | rust | validates the Rust SDK's optional serde impls | -| type_shapes/json_interchange_optional_fields_may_be_absent_or_null | - | - | - | - | - | - | y | - | - | - | rust | validates the Rust SDK's optional serde impls | -| type_shapes/json_interchange_serialize_matches_baml | - | - | - | - | - | - | y | - | - | - | rust | validates the Rust SDK's optional serde impls | -| type_shapes/json_interchange_serialize_matches_baml_outside_a_class | - | - | - | - | - | - | y | - | - | - | rust | validates the Rust SDK's optional serde impls | -| type_shapes/json_interchange_unions_take_the_first_arm_that_fits | - | - | - | - | - | - | y | - | - | - | rust | validates the Rust SDK's optional serde impls | -| type_shapes/lists_round_trip_empty_list | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/lists_round_trip_ints | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/lists_round_trip_list_container | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/lists_round_trip_optional_strings | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/lists_round_trip_union_list | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/literals_literal_values_convert_implicitly | - | - | - | - | y | - | - | - | - | - | all | | -| type_shapes/literals_return_literals | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/literals_round_trip_flag_mixed_literal_union | y | - | - | - | y | - | - | - | - | - | all | | -| type_shapes/literals_round_trip_literal42 | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/literals_round_trip_literal_draft | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/literals_round_trip_literal_escaped | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/literals_round_trip_literal_false | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/literals_round_trip_literal_true | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/literals_round_trip_literals | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/lorem_resume_reachable | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/main_all_namespaces_reachable | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/main_deep_namespace_thing_reachable | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/main_generated_models_ignore_extra_fields | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | validates Python Pydantic extra-field compatibility | -| type_shapes/main_lorem_resume_reachable | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/main_root_foo_reachable | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/main_root_imports_cleanly | y | y | y | y | - | - | y | - | y | - | all | | -| type_shapes/main_runtime_owned_builtin_leaves_expose_their_public_names | - | y | y | y | - | - | - | - | - | - | all | | -| type_shapes/make_foo | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/make_outer | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/maps_round_trip_enum_keyed_map | - | y | y | y | - | - | - | - | - | - | all | | -| type_shapes/maps_round_trip_list_valued_map | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/maps_round_trip_map_container | - | y | y | y | - | - | - | - | - | - | all | | -| type_shapes/maps_round_trip_resume | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/maps_round_trip_sentiment | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/maps_round_trip_simple_map | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/media_constructors_and_accessors | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/media_from_file_reads_now_and_keeps_the_base_name | - | - | - | - | - | - | - | - | y | - | java | validates the Java media wrapper's from_file, which is BAML's from_file | -| type_shapes/media_nested_optional_and_containers | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/media_return_and_round_trip_all_kinds | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/media_return_audio | y | - | - | - | - | - | y | - | y | y | all | | -| type_shapes/media_return_audio_preserves_url_and_mime | - | y | y | y | - | - | - | - | - | - | all | | -| type_shapes/media_return_image | y | - | - | - | - | - | y | - | y | y | all | | -| type_shapes/media_return_image_preserves_url_and_mime | - | y | y | y | - | - | - | - | - | - | all | | -| type_shapes/media_return_pdf | y | - | - | - | - | - | y | - | y | y | all | | -| type_shapes/media_return_pdf_preserves_url_and_mime | - | y | y | y | - | - | - | - | - | - | all | | -| type_shapes/media_return_video | y | - | - | - | - | - | y | - | y | y | all | | -| type_shapes/media_return_video_preserves_url_and_mime | - | y | y | y | - | - | - | - | - | - | all | | -| type_shapes/media_round_trip_all_media | y | y | y | y | y | - | - | y | y | y | all | | -| type_shapes/media_round_trip_audio | y | - | - | - | - | - | y | - | y | y | all | | -| type_shapes/media_round_trip_audio_preserves_url_and_mime | - | y | y | y | - | - | - | - | - | - | all | | -| type_shapes/media_round_trip_image | y | - | - | - | - | - | y | - | y | y | all | | -| type_shapes/media_round_trip_image_can_reuse_the_same_wrapper | - | y | y | y | - | - | - | - | - | - | all | | -| type_shapes/media_round_trip_image_list | - | - | - | - | - | - | - | - | - | y | all | | -| type_shapes/media_round_trip_image_map | - | - | - | - | - | - | - | - | - | y | all | | -| type_shapes/media_round_trip_image_or_audio | - | - | - | - | - | - | - | - | y | y | all | | -| type_shapes/media_round_trip_media | y | - | - | - | - | - | y | - | y | y | all | | -| type_shapes/media_round_trip_media_or_image_class | y | y | y | y | y | - | - | y | y | y | all | | -| type_shapes/media_round_trip_media_preserves_all_four_media_fields | - | y | y | y | - | - | - | - | - | - | all | | -| type_shapes/media_round_trip_media_shapes | - | - | - | - | - | - | - | - | - | y | all | | -| type_shapes/media_round_trip_optional_image_list | - | - | - | - | - | - | - | - | - | y | all | | -| type_shapes/media_round_trip_pdf | y | - | - | - | - | - | y | - | y | y | all | | -| type_shapes/media_round_trip_pdf_preserves_url_and_mime | - | y | y | y | - | - | - | - | - | - | all | | -| type_shapes/media_round_trip_video | y | - | - | - | - | - | y | - | y | y | all | | -| type_shapes/media_round_trip_video_preserves_url_and_mime | - | y | y | y | - | - | - | - | - | - | all | | -| type_shapes/media_uses_the_runtime_owned_wrapper_constructors | - | y | y | y | - | - | - | - | - | - | all | | -| type_shapes/nested_class_round_trip | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/no_op | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/nullable_class_fields_round_trip | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/nullable_container_boundaries_round_trip | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/nullable_container_fields_round_trip | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/nullable_recursive_classes_round_trip | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/opaque_rust_type_default_and_host_callback_positions | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/opaque_rust_type_nested_containers_classes_and_null | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/opaque_rust_type_round_trips_and_remains_reusable | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/optional_omitted_nullable_keys_read_as_null | - | y | y | y | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | TypeScript object literals and constructor inits may leave nullable keys out | -| type_shapes/optional_omitted_required_key_is_rejected | - | y | y | y | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | only TypeScript has a type-level check that a required key is present | -| type_shapes/optional_round_trip_optional_container | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/optional_round_trip_optional_int | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/optional_round_trip_optional_resume | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/optional_round_trip_optional_union | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/optional_round_trip_resume | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/optional_top_level_round_trips | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/overlapping_list_arms_preserve_exact_kind_for_empty_and_nonempty_values | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/overlapping_literal_arms_round_trip_with_exact_kind | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/overlapping_map_arms_preserve_exact_kind_for_empty_and_nonempty_values | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/pick_positive | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/pick_sentiment | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/primitive_class_round_trip | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/primitive_literal_union_constructors_round_trip_with_exact_kind_and_value | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/primitives_return_bigint | y | - | - | - | y | - | - | - | y | - | all | | -| type_shapes/primitives_return_bool | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/primitives_return_float | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/primitives_return_int | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/primitives_return_null | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/primitives_return_string | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/primitives_round_trip_bigint | y | - | - | - | y | - | - | - | y | - | all | | -| type_shapes/primitives_round_trip_bool | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/primitives_round_trip_float | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/primitives_round_trip_float_accepts_int | y | - | - | - | y | - | y | - | y | - | all | | -| type_shapes/primitives_round_trip_float_refuses_an_int_on_the_wire | - | - | - | - | - | - | y | - | - | - | rust | only the low-level Rust entry point can put an int on the wire where a float is declared | -| type_shapes/primitives_round_trip_int | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/primitives_round_trip_int_async | - | - | - | - | - | - | - | - | - | y | all | | -| type_shapes/primitives_round_trip_null | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/primitives_round_trip_primitives | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/primitives_round_trip_primitives_float_field_accepts_int | y | - | - | - | y | - | y | - | y | y | all | | -| type_shapes/primitives_round_trip_string | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/primitives_round_trip_uint8_array | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/recursion_int_binary_tree_deeper_than_the_wire_is_refused | y | y | y | y | y | - | y | y | y | y | all | | -| type_shapes/recursion_round_trip_int_binary_tree | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/recursion_round_trip_mutual_recursion | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/recursion_round_trip_scc_t1_t2_t3 | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/recursion_round_trip_scc_t4_t5_t6 | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/required_container_round_trips | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/return_bool | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/return_float | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/return_int | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/return_literals | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/return_null | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/return_string | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/root_foo_reachable | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/root_imports_cleanly | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_bool | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_box_int | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_complex_profile_preserves_deeply_nested_mixed_shape_class | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_dedup | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_deep | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_deep_thing_from_a | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_deep_thing_from_lorem | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_differing_instantiation | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_empty_list | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_empty_list_preserves_selected_arm | - | - | - | - | - | - | - | - | - | y | all | | -| type_shapes/round_trip_enums | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_fizz_buzz_foo_bar | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_fizz_foo_bar | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_float | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_float_accepts_int_constant | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_foo | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_foo_bar | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_forward_ref_g_node_int | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_forward_ref_other | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_generic_binary_tree_int | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_generic_linked_list_int | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_inner | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_int | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_int_binary_tree | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_ints | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_ipsum | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_list_container | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_list_valued_map | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_literal42 | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_literal_draft | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_literal_escaped | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_literal_false | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_literal_true | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_literals | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_lorem_resume | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_lorem_resume_from_ipsum | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_map_resume | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_map_sentiment | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_mutual_recursion | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_nested_generics | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_null | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_null_to_end | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_optional_container | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_optional_int | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_optional_plus_null | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_optional_resume | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_optional_strings | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_optional_union | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_outer | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_primitives | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_primitives_float_field_accepts_int_constant | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_required_resume | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_resume_or_http_response | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_root_foo_from_ab | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_root_foo_from_lorem | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_scct1_t2_t3 | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_scct4_t5_t6 | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_sentiment | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_sentiment_positive | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_simple_map | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_singleton_unwrap | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_string | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_string_list | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_thing_from_ab | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_uint8_array | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_union_container | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_union_list | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_union_t | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/round_trip_wrapper_int | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/routing_make_foo | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/routing_round_trip_deep_thing_from_a | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/routing_round_trip_deep_thing_from_lorem | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/routing_round_trip_foo | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/routing_round_trip_lorem_resume_from_ipsum | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/routing_round_trip_resume | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/routing_round_trip_resume_or_http_response | y | y | y | y | - | - | - | - | y | y | all | | -| type_shapes/routing_round_trip_root_foo | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/routing_round_trip_root_foo_from_ab | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/routing_round_trip_thing_from_ab | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/supported_namespaces_reachable | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/symbol_collisions_round_trip_deep | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/symbol_collisions_round_trip_fizz_buzz_foo_bar | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/symbol_collisions_round_trip_fizz_foo_bar | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/symbol_collisions_round_trip_foo_bar | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/symbol_collisions_round_trip_ipsum | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/transparent_alias_functions_round_trip | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/type_alias_declared_before_classes_is_importable | y | - | - | - | - | - | - | - | - | - | python_pydantic2 | validates Python-specific generated type alias ordering | -| type_shapes/typemap_is_installed_during_root_module_evaluation | - | y | y | y | - | - | - | - | - | - | all | | -| type_shapes/typemap_preserves_user_enum_and_generic_mappings | - | y | y | y | - | - | - | - | - | - | all | | -| type_shapes/typemap_resolves_every_runtime_owned_base_to_one_bridge_constructor_identity | - | y | y | y | - | - | - | - | - | - | all | | -| type_shapes/union_aliases_are_transparent_and_flatten_before_thresholding | - | - | - | - | - | - | - | y | - | - | all | | -| type_shapes/unions_consumption_surfaces | - | - | - | - | - | - | - | - | - | y | all | | -| type_shapes/unions_match_dispatches_by_type | - | - | - | - | y | - | - | - | - | - | all | | -| type_shapes/unions_round_trip_dedup | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/unions_round_trip_null_to_end | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/unions_round_trip_optional_plus_null | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/unions_round_trip_singleton_unwrap | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/unions_round_trip_str_or_int_list | y | - | - | - | y | - | y | - | y | - | all | | -| type_shapes/unions_round_trip_t | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/unions_round_trip_union_container | y | y | y | y | y | - | y | - | y | y | all | | -| type_shapes/unions_union_is_a_plain_std_variant | - | - | - | - | y | - | - | - | - | - | all | | -| type_shapes/void_no_op | y | y | y | y | y | - | y | - | y | y | all | | -| unsupported_only/compile_unsupported_only_package_compiles | - | - | - | - | - | - | - | y | - | - | all | | +| Test case | python_pydantic2 | typescript_node | typescript_web_chromium | typescript_web_cloudflare_workers | cpp | csharp | rust | go | java | swift | ruby_sorbet | Required in | Reason | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| docstrings_etc/class_doc_summary_and_attributes | - | - | - | - | - | - | - | y | - | - | - | all | | +| docstrings_etc/enum_doc_summary_and_members | - | - | - | - | - | - | - | y | - | - | - | all | | +| docstrings_etc/enum_summary_only_omits_member_comments | - | - | - | - | - | - | - | y | - | - | - | all | | +| docstrings_etc/go_codegen_function_doc_comment | - | - | - | - | - | - | - | y | - | - | - | all | | +| docstrings_etc/imports | - | - | - | - | - | - | - | y | - | - | - | all | | +| docstrings_etc/main_class_doc_summary_and_attributes_section | y | y | - | - | y | - | y | - | y | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| docstrings_etc/main_class_doc_summary_present | - | - | - | - | - | - | - | - | - | y | - | all | | +| docstrings_etc/main_enum_and_variant_docs_attached | - | - | - | - | - | - | - | - | - | y | - | all | | +| docstrings_etc/main_enum_doc_summary_and_members_section | y | y | - | - | y | - | y | - | y | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| docstrings_etc/main_enum_summary_only_omits_members_section | y | - | - | - | y | - | y | - | y | - | - | all | | +| docstrings_etc/main_field_docs_attached | - | - | - | - | - | - | - | - | - | y | - | all | | +| docstrings_etc/main_imports_symbols_reachable | y | y | y | y | - | - | y | - | y | y | - | all | | +| docstrings_etc/main_multi_line_class_doc_preserved | - | - | - | - | - | - | - | - | - | y | - | all | | +| docstrings_etc/main_no_inline_field_or_variant_doc_artifacts | y | y | - | - | y | - | y | - | y | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| docstrings_etc/main_undocumented_field_listed_as_bare_name_under_attributes | y | - | - | - | y | - | y | - | y | - | - | all | | +| docstrings_etc/no_inline_field_or_variant_doc_artifacts | - | - | - | - | - | - | - | y | - | - | - | all | | +| docstrings_etc/undocumented_field_has_no_doc_artifact | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/ambient_context_survives_await_rust_only | - | - | - | - | - | - | y | - | - | - | - | rust | Rust task scopes and lazy future cancellation are specific to Rust | +| function_calls/async_callback_can_use_originating_loop_resources_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | +| function_calls/async_callback_can_use_originating_loop_resources_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/async_callback_inherits_application_context_across_suspension_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | +| function_calls/async_callback_preserves_ambient_frame_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node async carrier, native signals, and entry-time snapshots are specific to TypeScript | +| function_calls/async_callback_reenters_async_baml_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python loop/ContextVar and Node AsyncLocalStorage assertions | +| function_calls/async_callback_reenters_sync_baml_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | sync callback re-entry differs between Python and Node | +| function_calls/async_entry_sync_callback_inherits_application_context_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | +| function_calls/async_host_runs_in_calling_task_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | covers Python asyncio task and event-loop identity | +| function_calls/async_reentry_preserves_async_local_storage_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/async_stream_schema_failure_is_parse_failed | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | exercises the Python asyncio streaming bridge | +| function_calls/baml_closure_decodes_multiple_args_and_structured_return_values | y | y | y | y | y | - | y | y | y | y | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, rust, go, java, swift | C# covers this canonical behavior in its native integration harness | +| function_calls/baml_closure_is_a_native_callable_with_host_language_arguments | y | y | y | y | y | - | y | y | y | y | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, rust, go, java, swift | C# covers this canonical behavior in its native integration harness | +| function_calls/baml_closure_is_reusable_and_retains_mutable_captures | y | y | y | y | y | - | y | y | y | y | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, rust, go, java, swift | C# covers this canonical behavior in its native integration harness | +| function_calls/baml_error_carries_baml_trace | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/baml_time_class_and_field_wire_names_are_exact | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/baml_time_constructors_and_parsers_return_lossless_internal_values | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/baml_time_go_constructed_values_round_trip_at_numeric_boundaries | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/baml_time_malformed_values_fail_at_the_earliest_typed_boundary | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/baml_time_nested_containers_and_baml_field_inspection | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/baml_time_nullable_and_defaulted_positions | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/baml_time_raw_class_transport_does_not_enforce_semantic_invariants | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/baml_trace_is_embedded_in_go_error_string | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/boolean_timeout_rejected_python_only | y | - | - | - | - | - | - | - | - | - | - | all | | +| function_calls/callable_entry_invokes_callback | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/callable_entry_waits_for_callback_completion | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/callback_bound_method_adoption_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | covers Python bound method identity via its exact SDK function | +| function_calls/callback_captures_internal_baml_context | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/callback_frame_and_reentry | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/callback_frame_is_installed_and_restored | - | - | - | - | - | - | y | - | - | - | - | all | | +| function_calls/callback_marker_adopts_once_during_recursion | y | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/callback_marker_adopts_sync_body | y | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/callback_marker_concurrent_reuse_and_later_direct_call | y | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/callback_marker_context_precedence | y | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/callback_marker_defaults_override_inherited_context | y | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/callback_marker_only_outer_wrapper_adopts | y | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/callback_marker_sync_entry_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | covers Python synchronous BAML entry and inline callback dispatch | +| function_calls/callback_reenters_baml | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/callback_reuse_across_event_loop_turns_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/callback_reuse_after_previous_application_loop_is_closed_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | +| function_calls/callback_suppresses_task_cancellation_and_returns_late_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python callback suppression of cancellation and late results | +| function_calls/callback_task_cancelled_before_first_execution_completes_call_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python task factory cancellation before callback execution | +| function_calls/callback_third_party_wrapper_is_not_adopted | y | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/callback_throws_caught_and_replaced_makes_the_function_infallible | - | - | - | - | - | - | y | - | - | - | - | rust | validates Rust-specific inferred callback error unions | +| function_calls/callback_throws_caught_then_rethrown_value_is_the_replacement_union | - | - | - | - | - | - | y | - | - | - | - | rust | validates Rust-specific inferred callback error unions | +| function_calls/callback_throws_rethrown_carries_the_effect_param_into_the_error_union | - | - | - | - | - | - | y | - | - | - | - | rust | validates Rust-specific inferred callback error unions | +| function_calls/cancel_controls_with_cancelled_context_go_only | - | - | - | - | - | - | - | y | - | - | - | go | Go cancellation controls remain usable with a canceled context | +| function_calls/cancellation_async_call_returns_none | y | - | - | - | y | - | y | - | y | y | - | all | | +| function_calls/cancellation_async_cancel_skips_later_step | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | drives Python asyncio cancellation (task.cancel / wait_for) | +| function_calls/cancellation_async_cancel_via_asyncio_timeout | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/cancellation_async_cancel_via_call_context | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/cancellation_async_cancel_via_future_cancel | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/cancellation_async_cancel_via_task_cancel | y | - | - | - | - | - | y | - | y | y | - | all | | +| function_calls/cancellation_async_cancel_via_task_group_sibling | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/cancellation_async_cancel_via_timeout_race | - | - | - | - | - | - | - | - | - | y | - | all | | +| function_calls/cancellation_can_pre_abort_async_baml_cancellation | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/cancellation_cancels_an_async_generated_free_function | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/cancellation_cancels_an_async_generated_instance_method | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/cancellation_detaches_a_completed_call_before_aborting_the_remaining_call | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/cancellation_future_destruction_detaches | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/cancellation_future_second_get_throws_future_error | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/cancellation_future_wait_for_times_out_while_in_flight | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/cancellation_future_wait_then_get | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/cancellation_immediately_cancels_every_call_attached_after_abort | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/cancellation_pre_aborts_a_generated_synchronous_call | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/cancellation_surfaces_a_reused_call_context_as_abort_error_with_baml_reason | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/cancellation_surfaces_async_cancellation_as_abort_error_with_baml_reason | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/cancellation_surfaces_cancellation_through_promise_all | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/cancellation_surfaces_sync_pre_aborted_cancellation_as_abort_error | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/cancellation_sync_call_returns_none | y | - | - | - | y | - | y | - | y | y | - | all | | +| function_calls/cancellation_sync_cancel_via_call_context | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/cancelled_waiter_does_not_end_host_execution | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/cancelled_waiter_keeps_host_context_through_cleanup_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python task unwinding and ContextVar semantics | +| function_calls/cancelled_waiter_keeps_host_resource_until_promise_exit_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node AsyncResource destruction and AsyncLocalStorage | +| function_calls/cancelled_waiter_preserves_callback_context | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/cancelled_waiter_stays_cancelled_when_callback_returns_late | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/cancelling_call_delivers_cancellation_and_allows_callback_cleanup_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python callback task and event loop ownership | +| function_calls/canonical_json_class_union_uses_declared_field_codecs | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/canonical_json_composes_through_containers_and_classes | - | - | - | - | - | - | y | y | - | - | - | all | | +| function_calls/canonical_json_defaults_and_callbacks | - | - | - | - | - | - | y | y | - | - | - | all | | +| function_calls/canonical_json_dynamic_union | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/canonical_json_rejects_extensions_before_dispatch | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/canonical_json_round_trips_at_top_level_and_through_alias | - | - | - | - | - | - | y | y | - | - | - | all | | +| function_calls/child_cancellation_does_not_cancel_input | y | y | y | y | - | - | y | - | - | - | - | all | | +| function_calls/clean_exit_helper_process | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/clean_exit_terminates_process_with_code | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/closure_call_async_can_be_reused_after_creation_loop_closes_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python returned closure reuse across application loops | +| function_calls/closure_call_async_cancels_its_retained_callback_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python closure async entry cancellation delivery | +| function_calls/closure_call_async_preserves_binding_and_exception_identity_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python closure async entry argument and error semantics | +| function_calls/closure_call_async_uses_invoking_loop_and_context_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python explicit async closure entry and context capture | +| function_calls/coawait_co_await_cancelled_call_throws_cancelled | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/coawait_co_await_completed_future_fast_path | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/coawait_co_await_pending_future_resumes | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/coawait_co_await_throws_typed_into_the_coroutine | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/coawait_co_await_yields_the_decoded_value | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/coawait_uncaught_coroutine_exception_reaches_join | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/completed_call_can_reuse_callback | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/completed_call_does_not_pin_reused_callback_to_old_context_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | +| function_calls/composite_token_observes_every_source | y | y | y | y | - | - | y | - | y | - | - | all | | +| function_calls/concurrent_calls_isolate_async_local_storage_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/concurrent_calls_keep_callback_results_independent | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/concurrent_calls_share_callback_without_sharing_context_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | +| function_calls/concurrent_host_invocations_isolate_context | y | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/concurrent_invocations_isolate_context | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/concurrent_reservation_attaches_once | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/configuration_snapshot_is_not_live | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/cooperative_abort_allows_callback_cleanup_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/cooperative_abort_cleans_up_retained_callback_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/coroutine_entry_uses_execution_context_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | covers Python coroutine creation versus first execution | +| function_calls/current_context_returns_detached_snapshot | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/deadline_reentry_does_not_reset_budget | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/dropping_started_future_cancels_rust_only | - | - | - | - | - | - | y | - | - | - | - | rust | Rust task scopes and lazy future cancellation are specific to Rust | +| function_calls/dynamic_application_map_preserves_control_like_keys | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/dynamic_call_accepts_controls | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/dynamic_call_async_accepts_controls | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/dynamic_name_and_handle_accept_controls | - | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/dynamic_name_and_handle_accept_controls_async | - | - | - | - | - | - | y | - | - | - | - | all | | +| function_calls/dynamic_type_bindings_async_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python dynamic type bindings accept Python classes and reflected BamlType handles | +| function_calls/empty_class_self_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/empty_controls | y | y | y | y | - | - | y | - | y | - | - | all | | +| function_calls/empty_json_containers_round_trip | - | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/encoding_time_counts_against_deadline_python_only | y | - | - | - | - | - | - | - | - | - | - | all | | +| function_calls/error_call_cancellation_preserves_context_identity | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/error_string_is_non_empty | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/errors_async_sibling_throws_typed | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/errors_baml_error_carries_baml_trace | y | - | - | - | - | - | y | - | y | y | - | all | | +| function_calls/errors_baml_trace_spliced_into_python_traceback | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/errors_cancellation_surfaces_as_baml_panic | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/errors_clean_exit_terminates_process_with_code | - | - | - | - | - | - | y | - | - | - | - | all | | +| function_calls/errors_clean_exit_terminates_process_with_code_0 | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/errors_clean_exit_terminates_process_with_code_7 | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/errors_host_invalid_argument_wraps_baml_errors_invalid_argument | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/errors_stdlib_error_surfaces_as_baml_error | y | - | - | - | - | - | y | - | y | y | - | all | | +| function_calls/errors_stdlib_error_surfaces_typed | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/errors_str_is_non_empty | y | - | - | - | - | - | y | - | y | y | - | all | | +| function_calls/errors_typed_throw_is_still_a_baml_error | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/errors_union_throws_preserves_class_name | y | y | y | y | y | - | y | - | y | y | - | all | | +| function_calls/errors_user_panic_surfaces_as_baml_panic | y | - | - | - | - | - | y | - | y | y | - | all | | +| function_calls/errors_user_throw_surfaces_declared_instance | y | - | - | - | y | - | y | - | y | y | - | all | | +| function_calls/explicit_controls_are_applied | - | - | - | - | - | - | - | - | - | y | - | all | | +| function_calls/explicit_controls_are_applied_async | - | - | - | - | - | - | - | - | - | y | - | all | | +| function_calls/failed_admission_does_not_consume_reservation | - | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/four_call_forms | y | y | y | y | - | - | y | - | y | y | - | all | | +| function_calls/four_call_forms_async | y | y | y | y | - | - | y | - | y | y | - | all | | +| function_calls/generic_calls_choose_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/generic_calls_consume_int_wrapper_baseline | y | - | - | - | - | - | y | - | y | y | - | all | | +| function_calls/generic_calls_extract_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/generic_calls_generic_free_fn_requires_binding | y | - | - | - | - | - | y | - | y | y | - | all | | +| function_calls/generic_calls_generic_static_infers_binding | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/generic_calls_genericbox_get_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/generic_calls_genericbox_new_static_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/generic_calls_genericbox_pair_with_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/generic_calls_identity_async_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/generic_calls_identity_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/generic_calls_identity_explicit_class_cannot_restate_an_instance | - | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_calls_instance_method_unparameterized_receiver_raises | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/generic_calls_list_head_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/generic_calls_make_int_box_reified | y | - | - | - | - | - | y | - | y | y | - | all | | +| function_calls/generic_calls_make_int_container_reified | y | - | - | - | - | - | y | - | y | y | - | all | | +| function_calls/generic_calls_make_int_str_bool_triple_reified | y | - | - | - | - | - | y | - | y | y | - | all | | +| function_calls/generic_calls_make_nested_box_reified | y | - | - | - | - | - | y | - | y | y | - | all | | +| function_calls/generic_calls_make_triple_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/generic_calls_named_static_distinct_typevar_names | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/generic_calls_one_type_arg_explicit | y | - | - | - | - | - | y | - | y | y | - | all | | +| function_calls/generic_calls_optional_only_unset_names_its_type_arg | - | - | - | - | - | - | - | - | - | y | - | all | | +| function_calls/generic_calls_parse_as_explicit | y | - | - | - | - | - | y | - | y | y | - | all | | +| function_calls/generic_calls_read_items_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/generic_calls_second_of_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/generic_calls_subscript_wrong_arity_raises | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/generic_calls_tag_or_value_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/generic_calls_two_type_args_explicit | y | - | - | - | - | - | y | - | y | y | - | all | | +| function_calls/generic_calls_wrap_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/generic_class_instantiated_at_int | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/generic_class_with_a_json_field_round_trips | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/generic_classes_containers_and_concrete_outputs | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/generic_defaulted_parameters_use_option_setters | - | - | - | - | - | - | - | y | - | - | - | go | pins Go's functional-option setters, generic when the defaulted value mentions a type parameter | +| function_calls/generic_host_callbacks_share_the_function_type_parameters | - | - | - | - | - | - | - | y | - | - | - | go | pins Go's generic host-callback adapters | +| function_calls/generic_identity_accepts_nested_dynamic_containers | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/generic_identity_inference_and_explicit_types | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/generic_inference_apply_closure_poisons_typevars_must_specify | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_apply_closure_typevars_specified_succeeds | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_body_only_var_still_requires_binding | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_choose_divergent_generic_instances_union | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_choose_infers_divergent_union | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_choose_infers_unified_typevar | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_choose_union_outside_container_is_sound | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_combine_invariant_class_arg_conflict_rejects | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_default_only_value_position | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | validates Python default-argument stubs and runtime TypeVar inference | +| function_calls/generic_inference_elem_type_heterogeneous_array_unifies | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_elem_type_homogeneous_array_is_single_type | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_elem_type_three_way_heterogeneous_array_unifies | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_extract_fully_unbound_nested_pair_recovers_all_vars | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_extract_infers_four_typevars_from_nesting | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_first_or_empty_list_requires_binding | y | - | - | - | - | - | - | - | y | - | - | python_pydantic2, java | a dynamic host has no static element type for an empty container; the typed SDKs state theirs and round-trip | +| function_calls/generic_inference_first_or_empty_list_round_trips_none | - | - | - | - | - | - | - | - | - | y | - | all | | +| function_calls/generic_inference_first_or_empty_list_with_explicit_type_round_trips | - | - | - | - | - | - | - | - | - | y | - | swift | Swift names `T` statically, so an empty list cannot leave it unbound; this pins the explicit binding round trip | +| function_calls/generic_inference_first_or_nonempty_infers_element | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_generic_static_infers_own_typevar | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_genericbox_get_infers_class_var_from_receiver | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_genericbox_pair_with_infers_method_typevar | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_genericbox_pair_with_unbound_receiver_recovers_class_var | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_glue_invariant_and_covariant_agree_binds | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_glue_invariant_vs_covariant_conflict_rejects | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_host_only_object_not_encodable_from_python | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_identity_async_infers | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_identity_enum_round_trips | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_identity_infers_generic_instance | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_identity_infers_primitives | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_identity_infers_user_class | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_identity_nested_unbound_round_trips | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_identity_null_binds_null | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_identity_null_with_explicit_optional_type_round_trips | - | - | - | - | - | - | - | - | - | y | - | swift | Swift names `T` statically, so a null argument infers nothing here; this pins the explicit binding round trip | +| function_calls/generic_inference_identity_unbound_generic_instance_round_trips | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_list_head_infers_from_recursive_generic | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_list_head_unbound_recursive_recovers_t_from_fields | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_make_triple_full_subscript_contradicted_by_actual_rejects | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_make_triple_heterogeneous_list_element_unions | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_make_triple_infers_multiple_typevars | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_make_triple_partial_explicit_then_infer | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_make_triple_partial_subscript_requires_full_arity | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_make_triple_subscript_fully_bound | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_make_triple_types_kwarg_contradicted_by_actual_rejects | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_maybe_id_null_round_trips | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_maybe_id_present_value_infers | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_merge_invariant_map_value_conflict_rejects | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_named_static_infers_distinct_typevars | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_one_type_arg_explicit_types_succeeds | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_pair_invariant_list_agree_binds | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_pair_invariant_list_conflict_rejects | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_parse_as_explicit_types_succeeds | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_read_items_infers_from_instance_wire_args | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_read_items_unbound_container_recovers_t_from_fields | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_return_only_var_still_requires_binding | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_second_of_infers_from_nested_generic | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_second_of_unbound_instance_recovers_field_type | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_tag_or_value_binds_generic_instance | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_triple_choose_join_includes_concrete_class | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_triple_choose_join_includes_enum_variant | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_triple_choose_three_covariant_join | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_two_typevar_union_is_uninferrable_rejects | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_union_concrete_sibling_absorbs_value_binds_never | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_union_concrete_sibling_keeps_the_static_type_arg | - | - | - | - | - | - | - | - | - | y | - | all | | +| function_calls/generic_inference_union_null_actual_binds_never | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_union_null_actual_keeps_the_static_type_arg | - | - | - | - | - | - | - | - | - | y | - | all | | +| function_calls/generic_inference_union_with_concrete_sibling_infers_typevar | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_values_of_empty_map_requires_binding | y | - | - | - | - | - | - | - | y | - | - | python_pydantic2, java | a dynamic host has no static value type for an empty map; the typed SDKs state theirs and round-trip | +| function_calls/generic_inference_values_of_empty_map_round_trips_empty_list | - | - | - | - | - | - | - | - | - | y | - | all | | +| function_calls/generic_inference_values_of_nonempty_returns_values | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_wrap_infers_and_returns_bound_generic | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_wrap_infers_and_returns_generic | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_nullable_type_variables_preserve_every_pointer_boundary | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/generic_receiver_and_static_helpers | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/generic_return_only_type_arguments | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/generic_union_input_and_engine_validation | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/go_codegen_context_deadline | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/go_codegen_default_argument_serialization_error_names_argument | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/go_codegen_defaulted_argument_type_matrix | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/go_codegen_defaulted_void | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/go_codegen_option_name_collisions | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/go_codegen_optional_arg_last_value_wins | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/go_codegen_person_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/hello_world_returns_literal | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/hidden_mode_does_not_inherit | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/host_baml_cancellation_remains_live_after_exit | y | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/host_baml_trace_override_and_callback_context_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | explicit Python call controls override instrumentation defaults | +| function_calls/host_callable_cancellation_while_dispatched | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/host_callable_class_argument | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/host_callable_closed_union_containers_and_nominal_arms | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/host_callable_closed_union_literal_optional_and_selected_empty_container_arms | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/host_callable_closed_union_round_trips | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/host_callable_declared_throw_is_catchable | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/host_callable_failure_under_open_throws_is_catchable | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/host_callable_hof_receives_a_baml_closure | y | y | y | y | y | - | y | - | y | y | - | all | | +| function_calls/host_callable_late_and_uncaught_failures_release_native_identity | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/host_callable_media_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/host_callable_nullable_optional_distinguishes_all_three_states | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/host_callable_optional_arguments | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/host_callable_optional_names_avoid_generated_and_projection_collisions | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/host_callable_panic_does_not_cross_cgo_boundary | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/host_callable_primitive_and_multiple_arguments | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/host_callable_reentrant_call_does_not_deadlock | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/host_callable_repeated_and_concurrent_reuse | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/host_callable_structured_round_trips | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/host_callable_two_cbs_callbacks_named_f_and_f_returns_both_run | y | y | y | y | y | - | y | y | y | y | - | all | | +| function_calls/host_callable_void_signatures | y | y | y | y | y | - | y | y | y | - | - | all | | +| function_calls/host_callables_adopts_a_custom_thenable_exactly_once | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/host_callables_adopts_a_promise_from_a_separate_browser_realm | - | - | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_web_chromium, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/host_callables_async_callable_future_completing_exceptionally_round_trips_original | - | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/host_callables_async_callable_returning_future_is_awaited_by_bridge | - | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/host_callables_async_callable_runs_to_completion | y | - | - | - | - | - | y | - | y | y | - | all | | +| function_calls/host_callables_awaits_a_promise_returning_callback | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/host_callables_baml_error_panicked_with_is_thrown_on | - | - | - | - | - | - | - | y | - | - | - | go | Go's completion classification of a BAML error a callback forwards by panicking with it | +| function_calls/host_callables_call_repeatedly_invokes_callback_n_times | y | y | y | y | y | - | y | - | y | y | - | all | | +| function_calls/host_callables_call_repeatedly_with_zero_n_returns_empty_list | y | y | y | y | y | - | y | - | y | y | - | all | | +| function_calls/host_callables_call_with_throwing_in_baml_catches_host_callable_error | y | y | y | y | y | - | y | - | y | y | - | all | | +| function_calls/host_callables_cancels_through_the_originating_runtime_after_runtime_replacement | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/host_callables_class_callback_round_trips_class_value | - | - | - | - | y | - | - | - | - | y | - | all | | +| function_calls/host_callables_class_callback_round_trips_pydantic_model | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/host_callables_completes_a_pending_host_call_after_runtime_replacement | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/host_callables_completes_through_the_metadata_fallback_for_a_hostile_thrown_object | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/host_callables_concurrent_throws_in_flight_rehydrate_to_their_own_object | - | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/host_callables_delivers_a_single_supplied_optional_by_name_defaulting_the_rest | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/host_callables_delivers_both_supplied_optionals_in_one_opts_object | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/host_callables_forwarded_async_cancellation_stays_a_panic | - | - | - | - | - | - | - | - | y | y | - | java, swift | pins a typed SDK's completion of a nested call's cancellation, which it surfaces as its native cancellation | +| function_calls/host_callables_forwarded_baml_error_is_held_to_the_declared_throws | - | - | - | - | - | - | - | y | - | - | - | go | Go's completion classification of a forwarded BAML error held to the declared throws type | +| function_calls/host_callables_forwarded_baml_error_is_thrown_as_itself | - | - | - | - | y | - | - | - | - | y | - | cpp, swift | pins a forwarded BAML error completed as thrown | +| function_calls/host_callables_forwarded_cancellation_stays_a_panic_under_throws | - | - | - | - | y | - | - | - | - | - | - | cpp | C++ surfaces BAML cancellation as baml::cancelled. | +| function_calls/host_callables_forwarded_panic_stays_a_panic_under_a_declared_throws_class | - | - | - | - | - | - | - | y | y | y | - | go, java, swift | pins a typed SDK's completion of a forwarded panic under a single-class throws | +| function_calls/host_callables_forwarded_type_mismatch_crosses_as_the_baml_error_it_is | - | - | - | - | - | - | - | - | y | - | - | java | pins the Java completion of a forwarded TypeMismatch, which Java surfaces as an IllegalArgumentException | +| function_calls/host_callables_ignores_a_host_promise_settlement_after_its_outer_call_is_cancelled | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/host_callables_int_return_callable_round_trip | y | y | y | y | y | - | y | - | y | y | - | all | | +| function_calls/host_callables_lambda_round_trip | y | - | - | - | y | - | y | - | y | y | - | all | | +| function_calls/host_callables_literal_union_callback_states_its_literal_types | - | - | - | - | - | - | - | - | - | y | - | all | | +| function_calls/host_callables_media_callback_states_the_media_type | - | - | - | - | - | - | - | - | - | y | - | all | | +| function_calls/host_callables_multiple_callable_keys_are_distinct | y | y | y | y | y | - | y | - | y | y | - | all | | +| function_calls/host_callables_multiple_throws_in_flight_do_not_collide_in_registry | y | - | - | - | y | - | y | - | y | y | - | all | | +| function_calls/host_callables_non_finite_json_result_is_a_contract_violation | - | - | - | - | - | - | - | y | - | - | - | go | Go's completion classification of a returned value that does not fit its declared type | +| function_calls/host_callables_omits_both_optionals_so_the_callback_s_own_defaults_apply | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/host_callables_optional_args_all_set_deliver_both | y | - | - | - | y | - | y | - | y | y | - | all | | +| function_calls/host_callables_optional_args_all_unset_apply_host_defaults | y | - | - | - | y | - | y | - | y | y | - | all | | +| function_calls/host_callables_optional_args_partially_set_deliver_by_name | y | - | - | - | y | - | y | - | y | y | - | all | | +| function_calls/host_callables_overlapping_container_union_callback_states_the_selected_list | - | - | - | - | - | - | - | - | - | y | - | all | | +| function_calls/host_callables_passes_a_generated_class_instance_into_the_callback | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/host_callables_preserves_a_rejected_promise_reason_by_identity | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/host_callables_preserves_an_error_whose_stack_is_not_a_string | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/host_callables_preserves_arbitrary_thrown_js_values_without_hanging | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/host_callables_preserves_number_kind_for_float_bigint_union | - | y | y | y | - | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | exercises JavaScript's distinct number and bigint host representations | +| function_calls/host_callables_preserves_same_realm_thrown_object_identity | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/host_callables_rejects_callable_args_on_the_generated_sync_path_instead_of_hanging | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/host_callables_release_fires_on_drop_of_callable | y | y | y | y | - | - | y | - | y | - | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, rust, java | callable release coverage depends on host weak-reference support and remains nondeterministic | +| function_calls/host_callables_resolves_integral_number_for_float_class_field | - | y | y | y | - | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | exercises the Node bridge's JavaScript number encoding in generated classes | +| function_calls/host_callables_resolves_integral_number_for_float_return | - | y | y | y | - | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | exercises the Node bridge's JavaScript number encoding for float host returns | +| function_calls/host_callables_returns_and_invokes_a_host_callable_nested_in_a_list | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/host_callables_returns_and_invokes_a_nested_host_callable | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/host_callables_round_trips_a_typed_baml_error_through_typed_catch_and_propagation | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/host_callables_round_trips_an_arrow_function_callback | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/host_callables_simple_sync_callable_returns_string | y | y | y | y | y | - | y | - | y | y | - | all | | +| function_calls/host_callables_surfaces_a_throwing_callback_as_a_baml_error | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/host_callables_surfaces_a_wrong_callback_return_type_as_host_contract_violation | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/host_callables_throwing_async_callable_round_trips_original_error | - | - | - | - | - | - | - | - | - | y | - | all | | +| function_calls/host_callables_throwing_async_callable_round_trips_original_python_exception | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/host_callables_throwing_callable_bamlerror_propagates_back_with_typed_fields | y | - | - | - | - | - | y | y | y | - | - | all | | +| function_calls/host_callables_throwing_callable_bamlerror_wrapping_codegenned_class_is_caught_in_baml | y | - | - | - | - | - | y | y | y | - | - | all | | +| function_calls/host_callables_throwing_callable_custom_host_exception_round_trips_with_identity | - | - | - | - | y | - | - | - | - | y | - | all | | +| function_calls/host_callables_throwing_callable_custom_python_exception_round_trips_with_identity | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/host_callables_throwing_callable_hostthrow_codegenned_class_is_caught_in_baml | - | - | - | - | y | - | - | - | - | y | - | all | | +| function_calls/host_callables_throwing_callable_hostthrow_propagates_back_with_typed_fields | - | - | - | - | y | - | - | - | - | y | - | all | | +| function_calls/host_callables_throwing_callable_keyerror_round_trips_with_identity | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/host_callables_throwing_callable_out_of_range_round_trips_with_identity | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/host_callables_throwing_callable_round_trips_original_host_exception | - | - | - | - | y | - | - | - | - | y | - | all | | +| function_calls/host_callables_throwing_callable_round_trips_original_python_exception | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/host_callables_thrown_value_of_another_go_type_is_a_contract_violation | - | - | - | - | - | - | - | y | - | - | - | go | Go's completion classification of a value thrown as a Go type other than the declared one | +| function_calls/host_callables_two_arg_callable_unpacks_positional_args | y | y | y | y | y | - | y | - | y | y | - | all | | +| function_calls/host_callables_typed_throw_too_deep_is_a_bridge_failure | - | - | - | - | y | - | - | - | - | - | - | cpp | C++ throws a BAML value with baml::host_throw. | +| function_calls/host_callables_unfit_callback_result_is_a_contract_violation | - | - | - | - | y | - | - | - | - | - | - | cpp | C++ can return only a malformed bigint as unfit. | +| function_calls/host_callback_cleanup_retains_context | y | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/host_callback_reentry_context | y | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/host_cancellation_waits_for_cleanup_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | covers Python task cancellation and asynchronous finally cleanup | +| function_calls/host_capture_avoids_getters_and_proxies_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | covers JS getters, Proxy traps, and custom thenables | +| function_calls/host_capture_preserves_application_values | y | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/host_capture_wire_stays_decodable_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | checks the Python capture adapter's wire output directly | +| function_calls/host_capture_wire_stays_decodable_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | checks the Node capture adapter's wire output directly. | +| function_calls/host_child_outlives_parent | y | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/host_common_types_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node observations are checked alongside Python by local SQL. | +| function_calls/host_context_inherits_and_restores | y | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/host_current_exposes_generated_cancel_token | y | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/host_defaults_baml_span_and_error_capture_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python instrumentation defaults Python-to-BAML span/capture requests | +| function_calls/host_errors_preserve_identity_and_restore_context | y | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/host_preserves_call_shape_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | covers JS receivers, variadic arguments, and native Promise results | +| function_calls/host_recording_audit_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | runs the Node half of the independent local SQL audit | +| function_calls/host_rejects_unsupported_configuration_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | covers unsupported Python generator/decorator shapes | +| function_calls/host_rejects_unsupported_configuration_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | covers JS generator and marker configuration rejection | +| function_calls/host_supplied_empty_and_null_bearing_json_containers_state_the_alias | - | - | - | - | - | - | - | - | - | y | - | all | | +| function_calls/host_supplied_json_supports_typed_narrowing | y | y | y | y | y | - | y | y | y | y | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, rust, go, java, swift | C# declares no function_calls suite (its native coverage is Rust-wrapped integration tests) | +| function_calls/host_thread_context_handoff_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | covers Python ContextVar handoff to a thread pool | +| function_calls/instance_method_cancellation_returns_exact_context_error | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/instance_method_media_receiver_default_and_ownership_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/instance_method_optional_arguments | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/instance_method_throw_preserves_current_go_error_contract | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/instance_methods_on_classes_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/instance_never_method_has_error_only_signature_and_returns_panic | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/instrument_decorator_forms_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | covers Python decorator forms | +| function_calls/instrument_preserves_sync_execution_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | covers Python signatures and positional argument binding | +| function_calls/invalid_controls_rejected | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/invalid_function_arguments_surface_baml_error | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/invalid_response_preserves_wire_diagnostics | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | exercises Python BamlError and generated Pydantic payloads | +| function_calls/invalid_timeout_rejected | y | y | y | y | - | - | y | - | y | - | - | all | | +| function_calls/invocation_inheritance | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/invocation_inheritance_multi_layer_context_patch_inherits_and_restores | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/invocation_lifecycle | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/invocation_lifecycle_failed_admission_does_not_consume_reservation | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/invocation_lifecycle_pre_cancelled_call_does_not_enter_callback | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/invocation_lifecycle_reservation_is_single_use | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/invocation_lifecycle_retained_effective_token_observes_late_parent_cancellation | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/invocation_lifecycle_zero_timeout_does_not_enter_callback | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/invocation_options | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/invocation_options_four_call_forms | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/invocation_options_four_call_forms_async | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/invocation_options_invalid_timeout_rejected | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/invocation_surfaces | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/invocation_surfaces_returned_callable_accepts_controls | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/json_bigint_round_trips_as_bigint | - | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/json_nested_at_the_wire_limit_crosses_and_one_level_more_is_refused | - | - | - | - | - | - | - | - | y | y | - | java, swift | pins a typed SDK encoder's exact message count at the wire's limit | +| function_calls/json_nested_deeper_than_the_wire_carries_is_refused_as_a_callback_result | y | y | y | y | y | - | y | y | y | y | - | all | | +| function_calls/json_nested_deeper_than_the_wire_carries_is_refused_as_an_argument | y | y | y | y | y | - | y | y | y | y | - | all | | +| function_calls/json_nested_to_the_wire_limit_crosses_and_one_level_more_does_not | - | - | - | - | y | - | - | - | - | - | - | cpp | pins the C++ encoder's exact wire measure at its cut. | +| function_calls/json_returned_from_host_callback_supports_typed_narrowing | y | y | y | y | y | - | y | y | y | y | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, rust, go, java, swift | C# declares no function_calls suite (its native coverage is Rust-wrapped integration tests) | +| function_calls/late_callback_keeps_async_local_storage_after_abort_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node callback Promises can outlive an aborted SDK waiter | +| function_calls/layered_callback_context_inheritance_and_restoration_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python sync/async re-entry and ContextVar propagation | +| function_calls/layered_callback_context_inheritance_and_restoration_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node async carrier, native signals, and entry-time snapshots are specific to TypeScript | +| function_calls/layered_callback_context_restores_after_returned_callable | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/legacy_controls_rejected | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/linking_the_sdk_leaves_serde_json_features_alone | - | - | - | - | - | - | y | - | - | - | - | rust | cargo feature unification is Rust-only | +| function_calls/live_token_cancels_after_admission | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/main_hello_world_returns_literal | y | - | - | - | y | - | y | - | y | y | y | all | | +| function_calls/main_rejects_mistyped_int_argument | y | y | y | y | - | - | - | - | - | - | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | statically typed SDKs cannot pass a mistyped argument | +| function_calls/main_returns_the_literal_async | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/main_returns_the_literal_sync | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/main_round_trips_a_single_positional_argument | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/main_round_trips_ints_bools_strings_and_floats | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/main_single_required_arg_round_trips | y | - | - | - | y | - | y | - | y | y | y | all | | +| function_calls/method_generated_name_collisions_stay_callable | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/method_self_all_supported_positions_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/methods_accept_controls | y | y | y | y | - | - | y | - | y | - | - | all | | +| function_calls/methods_accept_controls_async | y | y | y | y | - | - | y | - | - | - | - | all | | +| function_calls/methods_on_classes_create_constructs_a_greeter_async_plus_sync | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/methods_on_classes_exposes_sync_plus_async_bindings_for_both_flavors | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/methods_on_classes_greet_arg_echoes_a_non_self_argument_async_plus_sync | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/methods_on_classes_instance_greet_async_with_arg_round_trips | y | - | - | - | y | - | y | - | y | y | y | all | | +| function_calls/methods_on_classes_instance_greet_with_arg_round_trips | y | - | - | - | y | - | y | - | y | y | y | all | | +| function_calls/methods_on_classes_instance_who_async_round_trips | y | - | - | - | y | - | y | - | y | y | y | all | | +| function_calls/methods_on_classes_instance_who_round_trips | y | - | - | - | y | - | y | - | y | y | y | all | | +| function_calls/methods_on_classes_method_bindings_exist | y | - | - | - | - | - | y | - | y | - | y | all | | +| function_calls/methods_on_classes_static_create_async_round_trips | y | - | - | - | y | - | y | - | y | y | y | all | | +| function_calls/methods_on_classes_static_create_round_trips | y | - | - | - | y | - | y | - | y | y | y | all | | +| function_calls/methods_on_classes_who_returns_a_field_off_self_async_plus_sync | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/multi_layer_context_patch_inherits_and_restores | - | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/native_closure_can_cross_sync_and_async_entries_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node sync calls support native closures whose captures need no host dispatch | +| function_calls/native_signal_cancels_without_cancelling_input_token_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node async carrier, native signals, and entry-time snapshots are specific to TypeScript | +| function_calls/nil_host_callable_fails_before_dispatch | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/null_controls_preserve_inherited_context | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/omitted_argument_is_not_null | y | y | y | y | - | - | y | - | y | y | - | all | | +| function_calls/optional_args_async_samples | y | y | y | y | y | - | y | - | y | y | - | all | | +| function_calls/optional_args_bigint_in_i64_range | - | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/optional_args_covers_static_and_instance_optional_args | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/optional_args_covers_the_runtime_matrix | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/optional_args_negative_runtime_cases_reject | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/optional_args_opt_box_method_matrix | y | - | - | - | y | - | y | - | y | y | - | all | | +| function_calls/optional_args_python_unset_and_none_differ_in_one_call | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/optional_args_rejects_invalid_runtime_calls_that_bypass_types | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/optional_args_runtime_matrix | y | - | - | - | y | - | y | y | y | y | - | all | | +| function_calls/optional_args_treats_undefined_as_omitted_and_keeps_null_distinct | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/optional_args_unset_and_null_differ_in_one_call | - | - | - | - | y | - | - | - | - | y | - | all | | +| function_calls/options_snapshot_at_async_entry_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node async carrier, native signals, and entry-time snapshots are specific to TypeScript | +| function_calls/options_snapshot_when_coroutine_starts_python_only | y | - | - | - | - | - | - | - | - | - | - | all | | +| function_calls/parse_json_successful_value_uses_generated_json_projection | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/pre_aborted_call_does_not_dispatch_callback_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/pre_aborted_native_signal_does_not_enter_callback_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node async carrier, native signals, and entry-time snapshots are specific to TypeScript | +| function_calls/pre_cancelled_call_does_not_enter_callback | y | y | y | y | - | - | y | - | y | - | - | all | | +| function_calls/promise_all_failure_with_explicit_sibling_abort_preserves_error_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/promise_callback_preserves_async_local_storage_across_suspension_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/promise_callback_sync_reentry_rejects_host_callback_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/provider_error_preserves_native_error_metadata | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | exercises Python BamlError and generated Pydantic payloads | +| function_calls/raises_async_sibling_also_has_raises | y | - | - | - | y | - | y | - | y | - | - | all | | +| function_calls/raises_imports | - | - | - | - | - | - | y | - | - | - | - | all | | +| function_calls/raises_imports_symbols_reachable | y | - | - | - | y | - | - | - | y | - | - | all | | +| function_calls/raises_inferred_contract_without_clause_still_raises | y | - | - | - | y | - | y | - | y | - | - | all | | +| function_calls/raises_method_raises_block_in_pyi | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/raises_method_raises_blocks | - | - | - | - | y | - | - | - | - | - | - | all | | +| function_calls/raises_non_throwing_function_has_no_raises_block | y | - | - | - | y | - | y | - | y | - | - | all | | +| function_calls/raises_single_throws | y | - | - | - | y | - | y | - | y | - | - | all | | +| function_calls/raises_summary_precedes_raises_block | y | - | - | - | y | - | y | - | y | - | - | all | | +| function_calls/raises_union_throws_lists_all_names | y | - | - | - | y | - | y | - | y | - | - | all | | +| function_calls/reflect_facade_builds_runtime_declarations_and_decodes_their_instances | - | - | - | - | - | - | - | y | - | - | - | go | Go's hand-written reflect facade; the host_reflect fixture that would cover it does not compile | +| function_calls/reflected_type_closed_dynamic_unions_and_callback | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/reflected_type_composes_through_optional_containers_and_classes | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/reflected_type_primitive_literal_and_nominal_descriptors | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/reflected_type_top_level_and_runtime_produced_values | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/rejected_admission_does_not_consume_reservation | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/repeated_abort_does_not_interrupt_async_callback_cleanup_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python callback cancellation under an explicit controller | +| function_calls/repeated_cooperative_abort_does_not_interrupt_callback_cleanup_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/repeated_dispatches_each_start_with_entry_async_local_storage_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/repeated_dispatches_each_start_with_entry_context_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python ContextVar isolation between dispatch tasks | +| function_calls/repeated_dispatches_invoke_callback_in_order | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/reservation_does_not_inherit | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/reservation_is_single_use | - | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/retained_callback_uses_invocation_async_local_storage_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/retained_context_survives_parent_completion | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/retained_effective_token_observes_late_parent_cancellation | - | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/retained_effective_token_stays_live_after_callback | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/retained_host_callback_uses_later_invocations_application_context_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python callback task and event loop ownership | +| function_calls/retained_invocation_resolves_token_after_callback | y | - | - | - | - | - | - | - | - | - | - | all | | +| function_calls/returned_callable_accepts_controls | y | y | y | y | - | - | y | - | y | - | - | all | | +| function_calls/returned_callable_accepts_controls_async | - | - | - | - | - | - | y | - | - | - | - | all | | +| function_calls/returned_closure_preserves_callback_error_and_remains_reusable | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/returned_closure_retains_host_callback | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/returned_function_is_accepted_as_an_argument | - | - | - | - | - | - | y | - | - | - | - | all | | +| function_calls/reused_callback_uses_current_async_local_storage_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/ruby_lifecycle_hooks_generate_and_load | - | - | - | - | - | - | - | - | - | - | y | ruby_sorbet | Ruby lifecycle hook names must not run while requiring the generated SDK | +| function_calls/ruby_object_and_module_names_generate_load_and_call | - | - | - | - | - | - | - | - | - | - | y | ruby_sorbet | Ruby Object properties and Module callables need escaped names | +| function_calls/runtime_executes_the_generated_sdk_in_a_browser | - | - | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_web_chromium, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/runtime_executes_the_generated_sdk_in_node | - | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/runtime_executes_the_generated_sdk_in_workerd | - | - | - | y | - | - | - | - | - | - | - | python_pydantic2, typescript_web_cloudflare_workers, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/runtime_imports_the_generated_sdk_in_the_configured_runtime | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/same_callback_recurses_through_baml | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/same_callback_recurses_through_baml_without_reusing_an_entry_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | sync callback re-entry differs between Python and Node | +| function_calls/same_named_throws_ok_passes_through | - | - | - | - | - | - | y | - | - | - | - | rust | validates Rust-specific throws variant naming | +| function_calls/same_named_throws_selects_the_json_parse_error | - | - | - | - | - | - | y | - | - | - | - | rust | validates Rust-specific throws variant naming | +| function_calls/same_named_throws_selects_the_stdlib_parse_error | - | - | - | - | - | - | y | - | - | - | - | rust | validates Rust-specific throws variant naming | +| function_calls/single_required_arg_round_trips | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/specialized_callable_rejects_type_bindings | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/static_method_errors_never_cancellation_and_collision_names | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/static_method_media_json_type_and_rust_type_round_trips | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/static_method_required_default_and_structured_round_trips | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/stdlib_entrypoints_compiler_intrinsics_are_not_emitted_as_entry_points | y | y | - | - | y | - | y | - | y | y | y | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/stdlib_entrypoints_native_argv_callable_as_entry_point | y | - | - | - | y | - | y | - | y | y | y | all | | +| function_calls/stdlib_entrypoints_native_baml_sys_argv_is_callable_as_an_entry_point | - | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/stdlib_entrypoints_sysop_fs_exists_callable_as_entry_point | y | y | - | - | y | - | y | - | y | y | y | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/stdlib_error_surfaces_as_go_error | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/stream_schema_failure_is_parse_failed | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | exercises Python BamlError and generated Pydantic payloads | +| function_calls/suspended_callback_survives_gc_until_cancellation_cleanup_finishes_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | asyncio tasks retain callback bodies until actual completion | +| function_calls/sync_call_returns_null | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/sync_callback_can_run_async_baml_with_its_own_application_loop_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Node cannot synchronously drive an application loop | +| function_calls/sync_callback_inherits_call_time_async_local_storage_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/sync_callback_inherits_call_time_context_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | +| function_calls/sync_callback_of_async_entry_reenters_sync_baml_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | sync callback re-entry differs between Python and Node | +| function_calls/sync_callback_of_async_entry_runs_on_originating_loop_thread_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | +| function_calls/sync_callback_of_async_entry_runs_on_originating_loop_thread_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/sync_callback_reenters_sync_baml_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | sync callback re-entry differs between Python and Node | +| function_calls/sync_callback_runs_on_sync_callers_thread_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | +| function_calls/sync_callback_sync_reentry_rejects_host_callback_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/sync_cancel_via_context | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/sync_entry_async_callback_copies_context_across_suspension_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python ContextVar semantics with a bridge-owned loop | +| function_calls/sync_entry_async_callback_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | +| function_calls/sync_entry_cancellation_reaches_async_callback_on_worker_loop_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python cancellation on a bridge-owned callback loop | +| function_calls/sync_entry_in_running_loop_with_self_contained_async_callback_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | +| function_calls/sync_entry_in_running_loop_with_sync_callback_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | +| function_calls/sync_entry_rejection_does_not_start_promise_callback_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/sync_entry_rejection_preserves_application_context_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/sync_entry_rejects_before_dispatch_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/sync_entry_rejects_callback_before_async_reentry_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/sync_entry_rejects_callback_before_recursive_reentry_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/sync_entry_rejects_callback_before_sync_reentry_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/sync_entry_rejects_promise_callback_between_event_loop_turns_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/sync_entry_rejects_promise_callback_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/sync_entry_rejects_retained_callback_and_allows_async_reuse_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node sync entries must reject callbacks hidden in native closure captures | +| function_calls/sync_entry_rejects_sync_callback_between_event_loop_turns_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/sync_entry_rejects_sync_callback_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | +| function_calls/sync_entry_sync_callback_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | +| function_calls/sync_retained_closure_rejects_before_dispatch_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node cannot run JS callbacks while a sync native call blocks its event loop | +| function_calls/task_factory_failure_completes_dispatch_and_preserves_exception_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python asyncio task factory failures during dispatch | +| function_calls/taskgroup_failure_cancels_sibling_baml_call_and_preserves_error_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python callback task and event loop ownership | +| function_calls/timeout_upper_bound_accepted | y | y | y | y | - | - | y | - | y | - | - | all | | +| function_calls/to_thread_sync_entry_inherits_copied_application_context_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | +| function_calls/two_application_loops_on_two_threads_share_callback_without_rerouting_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | +| function_calls/unhandled_spawn_error_uses_host_default | - | - | - | - | y | - | - | y | y | y | - | cpp, go, java, swift | requires subprocess-level SDK harness support | +| function_calls/union_throws_preserves_concrete_class_identity | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/unknown_control_rejected | y | y | y | y | - | - | - | - | - | - | - | all | | +| function_calls/unmarked_callback_explicit_context_and_reentry | y | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/unpolled_future_starts_nothing_rust_only | - | - | - | - | - | - | y | - | - | - | - | rust | Rust task scopes and lazy future cancellation are specific to Rust | +| function_calls/unset_and_none_differ_in_one_call | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/user_panic_surfaces_as_go_error_without_panicking | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/user_throw_surfaces_declared_class_identity | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/web_sysops_maps_fetch_failures_and_timeouts_into_declared_baml_errors | - | - | y | y | - | - | - | - | - | - | - | python_pydantic2, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/web_sysops_rejects_http_streaming_and_unrelated_sysops | - | - | y | y | - | - | - | - | - | - | - | python_pydantic2, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/web_sysops_rejects_sync_and_async_baml_fs_read_promptly | - | - | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_web_chromium, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/web_sysops_rejects_sync_http_before_dispatching_fetch | - | - | y | y | - | - | - | - | - | - | - | python_pydantic2, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/web_sysops_rejects_unsupported_filesystem_operations | - | - | y | y | - | - | - | - | - | - | - | python_pydantic2, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/web_sysops_supports_sync_and_async_baml_fs_read_through_node_fs_read_file_sync | - | - | - | y | - | - | - | - | - | - | - | python_pydantic2, typescript_web_cloudflare_workers, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/web_sysops_trampolines_baml_http_fetch_to_global_fetch_and_buffers_the_response | - | - | y | y | - | - | - | - | - | - | - | python_pydantic2, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/web_sysops_trampolines_baml_http_send_with_method_headers_and_body | - | - | y | y | - | - | - | - | - | - | - | python_pydantic2, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| function_calls/zero_timeout_does_not_enter_callback | y | y | y | y | - | - | y | - | y | - | - | all | | +| host_reflect/a_runtime_type_is_one_declaration_and_rejects_serialization | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | BEP-066 host reflection is currently exposed only by Python, TypeScript, and Go | +| host_reflect/compiled_package_returns_class_graph | y | y | y | y | - | - | - | y | - | - | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, go | BEP-066 host reflection is currently exposed only by Python, TypeScript, and Go | +| host_reflect/generated_class_subclasses_resolve_to_declared_type | y | y | y | y | - | - | - | - | - | - | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | Python and TypeScript have generated-class subclass tokens; Go has no subclass construct | +| host_reflect/host_handles_expose_composition_only | y | y | y | y | - | - | - | y | - | - | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, go | BEP-066 host reflection is currently exposed only by Python, TypeScript, and Go | +| host_reflect/known_type_tokens_compose_and_reject_unknowns | y | y | y | y | - | - | - | y | - | - | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, go | BEP-066 host reflection is currently exposed only by Python, TypeScript, and Go | +| host_reflect/reflection_compile_errors_are_typed | y | y | y | y | - | - | - | y | - | - | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, go | BEP-066 host reflection is currently exposed only by Python, TypeScript, and Go | +| host_reflect/runtime_class_definition_preserves_nested_metadata | y | y | y | y | - | - | - | y | - | - | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, go | BEP-066 host reflection is currently exposed only by Python, TypeScript, and Go | +| host_reflect/runtime_enum_definition_decodes_alias | y | y | y | y | - | - | - | y | - | - | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, go | BEP-066 host reflection is currently exposed only by Python, TypeScript, and Go | +| host_reflect/wire_occurrences_are_fresh_and_handles_reject_serialization | - | y | y | y | - | - | - | y | - | - | - | all | | +| integration/baml_closures_execute_host_arguments_structured_returns_and_mutable_captures | - | - | - | - | - | y | - | - | - | - | - | csharp | C# canonical coverage executes through its native integration harness | +| integration/basic_calls_executes_sync_and_async | - | - | - | - | - | y | - | - | - | - | - | csharp | exercises C#-specific native SDK integration coverage | +| integration/cancel_token_any_propagates_native_cancellation | - | - | - | - | - | y | - | - | - | - | - | csharp | isolates the flaky native cancellation propagation check | +| integration/canonical_documentation_consumer_compiles_and_executes | - | - | - | - | - | y | - | - | - | - | - | csharp | validates the C#-specific documentation consumer | +| integration/checked_in_union_runtime_source_matches_generator | - | - | - | - | - | y | - | - | - | - | - | csharp | validates C#-specific generated union runtime source | +| integration/dynamic_values_executes_native_dynamic_value_parity | - | - | - | - | - | y | - | - | - | - | - | csharp | exercises C#-specific native SDK integration coverage | +| integration/dynamic_values_type_naming_a_runtime_declaration_twice_round_trips | - | - | - | - | - | y | - | - | - | - | - | csharp | exercises C#-specific ownership of a runtime declaration's repeated key | +| integration/failures_and_cancellation_executes_typed_failures_cancellation_and_exit | - | - | - | - | - | y | - | - | - | - | - | csharp | exercises C#-specific native SDK integration coverage | +| integration/generated_baml_clients_are_not_tracked | - | - | - | - | - | y | - | - | - | - | - | csharp | validates C#-specific generated-client repository hygiene | +| integration/generics_executes_inferred_and_explicit_generics | - | - | - | - | - | y | - | - | - | - | - | csharp | exercises C#-specific native SDK integration coverage | +| integration/generics_generated_surface_rejects_ambiguous_generic_calls | - | - | - | - | - | y | - | - | - | - | - | csharp | exercises C#-specific generated-surface compile coverage | +| integration/host_callable_hof_receives_a_baml_closure | - | - | - | - | - | y | - | - | - | - | - | csharp | C# canonical coverage executes through its native integration harness | +| integration/host_callables_callback_faults_complete_by_whose_they_are | - | - | - | - | - | y | - | - | - | - | - | csharp | C# completion-contract coverage executes through its native integration harness | +| integration/host_callables_json_nested_deeper_than_the_wire_carries_is_refused_as_a_callback_result | - | - | - | - | - | y | - | - | - | - | - | csharp | C# measures typed lists and class chains at the wire's exact limit | +| integration/host_callables_json_nested_deeper_than_the_wire_carries_is_refused_as_an_argument | - | - | - | - | - | y | - | - | - | - | - | csharp | C# measures typed lists and class chains at the wire's exact limit | +| integration/media_executes_media_in_both_directions | - | - | - | - | - | y | - | - | - | - | - | csharp | exercises C#-specific native SDK integration coverage | +| integration/primitive_edges_executes_native_primitive_and_nullable_edges | - | - | - | - | - | y | - | - | - | - | - | csharp | exercises C#-specific native SDK integration coverage | +| integration/release_abi_lifetimes_match_current_native_library | - | - | - | - | - | y | - | - | - | - | - | csharp | exercises the C# release ABI probe before publication | +| integration/stdlib_resources_executes_native_typed_resource_apis_lifetimes_and_state | - | - | - | - | - | y | - | - | - | - | - | csharp | exercises C#-specific native SDK integration coverage | +| integration/stdlib_structurals_executes_native_stdlib_structural_roundtrips | - | - | - | - | - | y | - | - | - | - | - | csharp | exercises C#-specific native SDK integration coverage | +| integration/streaming_executes_generated_native_stream_and_request_failure | - | - | - | - | - | y | - | - | - | - | - | csharp | exercises C#-specific native SDK integration coverage | +| integration/type_roundtrips_executes_nominals_collections_defaults_and_unions | - | - | - | - | - | y | - | - | - | - | - | csharp | exercises C#-specific native SDK integration coverage | +| llm_functions/a_runtime_declarations_type_value_holds_it_and_states_it_again | - | - | - | - | - | - | - | - | y | - | - | java | pins the Java BamlType holding a runtime declaration's engine reference | +| llm_functions/constructs_a_live_spec_without_a_synthetic_fqn | - | y | y | y | - | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | validates TypeScript's live FunctionSpec metadata surface | +| llm_functions/dynamic_runtime_stream_identity_and_flat_projection_parity | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | validates Python BamlRuntimeValue identity through the generated sync stream surface | +| llm_functions/dynamic_runtime_stream_is_an_elegant_async_iterable | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | validates Python's generated async-iterable stream surface | +| llm_functions/flat_stream_calls_exact_companion_fqn | - | - | - | - | y | - | - | - | - | - | - | cpp | C++-only exact flat-stream companion dispatch check. | +| llm_functions/flat_stream_controls_are_typed_options | - | - | - | - | - | - | - | y | - | - | - | go | pins the Go generator's typed option surface for flat stream controls. | +| llm_functions/function_spec_helpers_accept_invocation_controls | y | - | - | - | - | - | - | - | - | - | - | all | | +| llm_functions/function_spec_helpers_accept_invocation_controls_async | y | - | - | - | - | - | - | - | - | - | - | all | | +| llm_functions/function_spec_parse_replaces_the_parse_companion | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | validates Python's generated FunctionSpec parse surface | +| llm_functions/function_spec_parse_returns_closed_enum | - | - | - | - | - | - | - | y | - | - | - | go | pins Go's closed-enum FunctionSpec decoder surface. | +| llm_functions/function_spec_parse_returns_runtime_error_for_invalid_output | - | - | - | - | - | - | - | y | - | - | - | go | pins Go's FunctionSpec parse-error translation. | +| llm_functions/function_spec_parse_returns_typed_class_and_fills_missing_nullable_field | - | - | - | - | - | - | - | y | - | - | - | go | pins Go's typed FunctionSpec class decoder surface. | +| llm_functions/function_spec_prompt_is_accepted_as_an_argument | - | - | - | - | y | - | - | - | - | - | - | cpp | pins that a C++ prompt crosses back as an argument. | +| llm_functions/function_spec_prompt_uses_generated_ai_prompt_type_and_is_reusable | - | - | - | - | - | - | - | y | - | - | - | go | pins the Go FunctionSpec prompt facade and reusable context behavior. | +| llm_functions/keeps_a_portable_prompt_reusable_across_engine_reentry | - | y | y | y | - | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | validates TypeScript's portable Prompt and Image wrappers | +| llm_functions/legacy_function_companion_wire_bindings_are_absent | - | - | - | - | - | - | - | y | - | - | - | go | pins the Go generator's emitted wire-binding names. | +| llm_functions/main_baml_sdk_lorem_and_baml_sdk_ipsum_are_reachable | - | y | y | y | - | - | - | - | - | - | - | all | | +| llm_functions/main_classify_sentiment_factory_bindings | y | - | - | - | - | - | y | - | y | - | - | all | | +| llm_functions/main_extract_resume_factory_bindings | y | - | - | - | - | - | y | - | y | - | - | all | | +| llm_functions/main_extract_resume_operation_bindings | y | - | - | - | - | - | - | - | y | - | - | python_pydantic2, java | pins Python and Java flat operation binding spellings | +| llm_functions/main_extract_resume_spec_and_stream_bindings | - | - | - | - | - | - | y | - | - | - | - | rust | pins Rust generated spec and stream binding spellings | +| llm_functions/main_flat_stream_controls_live_on_the_stream_options | - | - | - | - | - | - | - | - | y | - | - | all | | +| llm_functions/main_function_spec_prompt_is_portable_and_reusable | - | - | - | - | - | - | - | - | y | - | - | all | | +| llm_functions/main_ipsum_classify_sentiment_sync_plus_async_factories_are_callable | - | y | y | y | - | - | - | - | - | - | - | all | | +| llm_functions/main_ipsum_sentiment_enum_has_positive_negative_neutral_members | - | y | y | y | - | - | - | - | - | - | - | all | | +| llm_functions/main_ipsum_sentiment_enum_shape | y | - | - | - | - | - | y | - | y | y | y | all | | +| llm_functions/main_lorem_extract_resume_exposes_flat_spec_and_stream_bindings | - | y | y | y | - | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | pins TypeScript's dollar-preserving flat stream binding names | +| llm_functions/main_lorem_extract_resume_sync_plus_async_factories_are_callable | - | y | y | y | - | - | - | - | - | - | - | all | | +| llm_functions/main_lorem_resume_class_shape | y | - | - | - | - | - | y | - | y | - | y | all | | +| llm_functions/main_lorem_resume_is_reachable | - | y | y | y | - | - | - | - | - | - | - | all | | +| llm_functions/main_lorem_streaming_doc_class_shape | y | - | - | - | - | - | y | - | y | - | y | all | | +| llm_functions/main_lorem_streaming_doc_is_reachable | - | y | y | y | - | - | - | - | - | - | - | all | | +| llm_functions/main_lorem_streaming_extract_exposes_flat_spec_and_stream_bindings | - | y | y | y | - | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | pins TypeScript's dollar-preserving flat streaming binding names | +| llm_functions/main_lorem_streaming_extract_sync_plus_async_factories_are_callable | - | y | y | y | - | - | - | - | - | - | - | all | | +| llm_functions/main_namespaces_reachable_via_explicit_import | y | - | - | - | - | - | y | - | y | - | - | all | | +| llm_functions/main_nullable_model_field_can_be_omitted | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | pins the Python generator's nullable-field `= None` default | +| llm_functions/main_replay_server_namespace_bindings | y | - | - | - | - | - | y | - | y | - | y | all | | +| llm_functions/main_root_imports_cleanly | y | y | y | y | - | - | y | - | y | - | - | all | | +| llm_functions/main_spec_replaces_prompt_parse_and_request_companions | - | - | - | - | - | - | y | - | - | - | - | rust | validates Rust's generated FunctionSpec prompt, parse, and request surface | +| llm_functions/main_streaming_extract_factory_bindings | y | - | - | - | - | - | y | - | y | - | - | all | | +| llm_functions/main_streaming_extract_operation_bindings | y | - | - | - | - | - | - | - | y | - | - | python_pydantic2, java | pins Python and Java flat streaming operation binding spellings | +| llm_functions/main_streaming_extract_spec_and_stream_bindings | - | - | - | - | - | - | y | - | - | - | - | rust | pins Rust generated streaming spec and stream binding spellings | +| llm_functions/main_types_and_bindings_reachable | - | - | - | - | - | - | - | - | - | y | - | all | | +| llm_functions/on_event_plain_call_delivers_events | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | host on_event listener coverage lands Python-first; other SDKs port separately | +| llm_functions/on_event_plain_call_delivers_model_call_model | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | host on_event listener coverage lands Python-first; other SDKs port separately | +| llm_functions/on_event_plain_call_raising_listener_does_not_fail | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | host on_event listener coverage lands Python-first; other SDKs port separately | +| llm_functions/on_event_stream_delivers_settle_events | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | host on_event listener coverage lands Python-first; other SDKs port separately | +| llm_functions/on_event_stream_raising_listener_does_not_fail | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | host on_event listener coverage lands Python-first; other SDKs port separately | +| llm_functions/projects_static_and_instance_llm_methods_without_invoking_a_provider | - | y | y | y | - | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | validates TypeScript static and instance operation member spellings | +| llm_functions/prompt_is_reusable_and_media_survives_request_preview | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | validates Python's portable Prompt and media wrapper surface | +| llm_functions/recording_lands_in_cwd_project | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | native Python recording lands where `baml query --local` reads | +| llm_functions/spec_projection_honors_cancellation | - | - | - | - | - | - | - | y | - | - | - | go | pins Go context cancellation through the Spec projection. | +| llm_functions/stream_async_for_keeps_context_after_creator_returns | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python stream lifetime/context regression | +| llm_functions/stream_contexts_are_isolated_during_concurrent_iteration | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python concurrent stream context isolation | +| llm_functions/stream_final_caches_only_settled_errors | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python final caching must distinguish results from control flow | +| llm_functions/stream_final_keeps_context_and_allows_a_step_patch | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python sync stream defaults and explicit step patches | +| llm_functions/stream_final_parse_failure_is_preserved_after_eof | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python EOF settlement retains final parse errors | +| llm_functions/stream_final_timeout_is_not_cached | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python task cancellation must not poison later final awaits | +| llm_functions/stream_keeps_creation_cancel_token | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python stream cancellation survives constructor return | +| llm_functions/stream_precancelled_admission_preserves_reservation | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python stream reservations must survive rejected admission | +| llm_functions/stream_recording_parent_chain | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | native Python recording ancestry, duration, values and step-count audit | +| llm_functions/streaming_e2e_async_class_typed_next_async_yields_10_partials | - | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| llm_functions/streaming_e2e_async_next_async_yields_10_partials | - | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| llm_functions/streaming_e2e_baml_driven_collect_keeps_the_s_stream_finished_union_engine_side | - | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| llm_functions/streaming_e2e_baml_driven_collect_returns_the_final_doc | - | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| llm_functions/streaming_e2e_next_yields_10_doc_partials_final_is_a_typed_streaming_doc | - | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| llm_functions/streaming_e2e_next_yields_10_partials_and_drains_to_stream_finished | - | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| llm_functions/streaming_e2e_stream | y | - | - | - | - | - | y | - | y | y | - | all | | +| llm_functions/streaming_e2e_stream_async | y | - | - | - | - | - | y | - | y | y | - | all | | +| llm_functions/streaming_e2e_stream_collect_in_baml | y | - | - | - | - | - | y | - | y | y | y | all | | +| llm_functions/streaming_e2e_stream_doc | y | - | - | - | - | - | y | - | y | y | - | all | | +| llm_functions/streaming_e2e_stream_doc_async | y | - | - | - | - | - | y | - | y | y | - | all | | +| llm_functions/streaming_e2e_stream_doc_collect_in_baml | y | - | - | - | - | - | y | - | y | y | y | all | | +| package_edges/compile_cross_package_types_compile | - | - | - | - | - | - | - | y | - | - | - | all | | +| package_edges/compile_llm_projection_default_overrides | - | - | - | - | - | - | - | y | - | - | - | go | pins Go generator options for authored Spec and Stream defaults. | +| type_shapes/alias_container_composition_and_defaults | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/alias_package_scope_collisions_compile_and_run | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/aliases_round_trip_alias_container | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/aliases_round_trip_maybe_rec | - | - | - | - | y | - | - | - | - | - | - | all | | +| type_shapes/aliases_round_trip_rec_list | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/aliases_round_trip_string_list | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/bridge_handles_media_clones_ordinary_owners_and_wire_keys_independently | - | y | y | y | - | - | - | - | - | - | - | all | | +| type_shapes/bridge_handles_media_clones_ownership_through_to_handle_and_from_handle | - | y | y | y | - | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | exercises TypeScript bridge media handle ownership APIs | +| type_shapes/bridge_handles_media_constructs_url_file_and_base64_descriptors | - | y | y | y | - | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | exercises TypeScript bridge media descriptor APIs | +| type_shapes/bridge_handles_media_exposes_stable_seed_tags | - | y | y | y | - | - | - | - | - | - | - | all | | +| type_shapes/bridge_handles_media_keeps_host_value_tags_outside_ordinary_clone_and_release | - | y | y | y | - | - | - | - | - | - | - | all | | +| type_shapes/bridge_handles_media_normalizes_key_halves_losslessly_and_returns_defensive_key_objects | - | y | y | y | - | - | - | - | - | - | - | all | | +| type_shapes/bridge_handles_media_rejects_an_invalid_ordinary_key_only_when_an_operation_resolves_it | - | y | y | y | - | - | - | - | - | - | - | all | | +| type_shapes/bridge_surface_exports_the_same_runtime_values_in_node_browsers_and_workers | - | y | y | y | - | - | - | - | - | - | - | all | | +| type_shapes/bridge_surface_preserves_the_public_constructor_names | - | y | y | y | - | - | - | - | - | - | - | all | | +| type_shapes/class_refs_make_outer | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/class_refs_round_trip_async_by_value_or_reference_rust_only | - | - | - | - | - | - | y | - | - | - | - | rust | Rust class parameters take `impl Borrow`, so callers pass a value or a reference | +| type_shapes/class_refs_round_trip_by_value_or_reference_rust_only | - | - | - | - | - | - | y | - | - | - | - | rust | Rust class parameters take `impl Borrow`, so callers pass a value or a reference | +| type_shapes/class_refs_round_trip_inner | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/class_refs_round_trip_outer | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/closed_union_zero_value_returns_an_input_error | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/complex_models_round_trip_complex_profile_accepts_plain_object_literals_no_class_constructors | - | y | y | y | - | - | - | - | - | - | - | all | | +| type_shapes/complex_models_round_trip_complex_profile_preserves_deeply_nested_mixed_shape_class | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/container_recursive_class_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/declared_enum_functions_and_class_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/deep_namespace_thing_reachable | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/defaulted_enum_argument_and_invalid_values | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/dynamic_union_accepts_natural_go_integers | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/dynamic_union_candidates_round_trip_as_concrete_go_values | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/dynamic_union_delegates_semantic_validation_to_baml | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/dynamic_union_nested_containers_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/dynamic_union_of_containers_uses_selected_type_metadata | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/dynamic_union_rejects_unserializable_go_values_in_bridge | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/enum_composition_matrix_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/enum_package_scope_collisions_compile_and_run | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/enums_pick_positive | y | y | y | y | y | - | y | - | y | - | - | all | | +| type_shapes/enums_pick_sentiment | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/enums_round_trip_enums | y | y | y | y | y | - | y | - | y | - | - | all | | +| type_shapes/enums_round_trip_sentiment | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/enums_round_trip_sentiment_positive | y | y | y | y | y | - | y | - | y | - | - | all | | +| type_shapes/every_supported_leaf_through_lists_and_maps | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/forward_refs_round_trip_g_node_int | y | y | y | y | - | - | y | - | y | - | - | all | | +| type_shapes/forward_refs_round_trip_node_symbol_exists | - | - | - | - | y | - | - | - | - | y | - | all | | +| type_shapes/forward_refs_round_trip_other | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/forward_refs_round_trip_rec_list | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/forward_refs_round_trip_rec_list_with_other | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/generic_generic | y | y | y | y | - | - | y | - | y | y | - | all | | +| type_shapes/generic_generic_wrapper_get_value | y | - | - | - | - | - | y | - | y | y | - | all | | +| type_shapes/generic_wrapper_get_value | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/generics_round_trip_box_int | y | y | y | y | - | - | y | - | y | y | - | all | | +| type_shapes/generics_round_trip_differing_instantiation | y | y | y | y | - | - | y | - | y | y | - | all | | +| type_shapes/generics_round_trip_generic_binary_tree_int | y | y | y | y | - | - | y | - | y | y | - | all | | +| type_shapes/generics_round_trip_generic_linked_list_int | y | y | y | y | - | - | y | - | y | y | - | all | | +| type_shapes/generics_round_trip_nested_generics | y | y | y | y | - | - | y | - | y | y | - | all | | +| type_shapes/generics_round_trip_wrapper_int | y | y | y | y | - | - | y | - | y | y | - | all | | +| type_shapes/handles_baml_fs_open_returns_a_typed_file_handle | - | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| type_shapes/handles_file_cursor_state_persists_across_calls | y | y | - | - | - | - | y | - | y | y | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| type_shapes/handles_http_get_response_fields_and_methods | y | y | - | - | - | - | y | - | y | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| type_shapes/handles_image_from_base64_roundtrips_payload | y | y | y | y | - | - | y | - | y | y | - | all | | +| type_shapes/handles_open_file_returns_file_handle | y | - | - | - | - | - | y | - | y | y | - | all | | +| type_shapes/host_created_media_round_trips_through_optional_list | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | validates Python host-created media wrapper encoding | +| type_shapes/host_from_file_reads_now_and_keeps_the_base_name | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | validates the Python media wrapper's from_file, which is BAML's from_file | +| type_shapes/json_interchange_both_reject_what_baml_rejects | - | - | - | - | - | - | y | - | - | - | - | rust | validates the Rust SDK's optional serde impls | +| type_shapes/json_interchange_deserialize_matches_baml | - | - | - | - | - | - | y | - | - | - | - | rust | validates the Rust SDK's optional serde impls | +| type_shapes/json_interchange_json_fields_match_baml | - | - | - | - | - | - | y | - | - | - | - | rust | validates the Rust SDK's optional serde impls | +| type_shapes/json_interchange_optional_fields_may_be_absent_or_null | - | - | - | - | - | - | y | - | - | - | - | rust | validates the Rust SDK's optional serde impls | +| type_shapes/json_interchange_serialize_matches_baml | - | - | - | - | - | - | y | - | - | - | - | rust | validates the Rust SDK's optional serde impls | +| type_shapes/json_interchange_serialize_matches_baml_outside_a_class | - | - | - | - | - | - | y | - | - | - | - | rust | validates the Rust SDK's optional serde impls | +| type_shapes/json_interchange_unions_take_the_first_arm_that_fits | - | - | - | - | - | - | y | - | - | - | - | rust | validates the Rust SDK's optional serde impls | +| type_shapes/lists_round_trip_empty_list | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/lists_round_trip_ints | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/lists_round_trip_list_container | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/lists_round_trip_optional_strings | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/lists_round_trip_union_list | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/literals_literal_values_convert_implicitly | - | - | - | - | y | - | - | - | - | - | - | all | | +| type_shapes/literals_return_literals | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/literals_round_trip_flag_mixed_literal_union | y | - | - | - | y | - | - | - | - | - | - | all | | +| type_shapes/literals_round_trip_literal42 | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/literals_round_trip_literal_draft | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/literals_round_trip_literal_escaped | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/literals_round_trip_literal_false | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/literals_round_trip_literal_true | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/literals_round_trip_literals | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/lorem_resume_reachable | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/main_all_namespaces_reachable | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/main_deep_namespace_thing_reachable | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/main_generated_models_ignore_extra_fields | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | validates Python Pydantic extra-field compatibility | +| type_shapes/main_lorem_resume_reachable | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/main_root_foo_reachable | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/main_root_imports_cleanly | y | y | y | y | - | - | y | - | y | - | - | all | | +| type_shapes/main_runtime_owned_builtin_leaves_expose_their_public_names | - | y | y | y | - | - | - | - | - | - | - | all | | +| type_shapes/make_foo | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/make_outer | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/maps_round_trip_enum_keyed_map | - | y | y | y | - | - | - | - | - | - | - | all | | +| type_shapes/maps_round_trip_list_valued_map | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/maps_round_trip_map_container | - | y | y | y | - | - | - | - | - | - | - | all | | +| type_shapes/maps_round_trip_resume | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/maps_round_trip_sentiment | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/maps_round_trip_simple_map | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/media_constructors_and_accessors | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/media_from_file_reads_now_and_keeps_the_base_name | - | - | - | - | - | - | - | - | y | - | - | java | validates the Java media wrapper's from_file, which is BAML's from_file | +| type_shapes/media_nested_optional_and_containers | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/media_return_and_round_trip_all_kinds | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/media_return_audio | y | - | - | - | - | - | y | - | y | y | - | all | | +| type_shapes/media_return_audio_preserves_url_and_mime | - | y | y | y | - | - | - | - | - | - | - | all | | +| type_shapes/media_return_image | y | - | - | - | - | - | y | - | y | y | - | all | | +| type_shapes/media_return_image_preserves_url_and_mime | - | y | y | y | - | - | - | - | - | - | - | all | | +| type_shapes/media_return_pdf | y | - | - | - | - | - | y | - | y | y | - | all | | +| type_shapes/media_return_pdf_preserves_url_and_mime | - | y | y | y | - | - | - | - | - | - | - | all | | +| type_shapes/media_return_video | y | - | - | - | - | - | y | - | y | y | - | all | | +| type_shapes/media_return_video_preserves_url_and_mime | - | y | y | y | - | - | - | - | - | - | - | all | | +| type_shapes/media_round_trip_all_media | y | y | y | y | y | - | - | y | y | y | - | all | | +| type_shapes/media_round_trip_audio | y | - | - | - | - | - | y | - | y | y | - | all | | +| type_shapes/media_round_trip_audio_preserves_url_and_mime | - | y | y | y | - | - | - | - | - | - | - | all | | +| type_shapes/media_round_trip_image | y | - | - | - | - | - | y | - | y | y | - | all | | +| type_shapes/media_round_trip_image_can_reuse_the_same_wrapper | - | y | y | y | - | - | - | - | - | - | - | all | | +| type_shapes/media_round_trip_image_list | - | - | - | - | - | - | - | - | - | y | - | all | | +| type_shapes/media_round_trip_image_map | - | - | - | - | - | - | - | - | - | y | - | all | | +| type_shapes/media_round_trip_image_or_audio | - | - | - | - | - | - | - | - | y | y | - | all | | +| type_shapes/media_round_trip_media | y | - | - | - | - | - | y | - | y | y | - | all | | +| type_shapes/media_round_trip_media_or_image_class | y | y | y | y | y | - | - | y | y | y | - | all | | +| type_shapes/media_round_trip_media_preserves_all_four_media_fields | - | y | y | y | - | - | - | - | - | - | - | all | | +| type_shapes/media_round_trip_media_shapes | - | - | - | - | - | - | - | - | - | y | - | all | | +| type_shapes/media_round_trip_optional_image_list | - | - | - | - | - | - | - | - | - | y | - | all | | +| type_shapes/media_round_trip_pdf | y | - | - | - | - | - | y | - | y | y | - | all | | +| type_shapes/media_round_trip_pdf_preserves_url_and_mime | - | y | y | y | - | - | - | - | - | - | - | all | | +| type_shapes/media_round_trip_video | y | - | - | - | - | - | y | - | y | y | - | all | | +| type_shapes/media_round_trip_video_preserves_url_and_mime | - | y | y | y | - | - | - | - | - | - | - | all | | +| type_shapes/media_uses_the_runtime_owned_wrapper_constructors | - | y | y | y | - | - | - | - | - | - | - | all | | +| type_shapes/nested_class_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/no_op | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/nullable_class_fields_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/nullable_container_boundaries_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/nullable_container_fields_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/nullable_recursive_classes_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/opaque_rust_type_default_and_host_callback_positions | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/opaque_rust_type_nested_containers_classes_and_null | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/opaque_rust_type_round_trips_and_remains_reusable | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/optional_omitted_nullable_keys_read_as_null | - | y | y | y | - | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | TypeScript object literals and constructor inits may leave nullable keys out | +| type_shapes/optional_omitted_required_key_is_rejected | - | y | y | y | - | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | only TypeScript has a type-level check that a required key is present | +| type_shapes/optional_round_trip_optional_container | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/optional_round_trip_optional_int | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/optional_round_trip_optional_resume | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/optional_round_trip_optional_union | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/optional_round_trip_resume | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/optional_top_level_round_trips | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/overlapping_list_arms_preserve_exact_kind_for_empty_and_nonempty_values | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/overlapping_literal_arms_round_trip_with_exact_kind | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/overlapping_map_arms_preserve_exact_kind_for_empty_and_nonempty_values | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/pick_positive | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/pick_sentiment | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/primitive_class_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/primitive_literal_union_constructors_round_trip_with_exact_kind_and_value | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/primitives_return_bigint | y | - | - | - | y | - | - | - | y | - | - | all | | +| type_shapes/primitives_return_bool | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/primitives_return_float | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/primitives_return_int | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/primitives_return_null | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/primitives_return_string | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/primitives_round_trip_bigint | y | - | - | - | y | - | - | - | y | - | - | all | | +| type_shapes/primitives_round_trip_bool | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/primitives_round_trip_float | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/primitives_round_trip_float_accepts_int | y | - | - | - | y | - | y | - | y | - | - | all | | +| type_shapes/primitives_round_trip_float_refuses_an_int_on_the_wire | - | - | - | - | - | - | y | - | - | - | - | rust | only the low-level Rust entry point can put an int on the wire where a float is declared | +| type_shapes/primitives_round_trip_int | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/primitives_round_trip_int_async | - | - | - | - | - | - | - | - | - | y | - | all | | +| type_shapes/primitives_round_trip_null | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/primitives_round_trip_primitives | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/primitives_round_trip_primitives_float_field_accepts_int | y | - | - | - | y | - | y | - | y | y | - | all | | +| type_shapes/primitives_round_trip_string | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/primitives_round_trip_uint8_array | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/recursion_int_binary_tree_deeper_than_the_wire_is_refused | y | y | y | y | y | - | y | y | y | y | - | all | | +| type_shapes/recursion_round_trip_int_binary_tree | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/recursion_round_trip_mutual_recursion | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/recursion_round_trip_scc_t1_t2_t3 | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/recursion_round_trip_scc_t4_t5_t6 | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/required_container_round_trips | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/return_bool | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/return_float | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/return_int | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/return_literals | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/return_null | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/return_string | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/root_foo_reachable | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/root_imports_cleanly | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_bool | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_box_int | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_complex_profile_preserves_deeply_nested_mixed_shape_class | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_dedup | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_deep | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_deep_thing_from_a | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_deep_thing_from_lorem | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_differing_instantiation | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_empty_list | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_empty_list_preserves_selected_arm | - | - | - | - | - | - | - | - | - | y | - | all | | +| type_shapes/round_trip_enums | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_fizz_buzz_foo_bar | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_fizz_foo_bar | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_float | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_float_accepts_int_constant | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_foo | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_foo_bar | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_forward_ref_g_node_int | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_forward_ref_other | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_generic_binary_tree_int | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_generic_linked_list_int | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_inner | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_int | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_int_binary_tree | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_ints | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_ipsum | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_list_container | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_list_valued_map | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_literal42 | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_literal_draft | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_literal_escaped | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_literal_false | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_literal_true | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_literals | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_lorem_resume | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_lorem_resume_from_ipsum | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_map_resume | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_map_sentiment | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_mutual_recursion | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_nested_generics | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_null | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_null_to_end | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_optional_container | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_optional_int | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_optional_plus_null | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_optional_resume | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_optional_strings | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_optional_union | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_outer | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_primitives | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_primitives_float_field_accepts_int_constant | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_required_resume | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_resume_or_http_response | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_root_foo_from_ab | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_root_foo_from_lorem | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_scct1_t2_t3 | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_scct4_t5_t6 | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_sentiment | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_sentiment_positive | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_simple_map | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_singleton_unwrap | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_string | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_string_list | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_thing_from_ab | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_uint8_array | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_union_container | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_union_list | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_union_t | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/round_trip_wrapper_int | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/routing_make_foo | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/routing_round_trip_deep_thing_from_a | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/routing_round_trip_deep_thing_from_lorem | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/routing_round_trip_foo | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/routing_round_trip_lorem_resume_from_ipsum | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/routing_round_trip_resume | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/routing_round_trip_resume_or_http_response | y | y | y | y | - | - | - | - | y | y | - | all | | +| type_shapes/routing_round_trip_root_foo | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/routing_round_trip_root_foo_from_ab | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/routing_round_trip_thing_from_ab | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/supported_namespaces_reachable | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/symbol_collisions_round_trip_deep | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/symbol_collisions_round_trip_fizz_buzz_foo_bar | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/symbol_collisions_round_trip_fizz_foo_bar | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/symbol_collisions_round_trip_foo_bar | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/symbol_collisions_round_trip_ipsum | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/transparent_alias_functions_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/type_alias_declared_before_classes_is_importable | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | validates Python-specific generated type alias ordering | +| type_shapes/typemap_is_installed_during_root_module_evaluation | - | y | y | y | - | - | - | - | - | - | - | all | | +| type_shapes/typemap_preserves_user_enum_and_generic_mappings | - | y | y | y | - | - | - | - | - | - | - | all | | +| type_shapes/typemap_resolves_every_runtime_owned_base_to_one_bridge_constructor_identity | - | y | y | y | - | - | - | - | - | - | - | all | | +| type_shapes/union_aliases_are_transparent_and_flatten_before_thresholding | - | - | - | - | - | - | - | y | - | - | - | all | | +| type_shapes/unions_consumption_surfaces | - | - | - | - | - | - | - | - | - | y | - | all | | +| type_shapes/unions_match_dispatches_by_type | - | - | - | - | y | - | - | - | - | - | - | all | | +| type_shapes/unions_round_trip_dedup | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/unions_round_trip_null_to_end | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/unions_round_trip_optional_plus_null | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/unions_round_trip_singleton_unwrap | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/unions_round_trip_str_or_int_list | y | - | - | - | y | - | y | - | y | - | - | all | | +| type_shapes/unions_round_trip_t | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/unions_round_trip_union_container | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/unions_union_is_a_plain_std_variant | - | - | - | - | y | - | - | - | - | - | - | all | | +| type_shapes/void_no_op | y | y | y | y | y | - | y | - | y | y | y | all | | +| unsupported_only/compile_unsupported_only_package_compiles | - | - | - | - | - | - | - | y | - | - | - | all | | ## Baseline comparison No required coverage pairs changed. - diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/Gemfile.lock b/baml_language/sdk_tests/crates/ruby_sorbet/Gemfile.lock index f0cd4e4fa73..4266dbae65d 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/Gemfile.lock +++ b/baml_language/sdk_tests/crates/ruby_sorbet/Gemfile.lock @@ -1,6 +1,7 @@ GEM remote: https://rubygems.org/ specs: + bigdecimal (4.1.2) ffi (1.17.4) ffi (1.17.4-aarch64-linux-gnu) ffi (1.17.4-aarch64-linux-musl) @@ -13,7 +14,39 @@ GEM ffi (1.17.4-x86_64-darwin) ffi (1.17.4-x86_64-linux-gnu) ffi (1.17.4-x86_64-linux-musl) + google-protobuf (4.35.1) + bigdecimal + rake (~> 13.3) + google-protobuf (4.35.1-aarch64-linux-gnu) + bigdecimal + rake (~> 13.3) + google-protobuf (4.35.1-aarch64-linux-musl) + bigdecimal + rake (~> 13.3) + google-protobuf (4.35.1-arm64-darwin) + bigdecimal + rake (~> 13.3) + google-protobuf (4.35.1-x64-mingw-ucrt) + bigdecimal + rake (~> 13.3) + google-protobuf (4.35.1-x86-linux-gnu) + bigdecimal + rake (~> 13.3) + google-protobuf (4.35.1-x86-linux-musl) + bigdecimal + rake (~> 13.3) + google-protobuf (4.35.1-x86_64-darwin) + bigdecimal + rake (~> 13.3) + google-protobuf (4.35.1-x86_64-linux-gnu) + bigdecimal + rake (~> 13.3) + google-protobuf (4.35.1-x86_64-linux-musl) + bigdecimal + rake (~> 13.3) minitest (5.27.0) + rake (13.4.2) + sorbet-runtime (0.6.13506) PLATFORMS aarch64-linux-gnu @@ -32,9 +65,12 @@ PLATFORMS DEPENDENCIES ffi (>= 1.17.4, < 2) + google-protobuf (~> 4.0) minitest (~> 5.0) + sorbet-runtime (~> 0.5) CHECKSUMS + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd ffi (1.17.4) sha256=bcd1642e06f0d16fc9e09ac6d49c3a7298b9789bcb58127302f934e437d60acf ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39 @@ -47,7 +83,19 @@ CHECKSUMS ffi (1.17.4-x86_64-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496 ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e + google-protobuf (4.35.1) sha256=a3a6471331d918f58dfa4d014a8f6286f0af2cf4840216bde52fcf2ea3fe3726 + google-protobuf (4.35.1-aarch64-linux-gnu) sha256=50ca44d0eeff3f8475e630a1accdd974256f3510694d574e2c9d6119ea8bc9e1 + google-protobuf (4.35.1-aarch64-linux-musl) sha256=d5c65cef6bd6498a9e5ed5f88cf6cf7e341c10b0a005e32137d5d1a2b6e8c18a + google-protobuf (4.35.1-arm64-darwin) sha256=d9c957df04fa89c749fa9a72a7b383eb4296efc9b2303dc6fd6fbe39c698ad6b + google-protobuf (4.35.1-x64-mingw-ucrt) sha256=0660b716c44a4d3baa5cb648cd95dc7faed87ea92cd08fe92cf9156415b35033 + google-protobuf (4.35.1-x86-linux-gnu) sha256=cc7492566b27ad8b5dfa3a7b6f9b11e905050cc53c2fa8ff22de375a9e7a70fb + google-protobuf (4.35.1-x86-linux-musl) sha256=ab403790b59e4dc588ffbed1eaacf05d4ca2f0d12ac9c13d6c64e69380d8c99e + google-protobuf (4.35.1-x86_64-darwin) sha256=66b62b4df00931018a692806df66393efa960d6d2b7da69735187249f950d3ee + google-protobuf (4.35.1-x86_64-linux-gnu) sha256=c786439087512a3fbd199e9897d265b855f951d4027e218ea55e858d45969edd + google-protobuf (4.35.1-x86_64-linux-musl) sha256=91890eb0002934a339fdb7d77a147c46b7474b6799db27872b747b905837f744 minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5 + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + sorbet-runtime (0.6.13506) sha256=94a7ff795cf2a67818a954883c0301de0b7222a9f732b24ab30ea5253e7568f6 BUNDLED WITH 4.0.4 diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_main.rb b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_main.rb new file mode 100644 index 00000000000..ef70a03c4bc --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_main.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" + +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class MainTest < Minitest::Test + def test_main_hello_world_returns_literal + assert_raises(ArgumentError) { BamlSdk.hello_world("extra") } + assert_equal "hello world", BamlSdk.hello_world + end + + def test_main_single_required_arg_round_trips + assert_raises(ArgumentError) { BamlSdk.single_required_arg } + assert_equal "hi", BamlSdk.single_required_arg("hi") + assert_raises(TypeError) { BamlSdk.single_required_arg(1) } + end + +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_methods_on_classes.rb b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_methods_on_classes.rb new file mode 100644 index 00000000000..67f21d6a796 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_methods_on_classes.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" + +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class MethodsOnClassesTest < Minitest::Test + Greeter = BamlSdk::MethodsOnClasses::Greeter + + # SDK_PARITY_LINT(skip): Ruby lifecycle hook names must not run while requiring the generated SDK + def test_ruby_lifecycle_hooks_generate_and_load + assert_equal 1, BamlSdk::MethodsOnClasses.baml_singleton_method_added + assert_equal 2, BamlSdk::MethodsOnClasses::HookNames.baml_method_added + end + + # SDK_PARITY_LINT(skip): Ruby Object properties and Module callables need escaped names + def test_ruby_object_and_module_names_generate_load_and_call + type = BamlSdk::MethodsOnClasses::HookNames + value = type.new(baml_display: "display", baml_tap: "tap", then_: "then", baml_gem: "gem") + result = BamlSdk::MethodsOnClasses.baml_name(value) + assert_equal "display", result.baml_display + assert_equal "tap", result.baml_tap + assert_equal "then", result.then_ + assert_equal "gem", result.baml_gem + assert_equal 3, type.baml_ancestors + assert_equal 4, type.baml_decorator + end + + def test_methods_on_classes_method_bindings_exist + assert_respond_to Greeter, :create + assert_respond_to Greeter, :create_async + [:who, :who_async, :greet, :greet_async].each do |name| + assert_includes Greeter.instance_methods, name + end + end + + def test_methods_on_classes_static_create_round_trips + g = Greeter.create("ada") + assert_instance_of Greeter, g + assert_equal "ada", g.name + end + + def test_methods_on_classes_static_create_async_round_trips + g = Greeter.create_async("grace").wait + assert_instance_of Greeter, g + assert_equal "grace", g.name + end + + def test_methods_on_classes_instance_who_round_trips + assert_equal "hopper", Greeter.create("hopper").who + end + + def test_methods_on_classes_instance_who_async_round_trips + assert_equal "hopper", Greeter.create_async("hopper").wait.who_async.wait + end + + def test_methods_on_classes_instance_greet_with_arg_round_trips + assert_equal "hi", Greeter.create("lovelace").greet("hi") + end + + def test_methods_on_classes_instance_greet_async_with_arg_round_trips + assert_equal "hi", Greeter.create_async("lovelace").wait.greet_async("hi").wait + end +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_stdlib_entrypoints.rb b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_stdlib_entrypoints.rb new file mode 100644 index 00000000000..0b72a85ddd1 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_stdlib_entrypoints.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" + +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class StdlibEntrypointsTest < Minitest::Test + def test_stdlib_entrypoints_native_argv_callable_as_entry_point + assert_instance_of Array, BamlSdk::Baml::Sys.argv + end + + def test_stdlib_entrypoints_sysop_fs_exists_callable_as_entry_point + assert_equal true, BamlSdk::Baml::Fs.exists(".") + end + + def test_stdlib_entrypoints_compiler_intrinsics_are_not_emitted_as_entry_points + assert Dir.exist?(File.join(__dir__, "baml_sdk")), "generated baml_sdk/ must exist" + { + "__vendor/log.rb" => %w[info debug warn error], + "baml/events.rb" => ["send"] + }.each do |file, names| + path = File.join(__dir__, "baml_sdk", file) + next unless File.file?(path) + + source = File.read(path) + names.each do |name| + wire = file == "__vendor/log.rb" ? "log.#{name}" : "baml.events.#{name}" + refute_includes source, "\"#{wire}\"" + refute_match(/def self\.#{name}(?:_async)?\(/, source) + end + end + end +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/typed_consumer.rb b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/typed_consumer.rb new file mode 100644 index 00000000000..57600e0f770 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/typed_consumer.rb @@ -0,0 +1,19 @@ +# typed: strict +require "sorbet-runtime" +require_relative "baml_sdk" + +T.let(BamlSdk.initialize!, NilClass) +T.let(BamlSdk.hello_world, String) +T.let(BamlSdk.hello_world_async, Baml::Call[String]) +T.let(BamlSdk::Baml::Float.parse("1.5"), Float) +T.let(BamlSdk::Baml::String.from_code_points([65]), String) +T.let(BamlSdk::Baml::Spawn::CancelToken.baml_new_async, Baml::Call[BamlSdk::Baml::Spawn::CancelToken]) +T.let(BamlSdk.single_required_arg("hello"), String) +person = BamlSdk::Person.new(person: "person", name: "Ryan", age: 30) +T.let(person.person, String) +T.let(person.name, String) +T.let(person.age, Integer) +result = T.let(BamlSdk.round_trip_person(person), BamlSdk::Person) +T.let(result.name, String) +T.let(result.age, Integer) +T.let(BamlSdk::StaticMethodEdges::Edge.nullable(nil), T.nilable(Integer)) diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/replay_harness.rb b/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/replay_harness.rb new file mode 100644 index 00000000000..36075de1711 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/replay_harness.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "net/http" +require "pathname" +require "tempfile" +require "timeout" + +module ReplayHarness + module_function + + def recording_path(name) + Pathname.new(__dir__).ascend do |parent| + next unless parent.basename.to_s == "sdk_tests" + + recording = parent.join("fixtures/llm_functions/recordings/#{name}.snap.sse") + raise "missing recording #{recording}" unless recording.file? + + return recording.to_s + end + raise "could not locate the sdk_tests/ ancestor directory" + end + + ENV_KEYS = %w[BAML_REPLAY_BASE_URL BAML_REPLAY_API_KEY].freeze + + def with_server(recording) + recording = recording_path(recording) + saved_env = ENV.to_h.slice(*ENV_KEYS) + Tempfile.create("baml-ruby-replay") do |file| + addr_file = file.path + file.close + addr = nil + thread = Thread.new do + Thread.current.report_on_exception = false + BamlSdk::Replay.replay_serve_until_shutdown(recording, addr_file) + end + begin + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 10 + loop do + unless thread.alive? + thread.value # Re-raise the original bridge/server failure. + raise "replay server exited before binding" + end + text = File.read(addr_file).strip + unless text.empty? + addr = text + break + end + if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline + raise Timeout::Error, "replay server did not bind within 10s" + end + sleep 0.02 + end + ENV["BAML_REPLAY_BASE_URL"] = "http://#{addr}" + ENV["BAML_REPLAY_API_KEY"] = "replay-test-key" + yield addr + ensure + begin + if addr && thread.alive? + uri = URI("http://#{addr}/__replay__/shutdown") + Net::HTTP.start(uri.host, uri.port, open_timeout: 5, read_timeout: 5) do |http| + response = http.post(uri.request_uri, "") + raise "replay shutdown failed: #{response.code}" unless response.is_a?(Net::HTTPSuccess) + end + end + ensure + begin + # A server that will not stop fails this test; the process exit reaps it. + raise Timeout::Error, "replay server did not stop within 10s" unless thread.join(10) + ensure + ENV_KEYS.each { |key| saved_env.key?(key) ? ENV[key] = saved_env[key] : ENV.delete(key) } + end + end + end + end + end +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_main.rb b/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_main.rb new file mode 100644 index 00000000000..b35aa82027d --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_main.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" + +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class MainTest < Minitest::Test + def test_main_lorem_resume_class_shape + assert_operator BamlSdk::Lorem::Resume, :<, T::Struct + assert_equal %i[email name], BamlSdk::Lorem::Resume.props.keys.sort + end + + def test_main_lorem_streaming_doc_class_shape + assert_operator BamlSdk::Lorem::StreamingDoc, :<, T::Struct + props = BamlSdk::Lorem::StreamingDoc.props + assert_equal %i[body title word_count], props.keys.sort + assert_equal String, props.fetch(:title).fetch(:type) + assert_equal Integer, props.fetch(:word_count).fetch(:type) + assert_equal "T.nilable(String)", props.fetch(:body).fetch(:type_object).to_s + doc = BamlSdk::Lorem::StreamingDoc.new(title: "title", body: nil, word_count: 1) + refute_respond_to doc, :title= + assert_raises(TypeError) { BamlSdk::Lorem::StreamingDoc.new(title: 1, word_count: 1) } + end + + def test_main_ipsum_sentiment_enum_shape + assert_operator BamlSdk::Ipsum::Sentiment, :<, T::Enum + assert_equal %w[NEGATIVE NEUTRAL POSITIVE], BamlSdk::Ipsum::Sentiment.values.map(&:serialize).sort + # T::Enum members serialize to strings; unlike Python's enum, they aren't strings themselves. + assert_equal "POSITIVE", BamlSdk::Ipsum::Sentiment::POSITIVE.serialize + assert_instance_of String, BamlSdk::Ipsum::Sentiment::POSITIVE.serialize + end + + def test_main_replay_server_namespace_bindings + %i[replay_serve_until_shutdown replay_serve_detached].each do |name| + assert_respond_to BamlSdk::Replay, name + assert_respond_to BamlSdk::Replay, "#{name}_async" + end + end +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_streaming_e2e.rb b/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_streaming_e2e.rb new file mode 100644 index 00000000000..79ac2661645 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_streaming_e2e.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" +require_relative "replay_harness" + +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class StreamingE2ETest < Minitest::Test + def test_streaming_e2e_stream_collect_in_baml + ReplayHarness.with_server("replay_extract_string") do + result = BamlSdk::Lorem.stream_e2e_collect("ignored-by-replay-server") + assert_instance_of BamlSdk::Lorem::StreamE2ECollectResult, result + assert_operator result.next_calls.length, :>=, 10 + result.next_calls.each { |value| assert_instance_of String, value } + assert_instance_of String, result.final_call + end + end + + def test_streaming_e2e_stream_doc_collect_in_baml + ReplayHarness.with_server("replay_extract_doc") do + doc = BamlSdk::Lorem.stream_e2e_collect_doc("ignored-by-replay-server") + assert_instance_of BamlSdk::Lorem::StreamingDoc, doc + assert_respond_to doc, :title + assert_instance_of String, doc.title + assert_instance_of Integer, doc.word_count + assert doc.body.nil? || doc.body.is_a?(String) + end + end +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/typed_consumer.rb b/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/typed_consumer.rb new file mode 100644 index 00000000000..330b7d94888 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/typed_consumer.rb @@ -0,0 +1,15 @@ +# typed: strict +require "sorbet-runtime" +require_relative "baml_sdk" + +doc = BamlSdk::Lorem::StreamingDoc.new(title: "Title", body: nil, word_count: 30) +T.let(doc.title, String) +T.let(doc.body, T.nilable(String)) +T.let(doc.word_count, T.nilable(Integer)) +T.let(BamlSdk::Lorem::StreamingDoc.new(title: "Partial").word_count, T.nilable(Integer)) +collected = BamlSdk::Lorem::StreamE2ECollectResult.new(next_calls: ["first"], final_call: "last") +T.let(collected.next_calls, T::Array[String]) +T.let(BamlSdk::Lorem.stream_e2e_collect("text"), BamlSdk::Lorem::StreamE2ECollectResult) +T.let(BamlSdk::Lorem.streaming_extract("text"), BamlSdk::Lorem::StreamingDoc) +T.let(BamlSdk::Ipsum::Sentiment::POSITIVE, BamlSdk::Ipsum::Sentiment) +T.let(BamlSdk::Ipsum.classify_sentiment("text"), BamlSdk::Ipsum::Sentiment) diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/setup.ps1 b/baml_language/sdk_tests/crates/ruby_sorbet/setup.ps1 index eab33c7508f..379918cb42e 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/setup.ps1 +++ b/baml_language/sdk_tests/crates/ruby_sorbet/setup.ps1 @@ -16,14 +16,13 @@ try { cargo build -p bridge_cffi if ($LASTEXITCODE -ne 0) { throw "cargo build -p bridge_cffi failed" } - cargo run --quiet -p sdk_test_codegen -- emit-bytecode --fixture function_calls --out (Join-Path $fixtureDir "function-calls.bytecode") - if ($LASTEXITCODE -ne 0) { throw "function_calls bytecode generation failed" } + cargo run --quiet -p sdk_test_codegen -- ruby_sorbet + if ($LASTEXITCODE -ne 0) { throw "ruby_sorbet SDK generation failed" } } finally { Pop-Location } $bridgeFixture = Join-Path $fixtureDir "baml_ruby_test_fixture.dll" -$missingGetter = Join-Path $fixtureDir "baml_ruby_missing_getter.dll" $threadFixture = Join-Path $fixtureDir "baml_ruby_thread_callback.dll" & cl.exe /nologo /std:c11 /W4 /WX /LD "/I$includeDir" ` @@ -31,18 +30,12 @@ $threadFixture = Join-Path $fixtureDir "baml_ruby_thread_callback.dll" "/Fe:$bridgeFixture" "/Fo:$(Join-Path $fixtureDir 'bridge_fixture.obj')" if ($LASTEXITCODE -ne 0) { throw "bridge fixture compilation failed" } -& cl.exe /nologo /std:c11 /W4 /WX /LD ` - (Join-Path $testRoot "test/native/missing_getter.c") ` - "/Fe:$missingGetter" "/Fo:$(Join-Path $fixtureDir 'missing_getter.obj')" -if ($LASTEXITCODE -ne 0) { throw "missing-getter fixture compilation failed" } - & cl.exe /nologo /std:c++17 /EHsc /W4 /WX /LD ` (Join-Path $testRoot "test/native/thread_callback.cpp") ` "/Fe:$threadFixture" "/Fo:$(Join-Path $fixtureDir 'thread_callback.obj')" if ($LASTEXITCODE -ne 0) { throw "thread-callback fixture compilation failed" } -$invalidLibrary = Join-Path $fixtureDir "not-a-library" -[IO.File]::WriteAllText($invalidLibrary, "not a dynamic library`n") +Write-Host "Sorbet static gate unavailable: checker 0.6.13506 has no Windows binary; runtime tests still run." $env:BUNDLE_GEMFILE = Join-Path $testRoot "Gemfile" $env:BUNDLE_PATH = Join-Path $targetDir "ruby-bundle" @@ -54,9 +47,6 @@ if ($env:NEXTEST_ENV) { Add-Content -Path $env:NEXTEST_ENV -Value "BUNDLE_GEMFILE=$env:BUNDLE_GEMFILE" Add-Content -Path $env:NEXTEST_ENV -Value "BUNDLE_PATH=$env:BUNDLE_PATH" Add-Content -Path $env:NEXTEST_ENV -Value "BAML_RUBY_TEST_FIXTURE=$bridgeFixture" - Add-Content -Path $env:NEXTEST_ENV -Value "BAML_RUBY_TEST_MISSING_GETTER=$missingGetter" - Add-Content -Path $env:NEXTEST_ENV -Value "BAML_RUBY_TEST_INVALID_LIBRARY=$invalidLibrary" Add-Content -Path $env:NEXTEST_ENV -Value "BAML_RUBY_TEST_THREAD_FIXTURE=$threadFixture" Add-Content -Path $env:NEXTEST_ENV -Value "BAML_RUBY_TEST_REAL_RUNTIME=$(Join-Path $targetDir 'debug/bridge_cffi.dll')" - Add-Content -Path $env:NEXTEST_ENV -Value "BAML_RUBY_TEST_REAL_BYTECODE=$(Join-Path $fixtureDir 'function-calls.bytecode')" } diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/setup.sh b/baml_language/sdk_tests/crates/ruby_sorbet/setup.sh index 6d242d67a6b..1ad0ab7ead2 100755 --- a/baml_language/sdk_tests/crates/ruby_sorbet/setup.sh +++ b/baml_language/sdk_tests/crates/ruby_sorbet/setup.sh @@ -15,9 +15,7 @@ mkdir -p "$FIXTURE_DIR" ( cd "$WORKSPACE_ROOT" cargo build -p bridge_cffi - cargo run --quiet -p sdk_test_codegen -- \ - emit-bytecode --fixture function_calls \ - --out "$FIXTURE_DIR/function-calls.bytecode" + cargo run --quiet -p sdk_test_codegen -- ruby_sorbet ) if [[ "$(uname -s)" == "Darwin" ]]; then @@ -36,15 +34,30 @@ cc -std=c11 -Wall -Wextra -Werror -D_POSIX_C_SOURCE=200809L \ "${C_SHARED[@]}" -pthread -I"$INCLUDE_DIR" \ "$TEST_ROOT/test/native/bridge_fixture.c" \ -o "$FIXTURE_DIR/libbaml_ruby_test_fixture.$LIB_EXT" -cc -std=c11 -Wall -Wextra -Werror \ - "${C_SHARED[@]}" \ - "$TEST_ROOT/test/native/missing_getter.c" \ - -o "$FIXTURE_DIR/libbaml_ruby_missing_getter.$LIB_EXT" c++ -std=c++17 -Wall -Wextra -Werror \ "${CXX_SHARED[@]}" \ "$TEST_ROOT/test/native/thread_callback.cpp" \ -o "$FIXTURE_DIR/libbaml_ruby_thread_callback.$LIB_EXT" -printf 'not a dynamic library\n' > "$FIXTURE_DIR/not-a-library" + +# The pinned checker has no Windows, 32-bit Linux or musl binary. Keep its +# bundle separate so those hosts can still install and run the runtime suite. +case "$(uname -s):$(uname -m)" in + Darwin:x86_64|Darwin:arm64) STATIC_SUPPORTED=1 ;; + Linux:x86_64|Linux:aarch64) + if getconf GNU_LIBC_VERSION >/dev/null 2>&1; then STATIC_SUPPORTED=1; else STATIC_SUPPORTED=0; fi + ;; + *) STATIC_SUPPORTED=0 ;; +esac +if [[ "$STATIC_SUPPORTED" == 1 ]]; then + ( + export BUNDLE_GEMFILE="$TEST_ROOT/test/static/Gemfile" + export BUNDLE_PATH="$TARGET_DIR/ruby-sorbet-static-bundle" + export BUNDLE_FROZEN=true + ruby -S bundle install --jobs 4 --retry 3 + ) +else + echo "Sorbet static gate unavailable: checker 0.6.13506 requires x86_64/arm64 macOS or x86_64/aarch64 glibc Linux; runtime tests still run." +fi export BUNDLE_GEMFILE="$TEST_ROOT/Gemfile" export BUNDLE_PATH="$TARGET_DIR/ruby-bundle" @@ -56,10 +69,7 @@ if [[ -n "${NEXTEST_ENV:-}" ]]; then echo "BUNDLE_GEMFILE=$BUNDLE_GEMFILE" echo "BUNDLE_PATH=$BUNDLE_PATH" echo "BAML_RUBY_TEST_FIXTURE=$FIXTURE_DIR/libbaml_ruby_test_fixture.$LIB_EXT" - echo "BAML_RUBY_TEST_MISSING_GETTER=$FIXTURE_DIR/libbaml_ruby_missing_getter.$LIB_EXT" - echo "BAML_RUBY_TEST_INVALID_LIBRARY=$FIXTURE_DIR/not-a-library" echo "BAML_RUBY_TEST_THREAD_FIXTURE=$FIXTURE_DIR/libbaml_ruby_thread_callback.$LIB_EXT" echo "BAML_RUBY_TEST_REAL_RUNTIME=$REAL_RUNTIME" - echo "BAML_RUBY_TEST_REAL_BYTECODE=$FIXTURE_DIR/function-calls.bytecode" } >> "$NEXTEST_ENV" fi diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/src/lib.rs b/baml_language/sdk_tests/crates/ruby_sorbet/src/lib.rs index b8fa7b8ae22..751d11e6d2c 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/src/lib.rs +++ b/baml_language/sdk_tests/crates/ruby_sorbet/src/lib.rs @@ -1,8 +1,60 @@ #[cfg(test)] -sdk_test_harness_runner::setup_guard!("SDK_TEST_RUBY_SORBET_SETUP"); +sdk_test_harness_runner::ruby_sorbet::test_suite! { + fixture docstrings_etc { minitest: later, sorbet: later } + fixture function_calls { minitest: on, sorbet: on } + fixture llm_functions { minitest: on, sorbet: on } + fixture type_shapes { minitest: on, sorbet: on } + fixture unsupported_only { minitest: later, sorbet: later } +} + +#[cfg(test)] +mod wrong_argument { + sdk_test_harness_runner::__ruby_sorbet_gate!(on, sorbet, { + let mut command = sdk_test_harness_runner::ruby_sorbet::sorbet_command("function_calls"); + command.arg( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("test/static/wrong_argument.rb"), + ); + let output = command.output().expect("run Sorbet negative consumer"); + let diagnostics = String::from_utf8_lossy(&output.stderr); + assert_eq!(output.status.code(), Some(100), "{diagnostics}"); + assert_eq!( + diagnostics.matches("https://srb.help/").count(), + 1, + "{diagnostics}" + ); + assert!( + diagnostics.contains("wrong_argument.rb:4:"), + "{diagnostics}" + ); + assert!( + diagnostics.contains("https://srb.help/7002"), + "{diagnostics}" + ); + }); +} #[cfg(test)] mod bridge_tests { + #[test] + fn encoding_walker() { + sdk_test_harness_runner::run_workspace_cmd( + "sdk_tests/crates/ruby_sorbet", + "ruby -S bundle exec ruby test/bridge_encode_test.rb", + "ruby-bundle", + "BUNDLE_PATH", + ); + } + + #[test] + fn synchronous_call_runtime() { + sdk_test_harness_runner::run_workspace_cmd( + "sdk_tests/crates/ruby_sorbet", + "ruby -S bundle exec ruby test/bridge_call_test.rb", + "ruby-bundle", + "BUNDLE_PATH", + ); + } + #[test] fn loader_and_lifecycle() { sdk_test_harness_runner::run_workspace_cmd( diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_call_test.rb b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_call_test.rb new file mode 100644 index 00000000000..0f48b780a08 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_call_test.rb @@ -0,0 +1,359 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "timeout" +require "open3" +$LOAD_PATH.unshift File.expand_path("../../../../sdks/ruby/bridge_ruby/lib", __dir__) +require "baml/bridge" + +class BridgeCallTest < Minitest::Test + Wire = BamlBridge::Cffi::V1 + Native = Baml::Bridge.const_get(:Native, false) + + class FakeApi + attr_reader :callback, :initialize_count, :released_calls, :cancelled_calls + attr_accessor :dispatch + attr_reader :released_handles + + def initialize + @initialize_count = 0 + @engine_id = 2**40 + @released_calls = [] + @cancelled_calls = [] + @released_handles = Queue.new + end + + def register_bridge!(_toolchain, _version); end + def ensure_not_forked!; end + def register_unhandled_spawn_error_callback(_callback); end + def shutdown_runtime; end + + def handle_clone(key) + key + end + + def initialize_runtime(_bytes, _metadata = nil) + @initialize_count += 1 + end + + def register_result_callback(callback) + @callback = callback + end + + def handle_release(key) + @released_handles << key + end + + def new_function_call + @engine_id += 1 + end + + def release_function_call(call_id) + @released_calls << call_id + end + + def cancel_function_call(call_id) + @cancelled_calls << call_id + 0 + end + + def call_function(bytes, callback_id) + @dispatch.call(Wire::CallFunctionArgs.decode(bytes), callback_id) + end + + def deliver(callback_id, **result) + bytes = Wire::BamlOutboundResult.encode(Wire::BamlOutboundResult.new(**result)) + pointer = FFI::MemoryPointer.new(:uint8, [bytes.bytesize, 1].max) + pointer.put_bytes(0, bytes) + @callback.call(callback_id, pointer, bytes.bytesize) + end + end + + def setup + @runtime = Baml::Bridge.const_get(:ProcessRuntime, false).new + @api = FakeApi.new + @runtime.define_singleton_method(:configured_runtime_path!) { "fake-runtime" } + @runtime.define_singleton_method(:open_library) { |*_args| Object.new } + @runtime.register_program("bytecode") + Native::Api.stub(:new, @api) { @runtime.initialize! } + end + + def released_handle + Timeout.timeout(5) { @api.released_handles.pop } + end + + def test_exit_panic_flushes_piped_output_when_decoded + source = <<~'RUBY' + require "baml/bridge" + protocol = Baml::Bridge::Protocol + result = protocol::Wire::BamlOutboundResult.new(panic: { is_exit_panic: true, exit_code: 7 }) + captured = protocol::Result.new(result, nil, nil) + at_exit { puts "UNREACHABLE" } + print "before exit" + warn "before exit on stderr" + protocol.decode_result(captured) + RUBY + stdout, stderr, status = Open3.capture3(RbConfig.ruby, "-I", $LOAD_PATH.first, "-e", source) + assert_equal 7, status.exitstatus, stderr + assert_equal "before exit", stdout + assert_equal "before exit on stderr\n", stderr + end + + def test_exit_panic_flushes_original_streams_when_globals_are_replaced + source = <<~'RUBY' + require "baml/bridge" + require "stringio" + STDOUT.sync = STDERR.sync = false + STDOUT.print "original stdout" + STDERR.print "original stderr" + $stdout = StringIO.new + $stderr = StringIO.new + Baml::Bridge::Protocol.exit_process(7) + RUBY + stdout, stderr, status = Open3.capture3(RbConfig.ruby, "-I", $LOAD_PATH.first, "-e", source) + assert_equal 7, status.exitstatus, stderr + assert_equal "original stdout", stdout + assert_equal "original stderr", stderr + end + + def test_cancel_waiting_for_the_mutex_is_interruptible + pending = Baml::Call.new(@api, 123) + mutex = pending.instance_variable_get(:@mutex) + entered, release = Queue.new, Queue.new + holder = Thread.new { mutex.synchronize { entered << true; release.pop } } + Timeout.timeout(5) { entered.pop } + assert_raises(Timeout::Error) { Timeout.timeout(0.05) { pending.cancel } } + assert_empty @api.cancelled_calls + refute pending.instance_variable_get(:@cancel_requested) + release << true + holder.join + assert pending.cancel + assert_equal [123], @api.cancelled_calls + ensure + release << true if release + holder&.join(5) + end + + def test_cancel_defers_an_interrupt_until_native_cancellation + @api.define_singleton_method(:cancel_function_call) do |id| + Thread.current.raise Interrupt + super(id) + end + pending = Baml::Call.new(@api, 123) + assert_raises(Interrupt) { pending.cancel } + assert_equal [123], @api.cancelled_calls + refute pending.cancel + end + + def test_callback_ids_wrap_and_skip_pending_ids + first = Object.new + last = Object.new + wrapped = Object.new + assert_equal 1, @runtime.send(:register_call, first) + @runtime.instance_variable_set(:@next_callback_id, 0xffff_ffff) + assert_equal 0xffff_ffff, @runtime.send(:register_call, last) + assert_equal 2, @runtime.send(:register_call, wrapped) + pending = @runtime.instance_variable_get(:@pending) + assert_same first, pending.fetch(1) + assert_same last, pending.fetch(0xffff_ffff) + assert_same wrapped, pending.fetch(2) + end + + def test_result_handles_are_owned_before_wait_and_after_partial_decode + handle = { handle_value: { key: 41, handle_type: :ADT_TAGGED_HEAP_HANDLE } } + @api.dispatch = ->(_args, id) { @api.deliver(id, ok: { list_value: { items: [handle, handle] } }) } + Thread.new { @runtime.call_async("user.test", {}); nil }.join + Timeout.timeout(5) { GC.start until @api.released_handles.size == 2 } + assert_equal [41, 41], 2.times.map { released_handle } + + @api.dispatch = lambda do |_args, id| + @api.deliver(id, ok: { list_value: { items: [handle, { prompt_ast_value: {} }, handle] } }) + end + assert_raises(Baml::Bridge::UnsupportedTypeError) { call } + assert_equal 41, released_handle # The occurrence decoding never reached. + Timeout.timeout(5) { GC.start until @api.released_handles.size == 1 } + assert_equal 41, released_handle + + @api.dispatch = ->(_args, id) { @api.deliver(id, ok: { list_value: { items: [handle, handle] } }) } + owners = call + refute_same owners.first, owners.last + GC.start + assert @api.released_handles.empty? + owners.each(&:close) + assert_equal [41, 41], 2.times.map { released_handle } + end + + def test_reflected_types_release_every_live_head_on_refusal_and_abandonment + reflected = { ty_value: { function: { + params: [{ ty: { map: { + key: { enum: { live: 41 } }, + value: { class_ty: { live: 42, type_args: [{ type_alias: { live: 41 } }] } } + } } }], + ret: { interface: { live: 43, bindings: [{ name: "Item", ty: { enum_variant: { live: 44, variant: "V" } } }] } }, + throws: { optional: { inner: { union: { options: [{ enum: { live: 45 } }, { class_ty: { anonymous_label: "display" } }] } } } } + } } } + @api.dispatch = ->(_args, id) { @api.deliver(id, ok: reflected) } + assert_raises(Baml::Bridge::UnsupportedTypeError) { call } + assert_equal [41, 41, 42, 43, 44, 45], 6.times.map { released_handle }.sort + + Thread.new { @runtime.call_async("user.reflected", {}); nil }.join + Timeout.timeout(5) { GC.start until @api.released_handles.size == 6 } + assert_equal [41, 41, 42, 43, 44, 45], 6.times.map { released_handle }.sort + GC.start + assert @api.released_handles.empty? + end + + def test_reflected_type_references_are_released_after_partial_decode + @api.dispatch = lambda do |_args, id| + @api.deliver(id, ok: { list_value: { items: [ + { handle_value: { key: 40, handle_type: :ADT_TAGGED_HEAP_HANDLE } }, + { ty_value: { list: { item: { class_ty: { live: 41 } } } } }, + { handle_value: { key: 42, handle_type: :ADT_TAGGED_HEAP_HANDLE } } + ] } }) + end + assert_raises(Baml::Bridge::UnsupportedTypeError) { call } + assert_equal [41, 42], 2.times.map { released_handle }.sort + Timeout.timeout(5) { GC.start until @api.released_handles.size == 1 } + assert_equal 40, released_handle + end + + def test_interrupted_callback_registration_is_retained_and_completed_once + runtime = Baml::Bridge.const_get(:ProcessRuntime, false).new + runtime.register_program("bytecode") + runtime.instance_variable_set(:@library, Object.new) + api = FakeApi.new + attempts = Hash.new(0) + { register_bridge!: nil, register_result_callback: :@result_callback, + register_unhandled_spawn_error_callback: :@spawn_callback }.each do |method, owner| + api.define_singleton_method(method) do |*args| + raise "callback was not retained" if owner && !runtime.instance_variable_get(owner) + attempts[method] += 1 + Thread.current.raise Interrupt + super(*args) + end + end + Native::Api.stub(:new, api) do + 3.times { assert_raises(Interrupt) { runtime.initialize! } } + runtime.initialize! + end + assert_equal [1, 1, 1], attempts.values + assert_equal 1, api.initialize_count + assert_same runtime.instance_variable_get(:@result_callback).function, api.callback + end + + def test_concurrent_calls_receive_only_their_own_result + dispatched = Queue.new + @api.dispatch = ->(args, id) { dispatched << [args, id] } + threads = 2.times.map { |i| Thread.new { call("value" => i) } } + Timeout.timeout(5) do + calls = 2.times.map { dispatched.pop } + refute_equal calls[0][0].call_id, calls[1][0].call_id + refute_equal calls[0][1], calls[1][1] + calls.reverse_each do |args, id| + @api.deliver(id, ok: { int_value: args.kwargs.first.value.int_value }) + end + assert_equal [0, 1], threads.map(&:value) + end + assert_empty pending + assert_equal 1, @api.initialize_count + end + + def test_callback_capture_errors_reach_caller + @api.dispatch = ->(_args, id) { @api.deliver(id, ok: { int_value: 1 }) } + Baml::Bridge::Protocol.stub(:capture_result, ->(*) { raise "capture failed" }) do + error = Timeout.timeout(5) { assert_raises(RuntimeError) { call } } + assert_equal "capture failed", error.message + end + assert_empty pending + end + + def test_dispatch_failure_removes_pending_call + @api.dispatch = ->(_args, _id) { raise "dispatch failed" } + error = assert_raises(RuntimeError) { call } + assert_equal "dispatch failed", error.message + assert_equal [(2**40) + 1], @api.released_calls + assert_empty pending + end + + def test_interrupt_after_allocation_releases_call + @api.define_singleton_method(:new_function_call) do + call_id = super() + Thread.current.raise Interrupt + call_id + end + assert_raises(Interrupt) { call } + assert_equal [(2**40) + 1], @api.released_calls + assert_empty pending + end + + def test_call_waiters_cache_outcomes_and_survive_interrupt_and_cancel + dispatched = Queue.new + @api.dispatch = ->(args, id) { dispatched << [args.call_id, id] } + result = @runtime.call_async("user.test", {}) + engine_id, callback_id = dispatched.pop + refute result.done? + assert_raises(Baml::WaitTimeout) { result.wait(timeout: 0.001) } + refute result.done? + + interrupted = Thread.new do + result.wait + rescue Interrupt => error + error + end + Thread.pass until interrupted.status == "sleep" + interrupted.raise(Interrupt) + assert_instance_of Interrupt, interrupted.value + refute result.done? + + waiters = 3.times.map { Thread.new { result.wait(timeout: 5) } } + Thread.pass until waiters.all? { |thread| thread.status == "sleep" } + assert result.cancel + refute result.cancel + assert_equal [engine_id], @api.cancelled_calls + refute result.done? + + callback = @runtime.instance_variable_get(:@result_callback) + handler = callback.instance_variable_get(:@handler) + delivering_threads = Queue.new + callback.instance_variable_set(:@handler, lambda do |id, bytes| + delivering_threads << Thread.current + handler.call(id, bytes) + end) + library = FFI::DynamicLibrary.open(ENV.fetch("BAML_RUBY_TEST_THREAD_FIXTURE"), FFI::DynamicLibrary::RTLD_NOW) + invoke = FFI::Function.new( + :void, %i[pointer uint32 pointer size_t], + library.find_function("baml_test_invoke_on_native_thread"), blocking: true + ) + bytes = Wire::BamlOutboundResult.encode(Wire::BamlOutboundResult.new(ok: { list_value: { items: [{ int_value: 42 }] } })) + pointer = FFI::MemoryPointer.new(:uint8, bytes.bytesize) + pointer.put_bytes(0, bytes) + GC.start + invoke.call(@api.callback, callback_id, pointer, bytes.bytesize) + refute_same Thread.current, delivering_threads.pop + values = waiters.map(&:value) + assert_equal [42], values.first + values.each { |value| assert_same values.first, value } + assert_same values.first, result.wait(timeout: 0) + assert result.done? + refute result.cancel + + failed = @runtime.call_async("user.test", {}) + _, callback_id = dispatched.pop + @api.deliver(callback_id, error: { value: { string_value: "cached error" } }) + assert failed.done? + error = assert_raises(Baml::Error) { failed.wait } + assert_equal "cached error", error.message + assert_same error, assert_raises(Baml::Error) { failed.wait } + assert_empty pending + end + + private + + def call(arguments = {}) + @runtime.call_async("user.namespace.OriginalName", arguments).wait + end + + def pending + @runtime.instance_variable_get(:@pending) + end +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_encode_test.rb b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_encode_test.rb new file mode 100644 index 00000000000..7afa176a0b6 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_encode_test.rb @@ -0,0 +1,411 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "timeout" +require "sorbet-runtime" +$LOAD_PATH.unshift File.expand_path("../../../../sdks/ruby/bridge_ruby/lib", __dir__) +require "baml/bridge" + +class BridgeEncodeTest < Minitest::Test + Encode = Baml::Bridge::Encode + Wire = BamlBridge::Cffi::V1 + Protocol = Baml::Bridge::Protocol + + class Color < T::Enum + enums do + Red = new("red") + Blue = new("blue") + end + end + Box = Struct.new(:value, :note, keyword_init: true) + + class Registry + def initialize + @types = {} + @fields = {} + end + + def define(type, name, fields = nil) + @types[type] = [name, fields] + @fields[name] = fields + end + + def encoding_type(type) + @types.fetch(type, [nil, nil]) + end + + def encoding_fields(name) + @fields.fetch(name) + end + + def enum_values(_name) + %w[red blue] + end + end + + class Api + attr_reader :cloned, :released + + def initialize + @cloned = [] + @released = [] + end + + def handle_clone(key) + @cloned << key + key + end + + def handle_release(key) + @released << key + end + end + + def setup + @registry = Registry.new + @registry.define(Color, "user.Color") + @registry.define(Box, "user.Box", { + "value" => [:value, [:param, 0, "T"]], + "note" => [:note, [:union, [[:null], [:string]]]] + }) + end + + def test_scope_attempts_every_release_before_raising_the_first_error + api = Api.new + failure = RuntimeError.new("release failed") + api.define_singleton_method(:handle_release) do |key| + super(key) + raise failure if key == 1 + end + scope = Baml::Bridge::EncodingScope.new + [1, 2, 3].each { |key| scope.own(api, key) } + assert_same failure, assert_raises(RuntimeError) { scope.close } + assert_equal [1, 2, 3], api.released + scope.close + assert_equal [1, 2, 3], api.released + end + + def test_string_slots_validate_binary_utf8_values_and_keys + assert_equal "café", encode("café".b, [:string]).string_value + assert_raises(TypeError) { encode("\xff".b, [:string]) } + slot = [:map, [:string], [:int]] + assert_equal "café", encode({ "café".b => 1 }, slot).map_value.entries.first.string_key + assert_raises(TypeError) { encode({ "\xff".b => 1 }, slot) } + end + + def test_scalars_follow_the_slot_and_the_hosts_rules + [[nil, :null, nil], [true, :bool, :bool_value], [false, :bool, :bool_value], + [7, :int, :int_value], [2.5, :float, :float_value], ["text", :string, :string_value]].each do |value, kind, arm| + encoded = encode(value, [kind]) + arm ? assert_equal(arm, encoded.value) : assert_nil(encoded.value) + assert_nil encoded.value_type + assert_equal value, encoded.public_send(arm) if arm + end + assert_equal 7.0, encode(7, [:float]).float_value + assert_raises(TypeError) { encode(true, [:int]) } + assert_raises(TypeError) { encode(1.0, [:int]) } + assert_raises(TypeError) { encode(2**62, [:int]) } + assert_equal -(2**62), encode(-(2**62), [:int]).int_value + assert_equal 2**62 - 1, encode(2**62 - 1, [:int]).int_value + assert_raises(TypeError) { encode(Object.new) } + end + + def test_integer_to_float_conversion_is_exact_at_any_magnitude + assert_raises(TypeError) { encode(2**53 + 1, [:float]) } + [2**53, 2**63, 2**80, -(2**80)].each do |value| + assert_equal value.to_f, encode(value, [:float]).float_value + end + assert_raises(TypeError) { encode(2**1024, [:float]) } + end + + def test_enums_take_members_as_is_and_strings_by_conversion + [Color::Red, "red"].each do |value| + encoded = encode(value, [:enum, "user.Color"]) + assert_equal "user.Color", encoded.enum_value.name + assert_equal "red", encoded.enum_value.value + assert_nil encoded.value_type + end + assert_raises(TypeError) { encode("green", [:enum, "user.Color"]) } + assert_raises(TypeError) { encode(Color::Red, [:enum, "user.Other"]) } + end + + def test_literals_are_matched_exactly + [7, "draft", true, false].each do |value| + assert_nil encode(value, [:literal, value]).value_type + end + assert_raises(TypeError) { encode(8, [:literal, 7]) } + assert_raises(TypeError) { encode(1, [:literal, true]) } + assert_raises(TypeError) { encode(1.0, [:literal, 1.0]) } + assert_equal :int_value, encode(1, union([:literal, 1.0], [:literal, 1])).value + end + + def test_unions_rank_identity_over_natural_shape_over_conversion + assert_equal :int_value, encode(5, union([:float], [:int])).value + assert_equal :float_value, encode(5, union([:float], [:string])).value + assert_equal :int_value, encode(5, union([:float], [:unknown])).value + assert_equal :string_value, encode("red", union([:enum, "user.Color"], [:string])).value + assert_equal :enum_value, encode(Color::Red, union([:unknown], [:enum, "user.Color"])).value + list = encode([5], union([:unknown], [:list, [:float]])) + assert_equal [:list, [:float]], Encode::Slot.from_wire(list.value_type) + assert_equal 5.0, list.list_value.values.first.float_value + assert_nil encode(nil, union([:int], [:null])).value + assert_equal :string_value, encode("text", union([:int], union([:float], union([:null], [:string])))).value + end + + def test_several_fits_collapse_when_identical_and_are_ambiguous_otherwise + assert_nil encode(nil, union(union([:int], [:null]), union([:string], [:null]))).value + assert_equal :int_value, encode(5, union([:literal, 5], [:int])).value + natural = encode([1], union([:list, union([:int], [:string])], [:list, [:int]])) + assert_equal [:list, [:int]], Encode::Slot.from_wire(natural.value_type) + error = assert_raises(TypeError) { encode([], union([:list, [:int]], [:list, [:string]])) } + assert_includes error.message, "fits more than one" + assert Encode::Slot.equivalent?(union([:int], [:string]), union([:string], [:int])) + end + + def test_distinct_mixed_scalar_lists_plan_in_linear_time + scalars = [1, 1.5, "s", true, nil] + subsets = (1...(1 << scalars.length)).map do |mask| + scalars.each_index.select { |index| mask[index] == 1 }.map { |index| scalars[index] } + end + value = subsets.combination(3).first(4_000) + tree = Encode::Read.new(@registry).read(value, "mixed") + planned = Timeout.timeout(10) { Encode::Plan.new(@registry).plan(tree, [:unknown]) } + assert_equal 4_000, planned.children.length + assert_equal :union, planned.ty[1][0] + assert_equal 4_000, planned.ty[1][1].length + end + + def test_type_identity_recurses_through_every_composite_and_function_part + left = union([:int], union([:string], [:int])) + right = union([:string], [:int]) + wrap = ->(ty) { [:map, [:string], [:list, [:class, "user.Box", [ty]]]] } + signature = ->(ty) { [:function, [["value", wrap.call(ty), false]], wrap.call(ty), ty] } + a, b = signature.call(left), signature.call(right) + assert Encode::Slot.equivalent?(a, b) + assert_equal a, Encode::Slot.union([a, b]) + changed_name = signature.call(right) + changed_name[1][0][0] = "other" + refute Encode::Slot.equivalent?(a, changed_name) + changed_mode = signature.call(right) + changed_mode[1][0][2] = true + refute Encode::Slot.equivalent?(a, changed_mode) + refute Encode::Slot.equivalent?(a, [:function, [], wrap.call(right), right]) + refute Encode::Slot.equivalent?(a, [:function, b[1], [:int], right]) + refute Encode::Slot.equivalent?(a, [:function, b[1], b[2], [:never]]) + end + + def test_invalid_nonbinary_strings_are_refused_while_reading + invalid = "\xff".b.force_encoding(Encoding::UTF_8) + [invalid, { invalid => 1 }].each do |value| + error = assert_raises(Encode::Refusal) { Encode::Read.new(@registry).read(value, "invalid") } + assert_includes error.message, "invalid string" + end + assert_equal :string_value, encode("café").value + end + + def test_unstatable_types_are_bridge_faults_and_still_type_errors_for_calls + [[:other], [:handle], [:literal, 1.5]].each do |slot| + assert_raises(Encode::Fault) { Encode::Slot.to_wire(slot) } + end + Protocol.define_function("user.unstatable", params: [["items", [:list, [:other]]]]) + assert_raises(TypeError) { Protocol.encode_call(1, "user.unstatable", { "items" => [] }) } + end + + def test_open_slots_take_the_values_own_type_and_fill_holes_with_unknown + assert_equal [:list, [:unknown]], type_of([]) + assert_equal [:map, [:string], [:unknown]], type_of({}) + assert_equal [:list, union([:int], [:string])], type_of([1, "a"]) + result = encode([[], [1]]) + assert_equal [:list, [:list, [:int]]], Encode::Slot.from_wire(result.value_type) + assert_equal [:list, [:int]], Encode::Slot.from_wire(result.list_value.values.first.value_type) + assert_equal [:list, [:list, [:list, [:int]]]], type_of([[[]], [[1]]]) + assert_equal [:list, [:int]], type_of([1], [:interface, "user.Anything"]) + mixed = [:list, union([:list, [:int]], [:list, [:string]])] + assert_equal mixed, type_of([[1], ["a"]]) + assert_equal mixed, type_of([[], [1], ["a"]]) + assert_equal [:list, union([:map, [:string], [:int]], [:map, [:string], [:string]])], + type_of([{ "a" => 1 }, { "b" => "text" }]) + end + + def test_realized_containers_state_the_slot_not_the_narrower_elements + slot = [:list, union([:int], [:string])] + assert_equal slot, type_of([1, 2], slot) + assert_equal [:list, [:float]], type_of([], [:list, [:float]]) + map = encode({ "a" => 3 }, [:map, [:string], [:float]]) + assert_equal [:map, [:string], [:float]], Encode::Slot.from_wire(map.value_type) + assert_equal 3.0, map.map_value.entries.first.value.float_value + end + + def test_fixed_class_arguments_drive_fields_and_optional_fields_are_present + result = encode(Box.new(value: 3), [:class, "user.Box", [[:float]]]) + assert_equal [:class, "user.Box", [[:float]]], Encode::Slot.from_wire(result.value_type) + assert_equal %w[value note], result.class_value.fields.map(&:string_key) + assert_equal 3.0, result.class_value.fields.first.value.float_value + assert_nil result.class_value.fields.last.value.value + assert_raises(TypeError) { encode({ "value" => 3 }, [:class, "user.Box", [[:int]]]) } + end + + def test_map_keys_are_strings_and_errors_carry_the_entry + [1, true, :symbol, Color::Red].each do |key| + error = assert_raises(TypeError) { encode({ key => 1 }) } + assert_includes error.message, "argument[key]" + assert_includes error.message, "map keys must be string" + end + error = assert_raises(TypeError) { encode({ "bad" => "not int" }, [:map, [:string], [:int]]) } + assert_includes error.message, 'argument["bad"]' + assert_raises(TypeError) { encode({ "red" => 1 }, [:map, [:enum, "user.Color"], [:int]]) } + end + + def test_a_value_the_wire_cannot_carry_is_refused_before_anything_is_created + api = Api.new + handle = Baml::Handle.new(api, 41, :ADT_TAGGED_HEAP_HANDLE) + scope = Baml::Bridge::EncodingScope.new + nested = 34.times.reduce(handle) { |value, _| { "nested" => value } } + error = assert_raises(TypeError) { encode(nested, scope: scope) } + assert_includes error.message, "nested too deeply" + assert_empty api.cloned + assert_raises(TypeError) { encode([handle], union([:list, [:int]], [:list, [:string]]), scope: scope) } + assert_empty api.cloned + result = encode([1, handle], scope: scope) + assert_equal [:list, [:unknown]], Encode::Slot.from_wire(result.value_type) + assert_equal [41], api.cloned + cyclic = [] + cyclic << cyclic + assert_raises(TypeError) { encode(cyclic) } + handle.close + ensure + scope&.close + end + + def test_an_endless_container_is_refused_at_the_node_budget + endless = Class.new(Array) do + def each + loop { yield 1 } + end + end.new + reader = Encode::Read.new(@registry, budget: 16) + error = assert_raises(TypeError) { reader.read(endless, "endless") } + assert_includes error.message, "node budget" + assert_equal 1 << 20, Encode::Read::NODE_BUDGET + reader = Encode::Read.new(@registry, budget: 2) + reader.read(1, "first") + assert_raises(TypeError) { reader.read([1], "second") } + end + + def test_planning_trials_share_one_budget_even_for_recursive_class_unions + left = Class.new(Struct.new(:child)) + right = Class.new(Struct.new(:child)) + a = [:class, "user.Left", [[:int]]] + b = [:class, "user.Left", [[:unknown]]] + c = [:class, "user.Right", [[:int]]] + d = [:class, "user.Right", [[:unknown]]] + @registry.define(left, "user.Left", { "child" => [:child, union([:null], c, d)] }) + @registry.define(right, "user.Right", { "child" => [:child, union([:null], a, b)] }) + value = 8.times.reduce(nil) { |child, index| (index.even? ? right : left).new(child) } + tree = Encode::Read.new(@registry).read(value, "recursive") + error = assert_raises(TypeError) { Encode::Plan.new(@registry, budget: 100).plan(tree, union(a, b)) } + assert_includes error.message, "planning-step budget" + assert_equal 1 << 20, Encode::Plan::STEP_BUDGET + assert_raises(TypeError) { encode(1, [:alias, "unsupported.cycle"]) } + end + + def test_reading_happens_once_and_host_exceptions_are_not_reclassified + reads = 0 + type = Class.new do + define_method(:value) { reads += 1; 1 } + end + @registry.define(type, "user.ReadOnce", { "value" => [:value, [:param, 0, "T"]] }) + slot = union([:class, "user.ReadOnce", [[:int]]], [:class, "user.ReadOnce", [[:string]]]) + assert_equal :class_value, encode(type.new, slot).value + assert_equal 1, reads + type.define_method(:value) { raise "getter failed" } + error = assert_raises(RuntimeError) { encode(type.new, slot) } + assert_equal "getter failed", error.message + end + + def test_encode_call_plans_and_checks_every_argument_before_cloning + api = Api.new + handle = Baml::Handle.new(api, 41, :ADT_TAGGED_HEAP_HANDLE) + scope = Baml::Bridge::EncodingScope.new + Protocol.define_function("user.encode_test", params: [["bad", [:int]], ["value", [:float]]]) + error = assert_raises(TypeError) do + Protocol.encode_call(1, "user.encode_test", { "handle" => handle, "bad" => "wrong" }, scope: scope) + end + assert_match(/\Auser.encode_test: bad:/, error.message) + assert_empty api.cloned + nested = 34.times.reduce(1) { |value, _| { "nested" => value } } + assert_raises(TypeError) do + Protocol.encode_call(1, "user.encode_test", { "handle" => handle, "deep" => nested }, scope: scope) + end + assert_empty api.cloned + args = Wire::CallFunctionArgs.decode(Protocol.encode_call(2, "user.encode_test", { "value" => 5, "handle" => handle }, scope: scope)) + assert_equal 5.0, args.kwargs.first.value.float_value + assert_equal [41], api.cloned + scope.close + assert_equal [41], api.released + handle.close + end + + def test_complete_host_call_uses_the_abi5_signature + native = Baml::Bridge.const_get(:Native, false) + received = [] + callback = FFI::Function.new(:void, %i[uint32 pointer size_t]) do |id, pointer, length| + received << [id, length.zero? ? "".b : pointer.read_bytes(length)] + end + table = native::ApiV1.new + table[:complete_host_call] = callback + api = native::Api.allocate + api.instance_variable_set(:@owner_pid, Process.pid) + api.instance_variable_set(:@functions, {}) + api.instance_variable_set(:@table, table) + completion = Wire::HostCallCompletion.encode(Wire::HostCallCompletion.new(returned: { int_value: 5 })) + api.complete_host_call(7, completion) + api.complete_host_call(8, "".b) + assert_equal [[7, completion], [8, "".b]], received + end + + def test_type_descriptors_round_trip_and_refuse_nonstatic_heads + slots = [[:null], [:never], [:unknown], [:enum, "user.Color"], [:interface, "user.I"], + [:literal, 3], [:literal, true], [:literal, "x"], [:class, "user.Box", [[:int]]], + [:map, [:string], [:list, union([:int], [:null])]]] + slots.each { |slot| assert_equal slot, Encode::Slot.from_wire(Encode::Slot.to_wire(slot)) } + %i[class_ty enum interface enum_variant type_alias].each do |kind| + klass = Wire.const_get({ class_ty: :BamlTyClass, enum: :BamlTyEnum, interface: :BamlTyInterface, + enum_variant: :BamlTyEnumVariant, type_alias: :BamlTyTypeAlias }.fetch(kind)) + [:live, :anonymous_label].each do |head| + ty = Wire::BamlTy.new(**{ kind => klass.new(**{ head => head == :live ? 41 : "display" }) }) + assert_equal [:other], Encode::Slot.from_wire(ty) + assert_raises(TypeError) { Encode::Slot.to_wire(Encode::Slot.from_wire(ty)) } + end + end + end + + def test_wire_depth_counts_statements_entries_and_the_argument_envelope + assert_equal 0, Encode::Slot.wire_depth(encode(1)) + assert_equal 4, Encode::Slot.wire_depth(encode([], [:list, [:int]])) + assert_equal 4, Encode::Slot.wire_depth(encode({ "x" => 1 })) + assert_equal 1, Encode::Slot.wire_depth(encode(Color::Red)) + nested = 48.times.reduce(1) { |value, _| [value] } + assert_equal 98, Encode::Slot.wire_depth(encode(nested)) + assert_raises(TypeError) { encode([nested]) } + end + + private + + def union(*members) + [:union, members] + end + + def encode(value, slot = [:unknown], scope: nil) + tree = Encode::Read.new(@registry).read(value, "argument") + plan = Encode::Plan.new(@registry).plan(tree, slot) + Encode::Commit.check_depth!(Encode::Commit.depth(plan), "argument") + result = Encode::Commit.commit(plan, scope: scope) + assert_equal Encode::Commit.depth(plan), Encode::Slot.wire_depth(result) + result + end + + def type_of(value, slot = [:unknown]) + Encode::Slot.from_wire(encode(value, slot).value_type) + end +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_loader_test.rb b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_loader_test.rb index 22aae85db9a..a55d8ce8415 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_loader_test.rb +++ b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_loader_test.rb @@ -3,88 +3,74 @@ require "minitest/autorun" require "open3" require "rbconfig" -require_relative "../../../../sdks/ruby/bridge_ruby/lib/baml/bridge" +require "timeout" +$LOAD_PATH.unshift File.expand_path("../../../../sdks/ruby/bridge_ruby/lib", __dir__) +require "baml/bridge" class BridgeLoaderTest < Minitest::Test - REQUIRED_FUNCTION_FIELDS = Baml::Bridge - .const_get(:Native, false) - .const_get(:ApiV1, false) - .const_get(:FUNCTION_FIELDS, false) - .map(&:to_s) - .freeze - - TERMINAL_MODES = %w[ - null-table - wrong-abi - truncated - version-mismatch - invalid-version-utf8 - null-version-pointer - registration-rejected - ].freeze - - def test_configuration_and_open_failures_can_retry - run_scenario("configuration_retry") - run_scenario("open_retry") - run_scenario("concurrent_open_failure_preserves_claim") - end - - def test_opened_incompatible_libraries_are_terminal - TERMINAL_MODES.each do |mode| - run_scenario("terminal_mode", mode) - end - run_scenario("terminal_missing_getter") - end - - def test_every_required_v1_function_is_validated - REQUIRED_FUNCTION_FIELDS.each do |field| - run_scenario("terminal_null_field", field) - end + def test_truncated_table + run_scenario("truncated") end - def test_invalid_bytecode_can_retry_and_ready_program_uses_exact_bytes - run_scenario("invalid_bytecode_retry_and_identity") + def test_null_function_slot + run_scenario("null_function") end - def test_concurrent_initialization_calls_native_once - run_scenario("concurrent_initialization") + def test_pid_check + run_scenario("pid_check") end - def test_fork_policy - run_scenario("fork_before_native_use") - run_scenario("fork_after_native_use") - run_scenario("fork_during_initialization") - run_scenario("fork_during_open_failure") + def test_different_program + run_scenario("different_program") end - def test_process_runtime_retains_registered_callback_across_gc - run_scenario("registered_callback_retention") + def test_abandoned_call_is_released + run_scenario("abandoned_call") end - def test_foreign_native_thread_callback_copies_bytes_and_contains_exceptions - run_scenario("foreign_thread_callback") - end + def test_fork_during_native_open_rejects_before_touching_the_inherited_mutex + skip "fork is unavailable" unless Process.respond_to?(:fork) - def test_real_bridge_initializes_real_bytecode - run_scenario("real_runtime_initialization") - end + runtime = Baml::Bridge.const_get(:ProcessRuntime, false).new + runtime.register_program("bytecode") + runtime.define_singleton_method(:configured_runtime_path!) { "fake-runtime" } + runtime.define_singleton_method(:open_library) do |*| + child = fork do + begin + Timeout.timeout(2) { runtime.initialize! } + exit! 1 + rescue Baml::Bridge::ForkSafetyError + exit! 0 + end + end + _, status = Process.wait2(child) + raise "child touched inherited loader state" unless status.success? + raise LoadError, "test open failed" + end + assert_raises(Baml::Bridge::RuntimeLoadError) { runtime.initialize! } + assert_equal false, runtime.instance_variable_get(:@native_load_in_progress) - def test_initialize_requires_byte_string - run_scenario("type_validation") + # A completed failed open left no native state, so a child may retry. + runtime.define_singleton_method(:open_library) { |*| raise LoadError, "retry reached open" } + child = fork do + begin + runtime.initialize! + exit! 1 + rescue Baml::Bridge::RuntimeLoadError => error + exit!(error.message.include?("retry reached open") ? 0 : 1) + end + end + _, status = Process.wait2(child) + assert status.success?, "failed native open must permit a child retry" end private - def run_scenario(name, *arguments) + def run_scenario(name) scenario = File.expand_path("bridge_scenario.rb", __dir__) - stdout, stderr, status = Open3.capture3( - RbConfig.ruby, - scenario, - name, - *arguments - ) + stdout, stderr, status = Open3.capture3(RbConfig.ruby, scenario, name) assert status.success?, <<~MESSAGE - Ruby bridge scenario #{([name] + arguments).join(" ")} failed + Ruby bridge scenario #{name} failed stdout: #{stdout} stderr: diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_scenario.rb b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_scenario.rb index 0d938cab612..34708ebcf4d 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_scenario.rb +++ b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_scenario.rb @@ -1,481 +1,61 @@ # frozen_string_literal: true -require "ffi" -require "thread" require "timeout" -require "tmpdir" -require_relative "../../../../sdks/ruby/bridge_ruby/lib/baml/bridge" +$LOAD_PATH.unshift File.expand_path("../../../../sdks/ruby/bridge_ruby/lib", __dir__) +require "baml/bridge" ENV["BAML_FAKE_RUNTIME_VERSION"] = Baml::Bridge::VERSION +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_FIXTURE") +ENV.delete("BAML_FAKE_NATIVE_MODE") +Baml::Bridge.register_program("valid-bytecode".b) -FIXTURE = ENV.fetch("BAML_RUBY_TEST_FIXTURE") -MISSING_GETTER = ENV.fetch("BAML_RUBY_TEST_MISSING_GETTER") -INVALID_LIBRARY = ENV.fetch("BAML_RUBY_TEST_INVALID_LIBRARY") -THREAD_FIXTURE = ENV.fetch("BAML_RUBY_TEST_THREAD_FIXTURE") -REAL_RUNTIME = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") -REAL_BYTECODE = ENV.fetch("BAML_RUBY_TEST_REAL_BYTECODE") -VALID_PROGRAM = "valid-bytecode".b.freeze - -module ScenarioAssertions - module_function - - def assert(condition, message = "assertion failed") - raise message unless condition - end - - def assert_equal(expected, actual) - raise "expected #{expected.inspect}, got #{actual.inspect}" unless expected == actual - end - - def assert_raises(error_class) +def assert_raises(error_class) + begin yield rescue error_class => error return error - rescue Exception => error - raise "expected #{error_class}, got #{error.class}: #{error.message}" end -end - -include ScenarioAssertions - -class FixtureInspection - def initialize(path) - flags = FFI::DynamicLibrary::RTLD_NOW | FFI::DynamicLibrary::RTLD_LOCAL - @library = FFI::DynamicLibrary.open(path, flags) - end - - def count(symbol) - FFI::Function.new(:uint32, [], @library.find_function(symbol)).call - end -end - -def fixture_inspection - @fixture_inspection ||= FixtureInspection.new(FIXTURE) -end - -def wait_for_child(child, timeout: 2) - Timeout.timeout(timeout) { Process.wait2(child).last } -rescue Timeout::Error - Process.kill("KILL", child) - Process.wait(child) - raise "forked child #{child} did not exit promptly" -end - -def use_fixture - ENV["BAML_RUNTIME_PATH"] = FIXTURE - ENV.delete("BAML_FAKE_NATIVE_MODE") - ENV.delete("BAML_FAKE_NULL_FIELD") -end - -def initialize_fixture(program = VALID_PROGRAM) - use_fixture - Baml::Bridge.initialize!(program) -end - -def terminal_failure(mode: nil, null_field: nil, path: FIXTURE) - ENV["BAML_RUNTIME_PATH"] = path - ENV["BAML_FAKE_NATIVE_MODE"] = mode - ENV["BAML_FAKE_NULL_FIELD"] = null_field - first = assert_raises(Baml::Bridge::IncompatibleRuntimeError) do - Baml::Bridge.initialize!(VALID_PROGRAM) - end - - ENV["BAML_RUNTIME_PATH"] = FIXTURE - ENV.delete("BAML_FAKE_NATIVE_MODE") - ENV.delete("BAML_FAKE_NULL_FIELD") - second = assert_raises(Baml::Bridge::IncompatibleRuntimeError) do - Baml::Bridge.initialize!(VALID_PROGRAM) - end - assert_equal(first.message, second.message) - - expected_free_count = case mode - when "version-mismatch", "invalid-version-utf8", "null-version-pointer" - 1 - when "registration-rejected" - 2 - else - 0 - end - assert_equal(expected_free_count, fixture_inspection.count("baml_test_free_count")) if path == FIXTURE - first + raise "expected #{error_class}" end case ARGV.fetch(0) -when "configuration_retry" - ENV.delete("BAML_RUNTIME_PATH") - assert_raises(Baml::Bridge::RuntimeConfigurationError) do - Baml::Bridge.initialize!(VALID_PROGRAM) - end - ENV["BAML_RUNTIME_PATH"] = "relative/library" - assert_raises(Baml::Bridge::RuntimeConfigurationError) do - Baml::Bridge.initialize!(VALID_PROGRAM) - end - ENV["BAML_RUNTIME_PATH"] = File.join(Dir.tmpdir, "missing-baml-runtime") - assert_raises(Baml::Bridge::RuntimeConfigurationError) do - Baml::Bridge.initialize!(VALID_PROGRAM) - end - initialize_fixture - assert_equal(1, fixture_inspection.count("baml_test_initialize_count")) - assert_equal(3, fixture_inspection.count("baml_test_free_count")) -when "open_retry" - ENV["BAML_RUNTIME_PATH"] = INVALID_LIBRARY - assert_raises(Baml::Bridge::RuntimeLoadError) do - Baml::Bridge.initialize!(VALID_PROGRAM) - end - - if Process.respond_to?(:fork) - read_pipe, write_pipe = IO.pipe - child = fork do - read_pipe.close - begin - initialize_fixture - write_pipe.write("ok") - exit! 0 - rescue Exception => error - write_pipe.write("#{error.class}: #{error.message}") - exit! 1 - end - end - write_pipe.close - status = wait_for_child(child) - result = read_pipe.read - assert(status.success?, result) - assert_equal("ok", result) - end - - initialize_fixture - assert_equal(1, fixture_inspection.count("baml_test_initialize_count")) - assert_equal(3, fixture_inspection.count("baml_test_free_count")) -when "concurrent_open_failure_preserves_claim" - runtime_class = Baml::Bridge.const_get(:ProcessRuntime, false) - runtime = runtime_class.new - first_claimed = Queue.new - second_claimed = Queue.new - first_open_failed = Queue.new - - runtime.define_singleton_method(:configured_runtime_path!) do - Thread.current[:baml_test_runtime_path] - end - runtime.define_singleton_method(:claim_process!) do - super() - claim_count = (Thread.current[:baml_test_claim_count] || 0) + 1 - Thread.current[:baml_test_claim_count] = claim_count - if claim_count == 1 - if Thread.current[:baml_test_runtime_path] == INVALID_LIBRARY - first_claimed << true - second_claimed.pop - else - second_claimed << true - first_open_failed.pop - end - end - end - - first_error = Queue.new - first = Thread.new do - Thread.current[:baml_test_runtime_path] = INVALID_LIBRARY - runtime.initialize!(VALID_PROGRAM) - rescue Exception => error - first_error << error - first_open_failed << true - end - first_claimed.pop - - second_error = Queue.new - second = Thread.new do - Thread.current[:baml_test_runtime_path] = FIXTURE - runtime.initialize!(VALID_PROGRAM) - rescue Exception => error - second_error << error - end - - [first, second].each(&:join) - error = first_error.pop - assert(error.is_a?(Baml::Bridge::RuntimeLoadError), error.inspect) - assert(second_error.empty?, "surviving initializer failed") - assert_equal(Process.pid, runtime.instance_variable_get(:@owner_pid)) - - if Process.respond_to?(:fork) - read_pipe, write_pipe = IO.pipe - child = fork do - read_pipe.close - error = assert_raises(Baml::Bridge::ForkSafetyError) do - runtime.initialize!(VALID_PROGRAM) - end - write_pipe.write(error.message) - exit! 0 - end - write_pipe.close - status = wait_for_child(child) - message = read_pipe.read - assert(status.success?, message) - assert(message.include?("must exec before using BAML"), message) - end -when "terminal_mode" - terminal_failure(mode: ARGV.fetch(1)) -when "terminal_missing_getter" - error = terminal_failure(path: MISSING_GETTER) - assert_equal( - "Unable to resolve #{MISSING_GETTER.inspect}!baml_get_api_v1: symbol not found", - error.message - ) -when "terminal_null_field" - terminal_failure(null_field: ARGV.fetch(1)) -when "invalid_bytecode_retry_and_identity" - use_fixture - original = VALID_PROGRAM.dup - assert_raises(Baml::Bridge::ProgramInitializationError) do - Baml::Bridge.initialize!("invalid".b) - end - Baml::Bridge.initialize!(original) - original.replace("changed-program") - Baml::Bridge.initialize!(VALID_PROGRAM.dup) - assert_raises(Baml::Bridge::ProgramConflictError) do - Baml::Bridge.initialize!(original) - end - assert_equal(2, fixture_inspection.count("baml_test_initialize_count")) - assert_equal(1, fixture_inspection.count("baml_test_register_count")) - assert_equal(4, fixture_inspection.count("baml_test_free_count")) -when "concurrent_initialization" - use_fixture - ENV["BAML_FAKE_INIT_DELAY_MS"] = "25" - errors = Queue.new - threads = Array.new(12) do - Thread.new do - Baml::Bridge.initialize!(VALID_PROGRAM.dup) - rescue Exception => error - errors << error - end - end - threads.each(&:join) - assert(errors.empty?, "concurrent initialization errors: #{errors.size}") - assert_equal(1, fixture_inspection.count("baml_test_initialize_count")) - assert_equal(1, fixture_inspection.count("baml_test_register_count")) - assert_equal(3, fixture_inspection.count("baml_test_free_count")) -when "fork_before_native_use" - unless Process.respond_to?(:fork) - exit 0 - end - read_pipe, write_pipe = IO.pipe +when "truncated", "null_function" + ENV["BAML_FAKE_NATIVE_MODE"] = ARGV[0] + error = assert_raises(Baml::Bridge::IncompatibleRuntimeError) { Baml::Bridge.initialize! } + expected = ARGV[0] == "truncated" ? "BamlApiV1 is truncated" : "BamlApiV1.media_from_file_content is null" + raise error.message unless error.message.include?(expected) +when "pid_check" + exit 0 unless Process.respond_to?(:fork) + + Baml::Bridge.initialize! + api = Baml::Bridge.instance_variable_get(:@process_runtime).instance_variable_get(:@api) + pending = Baml::Call.new(api, 1) + settled = Baml::Call.new(api, 2) + wire = BamlBridge::Cffi::V1 + settled.complete(wire::BamlOutboundResult.encode(wire::BamlOutboundResult.new(ok: { int_value: 42 }))) + raise "parent result changed" unless settled.wait == 42 child = fork do - read_pipe.close - begin - initialize_fixture - write_pipe.write("ok") - exit! 0 - rescue Exception => error - write_pipe.write("#{error.class}: #{error.message}") - exit! 1 - end - end - write_pipe.close - status = wait_for_child(child) - result = read_pipe.read - assert(status.success?, result) - assert_equal("ok", result) -when "fork_after_native_use" - unless Process.respond_to?(:fork) - exit 0 - end - initialize_fixture - read_pipe, write_pipe = IO.pipe - child = fork do - read_pipe.close - error = assert_raises(Baml::Bridge::ForkSafetyError) do - Baml::Bridge.initialize!(VALID_PROGRAM) - end - write_pipe.write(error.message) + assert_raises(Baml::Bridge::ForkSafetyError) { Baml::Bridge.initialize! } + assert_raises(Baml::Bridge::ForkSafetyError) { pending.wait } + assert_raises(Baml::Bridge::ForkSafetyError) { settled.wait } exit! 0 end - write_pipe.close - status = wait_for_child(child) - message = read_pipe.read - assert(status.success?, message) - assert(message.include?("must exec before using BAML"), message) - Baml::Bridge.initialize!(VALID_PROGRAM.dup) - assert_equal(1, fixture_inspection.count("baml_test_initialize_count")) -when "fork_during_initialization" - unless Process.respond_to?(:fork) - exit 0 - end - use_fixture - ENV["BAML_FAKE_INIT_DELAY_MS"] = "750" - worker_errors = Queue.new - worker = Thread.new do - Baml::Bridge.initialize!(VALID_PROGRAM) - rescue Exception => error - worker_errors << error - end - deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5 - until fixture_inspection.count("baml_test_initialize_started") == 1 - raise "native initialization did not start" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline - - sleep 0.01 - end - - read_pipe, write_pipe = IO.pipe - child = fork do - read_pipe.close - error = assert_raises(Baml::Bridge::ForkSafetyError) do - Baml::Bridge.initialize!(VALID_PROGRAM) - end - write_pipe.write(error.message) - exit! 0 - end - write_pipe.close - status = wait_for_child(child) - message = read_pipe.read - assert(status.success?, message) - assert(message.include?("must exec before using BAML"), message) - worker.join - assert(worker_errors.empty?, "parent initialization failed") - Baml::Bridge.initialize!(VALID_PROGRAM.dup) - assert_equal(1, fixture_inspection.count("baml_test_initialize_count")) -when "fork_during_open_failure" - unless Process.respond_to?(:fork) - exit 0 - end - - runtime_class = Baml::Bridge.const_get(:ProcessRuntime, false) - runtime = runtime_class.new - open_started = Queue.new - release_open = Queue.new - open_failed = Queue.new - release_failure = Queue.new - runtime.define_singleton_method(:open_library) do |path, flags| - if path == INVALID_LIBRARY - open_started << true - release_open.pop - end - super(path, flags) - end - runtime.define_singleton_method(:load_api!) do |path| - super(path) - rescue Baml::Bridge::RuntimeLoadError - open_failed << true - release_failure.pop + begin + status = Timeout.timeout(10) { Process.wait2(child).last } + raise "forked child failed" unless status.success? + rescue Timeout::Error + Process.kill("KILL", child) + Process.wait(child) raise end - - ENV["BAML_RUNTIME_PATH"] = INVALID_LIBRARY - worker_error = Queue.new - worker = Thread.new do - runtime.initialize!(VALID_PROGRAM) - rescue Exception => error - worker_error << error - end - open_started.pop - - read_pipe, write_pipe = IO.pipe - child = fork do - read_pipe.close - error = assert_raises(Baml::Bridge::ForkSafetyError) do - runtime.initialize!(VALID_PROGRAM) - end - write_pipe.write(error.message) - exit! 0 - end - write_pipe.close - status = wait_for_child(child) - message = read_pipe.read - assert(status.success?, message) - assert(message.include?("must exec before using BAML"), message) - - release_open << true - open_failed.pop - ENV["BAML_RUNTIME_PATH"] = FIXTURE - read_pipe, write_pipe = IO.pipe - child = fork do - read_pipe.close - begin - runtime.initialize!(VALID_PROGRAM) - write_pipe.write("ok") - exit! 0 - rescue Exception => error - write_pipe.write("#{error.class}: #{error.message}") - exit! 1 - end - end - write_pipe.close - status = wait_for_child(child) - result = read_pipe.read - assert(status.success?, result) - assert_equal("ok", result) - - release_failure << true - worker.join - error = worker_error.pop - assert(error.is_a?(Baml::Bridge::RuntimeLoadError), error.inspect) -when "registered_callback_retention" - initialize_fixture - runtime = Baml::Bridge.instance_variable_get(:@process_runtime) - callback = runtime.instance_variable_get(:@result_callback) - assert(!callback.nil?, "process runtime did not retain its result callback") - callback = nil - 3.times { GC.start(full_mark: true, immediate_sweep: true) } - - flags = FFI::DynamicLibrary::RTLD_NOW | FFI::DynamicLibrary::RTLD_LOCAL - library = FFI::DynamicLibrary.open(FIXTURE, flags) - invoke = FFI::Function.new( - :int32, - %i[uint32 pointer size_t], - library.find_function("baml_test_invoke_registered_callback_on_thread"), - blocking: true - ) - payload_bytes = "retained\x00\xff".b - payload = FFI::MemoryPointer.new(:uint8, payload_bytes.bytesize) - payload.put_bytes(0, payload_bytes) - caller_thread = Thread.current.object_id - assert_equal(0, invoke.call(73, payload, payload_bytes.bytesize)) - call_id, copied, callback_thread = runtime.send(:pop_result_event) - assert_equal(73, call_id) - assert_equal(payload_bytes, copied) - assert(copied.frozen?, "registered callback bytes were not frozen") - assert_equal(Encoding::BINARY, copied.encoding) - assert(callback_thread != caller_thread, "registered callback did not use a foreign thread") - retained_callback = runtime.instance_variable_get(:@result_callback) - assert(!retained_callback.nil?, "process runtime released its result callback") - assert_equal(nil, retained_callback.pop_error) -when "foreign_thread_callback" - native = Baml::Bridge.const_get(:Native, false) - callbacks = Queue.new - callback_class = native.const_get(:BorrowedBytesCallback, false) - callback = callback_class.new do |call_id, bytes| - callbacks << [call_id, bytes, Thread.current.object_id] - end - flags = FFI::DynamicLibrary::RTLD_NOW | FFI::DynamicLibrary::RTLD_LOCAL - library = FFI::DynamicLibrary.open(THREAD_FIXTURE, flags) - invoke = FFI::Function.new( - :void, - %i[pointer uint32 pointer size_t], - library.find_function("baml_test_invoke_on_native_thread"), - blocking: true - ) - payload_bytes = "a\x00\xffz".b - payload = FFI::MemoryPointer.new(:uint8, payload_bytes.bytesize) - payload.put_bytes(0, payload_bytes) - caller_thread = Thread.current.object_id - invoke.call(callback.function, 41, payload, payload_bytes.bytesize) - call_id, copied, callback_thread = callbacks.pop - assert_equal(41, call_id) - assert_equal(payload_bytes, copied) - assert(copied.frozen?, "callback bytes were not frozen") - assert_equal(Encoding::BINARY, copied.encoding) - assert(callback_thread != caller_thread, "callback did not enter Ruby from a foreign thread") - assert_equal(nil, callback.pop_error) - - failing = callback_class.new { raise "callback boom" } - invoke.call(failing.function, 42, payload, payload_bytes.bytesize) - error = failing.pop_error - assert(error.is_a?(RuntimeError), "callback exception was not contained") - assert_equal("callback boom", error.message) -when "real_runtime_initialization" - ENV["BAML_RUNTIME_PATH"] = REAL_RUNTIME - bytecode = File.binread(REAL_BYTECODE).freeze - Baml::Bridge.initialize!(bytecode) - Baml::Bridge.initialize!(bytecode.dup) -when "type_validation" - assert_raises(TypeError) { Baml::Bridge.initialize!(Object.new) } +when "different_program" + Baml::Bridge.initialize! + assert_raises(Baml::Bridge::ProgramConflictError) { Baml::Bridge.register_program("different") } +when "abandoned_call" + assert_raises(TypeError) { Baml::Bridge.call_async("user.test", { "x" => Object.new }) } + library = FFI::DynamicLibrary.open(ENV.fetch("BAML_RUBY_TEST_FIXTURE"), FFI::DynamicLibrary::RTLD_NOW) + count = FFI::Function.new(:uint32, [], library.find_function("baml_test_release_count")).call + raise "abandoned call released #{count} times" unless count == 1 else raise "unknown scenario #{ARGV.first.inspect}" end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/test/native/bridge_fixture.c b/baml_language/sdk_tests/crates/ruby_sorbet/test/native/bridge_fixture.c index c9d1205fa7e..3aa760588f4 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/test/native/bridge_fixture.c +++ b/baml_language/sdk_tests/crates/ruby_sorbet/test/native/bridge_fixture.c @@ -9,115 +9,48 @@ #include #include -#if defined(_WIN32) -# include -#else -# include -# include -#endif - -static uint32_t initialize_count = 0; -static volatile uint32_t initialize_started = 0; -static uint32_t free_count = 0; -static uint32_t register_count = 0; -static BamlResultCallback registered_result_callback = NULL; +static uint32_t release_count = 0; -static BamlBuffer buffer_from_literal(const char *value) { - BamlBuffer buffer = {(const int8_t *)value, strlen(value)}; +static BamlBuffer empty_buffer(void) { + BamlBuffer buffer = {NULL, 0}; return buffer; } -static const char *fixture_version(void) { - const char *version = getenv("BAML_FAKE_RUNTIME_VERSION"); - return version == NULL ? "missing BAML_FAKE_RUNTIME_VERSION" : version; -} - static BamlBuffer probe_version(void) { - const char *mode = getenv("BAML_FAKE_NATIVE_MODE"); - if (mode != NULL && strcmp(mode, "version-mismatch") == 0) { - return buffer_from_literal("9.9.9"); - } - if (mode != NULL && strcmp(mode, "invalid-version-utf8") == 0) { - static const uint8_t invalid[] = {UINT8_C(0xff)}; - BamlBuffer buffer = {(const int8_t *)invalid, sizeof(invalid)}; - return buffer; - } - if (mode != NULL && strcmp(mode, "null-version-pointer") == 0) { - BamlBuffer buffer = {NULL, 1}; - return buffer; - } - return buffer_from_literal(fixture_version()); -} - -static void delay_initialize_if_requested(void) { - const char *raw = getenv("BAML_FAKE_INIT_DELAY_MS"); - if (raw == NULL) { - return; - } - unsigned long milliseconds = strtoul(raw, NULL, 10); -#if defined(_WIN32) - Sleep((DWORD)milliseconds); -#else - struct timespec duration = { - .tv_sec = (time_t)(milliseconds / 1000), - .tv_nsec = (long)((milliseconds % 1000) * 1000000), - }; - nanosleep(&duration, NULL); -#endif + const char *version = getenv("BAML_FAKE_RUNTIME_VERSION"); + BamlBuffer buffer = {(const int8_t *)version, strlen(version)}; + return buffer; } static BamlBuffer probe_initialize(const uint8_t *bytecode, size_t length) { - static const uint8_t valid[] = "valid-bytecode"; - initialize_count += 1; - initialize_started = 1; - delay_initialize_if_requested(); - if (length == sizeof(valid) - 1 && bytecode != NULL && - memcmp(bytecode, valid, sizeof(valid) - 1) == 0) { - BamlBuffer buffer = {NULL, 0}; - return buffer; - } - return buffer_from_literal("invalid test bytecode"); -} - -static void probe_free(BamlBuffer buffer) { - (void)buffer; - free_count += 1; + (void)bytecode; + (void)length; + return empty_buffer(); } -static void probe_register_result(BamlResultCallback callback) { - registered_result_callback = callback; -} +static void probe_free(BamlBuffer buffer) { (void)buffer; } +static void probe_register_result(BamlResultCallback callback) { (void)callback; } -static void probe_call( - const uint8_t *encoded_args, - size_t length, - uint32_t callback_id) { - (void)encoded_args; +static void probe_call(const uint8_t *args, size_t length, uint32_t callback_id) { + (void)args; (void)length; (void)callback_id; } -static uint64_t probe_new_call(void) { - return 1; -} - -static int32_t probe_cancel(uint64_t id) { - return id == 0 ? 1 : 0; -} +static uint64_t probe_new_call(void) { return 1; } +static int32_t probe_cancel(uint64_t id) { return id == 0 ? 1 : 0; } -static void probe_register_host_dispatch(BamlHostDispatchCallback callback) { - (void)callback; +static int32_t probe_release_call(uint64_t id) { + (void)id; + release_count += 1; + return 0; } -static void probe_register_host_release(BamlHostReleaseCallback callback) { - (void)callback; -} +static void probe_register_host_dispatch(BamlHostDispatchCallback callback) { (void)callback; } +static void probe_register_host_release(BamlHostReleaseCallback callback) { (void)callback; } -static void probe_complete_host_call( - uint32_t call_id, - const int8_t *content, - size_t length) { - (void)call_id; +static void probe_complete_host_call(uint32_t id, const int8_t *content, size_t length) { + (void)id; (void)content; (void)length; } @@ -134,78 +67,31 @@ static BamlCffiStatus probe_release(uint64_t key) { } static BamlCffiStatus probe_media_constructor( - int32_t media_kind, - const char *value, - const char *mime_type_or_null, - uint64_t *out_key, - int32_t *out_handle_type) { - (void)media_kind; + int32_t kind, const char *value, const char *mime, uint64_t *key, int32_t *type) { + (void)kind; (void)value; - (void)mime_type_or_null; - (void)out_key; - (void)out_handle_type; + (void)mime; + (void)key; + (void)type; return BAML_CFFI_STATUS_UNSUPPORTED_HANDLE_TYPE; } -static BamlCffiStatus probe_media_accessor( - uint64_t key, - int32_t handle_type, - BamlBuffer *out) { +static BamlCffiStatus probe_media_accessor(uint64_t key, int32_t type, BamlBuffer *out) { (void)key; - (void)handle_type; + (void)type; (void)out; return BAML_CFFI_STATUS_INVALID_HANDLE; } static BamlBuffer probe_register_bridge(const BamlBridgeInfoV1 *info) { - const char *expected_version = fixture_version(); - size_t expected_version_len = strlen(expected_version); - register_count += 1; - if (getenv("BAML_FAKE_NATIVE_MODE") != NULL && - strcmp(getenv("BAML_FAKE_NATIVE_MODE"), "registration-rejected") == 0) { - return buffer_from_literal("registration rejected by test fixture"); - } - if (info == NULL || info->struct_size < sizeof(BamlBridgeInfoV1)) { - return buffer_from_literal("invalid bridge info"); - } - if (info->language != BAML_BRIDGE_LANGUAGE_RUBY) { - return buffer_from_literal("expected Ruby bridge language 10"); - } - if (info->sdk_version_len != expected_version_len || - info->sdk_version == NULL || - memcmp(info->sdk_version, expected_version, expected_version_len) != 0) { - return buffer_from_literal("unexpected Ruby toolchain version"); - } - static const char expected_name[] = "Baml::Bridge"; - if (info->bridge_runtime_name_len != sizeof(expected_name) - 1 || - info->bridge_runtime_name == NULL || - memcmp(info->bridge_runtime_name, expected_name, sizeof(expected_name) - 1) != 0) { - return buffer_from_literal("unexpected Ruby bridge runtime name"); - } - if (info->bridge_runtime_version_len != expected_version_len || - info->bridge_runtime_version == NULL || - memcmp(info->bridge_runtime_version, expected_version, expected_version_len) != 0) { - return buffer_from_literal("unexpected Ruby bridge runtime version"); - } - BamlBuffer buffer = {NULL, 0}; - return buffer; + (void)info; + return empty_buffer(); } -static void probe_register_unhandled_spawn_error( - BamlUnhandledSpawnErrorCallback callback) { - (void)callback; -} - -static BamlBuffer probe_shutdown_runtime(void) { - BamlBuffer buffer = {NULL, 0}; - return buffer; -} +static void probe_register_unhandled_spawn_error(BamlUnhandledSpawnErrorCallback callback) { (void)callback; } -static BamlBuffer probe_initialize_with_metadata( - const uint8_t *bytecode, - size_t length, - const char *baml_toml) { - (void)baml_toml; +static BamlBuffer probe_initialize_with_metadata(const uint8_t *bytecode, size_t length, const char *metadata) { + (void)metadata; return probe_initialize(bytecode, length); } @@ -214,11 +100,31 @@ static BamlCffiStatus probe_clock(uint64_t id, uint64_t *out) { *out = 0; return BAML_CFFI_STATUS_OK; } -static int32_t probe_release_call(uint64_t id) { (void)id; return 0; } + static void probe_dispatch_v2(BamlHostDispatchV2 callback) { (void)callback; } static void probe_cancel_callback(BamlHostCancel callback) { (void)callback; } static uint32_t probe_protocol(void) { return 1; } +static BamlCffiStatus probe_trace_selection(uint64_t id, uint64_t key, BamlBuffer *out, uint64_t *reservation) { + (void)id; + (void)key; + (void)out; + (void)reservation; + return BAML_CFFI_STATUS_INVALID_HANDLE; +} + +static BamlCffiStatus probe_invocation_context(uint64_t key, BamlBuffer *out) { + (void)key; + (void)out; + return BAML_CFFI_STATUS_INVALID_HANDLE; +} + +static BamlCffiStatus probe_media_from_file_content( + int32_t kind, const char *file, const char *base64, const char *mime, uint64_t *key, int32_t *type) { + (void)file; + return probe_media_constructor(kind, base64, mime, key, type); +} + static const BamlApiV1 default_api = { .abi_version = BAML_API_V1_ABI_VERSION, .struct_size = sizeof(BamlApiV1), @@ -243,138 +149,29 @@ static const BamlApiV1 default_api = { .media_mime_type = probe_media_accessor, .register_bridge = probe_register_bridge, .register_unhandled_spawn_error_callback = probe_register_unhandled_spawn_error, - .shutdown_runtime = probe_shutdown_runtime, + .shutdown_runtime = empty_buffer, .initialize_runtime_from_blob_with_metadata = probe_initialize_with_metadata, .invocation_clock_ns = probe_clock, .release_function_call = probe_release_call, .register_host_dispatch_v2 = probe_dispatch_v2, .register_host_cancel_callback = probe_cancel_callback, .invocation_protocol_version = probe_protocol, + .trace_selection = probe_trace_selection, + .invocation_context = probe_invocation_context, + .media_name = probe_media_accessor, + .media_from_file_content = probe_media_from_file_content, }; -static BamlApiV1 probe_api; - -#define NULL_FIELD(field_name) \ - if (strcmp(field, #field_name) == 0) { \ - probe_api.field_name = NULL; \ - return; \ - } - -static void null_requested_field(const char *field) { - if (field == NULL) { - return; - } - NULL_FIELD(version) - NULL_FIELD(initialize_runtime_from_blob) - NULL_FIELD(free_buffer) - NULL_FIELD(register_callback) - NULL_FIELD(call_function) - NULL_FIELD(new_function_call) - NULL_FIELD(cancel_function_call) - NULL_FIELD(register_host_dispatch_callback) - NULL_FIELD(register_host_release_callback) - NULL_FIELD(complete_host_call) - NULL_FIELD(handle_clone) - NULL_FIELD(handle_release) - NULL_FIELD(media_from_url) - NULL_FIELD(media_from_file) - NULL_FIELD(media_from_base64) - NULL_FIELD(media_url) - NULL_FIELD(media_file) - NULL_FIELD(media_base64) - NULL_FIELD(media_mime_type) - NULL_FIELD(register_bridge) - NULL_FIELD(register_unhandled_spawn_error_callback) - NULL_FIELD(shutdown_runtime) - NULL_FIELD(initialize_runtime_from_blob_with_metadata) - NULL_FIELD(invocation_clock_ns) - NULL_FIELD(release_function_call) - NULL_FIELD(register_host_dispatch_v2) - NULL_FIELD(register_host_cancel_callback) - NULL_FIELD(invocation_protocol_version) -} - BAML_CFFI_API const BamlApiV1 *baml_get_api_v1(void) { + static BamlApiV1 api; const char *mode = getenv("BAML_FAKE_NATIVE_MODE"); - probe_api = default_api; - - if (mode != NULL && strcmp(mode, "null-table") == 0) { - return NULL; + api = default_api; + if (mode != NULL && strcmp(mode, "truncated") == 0) { + api.struct_size = offsetof(BamlApiV1, media_from_file_content); + } else if (mode != NULL && strcmp(mode, "null_function") == 0) { + api.media_from_file_content = NULL; } - if (mode != NULL && strcmp(mode, "wrong-abi") == 0) { - probe_api.abi_version = 999; - } else if (mode != NULL && strcmp(mode, "truncated") == 0) { - probe_api.struct_size = offsetof(BamlApiV1, register_bridge); - } - null_requested_field(getenv("BAML_FAKE_NULL_FIELD")); - return &probe_api; + return &api; } -BAML_CFFI_API uint32_t baml_test_initialize_count(void) { - return initialize_count; -} - -BAML_CFFI_API uint32_t baml_test_initialize_started(void) { - return initialize_started; -} - -BAML_CFFI_API uint32_t baml_test_free_count(void) { - return free_count; -} - -BAML_CFFI_API uint32_t baml_test_register_count(void) { - return register_count; -} - -typedef struct CallbackInvocation { - uint32_t call_id; - const uint8_t *content; - size_t length; -} CallbackInvocation; - -#if defined(_WIN32) -static DWORD WINAPI invoke_registered_callback(LPVOID raw_invocation) { - const CallbackInvocation *invocation = (const CallbackInvocation *)raw_invocation; - registered_result_callback( - invocation->call_id, - (const int8_t *)invocation->content, - invocation->length); - return 0; -} -#else -static void *invoke_registered_callback(void *raw_invocation) { - const CallbackInvocation *invocation = (const CallbackInvocation *)raw_invocation; - registered_result_callback( - invocation->call_id, - (const int8_t *)invocation->content, - invocation->length); - return NULL; -} -#endif - -BAML_CFFI_API int32_t baml_test_invoke_registered_callback_on_thread( - uint32_t call_id, - const uint8_t *content, - size_t length) { - if (registered_result_callback == NULL) { - return 1; - } - CallbackInvocation invocation = {call_id, content, length}; -#if defined(_WIN32) - HANDLE thread = CreateThread(NULL, 0, invoke_registered_callback, &invocation, 0, NULL); - if (thread == NULL) { - return 2; - } - WaitForSingleObject(thread, INFINITE); - CloseHandle(thread); -#else - pthread_t thread; - if (pthread_create(&thread, NULL, invoke_registered_callback, &invocation) != 0) { - return 2; - } - if (pthread_join(thread, NULL) != 0) { - return 3; - } -#endif - return 0; -} +BAML_CFFI_API uint32_t baml_test_release_count(void) { return release_count; } diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/test/native/missing_getter.c b/baml_language/sdk_tests/crates/ruby_sorbet/test/native/missing_getter.c deleted file mode 100644 index 17d0d43d110..00000000000 --- a/baml_language/sdk_tests/crates/ruby_sorbet/test/native/missing_getter.c +++ /dev/null @@ -1,9 +0,0 @@ -#if defined(_WIN32) -# define TEST_EXPORT __declspec(dllexport) -#else -# define TEST_EXPORT __attribute__((visibility("default"))) -#endif - -TEST_EXPORT int baml_test_not_the_api(void) { - return 1; -} diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/test/static/Gemfile b/baml_language/sdk_tests/crates/ruby_sorbet/test/static/Gemfile new file mode 100644 index 00000000000..941424866e8 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/test/static/Gemfile @@ -0,0 +1,4 @@ +source "https://rubygems.org" + +# Separate from the runtime bundle: Sorbet has no Windows or 32-bit Linux binary. +gem "sorbet", "= 0.6.13506", require: false diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/test/static/Gemfile.lock b/baml_language/sdk_tests/crates/ruby_sorbet/test/static/Gemfile.lock new file mode 100644 index 00000000000..4ab484a6ea4 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/test/static/Gemfile.lock @@ -0,0 +1,27 @@ +GEM + remote: https://rubygems.org/ + specs: + sorbet (0.6.13506) + sorbet-static (= 0.6.13506) + sorbet-static (0.6.13506-aarch64-linux) + sorbet-static (0.6.13506-universal-darwin) + sorbet-static (0.6.13506-x86_64-linux) + +PLATFORMS + aarch64-linux + arm64-darwin + universal-darwin + x86_64-darwin + x86_64-linux + +DEPENDENCIES + sorbet (= 0.6.13506) + +CHECKSUMS + sorbet (0.6.13506) sha256=b6fd9f9ab2563c709b9308437cb40754e190667fddfe841723a7dda430978cd6 + sorbet-static (0.6.13506-aarch64-linux) sha256=04405070206de265a87e6544454092cd4c7b275415dccf76a4ab41649819ae9f + sorbet-static (0.6.13506-universal-darwin) sha256=744918a125841c84d0804410bfda068a6dc8182285ba50ddb1e8ce5955b24700 + sorbet-static (0.6.13506-x86_64-linux) sha256=b57d36d1db374e10109bbd5af3bec0edf6f94f3e86dbffe69199763909ad2c91 + +BUNDLED WITH + 4.0.4 diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/test/static/minitest.rbi b/baml_language/sdk_tests/crates/ruby_sorbet/test/static/minitest.rbi new file mode 100644 index 00000000000..3f82dd7ad28 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/test/static/minitest.rbi @@ -0,0 +1,6 @@ +# typed: true +# Runtime tests use minitest without type annotations; the strict consumers +# exercise generated signatures directly, without test-framework assertions. +module Minitest + class Test; end +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/test/static/wrong_argument.rb b/baml_language/sdk_tests/crates/ruby_sorbet/test/static/wrong_argument.rb new file mode 100644 index 00000000000..8780550a54d --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/test/static/wrong_argument.rb @@ -0,0 +1,4 @@ +# typed: strict +require_relative "../../function_calls/generated/baml_sdk" + +BamlSdk.single_required_arg(42) # error: 7002 diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_literals.rb b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_literals.rb new file mode 100644 index 00000000000..0a0adf7761a --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_literals.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class LiteralsTest < Minitest::Test + def test_literals_return_literals + assert_equal 42, BamlSdk::Literals.return_literal42() + assert_equal -1, BamlSdk::Literals.return_literal_neg_one() + assert_equal "draft", BamlSdk::Literals.return_literal_draft() + assert_equal "has \"quotes\"", BamlSdk::Literals.return_literal_escaped() + assert_equal true, BamlSdk::Literals.return_literal_true() + assert_equal false, BamlSdk::Literals.return_literal_false() + end + + def test_literals_round_trip_literal42 + assert_equal 42, BamlSdk::Literals.round_trip_literal42(42) + end + + def test_literals_round_trip_literal_draft + assert_equal "draft", BamlSdk::Literals.round_trip_literal_draft("draft") + end + + def test_literals_round_trip_literal_escaped + assert_equal "has \"quotes\"", BamlSdk::Literals.round_trip_literal_escaped("has \"quotes\"") + end + + def test_literals_round_trip_literal_true + assert_equal true, BamlSdk::Literals.round_trip_literal_true(true) + end + + def test_literals_round_trip_literal_false + assert_equal false, BamlSdk::Literals.round_trip_literal_false(false) + end + + def test_literals_round_trip_literals + lit = BamlSdk::Literals::Literals.new(literal_42: 42, literal_draft: "draft", literal_escaped: "has \"quotes\"", literal_true: true, literal_false: false) + assert_equal lit, BamlSdk::Literals.round_trip_literals(lit) + end + +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_primitives.rb b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_primitives.rb new file mode 100644 index 00000000000..bc73547dd1a --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_primitives.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class PrimitivesTest < Minitest::Test + def test_primitives_return_int + assert_equal 42, BamlSdk::Primitives.return_int() + end + +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_void.rb b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_void.rb new file mode 100644 index 00000000000..25fa0b2afd1 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_void.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class VoidTest < Minitest::Test + def test_void_no_op + assert_nil BamlSdk::Void.no_op() + end + +end diff --git a/baml_language/sdk_tests/fixtures/function_calls/baml_src/ns_methods_on_classes/ruby_hooks.baml b/baml_language/sdk_tests/fixtures/function_calls/baml_src/ns_methods_on_classes/ruby_hooks.baml new file mode 100644 index 00000000000..770d30722a2 --- /dev/null +++ b/baml_language/sdk_tests/fixtures/function_calls/baml_src/ns_methods_on_classes/ruby_hooks.baml @@ -0,0 +1,15 @@ +// Ruby calls these lifecycle hooks while loading generated declarations. +function singleton_method_added() -> int { 1 } + +class HookNames { + display string + tap string + then string + gem string + + function method_added() -> int { 2 } + function ancestors() -> int { 3 } + function decorator() -> int { 4 } +} + +function name(value: HookNames) -> HookNames { value } diff --git a/baml_language/sdk_tests/harness_runner/src/lib.rs b/baml_language/sdk_tests/harness_runner/src/lib.rs index 3357b1d8562..f257d5b0ff6 100644 --- a/baml_language/sdk_tests/harness_runner/src/lib.rs +++ b/baml_language/sdk_tests/harness_runner/src/lib.rs @@ -32,6 +32,7 @@ use std::{ mod command; pub mod csharp; pub mod fixtures; +pub mod ruby_sorbet; pub use command::{assert_stdout_contains, run_command}; diff --git a/baml_language/sdk_tests/harness_runner/src/ruby_sorbet.rs b/baml_language/sdk_tests/harness_runner/src/ruby_sorbet.rs new file mode 100644 index 00000000000..29bfe579ac3 --- /dev/null +++ b/baml_language/sdk_tests/harness_runner/src/ruby_sorbet.rs @@ -0,0 +1,117 @@ +//! Ruby's fixture gates keep each minitest file in its own process, and check +//! the entire generated package and test overlay against the gem's shipped RBI. +use std::{env, fs, path::PathBuf, process::Command}; + +use crate::{fixture_path, run_command, workspace_root_from_manifest}; + +pub fn minitest(fixture: &str) { + let generated = fixture_path(fixture, "generated"); + let mut tests: Vec<_> = fs::read_dir(&generated) + .expect("generated Ruby fixture") + .map(|entry| entry.expect("generated fixture entry").path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("test_") && name.ends_with(".rb")) + }) + .collect(); + tests.sort(); + let manifest = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let runtime = workspace_root_from_manifest(&manifest).join("sdks/ruby/bridge_ruby/lib"); + // Fixtures without ports still prove the generated SDK loads. + if tests.is_empty() { + tests.push(PathBuf::from("-e0")); + } + for test in tests { + run_command( + Command::new("ruby") + .args(["-S", "bundle", "exec", "ruby", "-I"]) + .arg(&runtime) + .arg("-I") + .arg(&generated) + .arg("-rbaml_sdk") + .arg(&test) + .current_dir(&generated), + &format!("Ruby {fixture} {}", test.display()), + ); + } +} + +/// Also used by the single negative consumer, which must reject a wrong type. +pub fn sorbet_command(fixture: &str) -> Command { + let manifest = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let workspace = workspace_root_from_manifest(&manifest); + let target = env::var_os("CARGO_TARGET_DIR") + .map_or_else(|| workspace.join("target"), |path| workspace.join(path)); + let mut command = Command::new("ruby"); + command + .args([ + "-S", + "bundle", + "exec", + "srb", + "tc", + "--no-config", + "--color=never", + "--no-error-sections", + "--no-error-count", + ]) + .env("BUNDLE_GEMFILE", manifest.join("test/static/Gemfile")) + .env("BUNDLE_PATH", target.join("ruby-sorbet-static-bundle")) + .env("BUNDLE_FROZEN", "true") + .arg(workspace.join("sdks/ruby/bridge_ruby/rbi/baml.rbi")) + .arg(manifest.join("test/static/minitest.rbi")) + .arg(".") + .current_dir(fixture_path(fixture, "generated")); + command +} + +/// Expand a Ruby check, preserving the static checker's supported platforms. +#[macro_export] +#[doc(hidden)] +macro_rules! __ruby_sorbet_gate { + (on, minitest, $body:block) => { + #[test] + fn minitest() $body + }; + (on, sorbet, $body:block) => { + #[test] + #[cfg_attr( + not(all( + any(target_arch = "x86_64", target_arch = "aarch64"), + any(target_os = "macos", all(target_os = "linux", target_env = "gnu")) + )), + ignore = "Sorbet 0.6.13506 binaries require x86_64/arm64 macOS or x86_64/aarch64 glibc Linux; runtime tests still run" + )] + fn sorbet() $body + }; + (later, $name:ident, $body:block) => { + #[test] + #[ignore = "generated Ruby API not complete enough for this fixture yet"] + fn $name() $body + }; +} + +#[macro_export] +macro_rules! ruby_sorbet_test_suite { + ( $( fixture $name:ident { minitest: $minitest:ident, sorbet: $sorbet:ident } )+ ) => { + $crate::setup_guard!("SDK_TEST_RUBY_SORBET_SETUP"); + $crate::fixture_manifest!( $( $name ),+ ); + + $( + mod $name { + $crate::__ruby_sorbet_gate!($minitest, minitest, { + $crate::ruby_sorbet::minitest(stringify!($name)); + }); + $crate::__ruby_sorbet_gate!($sorbet, sorbet, { + $crate::run_command( + &mut $crate::ruby_sorbet::sorbet_command(stringify!($name)), + concat!("Ruby ", stringify!($name), " srb tc"), + ); + }); + } + )+ + }; +} + +pub use crate::ruby_sorbet_test_suite as test_suite; diff --git a/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md b/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md new file mode 100644 index 00000000000..47fa4fb0812 --- /dev/null +++ b/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md @@ -0,0 +1,116 @@ +--- +date: 2026-10-18 +repository: baml4 +--- +# State of BAML↔Ruby completeness + +The Ruby/Sorbet SDK generates typed functions, classes and enums for user code and supported standard-library symbols. It runs sync and async calls through the V1 C bridge. The tables below separate tested support from remaining gaps. + +✅ means a named Ruby test proves the stated support, 🚧 means only part of the row is tested or supported, and ❌ means unsupported or not yet parity-verified. The Python column is copied from `ref-python-state-of-completeness.md` at commit `b74b83a3ab`, including its qualifications; it is not a claim about today's Python SDK. Links to `customizable/test_*.rb` identify real-engine tests; streaming tests use the keyless replay server. Fake-bridge tests are identified separately. + +Validation: `cargo nextest run -p sdk_test_ruby_sorbet` passes all 11 enabled harness tests, including minitest and Sorbet checks for three shared fixtures, the fixture manifest and runtime checks. The `docstrings_etc` and `unsupported_only` fixtures' minitest and Sorbet gates are ignored because they have no test files. + +## Function-call forms + +| Call form | Python | Ruby | Ruby surface and evidence | +| --- | --- | --- | --- | +| Free function (sync) | ✅ | ✅ | Generated namespace methods call the engine: [`test_main_hello_world_returns_literal`][function-main], [`test_main_single_required_arg_round_trips`][function-main]. | +| Free function (async) | ✅ | ✅ | `_async` siblings return `Baml::Call[T]`; `.wait` returns the result: [`test_methods_on_classes_static_create_async_round_trips`][methods]. | +| Static method | ✅ | ✅ | Generated class methods and `_async` siblings: [`test_methods_on_classes_static_create_round_trips`][methods], [`test_methods_on_classes_static_create_async_round_trips`][methods]. | +| Instance method | ✅ | ✅ | Generated instance methods encode the receiver: [`test_methods_on_classes_instance_greet_with_arg_round_trips`][methods], [`test_methods_on_classes_instance_greet_async_with_arg_round_trips`][methods]. | +| Required args (positional) | ✅ | ✅ | Required parameters are positional: [`test_main_single_required_arg_round_trips`][function-main]. | +| Required args (keyword) | ✅ | n/a | Generated methods require positional arguments. | +| Optional args (omitted → default) | ✅ | ❌ | Callables with defaulted arguments are not generated. | +| Optional args (supplied) | ✅ | ❌ | Callables with defaulted arguments are not generated. Nullable parameters without defaults remain required. | +| Streaming | ✅ | ❌ | No host stream wrapper or generated stream companions. BAML-internal collection remains supported: [`test_streaming_e2e_stream_collect_in_baml`][streaming], [`test_streaming_e2e_stream_doc_collect_in_baml`][streaming]. | +| `$build_request` companion | ✅ | ❌ | No function-spec wrapper or generated spec companions. | +| Generic function / method (inferred) | ✅ | ❌ | Generic declarations are not generated. | +| Generic function / method (`_types=` kwarg) | ✅ | ❌ | No explicit type-binding API; generic declarations are not generated. | +| Generic function / method (subscript) | ✅ | ❌ | No callable-subscript API. | +| Host callback param | ✅ | ❌ | Declarations requiring function types are not generated. | + +Per-call timeout, cancel-token and trace options are unsupported. No `_baml:` keyword is generated. Watcher declarations are not generated. + +## Runtime behaviors + +These rows describe what happens when a call returns, throws, or terminates. + +| Runtime behavior | Python | Ruby | Ruby outcome and evidence | +| --- | --- | --- | --- | +| Normal return | ✅ | ✅ | Decodes the `ok` value: [`test_main_hello_world_returns_literal`][function-main], [`test_methods_on_classes_static_create_round_trips`][methods], [`test_streaming_e2e_stream_doc_collect_in_baml`][streaming]. | +| BAML error | ✅ (docs-only) | 🚧 | Raises `Baml::Error < StandardError` with a message and `type_name`. The fake-bridge [`test_call_waiters_cache_outcomes_and_survive_interrupt_and_cancel`][bridge-call] checks a cached error. Decoded payloads and BAML traces are unsupported. | +| BAML panic | ✅ | 🚧 | Raises `Baml::Panic < Exception`, not `StandardError`. Ordinary `rescue => e` does not catch it. Decoded payloads and BAML traces are unsupported. | +| Host-callback error | ✅ | ❌ | No host-callback dispatch or exception round trip. | +| Cancellation | ✅ (async only) | 🚧 | `Call#cancel` requests cancellation; a `wait(timeout:)` timeout raises `Baml::WaitTimeout` without cancelling. The fake-bridge [`test_call_waiters_cache_outcomes_and_survive_interrupt_and_cancel`][bridge-call] checks a cancellation race. Cancel-token options, invocation timeouts and a dedicated cancellation exception are unsupported. | +| OS exit | ✅ | ❌ | Exit panics do not terminate the Ruby process. | + +The fake-bridge test [`test_call_waiters_cache_outcomes_and_survive_interrupt_and_cancel`][bridge-call] checks multiple waiters, cached results and errors, an interrupted waiter, foreign-thread completion and a cancellation race. + +Generated packages initialize on their first call or through `initialize!`. Loading a different program in the same process and using an inherited runtime after fork raise errors: fixture-backed [`test_different_program`][loader], [`test_pid_check`][loader]. + +Gem packaging, clean-app installation and native-library cache lookup are unsupported. Supply the native bridge through `BAML_BRIDGE_PATH`. Publishing and automatic bridge download remain out of scope. + +## Static typing + +The [shared Ruby harness][static-gate] runs `srb tc` on each of the three enabled generated packages and its tests with Sorbet **0.6.13506**, pinned in a separate [test bundle][static-gemfile]. Every generated Ruby file, including bytecode and model declarations, uses `# typed: strict`. No SDK functions or models are recreated in test RBIs. + +Strict consumers in the fixture overlays cover sync and async returns, stdlib methods, optionals, classes and enums. One negative consumer requires exactly one wrong-argument diagnostic (`7002`), so a regression to `T.untyped` fails the gate. + +The [boundary RBI][static-bridge] describes the runtime-facing API, including call helpers; it does not recreate generated SDK declarations. The bridge's decoded return remains dynamic, so this gate proves generated declarations and consumer checking, not the runtime's adherence to BAML return types. Real-engine and replay tests cover runtime behavior separately. + +Typing still has limits: single literal values use their Ruby primitive types, as checked by [`test_literals_return_literals`][literals]. Type aliases, literal unions and general unions are unsupported; declarations requiring them are not generated. Generated docstrings and `Raises` sections are unsupported. JSON has no special `T.untyped` mapping. Declarations requiring function types are not generated. Method signatures retain Sorbet's lazy validation. + +The pinned checker runs on x86_64/arm64 macOS and x86_64/aarch64 glibc Linux. Windows, 32-bit Linux and musl hosts explicitly ignore the static test because no compatible binary is supplied; their runtime tests still run. Setup installs the checker under `target/ruby-sorbet-static-bundle` only on supported hosts, and installation failures there fail setup. + +## Value kinds + +Encode means Ruby → BAML; decode means BAML → Ruby. The generated-package tests make real calls. + +| Value kind | Python | Ruby | Ruby value and evidence | +| --- | --- | --- | --- | +| Null | ✅ | ✅ | `nil`: [`test_void_no_op`][void]. The compiler represents both null and void/unit as the same type. | +| Bool | ✅ | 🚧 | `true` / `false`: [`test_stdlib_entrypoints_sysop_fs_exists_callable_as_entry_point`][stdlib] checks decode. Primitive round-trip parity is not verified. | +| Int (i64) | ✅ | 🚧 | `Integer` within i64: [`test_primitives_return_int`][primitives] checks decode. Primitive round-trip parity is not verified. | +| Bigint (outside i64) | ✅ | ❌ | No bigint encoding, decoding or generated type mapping. | +| Float | ✅ | 🚧 | `Float`; primitive round-trip parity is not verified. Generated inputs require `Float`, not `Integer`. | +| String | ✅ | ✅ | `String`: [`test_main_single_required_arg_round_trips`][function-main]. | +| Bytes | ✅ | ❌ | No bytes codec or generated type mapping. | +| List | ✅ | 🚧 | `Array`: [`test_streaming_e2e_stream_collect_in_baml`][streaming] checks decoded string lists. List round-trip parity is not verified. | +| Map | ✅ | 🚧 | String-keyed `Hash` values are supported but not parity-verified. Non-string keys are unsupported. | +| Enum | ✅ | 🚧 | Generated `T::Enum`: [`test_main_ipsum_sentiment_enum_shape`][llm-main] checks its shape. Enum-variant types and dynamic enums are unsupported. Enum round-trip parity is not verified. | +| Class | ✅ | ✅ | Generated `T::Struct`, registered by BAML type name: [`test_methods_on_classes_static_create_round_trips`][methods], [`test_methods_on_classes_instance_greet_with_arg_round_trips`][methods], [`test_streaming_e2e_stream_doc_collect_in_baml`][streaming]. | +| Generic explicitly reified by BAML-known type | ✅ | ❌ | Generic classes and declarations requiring them are not generated. | +| Generic implicitly reified by BAML-known type | ✅ | ❌ | Generic classes and declarations requiring them are not generated. | +| Generic reified by host-only type | ❌ (need more rules for serializing Python-only types) | ❌ | No generic value support. | +| Union | ✅ (union metadata dropped) | ❌ | General unions are unsupported. Optionals remain supported as `T.nilable`, checked by [`test_main_lorem_streaming_doc_class_shape`][llm-main]. | +| BAML interface | ❌ | ❌ | No interface value support; affected declarations are not generated. | +| Media | ✅ | ❌ | No media values, codec or generated type mapping. | +| Stream | ✅ | ❌ | No host stream wrapper; declarations requiring stream values are not generated. | +| Host callable | ✅ | ❌ | No host-value registry or dispatch; declarations requiring function types are not generated. | +| Host callable (async) | ✅ (works, but returned coroutine is driven to completion on new asyncio event loop) | ❌ | No host-callback dispatch. | +| BAML closure (function reference) | ❌ | ❌ | No callable closure wrapper; declarations requiring function types are not generated. | +| BAML closure (closure / bound method / generic function / function) | ❌ | ❌ | No callable closure wrapper; declarations requiring function types are not generated. | +| BAML type reference values | ❌ | ❌ | No first-class BAML type-value encoding or decoding. | +| BAML type definition values | ❌ | ❌ | No type-definition values. | +| BAML `$rust_type` values: files, sockets, etc. | ✅ | 🚧 | Generated resource structs retain `Baml::Handle` fields. File, HTTP and socket values are not parity-verified. | +| Native exception thrown by a host callback | ✅ | ❌ | No opaque host-value registry or exception round trip. | +| Unused SDK heap handles (`Handle`) | 🚧 | 🚧 | The fake-bridge [`test_result_handles_are_owned_before_wait_and_after_partial_decode`][bridge-call] checks result ownership and GC release. Real-engine handle ownership is not parity-verified. | +| Unused SDK collector values (`Collector`) | 🚧 | ❌ | No collector wrapper or verified collector-value path. | +| Unused SDK prompt values (`PromptAst`) | 🚧 | ❌ | No prompt-value support. | +| BAML builtin type (`Future` / `UnscheduledFuture`) | ❌ | ❌ | No first-class engine future values. `Baml::Call` is the host call promise, not a BAML future value. | +| Arbitrary unsupported host object | ❌ | ❌ | Encoding raises `TypeError`. | +| Cyclic / self-referential objects | ❌ | ❌ | No cyclic-object encoding or identity preservation. Recursive type declarations and acyclic recursive values do not provide cycle handling. | + +[function-main]: ../../../sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_main.rb +[streaming]: ../../../sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_streaming_e2e.rb +[void]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_void.rb +[bridge-call]: ../../../sdk_tests/crates/ruby_sorbet/test/bridge_call_test.rb +[static-gemfile]: ../../../sdk_tests/crates/ruby_sorbet/test/static/Gemfile +[static-bridge]: ../../ruby/bridge_ruby/rbi/baml.rbi +[static-gate]: ../../../sdk_tests/harness_runner/src/ruby_sorbet.rs +[methods]: ../../../sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_methods_on_classes.rb +[primitives]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_primitives.rb +[literals]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_literals.rb +[llm-main]: ../../../sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_main.rb +[stdlib]: ../../../sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_stdlib_entrypoints.rb +[loader]: ../../../sdk_tests/crates/ruby_sorbet/test/bridge_loader_test.rb diff --git a/baml_language/sdks/ruby/bridge_ruby/Gemfile b/baml_language/sdks/ruby/bridge_ruby/Gemfile index ab0bfee05ca..4cfe6252ff4 100644 --- a/baml_language/sdks/ruby/bridge_ruby/Gemfile +++ b/baml_language/sdks/ruby/bridge_ruby/Gemfile @@ -1,3 +1,5 @@ source "https://rubygems.org" gem "ffi", ">= 1.17.4", "< 2" +gem "google-protobuf", "~> 4.0" +gem "sorbet-runtime", "~> 0.5" diff --git a/baml_language/sdks/ruby/bridge_ruby/README.md b/baml_language/sdks/ruby/bridge_ruby/README.md index 977d57c88fb..76bc180d7ca 100644 --- a/baml_language/sdks/ruby/bridge_ruby/README.md +++ b/baml_language/sdks/ruby/bridge_ruby/README.md @@ -1,11 +1,43 @@ -# BAML Ruby V1 bridge +# BAML Ruby/Sorbet runtime -This directory contains private runtime plumbing for generated Ruby/Sorbet SDKs. Generated code calls: +The Ruby runtime runs generated BAML Ruby/Sorbet SDKs. It contains pure Ruby code and the committed protobuf clients, and depends on `ffi`, `google-protobuf` and `sorbet-runtime`. Ruby 3.2 or newer is required. + +Add a Ruby generator to your BAML project and generate the SDK: + +```sh +baml generate add ruby/sorbet +baml generate --output-dir ./generated +``` + +The generator uses `output_type = "ruby/sorbet"` and `naming_convention = "language"` in `baml.toml`. It embeds the program and runtime metadata in `generated/baml_sdk.rb` and `generated/baml_sdk/`. Put `generated/` on the application's load path: ```ruby -Baml::Bridge.initialize!(compiled_program_bytes) +require "baml_sdk" + +puts BamlSdk.hello_world +call = BamlSdk.hello_world_async +puts call.wait ``` -The bridge loads the absolute library path in `BAML_RUNTIME_PATH`, validates the complete V1 C table, requires an exact canonical BAML toolchain version, registers `Baml::Bridge` as bridge language `10` with its stamped bridge runtime version, and initializes one exact generated program per process. +Generated functions initialize the runtime on their first call. `BamlSdk.initialize!` initializes it eagerly. The process can load only one generated BAML program; a forked child must exec before using an inherited runtime. + +## Sorbet types + +`rbi/baml.rbi` is the runtime contract used to check generated code. Check +it alongside your generated `baml_sdk.rb` and `baml_sdk/` sources. + +Generated structs compare by their fields. + +## Native library lookup + +Set `BAML_BRIDGE_PATH` to the absolute filename of a native bridge library built for the runtime's canonical BAML toolchain version. The loader checks the bridge ABI and canonical toolchain version before use. + +## Build locally + +The committed `google-protobuf` clients under `lib/baml_bridge/cffi/v1/` come from the authoritative CFFI V1 schemas. Regenerate them from `baml_language/` with: + +```sh +cargo build -p bridge_ctypes +``` -This checkpoint deliberately has no public gem packaging, runtime discovery, download, unload, reset, or project-switching API. Packaging and the public gem name remain release work. +A clean regeneration must leave these clients unchanged. diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge.rb index ed346d9cdae..a72bf6dcaa3 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge.rb @@ -3,6 +3,10 @@ require_relative "bridge/version" require_relative "bridge/errors" require_relative "bridge/native" +require_relative "bridge/call" +require_relative "bridge/handle" +require_relative "value_equality" +require_relative "bridge/protocol" require_relative "bridge/process_runtime" module Baml @@ -10,8 +14,28 @@ module Bridge @process_runtime = ProcessRuntime.new class << self - def initialize!(compiled_program_bytes) - @process_runtime.initialize!(compiled_program_bytes) + def register_program(bytecode, metadata = nil) + @process_runtime.register_program(bytecode, metadata) + end + + def define_class(wire, klass, fields:) + Protocol.define_class(wire, klass, fields: fields) + end + + def define_enum(wire, klass) + Protocol.define_enum(wire, klass) + end + + def define_function(wire, params:) + Protocol.define_function(wire, params: params) + end + + def call_async(wire, args) + @process_runtime.call_async(wire, args) + end + + def initialize! + @process_runtime.initialize! end end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/call.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/call.rb new file mode 100644 index 00000000000..ab633b2d6ef --- /dev/null +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/call.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require "sorbet-runtime" +require "thread" + +module Baml + class WaitTimeout < StandardError; end + + class Call + extend T::Generic + Value = type_member + + def initialize(api, call_id) + @api = api + @call_id = call_id + @mutex = Mutex.new + @condition = ConditionVariable.new + @state = :pending + @decoded = false + @cancel_requested = false + end + + def wait(timeout: nil) + @api.ensure_not_forked! + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout if timeout + @mutex.synchronize do + while @state == :pending + remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC) if deadline + raise WaitTimeout, "BAML call is still running" if remaining && remaining <= 0 + + @condition.wait(@mutex, remaining) + end + # A waiter's interrupt must not become the shared decoding error. + Thread.handle_interrupt(Exception => :never) do + unless @decoded + begin + raise @outcome if @outcome.is_a?(Exception) + + @value = Bridge::Protocol.decode_result(@outcome, @api) + rescue Exception => error # rubocop:disable Lint/RescueException -- cache the engine outcome + @error = error + end + @decoded = true + @outcome = nil + end + end + raise @error if @error + + @value + end + end + + def cancel + @mutex.synchronize do + return false if @cancel_requested || @state == :settled + + # Acquiring the mutex is interruptible; once flagged, cancellation must + # reach the bridge even if an interrupt arrives before the native call. + Thread.handle_interrupt(Exception => :never) do + @cancel_requested = true + @api.cancel_function_call(@call_id).zero? + end + end + end + + def done? + @mutex.synchronize { @state == :settled } + end + + # Internal: the callback delivers an envelope owning its handle references + # (or an error). Waiters build and cache Ruby values. + def complete(outcome) + @mutex.synchronize do + return if @state == :settled + + @outcome = outcome + @state = :settled + @condition.broadcast + end + end + end +end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/commit.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/commit.rb new file mode 100644 index 00000000000..662b9dae3ec --- /dev/null +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/commit.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Baml + module Bridge + module Encode + module Commit + WIRE_LIMIT = 100 + ARGUMENT_DEPTH = 2 + module_function + + def depth(node) + statement = %i[list map class].include?(node.kind) ? 1 + Slot.wire_depth(Slot.to_wire(node.ty)) : 0 + payload = case node.kind + when :enum, :handle then 1 + when :list then 1 + (node.children.map { |child| 1 + depth(child) }.max || 0) + when :map, :class then 1 + (node.children.map { |_, child| 2 + depth(child) }.max || 0) + else 0 + end + [statement, payload].max + end + + def check_depth!(depth, path, enclosing: ARGUMENT_DEPTH) + raise Fault, "#{path}: nested too deeply" if enclosing + depth > WIRE_LIMIT + end + + def commit(node, scope: nil) + fields = case node.kind + when :null then {} + when :int, :float, :bool, :string then { "#{node.kind}_value".to_sym => node.value } + when :enum + { enum_value: Wire::InboundEnumValue.new(name: node.ty[1], value: node.value) } + when :handle + raise Error, "BAML handles can only be sent inside a call" unless scope + + key, kind = node.value.clone_for_wire(scope) + { handle: Wire::BamlHandle.new(key: key, handle_type: kind) } + when :list + { value_type: Slot.to_wire(node.ty), list_value: Wire::InboundListValue.new( + values: node.children.map { |child| commit(child, scope: scope) } + ) } + when :map, :class + entries = node.children.map do |key, child| + Wire::InboundMapEntry.new(string_key: key, value: commit(child, scope: scope)) + end + payload = if node.kind == :map + { map_value: Wire::InboundMapValue.new(entries: entries) } + else + { class_value: Wire::InboundClassValue.new(fields: entries) } + end + { value_type: Slot.to_wire(node.ty), **payload } + else raise Error, "Undecided BAML encoding node #{node.kind}" + end + Wire::InboundValue.new(**fields) + end + end + end + end +end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/plan.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/plan.rb new file mode 100644 index 00000000000..1fe3f45cecb --- /dev/null +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/plan.rb @@ -0,0 +1,258 @@ +# frozen_string_literal: true + +module Baml + module Bridge + module Encode + Node = Struct.new(:kind, :ty, :value, :children, :path, keyword_init: true) + + class Plan + # Ruby has no memo, and a step costs about 5 µs: use less fuel than Rust. + STEP_BUDGET = 1 << 20 + + def initialize(registry, budget: STEP_BUDGET) + @registry = registry + @remaining = budget + end + + def plan(tree, slot) + spend(1, tree.path) + slot = Slot.unfold(slot) + return choose(tree, slot[1]) if slot[0] == :union + return node(tree, [:opaque]) if tree.kind == :handle + if %i[unknown interface].include?(slot[0]) + own = natural(tree) + return restate(own, fill_type(own.ty)) + end + + case slot[0] + when :null + return node(tree, [:null]) if tree.kind == :null + when :int, :float, :bool, :string + if (fit = Rules.fit(tree.value, slot[0])) && scalar?(tree) + return node(tree, [slot[0]], kind: slot[0], value: fit[1]) + end + when :literal + expected = slot[1] + kind = Rules.kind(expected) + if %i[int string bool].include?(kind) && scalar?(tree) && + (fit = Rules.fit(tree.value, kind)) && fit[1] == expected + return node(tree, [kind], kind: kind, value: fit[1]) + end + when :enum + if (tree.kind == :enum && tree.name == slot[1]) || tree.kind == :string + if @registry.enum_values(slot[1]).include?(tree.value) + return node(tree, [:enum, slot[1]], kind: :enum) + end + refuse(tree, "unknown variant #{tree.value.inspect} of #{slot[1]}") + end + when :list + if tree.kind == :list + return node(tree, slot, children: tree.children.map { |child| plan(child, slot[1]) }) + end + when :map + if tree.kind == :map + entries = tree.children.map do |key, child| + [map_key(key, slot[1]), plan(child, slot[2])] + end + return node(tree, slot, children: entries) + end + when :class + return instance(tree, slot) if tree.kind == :class && tree.name == slot[1] + end + refuse(tree, "cannot encode #{tree.kind} as #{slot.inspect}") + end + + private + + def spend(steps, path) + @remaining -= steps + raise Fault, "#{path}: encoding exceeds the planning-step budget" if @remaining.negative? + end + + def scalar?(tree) + %i[int float bool string].include?(tree.kind) || (tree.kind == :other && tree.value.is_a?(Integer)) + end + + def refuse(tree, message) + raise Refusal, "#{tree.path}: #{message}" + end + + def node(tree, ty, kind: tree.kind, value: tree.value, children: nil) + Node.new(kind: kind, ty: ty, value: value, children: children, path: tree.path) + end + + def instance(tree, slot) + args = slot[2] || [] + fields = @registry.encoding_fields(slot[1]) + children = tree.children.map do |name, child| + [name, plan(child, Slot.substitute(fields.fetch(name)[1], args))] + end + node(tree, [:class, slot[1], args], children: children) + end + + def natural(tree) + spend(1, tree.path) + case tree.kind + when :null, :int, :float, :bool, :string + node(tree, [tree.kind]) + when :enum + unless @registry.enum_values(tree.name).include?(tree.value) + refuse(tree, "unknown variant #{tree.value.inspect} of #{tree.name}") + end + node(tree, [:enum, tree.name]) + when :class then instance(tree, [:class, tree.name, []]) + when :handle then node(tree, [:opaque]) + when :list + children = tree.children.map { |child| natural(child) } + spend(children.length, tree.path) + inner = join(children) + node(tree, [:list, inner], children: children) + when :map + children = tree.children.map do |key, child| + [map_key(key, [:string]), natural(child)] + end + spend(children.length, tree.path) + values = children.map(&:last) + inner = join(values) + node(tree, [:map, [:string], inner], children: children.map(&:first).zip(values)) + else refuse(tree, "cannot encode #{tree.value.class}: it has no BAML type") + end + end + + def map_key(key, slot) + unless key.kind == :string + refuse(key, "map keys must be string") + end + planned = plan(key, slot) + refuse(key, "map keys must be string") unless planned.kind == :string + planned.value + end + + def rank(tree, slot) + return 2 if %i[unknown interface].include?(slot[0]) || tree.kind == :handle + return 0 if tree.kind == :null && slot[0] == :null + return 0 if %i[class enum].include?(tree.kind) && tree.kind == slot[0] && tree.name == slot[1] + return 1 if %i[list map].include?(tree.kind) && tree.kind == slot[0] + return 3 if tree.kind == :string && slot[0] == :enum + return unless scalar?(tree) + + kind = slot[0] == :literal ? Rules.kind(slot[1]) : slot[0] + fit = Rules.fit(tree.value, kind) + fit && (fit[0] == :as_is ? 0 : 3) + end + + def choose(tree, members) + members = Slot.union(members) + return plan(tree, members) unless members[0] == :union + + ranked = members[1].filter_map do |member| + priority = rank(tree, member) + [priority, member] if priority + end.group_by(&:first).sort + failures = [] + ranked.each do |_, group| + fits = [] + group.each do |_, member| + begin + candidate = plan(tree, member) + fits << candidate unless fits.any? { |seen| Slot.equivalent?(seen.ty, candidate.ty) } + rescue Fault + raise + rescue Refusal => error + failures << error + end + end + next if fits.empty? + return fits.first if fits.length == 1 + + own = begin + natural(tree).ty + rescue Fault + raise + rescue Refusal + nil + end + preferred = fits.find { |candidate| own && Slot.equivalent?(candidate.ty, own) } + return preferred if preferred + + refuse(tree, "fits more than one union member") + end + raise failures.first if failures.length == 1 + + refuse(tree, "cannot encode #{tree.kind}: no union member fits") + end + + # Only holes borrow a sibling's type. Closed containers keep their + # types: [[1], ["a"]] is (int[] | string[])[], not (int | string)[][]. + def join(nodes) + distinct = {} + add = ->(ty) { distinct[Slot.canonical(ty)] ||= ty } + nodes.each { |child| add.call(child.ty) unless open_type?(child.ty) } + nodes.map! do |child| + if open_type?(child.ty) + target = distinct.each_value.find { |ty| fills?(child.ty, ty) } + if target + child = restate(child, target) + else + add.call(child.ty) + end + end + child + end + distinct.empty? ? [:hole] : Slot.union(distinct.values) + end + + def open_type?(ty) + case ty[0] + when :hole, :opaque then true + when :list then open_type?(ty[1]) + when :map then open_type?(ty[1]) || open_type?(ty[2]) + when :union then ty[1].any? { |item| open_type?(item) } + else false + end + end + + def fills?(own, target) + return true if own[0] == :hole + return false if own[0] == :opaque || own[0] != target[0] + + case own[0] + when :list then fills?(own[1], target[1]) + when :map then fills?(own[1], target[1]) && fills?(own[2], target[2]) + else Slot.equivalent?(own, target) + end + end + + def fill_type(ty) + case ty[0] + when :hole, :opaque then [:unknown] + when :list then [:list, fill_type(ty[1])] + when :map then [:map, fill_type(ty[1]), fill_type(ty[2])] + when :union + members = ty[1].map { |item| fill_type(item) } + members.include?([:unknown]) ? [:unknown] : Slot.union(members) + else ty + end + end + + def restate(node, target) + return node unless open_type?(node.ty) + + ty = if fills?(node.ty, target) + target + elsif target[0] == :union && (member = target[1].find { |item| fills?(node.ty, item) }) + member + else + fill_type(node.ty) + end + children = case node.kind + when :list then node.children.map { |child| restate(child, ty[1]) } + when :map then node.children.map { |key, child| [key, restate(child, ty[2])] } + else return node + end + Node.new(kind: node.kind, ty: ty, value: node.value, children: children, path: node.path) + end + end + end + end +end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/read.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/read.rb new file mode 100644 index 00000000000..3194cb23b85 --- /dev/null +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/read.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Baml + module Bridge + module Encode + Tree = Struct.new(:kind, :value, :name, :children, :path, keyword_init: true) + + # Only reading runs host code. All arguments share one budget, charged + # while pulling children, never trusting a host-reported collection size. + class Read + MAX_DEPTH = 50 + NODE_BUDGET = 1 << 20 + + def initialize(registry, budget: NODE_BUDGET) + @registry = registry + @remaining = budget + end + + def read(value, path, depth = 1) + raise Fault, "#{path}: nested too deeply" if depth > MAX_DEPTH + raise Fault, "#{path}: value exceeds the node budget" if @remaining.zero? + + @remaining -= 1 + name, fields = @registry.encoding_type(value.class) + children = nil + kind = if value.nil? + :null + elsif value.is_a?(::Baml::Handle) + :handle + elsif name && !fields + value = value.serialize.dup.freeze + :enum + elsif name + children = fields.map do |wire, (ruby, _descriptor)| + [wire, read(value.public_send(ruby), "#{path}.#{wire}", depth + 1)] + end + :class + elsif value.is_a?(Array) + children = [] + value.each_with_index { |item, index| children << read(item, "#{path}[#{index}]", depth + 1) } + :list + elsif value.is_a?(Hash) + children = [] + value.each do |key, item| + key_tree = read(key, "#{path}[key]", depth + 1) + entry_path = "#{path}[#{key.is_a?(String) ? key.inspect : 'entry'}]" + children << [key_tree, read(item, entry_path, depth + 1)] + end + :map + else + Rules.kind(value) + end + if value.is_a?(String) + unless value.encoding == Encoding::BINARY || value.valid_encoding? + raise Refusal, "#{path}: cannot encode an invalid string" + end + value = value.dup.freeze + end + Tree.new(kind: kind, value: value, name: name, children: children, path: path) + end + end + end + end +end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/rules.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/rules.rb new file mode 100644 index 00000000000..4181df51bfd --- /dev/null +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/rules.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Baml + module Bridge + module Encode + module Rules + INT_RANGE = (-(2**62)..(2**62 - 1)).freeze + module_function + + def kind(value) + case value + when nil then :null + when true, false then :bool + when Integer then INT_RANGE.cover?(value) ? :int : :other + when Float then :float + when String then :string + else :other + end + end + + # The rank and converted payload come from the same rule. An integer + # outside Int63 can still fit float, but never travels as int or bigint. + def fit(value, slot) + if slot == :string && value.is_a?(String) && value.encoding == Encoding::BINARY + text = value.dup.force_encoding(Encoding::UTF_8) + return text.valid_encoding? ? [:converted, text] : nil + end + natural = kind(value) + return [:as_is, value] if natural == slot && natural != :other + return unless value.is_a?(Integer) && slot == :float + + float = value.to_f + [:converted, float] if float.finite? && float.to_i == value + end + end + end + end +end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/slot.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/slot.rb new file mode 100644 index 00000000000..b4e75242960 --- /dev/null +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/slot.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +module Baml + module Bridge + module Encode + Wire = ::BamlBridge::Cffi::V1 + + class Refusal < TypeError; end + class Fault < Refusal; end + + module Slot + module_function + + def union(members) + members = flatten(members) + distinct = members.uniq { |item| canonical(item) } + distinct.length == 1 ? distinct.first : [:union, distinct] + end + + def flatten(members) + members.flat_map { |item| item[0] == :union ? flatten(item[1]) : [item] } + end + + # Aliases are not supported by the foundation. Leave their heads intact + # rather than following an unguarded cycle; later ports add unfolding here. + def unfold(slot) + slot + end + + def stated_form(slot) + case slot[0] + when :union then union(slot[1].map { |item| stated_form(item) }) + when :list then [:list, stated_form(slot[1])] + when :map then [:map, stated_form(slot[1]), stated_form(slot[2])] + when :class then [:class, slot[1], (slot[2] || []).map { |item| stated_form(item) }] + else slot + end + end + + # Hash keys use one spelling for every type, including unions nested + # inside function signatures. Keep the first-seen spelling on the wire. + def canonical(slot) + case slot[0] + when :union + members = flatten(slot[1]).map { |item| canonical(item) }.uniq.sort_by { |item| Marshal.dump(item) } + members.length == 1 ? members.first : [:union, members] + when :literal then [:literal, slot.length == 3 ? slot[1] : Rules.kind(slot.last), slot.last] + when :list then [:list, canonical(slot[1])] + when :map then [:map, canonical(slot[1]), canonical(slot[2])] + when :class then [:class, slot[1], (slot[2] || []).map { |item| canonical(item) }] + when :function + [:function, slot[1].map { |name, ty, optional| [name, canonical(ty), optional] }, canonical(slot[2]), canonical(slot[3])] + else slot + end + end + + def equivalent?(left, right) + canonical(left) == canonical(right) + end + + def substitute(slot, args) + case slot[0] + when :param then args.fetch(slot[1]) { [:other] } + when :list then [:list, substitute(slot[1], args)] + when :map then [:map, substitute(slot[1], args), substitute(slot[2], args)] + when :union then union(slot[1].map { |item| substitute(item, args) }) + when :class then [:class, slot[1], (slot[2] || []).map { |item| substitute(item, args) }] + else slot + end + end + + def to_wire(slot) + slot = stated_form(slot) + kind, *args = slot + fields = case kind + when :int, :float, :string, :bool, :null + { primitive: Wire::BamlTyPrimitive.new(kind: "BAML_TY_PRIMITIVE_#{kind.upcase}".to_sym) } + when :unknown then { unknown: Wire::BamlTyUnknown.new } + when :never then { never: Wire::BamlTyNever.new } + when :class + { class_ty: Wire::BamlTyClass.new(name: args[0], type_args: (args[1] || []).map { |item| to_wire(item) }) } + when :enum then { enum: Wire::BamlTyEnum.new(name: args[0]) } + when :interface then { interface: Wire::BamlTyInterface.new(name: args[0]) } + when :list then { list: Wire::BamlTyList.new(item: to_wire(args[0])) } + when :map then { map: Wire::BamlTyMap.new(key: to_wire(args[0]), value: to_wire(args[1])) } + when :union then { union: Wire::BamlTyUnion.new(options: args[0].map { |item| to_wire(item) }) } + when :literal + literal_kind = Rules.kind(args[0]) + unless %i[int string bool].include?(literal_kind) + raise Fault, "cannot encode this literal type" + end + { literal: Wire::BamlTyLiteral.new(**{ "#{literal_kind}_value".to_sym => args[0] }) } + else raise Fault, "cannot encode type #{slot.inspect}" + end + Wire::BamlTy.new(**fields) + end + + def from_wire(ty) + return [:other] unless ty + + body = ty.public_send(ty.ty) if ty.ty + if body.respond_to?(:head) && body.head != :name + return [:other] + end + case ty.ty + when :primitive + kind = body.kind.to_s.delete_prefix("BAML_TY_PRIMITIVE_").downcase.to_sym + %i[int float string bool null].include?(kind) ? [kind] : [:other] + when :unknown then [:unknown] + when :never then [:never] + when :class_ty then [:class, body.name, body.type_args.map { |item| from_wire(item) }] + when :enum then [:enum, body.name] + when :interface + body.type_args.empty? && body.bindings.empty? ? [:interface, body.name] : [:other] + when :list then [:list, from_wire(body.item)] + when :map then [:map, from_wire(body.key), from_wire(body.value)] + when :optional then union([from_wire(body.inner), [:null]]) + when :union then union(body.options.map { |item| from_wire(item) }) + when :literal + %i[int_value string_value bool_value].include?(body.literal) ? [:literal, body.public_send(body.literal)] : [:other] + else [:other] + end + end + + # Protobuf reflection includes every nested message, including type + # annotations. Use the schema itself for depth and live-reference walks. + def each_message(message) + message.class.descriptor.each do |field| + next unless field.type == :message + + value = field.get(message) + if field.label == :repeated + value.each { |child| yield child } + elsif value + yield value + end + end + end + + def wire_depth(message) + deepest = 0 + each_message(message) { |child| deepest = [deepest, 1 + wire_depth(child)].max } + deepest + end + end + end + end +end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/errors.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/errors.rb index c4258b1834f..4576d91787f 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/errors.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/errors.rb @@ -1,22 +1,41 @@ # frozen_string_literal: true module Baml + module Failure + attr_reader :type_name + + def initialize(value, type_name: nil) + @type_name = type_name + message = value.respond_to?(:message) ? value.message.to_s : value.to_s + super(message.empty? ? (type_name || value.inspect) : message) + end + end + + class Error < StandardError + include Failure + end + + # Panics must escape ordinary `rescue => error`, just like Python's BaseException. + class Panic < Exception # rubocop:disable Lint/InheritException + include Failure + end + module Bridge class Error < StandardError; end + class UnsupportedTypeError < Error; end # The runtime path is absent or unusable before a native library is opened. - # Correcting BAML_RUNTIME_PATH and retrying is safe. + # Correcting BAML_BRIDGE_PATH and retrying is safe. class RuntimeConfigurationError < Error; end class RuntimeLoadError < Error; end - # The configured library opened but cannot safely satisfy the V1 contract. - # The process caches this failure and never unloads or replaces the library. + # The configured library cannot satisfy the V1 contract. class IncompatibleRuntimeError < Error; end # The native table rejected bytecode without changing the current runtime. class ProgramInitializationError < Error; end - # One process may initialize only one exact generated BAML program. + # One process may register only one exact generated BAML program. class ProgramConflictError < Error; end # Native state inherited across fork is intentionally never touched. diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/handle.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/handle.rb new file mode 100644 index 00000000000..21cac1a1bdb --- /dev/null +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/handle.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module Baml + class Handle + def initialize(api, key, handle_type) + @api = api + @key = [key] + @handle_type = handle_type + @mutex = Mutex.new + ObjectSpace.define_finalizer(self, self.class.finalizer(api, @key, Process.pid)) + end + + # The finalizer owns only the key cell, never the Ruby handle itself. + def self.finalizer(api, key, owner_pid) + proc do + if Process.pid == owner_pid && key[0] + owned = key[0] + key[0] = nil + begin + api.handle_release(owned) + rescue StandardError + # Finalization cannot report a native cleanup failure to a caller. + end + end + end + end + + def close + Thread.handle_interrupt(Exception => :never) do + @mutex.synchronize do + if @key[0] + @api.handle_release(@key[0]) + @key[0] = nil + end + end + end + nil + end + + def clone_for_wire(scope) + Thread.handle_interrupt(Exception => :never) do + @mutex.synchronize do + raise Bridge::Error, "BAML handle is closed" unless @key[0] + + key = @api.handle_clone(@key[0]) + scope.own(@api, key) + [key, @handle_type] + end + end + end + end + + module Bridge + class EncodingScope + def initialize + @handles = [] + end + + def own(api, key) + @handles << [api, key] + end + + def submitted! + @handles.clear + end + + def close + handles = @handles.dup + @handles.clear + first_error = nil + handles.each do |api, key| + begin + api.handle_release(key) + rescue Exception => error # rubocop:disable Lint/RescueException -- attempt every owned release + first_error ||= error + end + end + raise first_error if first_error + end + end + end +end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/native.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/native.rb index 1d2461b8a56..eb1714fe051 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/native.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/native.rb @@ -57,6 +57,10 @@ class ApiV1 < FFI::Struct release_function_call register_host_dispatch_v2 register_host_cancel_callback + trace_selection + invocation_context + media_name + media_from_file_content ].freeze layout :abi_version, :uint32, @@ -71,15 +75,30 @@ class ApiV1 < FFI::Struct %i[pointer size_t], { blocking: true } ], + initialize_runtime_from_blob_with_metadata: [ + Buffer.by_value, + %i[pointer size_t string], + { blocking: true } + ], free_buffer: [:void, [Buffer.by_value]], register_callback: [:void, [:pointer]], - register_bridge: [Buffer.by_value, [:pointer]] + complete_host_call: [:void, %i[uint32 pointer size_t], { blocking: true }], + new_function_call: [:uint64, []], + release_function_call: [:int32, [:uint64]], + cancel_function_call: [:int32, [:uint64]], + handle_clone: [:int32, %i[uint64 pointer]], + handle_release: [:int32, [:uint64]], + call_function: [:void, %i[pointer size_t uint32], { blocking: true }], + register_bridge: [Buffer.by_value, [:pointer]], + register_unhandled_spawn_error_callback: [:void, [:pointer]], + shutdown_runtime: [Buffer.by_value, [], { blocking: true }] }.freeze class Api attr_reader :library, :runtime_version - def initialize(library, path, toolchain_version, bridge_runtime_version) + def initialize(library, path, toolchain_version) + @owner_pid = Process.pid @library = library @path = path @functions = {} @@ -91,26 +110,101 @@ def initialize(library, path, toolchain_version, bridge_runtime_version) "BAML runtime version mismatch: Ruby bridge requires #{toolchain_version}, " \ "but #{path.inspect} reports #{@runtime_version.inspect}" end - - register_bridge!(toolchain_version, bridge_runtime_version) end def register_result_callback(callback) function(:register_callback).call(callback) end - def initialize_runtime(bytecode) + def register_unhandled_spawn_error_callback(callback) + function(:register_unhandled_spawn_error_callback).call(callback) + end + + def shutdown_runtime + diagnostic = read_owned_utf8(function(:shutdown_runtime).call, "shutdown_runtime") + raise Error, "BAML runtime shutdown failed: #{diagnostic}" unless diagnostic.empty? + end + + def initialize_runtime(bytecode, metadata = nil) pointer = memory_for(bytecode) - diagnostic = read_owned_utf8( - function(:initialize_runtime_from_blob).call(pointer, bytecode.bytesize), - "initialize_runtime_from_blob" - ) + operation = metadata.nil? ? :initialize_runtime_from_blob : :initialize_runtime_from_blob_with_metadata + arguments = [pointer, bytecode.bytesize] + arguments << metadata unless metadata.nil? + diagnostic = read_owned_utf8(function(operation).call(*arguments), operation) return if diagnostic.empty? raise ProgramInitializationError, "Native BAML program initialization failed: #{diagnostic}" end + def complete_host_call(callback_id, bytes) + function(:complete_host_call).call(callback_id, memory_for(bytes), bytes.bytesize) + end + + def new_function_call + call_id = function(:new_function_call).call + raise Error, "BAML engine call IDs are exhausted" if call_id.zero? + + call_id + end + + def release_function_call(call_id) + function(:release_function_call).call(call_id) + end + + def cancel_function_call(call_id) + function(:cancel_function_call).call(call_id) + end + + def handle_clone(key) + out_key = FFI::MemoryPointer.new(:uint64) + status = function(:handle_clone).call(key, out_key) + raise Error, "Unable to clone BAML handle #{key}: status #{status}" unless status.zero? + + out_key.read_uint64 + end + + def handle_release(key) + status = function(:handle_release).call(key) + raise Error, "Unable to release BAML handle #{key}: status #{status}" unless status.zero? + end + + def call_function(encoded_args, callback_id) + pointer = memory_for(encoded_args) + function(:call_function).call(pointer, encoded_args.bytesize, callback_id) + end + + def ensure_not_forked! + if @owner_pid != Process.pid + raise ForkSafetyError, "Forked child #{Process.pid} must exec before using BAML" + end + end + + def register_bridge!(toolchain_version, bridge_runtime_version) + toolchain_pointer = memory_for(toolchain_version.b) + name_pointer = memory_for(BRIDGE_RUNTIME_NAME.b) + bridge_version_pointer = memory_for(bridge_runtime_version.b) + info = BridgeInfoV1.new + info[:struct_size] = BridgeInfoV1.size + info[:language] = RUBY_BRIDGE_LANGUAGE + info[:sdk_version] = toolchain_pointer + info[:sdk_version_len] = toolchain_version.bytesize + info[:bridge_runtime_name] = name_pointer + info[:bridge_runtime_name_len] = BRIDGE_RUNTIME_NAME.bytesize + info[:bridge_runtime_version] = bridge_version_pointer + info[:bridge_runtime_version_len] = bridge_runtime_version.bytesize + + diagnostic = read_owned_utf8( + function(:register_bridge).call(info.pointer), + "register_bridge" + ) + return if diagnostic.empty? + + raise IncompatibleRuntimeError, + "Native bridge registration rejected #{BRIDGE_RUNTIME_NAME} " \ + "#{bridge_runtime_version}: #{diagnostic}" + end + private def read_table @@ -154,32 +248,9 @@ def validate_table! end end - def register_bridge!(toolchain_version, bridge_runtime_version) - toolchain_pointer = memory_for(toolchain_version.b) - name_pointer = memory_for(BRIDGE_RUNTIME_NAME.b) - bridge_version_pointer = memory_for(bridge_runtime_version.b) - info = BridgeInfoV1.new - info[:struct_size] = BridgeInfoV1.size - info[:language] = RUBY_BRIDGE_LANGUAGE - info[:sdk_version] = toolchain_pointer - info[:sdk_version_len] = toolchain_version.bytesize - info[:bridge_runtime_name] = name_pointer - info[:bridge_runtime_name_len] = BRIDGE_RUNTIME_NAME.bytesize - info[:bridge_runtime_version] = bridge_version_pointer - info[:bridge_runtime_version_len] = bridge_runtime_version.bytesize - - diagnostic = read_owned_utf8( - function(:register_bridge).call(info.pointer), - "register_bridge" - ) - return if diagnostic.empty? - - raise IncompatibleRuntimeError, - "Native bridge registration rejected #{BRIDGE_RUNTIME_NAME} " \ - "#{bridge_runtime_version}: #{diagnostic}" - end - def function(name) + ensure_not_forked! + @functions[name] ||= begin return_type, parameter_types, options = FUNCTION_SIGNATURES.fetch(name) FFI::Function.new(return_type, parameter_types, @table[name], **(options || {})) @@ -226,39 +297,28 @@ def memory_for(bytes) end end - # Owns an FFI callback and copies borrowed bytes before invoking Ruby. - # Every exception is retained for the Ruby caller instead of crossing C. + # Copy borrowed bytes, or pass the copy error to receive_result. The + # handler settles failures inside Ruby; no exception may cross FFI. class BorrowedBytesCallback attr_reader :function def initialize(&handler) - raise ArgumentError, "callback handler is required" unless handler - @handler = handler - @errors = Queue.new @function = FFI::Function.new(:void, %i[uint32 pointer size_t]) do |call_id, pointer, length| - begin - bytes = if length.zero? - String.new(encoding: Encoding::BINARY) - elsif pointer.nil? || pointer.null? - raise ArgumentError, "native callback pointer is null with #{length} bytes" - else - pointer.read_bytes(length) - end - @handler.call(call_id, bytes.force_encoding(Encoding::BINARY).freeze) - rescue Exception => error # rubocop:disable Lint/RescueException -- exceptions cannot cross FFI - @errors << error + outcome = begin + if length.zero? + "".b.freeze + elsif pointer.nil? || pointer.null? + raise ArgumentError, "native callback pointer is null with #{length} bytes" + else + pointer.read_bytes(length).b.freeze + end + rescue Exception => error # rubocop:disable Lint/RescueException -- settle copy errors inside Ruby + error end + @handler.call(call_id, outcome) end end - - # The generated call layer will drain this queue after callbacks and - # surface errors on the caller's Ruby thread. - def pop_error - @errors.pop(true) - rescue ThreadError - nil - end end end end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/process_runtime.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/process_runtime.rb index 54112053910..988ecad8052 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/process_runtime.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/process_runtime.rb @@ -6,7 +6,7 @@ module Baml module Bridge class ProcessRuntime - RUNTIME_PATH_ENV = "BAML_RUNTIME_PATH" + RUNTIME_PATH_ENV = "BAML_BRIDGE_PATH" def initialize @mutex = Mutex.new @@ -14,53 +14,165 @@ def initialize @native_load_in_progress = false @library = nil @api = nil + @bridge_registered = false @result_callback = nil - @result_events = Queue.new + @result_registered = false + @spawn_callback = nil + @spawn_registered = false + @initialized = false @program_bytes = nil - @terminal_error = nil + @metadata = nil + @pending_mutex = Mutex.new + @pending = {} + @next_callback_id = 1 end - def initialize!(compiled_program_bytes) - program = owned_program_bytes(compiled_program_bytes) + def register_program(bytecode, metadata = nil) ensure_not_forked! + raise TypeError, "compiled BAML program must be a String of bytes" unless bytecode.is_a?(String) + raise TypeError, "BAML metadata must be a String or nil" unless metadata.nil? || metadata.is_a?(String) - candidate_path = nil - if @api.nil? && @terminal_error.nil? - candidate_path = configured_runtime_path! - claim_process! - end - + program = bytecode.dup.force_encoding(Encoding::BINARY).freeze + metadata = metadata&.dup&.freeze @mutex.synchronize do - ensure_not_forked! - raise @terminal_error if @terminal_error + if @program_bytes && (@program_bytes != program || @metadata != metadata) + raise ProgramConflictError, "This Ruby process already registered a different generated BAML program" + end - if @program_bytes - return nil if @program_bytes == program + @program_bytes = program + @metadata = metadata + end + nil + end - raise ProgramConflictError, - "This Ruby process already initialized a different generated BAML program" + def initialize! + ensure_not_forked! + claim_process! + @mutex.synchronize do + return if @initialized + raise RuntimeConfigurationError, "No BAML program is registered" unless @program_bytes + + unless @library + @runtime_path = configured_runtime_path! + flags = FFI::DynamicLibrary::RTLD_NOW | FFI::DynamicLibrary::RTLD_LOCAL + begin + @native_load_in_progress = true + @library = open_library(@runtime_path, flags) + rescue LoadError => error + raise RuntimeLoadError, "Unable to open BAML runtime #{@runtime_path.inspect}: #{error.message}" + ensure + @native_load_in_progress = false + end end - - load_api!(candidate_path) unless @api - begin - @api.initialize_runtime(program) - rescue IncompatibleRuntimeError => error - @terminal_error = error - raise + @api ||= Native::Api.new(@library, @runtime_path, TOOLCHAIN_VERSION) + unless @bridge_registered + Thread.handle_interrupt(Exception => :never) do + @api.register_bridge!(TOOLCHAIN_VERSION, BRIDGE_RUNTIME_VERSION) + @bridge_registered = true + end + end + unless @result_registered + @result_callback ||= Native::BorrowedBytesCallback.new(&method(:receive_result)) + Thread.handle_interrupt(Exception => :never) do + @api.register_result_callback(@result_callback.function) + @result_registered = true + end + end + unless @spawn_registered + @spawn_callback ||= FFI::Function.new(:void, %i[pointer size_t int32]) do |pointer, length, cancelled| + begin + Protocol.decode_result(pointer.read_bytes(length), @api) + rescue Exception => error # rubocop:disable Lint/RescueException -- match Python's spawn-error policy + warn error.full_message + $stderr.flush + Kernel.exit!(1) if cancelled.zero? + end + end + Thread.handle_interrupt(Exception => :never) do + @api.register_unhandled_spawn_error_callback(@spawn_callback) + @spawn_registered = true + end + end + Thread.handle_interrupt(Exception => :never) do + @api.initialize_runtime(@program_bytes, @metadata) + @initialized = true + at_exit do + begin + @api.shutdown_runtime + rescue ForkSafetyError + # A forked child must not shut down the parent's runtime. + end + end end - @program_bytes = program nil end end + def call_async(function_name, arguments) + call_id = callback_id = nil + submitted = false + scope = EncodingScope.new + initialize! + # Retain native ownership before a deferred interrupt can reach Ruby. + Thread.handle_interrupt(Exception => :never) { call_id = @api.new_function_call } + encoded = Protocol.encode_call(call_id, function_name, arguments, scope: scope) + Thread.handle_interrupt(Exception => :never) do + call = ::Baml::Call.new(@api, call_id) + callback_id = register_call(call) + @api.call_function(encoded, callback_id) + submitted = true + scope.submitted! + call + end + ensure + Thread.handle_interrupt(Exception => :never) do + unless submitted + @pending_mutex.synchronize { @pending.delete(callback_id) } if callback_id + begin + scope&.close + ensure + @api.release_function_call(call_id) if call_id + end + end + end + end + private - def owned_program_bytes(value) - unless value.is_a?(String) - raise TypeError, "compiled BAML program must be a String of bytes" + def register_call(call) + @pending_mutex.synchronize do + first = @next_callback_id + loop do + callback_id = @next_callback_id + @next_callback_id = callback_id == 0xffff_ffff ? 1 : callback_id + 1 + unless @pending.key?(callback_id) + @pending[callback_id] = call + return callback_id + end + raise Error, "BAML callback correlation IDs are exhausted" if @next_callback_id == first + end + end + end + + def claim_process! + ensure_not_forked! + # Claim before the mutex and native open: a fork during loading must + # reject inherited state without touching the vanished thread's lock. + @owner_pid ||= Process.pid + end + + def ensure_not_forked! + owner = @owner_pid + return unless owner && owner != Process.pid + + if @library.nil? && @api.nil? && !@native_load_in_progress + @mutex = Mutex.new + @owner_pid = nil + return end - value.dup.force_encoding(Encoding::BINARY).freeze + raise ForkSafetyError, + "BAML native state belongs to process #{owner}; forked child #{Process.pid} must exec before using BAML" end def configured_runtime_path! @@ -72,103 +184,30 @@ def configured_runtime_path! path = Pathname.new(value) unless path.absolute? - raise RuntimeConfigurationError, - "#{RUNTIME_PATH_ENV} must be an absolute path: #{value.inspect}" + raise RuntimeConfigurationError, "#{RUNTIME_PATH_ENV} must be an absolute path: #{value.inspect}" end unless path.file? - raise RuntimeConfigurationError, - "#{RUNTIME_PATH_ENV} does not name a file: #{value.inspect}" + raise RuntimeConfigurationError, "#{RUNTIME_PATH_ENV} does not name a file: #{value.inspect}" end path.to_s.freeze end - def claim_process! - current_pid = Process.pid - owner_pid = @owner_pid - if owner_pid && owner_pid != current_pid - raise_fork_error(owner_pid, current_pid) - end + def receive_result(callback_id, outcome) + call = @pending_mutex.synchronize { @pending.delete(callback_id) } + return unless call - # Assignment occurs before entering @mutex. A child created while a - # load is in progress therefore rejects inherited state without ever - # touching a mutex that may have been locked by a vanished thread. - @owner_pid ||= current_pid - end - - def ensure_not_forked! - owner_pid = @owner_pid - current_pid = Process.pid - return unless owner_pid && owner_pid != current_pid - - # Once a failed native open returns, a child has no native state to - # inherit. Replace the mutex that may still be locked by a vanished - # parent thread, then let the child retry with its own process claim. - if @library.nil? && @api.nil? && @terminal_error.nil? && !@native_load_in_progress - @mutex = Mutex.new - @owner_pid = nil - return + begin + outcome = Protocol.capture_result(outcome, @api) unless outcome.is_a?(Exception) + rescue Exception => error # rubocop:disable Lint/RescueException -- settle parse failures inside Ruby + outcome = error end - - raise_fork_error(owner_pid, current_pid) - end - - def raise_fork_error(owner_pid, current_pid) - raise ForkSafetyError, - "BAML native state belongs to process #{owner_pid}; forked child " \ - "#{current_pid} must exec before using BAML" - end - - def receive_result(call_id, bytes) - @result_events << [call_id, bytes, Thread.current.object_id].freeze - end - - # The generated call layer will drain one event for each native result - # before result delivery is enabled. - def pop_result_event - @result_events.pop(true) - rescue ThreadError - nil + call.complete(outcome) end def open_library(path, flags) FFI::DynamicLibrary.open(path, flags) end - - def load_api!(path) - flags = FFI::DynamicLibrary::RTLD_NOW | FFI::DynamicLibrary::RTLD_LOCAL - begin - @native_load_in_progress = true - @library = open_library(path, flags) - rescue LoadError => error - raise RuntimeLoadError, - "Unable to open BAML runtime #{path.inspect}: #{error.message}" - ensure - @native_load_in_progress = false - end - - begin - api = Native::Api.new( - @library, - path, - TOOLCHAIN_VERSION, - BRIDGE_RUNTIME_VERSION - ) - callback = Native::BorrowedBytesCallback.new(&method(:receive_result)) - api.register_result_callback(callback.function) - @result_callback = callback - @api = api - rescue Error => error - @terminal_error = error - raise - rescue LoadError, StandardError => error - wrapped = IncompatibleRuntimeError.new( - "Unable to use BAML runtime #{path.inspect}: #{error.class}: #{error.message}" - ) - @terminal_error = wrapped - raise wrapped - end - end end end end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/protocol.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/protocol.rb new file mode 100644 index 00000000000..c35ede9193b --- /dev/null +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/protocol.rb @@ -0,0 +1,270 @@ +# frozen_string_literal: true + +require "baml_bridge/cffi/v1/baml_inbound_pb" +require "baml_bridge/cffi/v1/baml_outbound_pb" +require_relative "encode/slot" +require_relative "encode/rules" +require_relative "encode/read" +require_relative "encode/plan" +require_relative "encode/commit" + +module Baml + module Bridge + module Protocol + Wire = ::BamlBridge::Cffi::V1 + # Each wire occurrence owns a reference, even when several share a key. + # Wait transfers those references to wrappers; an abandoned or failed + # decode releases the rest without retaining the captured result itself. + class Result + attr_reader :message + + def initialize(message, value, api) + @message = message + @keys = [] + Protocol.visit_handles(value) do |handle| + @keys << handle.key unless %i[HOST_VALUE_CALLABLE HOST_VALUE_OPAQUE].include?(handle.handle_type) + end + @cleanup = self.class.finalizer(api, @keys, Process.pid) + ObjectSpace.define_finalizer(self, @cleanup) + end + + def self.finalizer(api, keys, owner_pid) + proc do + if Process.pid == owner_pid + until keys.empty? + key = keys.pop + begin + api.handle_release(key) + rescue StandardError + # Cleanup cannot replace the result's decoding error. + end + end + end + end + end + + def adopt(key) + Thread.handle_interrupt(Exception => :never) do + index = @keys.index(key) or raise Error, "BAML result handle #{key} was already adopted" + owner = yield + @keys.delete_at(index) + owner + end + end + + def close + @cleanup&.call + end + end + @types = {} + @names = {} + @fields = {} + @functions = {} + @enums = {} + module_function + + # The generator owns Ruby naming, including renamed fields. No constant + # lookup or naming conversion happens while decoding untrusted wire data. + def define_class(name, type, fields:) + @types[name] = type + @names[type] = name + @fields[type] = fields.transform_values { |ruby, descriptor| [ruby, descriptor].freeze }.freeze + end + + def define_enum(name, type) + @types[name] = type + @names[type] = name + @fields[type] = nil + @enums[name] = type.values.map(&:serialize).freeze + end + + def define_function(name, params:) + @functions[name] = params.to_h.freeze + end + + def registered_type(name) + @types.fetch(name) do + raise UnsupportedTypeError, "BAML type #{name.inspect} is not registered" + end + end + + def encoding_type(type) + [@names[type], @fields[type]] + end + + def encoding_fields(name) + @fields.fetch(registered_type(name)) + end + + def enum_values(name) + @enums.fetch(name) + end + + def encode_call(call_id, function_name, arguments, scope: nil) + hints = @functions.fetch(function_name, {}) + reader = Encode::Read.new(self) + planner = Encode::Plan.new(self) + trees = arguments.map { |name, value| [name, reader.read(value, name)] } + plans = trees.map { |name, tree| [name, planner.plan(tree, hints.fetch(name, [:unknown]))] } + plans.each { |name, plan| Encode::Commit.check_depth!(Encode::Commit.depth(plan), name) } + kwargs = plans.map do |name, plan| + value = Encode::Commit.commit(plan, scope: scope) + Encode::Commit.check_depth!(Encode::Slot.wire_depth(value), name) + Wire::InboundMapEntry.new(string_key: name, value: value) + end + Wire::CallFunctionArgs.encode(Wire::CallFunctionArgs.new( + call_id: call_id, function_name: function_name, kwargs: kwargs, + invocation: Wire::InvocationOptions.new + )) + rescue Encode::Refusal => error + raise error.class, "#{function_name}: #{error.message}" + end + + def capture_result(bytes, api = nil) + message = Wire::BamlOutboundResult.decode(bytes) + value = case message.result + when :ok then message.ok + when :error then message.error.value + when :panic then message.panic.value + end + Result.new(message, value, api) + end + + def visit_handles(value, &block) + return unless value + + case value.value + when :handle_value + block.call(value.handle_value) + when :class_value + value.class_value.fields.each { |field| visit_handles(field.value, &block) } + when :list_value + value.list_value.items.each { |item| visit_handles(item, &block) } + when :map_value + value.map_value.entries.each { |entry| visit_handles(entry.value, &block) } + when :union_variant_value + visit_handles(value.union_variant_value.value, &block) + when :ty_value + visit_type_handles(value.ty_value, &block) + end + end + + def visit_type_handles(type, &block) + if type.respond_to?(:head) && type.head == :live + block.call(Wire::BamlHandle.new(key: type.live, handle_type: :ADT_TAGGED_HEAP_HANDLE)) + end + Encode::Slot.each_message(type) { |child| visit_type_handles(child, &block) } + end + + def exit_process(code) + [$stdout, $stderr, STDOUT, STDERR].uniq(&:object_id).each do |output| + begin + output.flush + rescue StandardError + # A broken output must not prevent the requested process exit. + end + end + Kernel.exit!(code) + end + + def decode_result(bytes, api = nil) + captured = bytes.is_a?(Result) ? bytes : capture_result(bytes, api) + result = captured.message + owners = { handles: captured } + case result.result + when :ok + decode_value(result.ok, api, **owners) + when :error + raise_failure(::Baml::Error, result.error.value, api, **owners) + when :panic + return exit_process(result.panic.exit_code) if result.panic.is_exit_panic + + raise_failure(::Baml::Panic, result.panic.value, api, **owners) + else + raise Error, "BAML returned an empty result envelope" + end + ensure + captured&.close + end + + def decode_value(value, api = nil, handles: nil) + raise Error, "BAML returned an empty value" unless value + + case value.value + when :string_value, :int_value, :float_value, :bool_value + value.public_send(value.value) + when :handle_value + handle = value.handle_value + if %i[HOST_VALUE_CALLABLE HOST_VALUE_OPAQUE].include?(handle.handle_type) + raise UnsupportedTypeError, "BAML host-value handles are not supported" + end + build = -> { ::Baml::Handle.new(api, handle.key, handle.handle_type) } + handles ? handles.adopt(handle.key, &build) : build.call + when nil, :null_value + # V1 currently emits an absent oneof for null. + nil + when :class_value + object = value.class_value + type = registered_type(object.name) + fields = @fields.fetch(type) + decoded = object.fields.to_h { |field| [fields.fetch(field.key)[0], decode_value(field.value, api, handles: handles)] } + type.new(**decoded) + when :enum_value + enum = value.enum_value + if enum.is_dynamic + raise UnsupportedTypeError, "BAML dynamic enum #{enum.name} is not yet supported" + end + registered_type(enum.name).deserialize(enum.value) + when :list_value + value.list_value.items.map { |item| decode_value(item, api, handles: handles) } + when :map_value + map = value.map_value + map.entries.to_h { |entry| [decode_map_key(entry.key, map.key_type), decode_value(entry.value, api, handles: handles)] } + when :union_variant_value + decode_value(value.union_variant_value.value, api, handles: handles) + when :literal_value + literal = value.literal_value + case literal.literal + when :string_value, :int_value, :bool_value + literal.public_send(literal.literal) + when :float_value + Float(literal.float_value) + else + raise UnsupportedTypeError, "BAML literal kind #{literal.literal.inspect} is not yet supported" + end + else + raise UnsupportedTypeError, "BAML result kind #{value.value} is not yet supported" + end + end + + def decode_map_key(key, type) + case type&.ty + when :primitive + case type.primitive.kind + when :BAML_TY_PRIMITIVE_STRING then key + else + raise UnsupportedTypeError, "BAML map key type #{type.primitive.kind} is not yet supported" + end + else + raise UnsupportedTypeError, "BAML map key type #{type&.ty.inspect} is not yet supported" + end + end + + def raise_failure(error_class, value, api = nil, handles: nil) + while value&.value == :union_variant_value + value = value.union_variant_value.value + end + if value&.value == :class_value + object = value.class_value + message = object.fields.find { |field| field.key == "message" }&.value + raise error_class.new(message ? decode_value(message, api, handles: handles).to_s : object.name, type_name: object.name) + end + + decoded = decode_value(value, api, handles: handles) + kind = value.value == :literal_value ? value.literal_value.literal : (value.value || :null_value) + raise error_class.new(decoded.to_s, type_name: kind.to_s.delete_suffix("_value")) + end + + end + end +end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/value_equality.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/value_equality.rb new file mode 100644 index 00000000000..1586688bce3 --- /dev/null +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/value_equality.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Baml + # Generated structs are values. + module ValueEquality + def ==(other) + other.instance_of?(self.class) && + self.class.props.each_key.all? { |field| public_send(field) == other.public_send(field) } + end + + def eql?(other) + other.instance_of?(self.class) && + self.class.props.each_key.all? { |field| public_send(field).eql?(other.public_send(field)) } + end + + def hash + [self.class, self.class.props.keys.map { |field| public_send(field) }].hash + end + end +end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml_bridge/cffi/v1/baml_handle_pb.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml_bridge/cffi/v1/baml_handle_pb.rb new file mode 100644 index 00000000000..fe6022b3e86 --- /dev/null +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml_bridge/cffi/v1/baml_handle_pb.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: baml_bridge/cffi/v1/baml_handle.proto + +require 'google/protobuf' + + +descriptor_data = "\n%baml_bridge/cffi/v1/baml_handle.proto\x12\x13\x62\x61ml_bridge.cffi.v1\"S\n\nBamlHandle\x12\x0b\n\x03key\x18\x01 \x01(\x04\x12\x38\n\x0bhandle_type\x18\x02 \x01(\x0e\x32#.baml_bridge.cffi.v1.BamlHandleType*\xa7\x03\n\x0e\x42\x61mlHandleType\x12\x16\n\x12HANDLE_UNSPECIFIED\x10\x00\x12\x16\n\x12UNTAGGED_RUST_DATA\x10\x01\x12\x15\n\x11UNTAGGED_BEX_HEAP\x10\x02\x12\x10\n\x0c\x46UNCTION_REF\x10\x05\x12\x13\n\x0f\x41\x44T_MEDIA_IMAGE\x10\x06\x12\x13\n\x0f\x41\x44T_MEDIA_AUDIO\x10\x07\x12\x13\n\x0f\x41\x44T_MEDIA_VIDEO\x10\x08\x12\x11\n\rADT_MEDIA_PDF\x10\t\x12\x15\n\x11\x41\x44T_MEDIA_GENERIC\x10\n\x12\x12\n\x0e\x41\x44T_PROMPT_AST\x10\x0b\x12\x0c\n\x08\x41\x44T_TYPE\x10\r\x12\x1a\n\x16\x41\x44T_TAGGED_HEAP_HANDLE\x10\x0e\x12\x17\n\x13HOST_VALUE_CALLABLE\x10\x0f\x12\x15\n\x11HOST_VALUE_OPAQUE\x10\x10\x12\x15\n\x11\x41\x44T_FUNCTION_SPEC\x10\x11\x12\x15\n\x11\x41\x44T_RUNTIME_VALUE\x10\x12\x12\x14\n\x10INVOCATION_STATE\x10\x13\x12\x15\n\x11TRACE_RESERVATION\x10\x14\"\x04\x08\x03\x10\x03\"\x04\x08\x04\x10\x04\x42\nH\x03Z\x06./cffib\x06proto3" + +pool = ::Google::Protobuf::DescriptorPool.generated_pool +pool.add_serialized_file(descriptor_data) + +module BamlBridge + module Cffi + module V1 + BamlHandle = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlHandle").msgclass + BamlHandleType = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlHandleType").enummodule + end + end +end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml_bridge/cffi/v1/baml_inbound_pb.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml_bridge/cffi/v1/baml_inbound_pb.rb new file mode 100644 index 00000000000..9d0dd9e5c19 --- /dev/null +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml_bridge/cffi/v1/baml_inbound_pb.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: baml_bridge/cffi/v1/baml_inbound.proto + +require 'google/protobuf' + +require 'baml_bridge/cffi/v1/baml_handle_pb' +require 'baml_bridge/cffi/v1/baml_type_pb' +require 'baml_bridge/cffi/v1/baml_outbound_pb' + + +descriptor_data = "\n&baml_bridge/cffi/v1/baml_inbound.proto\x12\x13\x62\x61ml_bridge.cffi.v1\x1a%baml_bridge/cffi/v1/baml_handle.proto\x1a#baml_bridge/cffi/v1/baml_type.proto\x1a\'baml_bridge/cffi/v1/baml_outbound.proto\"\xde\x05\n\x0cInboundValue\x12/\n\nvalue_type\x18\x01 \x01(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTy\x12\x16\n\x0cstring_value\x18\x02 \x01(\tH\x00\x12\x13\n\tint_value\x18\x03 \x01(\x03H\x00\x12\x15\n\x0b\x66loat_value\x18\x04 \x01(\x01H\x00\x12\x14\n\nbool_value\x18\x05 \x01(\x08H\x00\x12;\n\nlist_value\x18\x06 \x01(\x0b\x32%.baml_bridge.cffi.v1.InboundListValueH\x00\x12\x39\n\tmap_value\x18\x07 \x01(\x0b\x32$.baml_bridge.cffi.v1.InboundMapValueH\x00\x12=\n\x0b\x63lass_value\x18\x08 \x01(\x0b\x32&.baml_bridge.cffi.v1.InboundClassValueH\x00\x12;\n\nenum_value\x18\t \x01(\x0b\x32%.baml_bridge.cffi.v1.InboundEnumValueH\x00\x12\x31\n\x06handle\x18\n \x01(\x0b\x32\x1f.baml_bridge.cffi.v1.BamlHandleH\x00\x12\x1a\n\x10uint8array_value\x18\x0b \x01(\x0cH\x00\x12\x16\n\x0c\x62igint_value\x18\x0c \x01(\tH\x00\x12/\n\x08ty_value\x18\r \x01(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTyH\x00\x12:\n\x0bmedia_value\x18\x0f \x01(\x0b\x32#.baml_bridge.cffi.v1.BamlValueMediaH\x00\x12\x43\n\x10prompt_ast_value\x18\x10 \x01(\x0b\x32\'.baml_bridge.cffi.v1.BamlValuePromptAstH\x00\x12\x19\n\x0fjs_number_value\x18\x11 \x01(\x01H\x00\x42\x07\n\x05valueJ\x04\x08\x0e\x10\x0fR\x0cty_def_value\"E\n\x10InboundListValue\x12\x31\n\x06values\x18\x01 \x03(\x0b\x32!.baml_bridge.cffi.v1.InboundValue\"H\n\x0fInboundMapValue\x12\x35\n\x07\x65ntries\x18\x01 \x03(\x0b\x32$.baml_bridge.cffi.v1.InboundMapEntry\"\xc2\x01\n\x0fInboundMapEntry\x12\x14\n\nstring_key\x18\x01 \x01(\tH\x00\x12\x11\n\x07int_key\x18\x02 \x01(\x03H\x00\x12\x12\n\x08\x62ool_key\x18\x03 \x01(\x08H\x00\x12\x39\n\x08\x65num_key\x18\x05 \x01(\x0b\x32%.baml_bridge.cffi.v1.InboundEnumValueH\x00\x12\x30\n\x05value\x18\x06 \x01(\x0b\x32!.baml_bridge.cffi.v1.InboundValueB\x05\n\x03key\"O\n\x11InboundClassValue\x12\x34\n\x06\x66ields\x18\x02 \x03(\x0b\x32$.baml_bridge.cffi.v1.InboundMapEntryJ\x04\x08\x01\x10\x02\"/\n\x10InboundEnumValue\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"e\n\tBamlTyArg\x12\x10\n\x08type_var\x18\x01 \x01(\t\x12/\n\ntype_value\x18\x02 \x01(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTyJ\x04\x08\x03\x10\x04R\x0ftype_definition\"\x8b\x02\n\x10\x43\x61llFunctionArgs\x12\x34\n\x06kwargs\x18\x01 \x03(\x0b\x32$.baml_bridge.cffi.v1.InboundMapEntry\x12\x0f\n\x07\x63\x61ll_id\x18\x02 \x01(\x04\x12\x31\n\ttype_args\x18\x03 \x03(\x0b\x32\x1e.baml_bridge.cffi.v1.BamlTyArg\x12\x17\n\rfunction_name\x18\x04 \x01(\tH\x00\x12\x19\n\x0f\x66unction_handle\x18\x05 \x01(\x04H\x00\x12:\n\ninvocation\x18\x06 \x01(\x0b\x32&.baml_bridge.cffi.v1.InvocationOptionsB\r\n\x0b\x63\x61ll_target\"\xd7\x01\n\x11InvocationOptions\x12\x32\n\x05trace\x18\x01 \x01(\x0b\x32#.baml_bridge.cffi.v1.TraceSelection\x12\x31\n\x06\x63\x61ncel\x18\x02 \x01(\x0b\x32!.baml_bridge.cffi.v1.InboundValue\x12\x18\n\x0b\x64\x65\x61\x64line_ns\x18\x03 \x01(\x04H\x00\x88\x01\x01\x12\x17\n\x0finherited_state\x18\x04 \x01(\x04\x12\x18\n\x10host_environment\x18\x05 \x01(\x04\x42\x0e\n\x0c_deadline_ns\"j\n\x0eTraceSelection\x12\x34\n\x07options\x18\x01 \x01(\x0b\x32!.baml_bridge.cffi.v1.TraceOptionsH\x00\x12\x15\n\x0breservation\x18\x02 \x01(\x04H\x00\x42\x0b\n\tselection\"\xef\x02\n\x0cTraceOptions\x12\x31\n\x04mode\x18\x01 \x01(\x0e\x32\x1e.baml_bridge.cffi.v1.TraceModeH\x00\x88\x01\x01\x12\x13\n\x06inputs\x18\x02 \x01(\x08H\x01\x88\x01\x01\x12\x13\n\x06output\x18\x03 \x01(\x08H\x02\x88\x01\x01\x12\x12\n\x05\x65rror\x18\x04 \x01(\x08H\x03\x88\x01\x01\x12\x18\n\x0b\x64istinct_id\x18\x05 \x01(\tH\x04\x88\x01\x01\x12\x41\n\x08metadata\x18\x06 \x03(\x0b\x32/.baml_bridge.cffi.v1.TraceOptions.MetadataEntry\x1aX\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x36\n\x05value\x18\x02 \x01(\x0b\x32\'.baml_bridge.cffi.v1.TraceMetadataValue:\x02\x38\x01\x42\x07\n\x05_modeB\t\n\x07_inputsB\t\n\x07_outputB\x08\n\x06_errorB\x0e\n\x0c_distinct_id\"\x89\x01\n\x12TraceMetadataValue\x12\x16\n\x0cstring_value\x18\x01 \x01(\tH\x00\x12\x13\n\tint_value\x18\x02 \x01(\x12H\x00\x12\x15\n\x0b\x66loat_value\x18\x03 \x01(\x01H\x00\x12\x14\n\nbool_value\x18\x04 \x01(\x08H\x00\x12\x10\n\x06remove\x18\x05 \x01(\x08H\x00\x42\x07\n\x05value\"&\n\x07\x43\x61llAck\x12\x0f\n\x05\x65rror\x18\x01 \x01(\tH\x00\x42\n\n\x08response\"\xf9\x01\n\x12HostCallCompletion\x12\x35\n\x08returned\x18\x01 \x01(\x0b\x32!.baml_bridge.cffi.v1.InboundValueH\x00\x12\x33\n\x06thrown\x18\x02 \x01(\x0b\x32!.baml_bridge.cffi.v1.InboundValueH\x00\x12\x34\n\x06raised\x18\x03 \x01(\x0b\x32\".baml_bridge.cffi.v1.HostExceptionH\x00\x12\x1c\n\x12\x63ontract_violation\x18\x04 \x01(\tH\x00\x12\x18\n\x0e\x62ridge_failure\x18\x05 \x01(\tH\x00\x42\t\n\x07outcome\"|\n\rHostException\x12\x0f\n\x07message\x18\x01 \x01(\t\x12\x12\n\nclass_name\x18\x02 \x01(\t\x12\x10\n\x08language\x18\x03 \x01(\t\x12\x16\n\ttraceback\x18\x04 \x01(\tH\x00\x88\x01\x01\x12\x0e\n\x06handle\x18\x05 \x01(\x04\x42\x0c\n\n_traceback*j\n\tTraceMode\x12\x1a\n\x16TRACE_MODE_UNSPECIFIED\x10\x00\x12\x15\n\x11TRACE_MODE_HIDDEN\x10\x01\x12\x15\n\x11TRACE_MODE_TIMING\x10\x02\x12\x13\n\x0fTRACE_MODE_SPAN\x10\x03\x42\nH\x03Z\x06./cffib\x06proto3" + +pool = ::Google::Protobuf::DescriptorPool.generated_pool +pool.add_serialized_file(descriptor_data) + +module BamlBridge + module Cffi + module V1 + InboundValue = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.InboundValue").msgclass + InboundListValue = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.InboundListValue").msgclass + InboundMapValue = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.InboundMapValue").msgclass + InboundMapEntry = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.InboundMapEntry").msgclass + InboundClassValue = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.InboundClassValue").msgclass + InboundEnumValue = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.InboundEnumValue").msgclass + BamlTyArg = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyArg").msgclass + CallFunctionArgs = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.CallFunctionArgs").msgclass + InvocationOptions = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.InvocationOptions").msgclass + TraceSelection = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.TraceSelection").msgclass + TraceOptions = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.TraceOptions").msgclass + TraceMetadataValue = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.TraceMetadataValue").msgclass + CallAck = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.CallAck").msgclass + HostCallCompletion = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.HostCallCompletion").msgclass + HostException = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.HostException").msgclass + TraceMode = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.TraceMode").enummodule + end + end +end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml_bridge/cffi/v1/baml_outbound_pb.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml_bridge/cffi/v1/baml_outbound_pb.rb new file mode 100644 index 00000000000..17146865bbb --- /dev/null +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml_bridge/cffi/v1/baml_outbound_pb.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: baml_bridge/cffi/v1/baml_outbound.proto + +require 'google/protobuf' + +require 'baml_bridge/cffi/v1/baml_handle_pb' +require 'baml_bridge/cffi/v1/baml_type_pb' + + +descriptor_data = "\n\'baml_bridge/cffi/v1/baml_outbound.proto\x12\x13\x62\x61ml_bridge.cffi.v1\x1a%baml_bridge/cffi/v1/baml_handle.proto\x1a#baml_bridge/cffi/v1/baml_type.proto\"\xc6\x01\n\x12\x42\x61mlOutboundResult\x12\x34\n\x02ok\x18\x01 \x01(\x0b\x32&.baml_bridge.cffi.v1.BamlOutboundValueH\x00\x12\x37\n\x05\x65rror\x18\x02 \x01(\x0b\x32&.baml_bridge.cffi.v1.BamlOutboundErrorH\x00\x12\x37\n\x05panic\x18\x03 \x01(\x0b\x32&.baml_bridge.cffi.v1.BamlOutboundPanicH\x00\x42\x08\n\x06result\"Y\n\x11\x42\x61mlOutboundError\x12\x35\n\x05value\x18\x01 \x01(\x0b\x32&.baml_bridge.cffi.v1.BamlOutboundValue\x12\r\n\x05trace\x18\x02 \x03(\t\"\x83\x01\n\x11\x42\x61mlOutboundPanic\x12\x35\n\x05value\x18\x01 \x01(\x0b\x32&.baml_bridge.cffi.v1.BamlOutboundValue\x12\r\n\x05trace\x18\x02 \x03(\t\x12\x15\n\ris_exit_panic\x18\x03 \x01(\x08\x12\x11\n\texit_code\x18\x04 \x01(\x03\"\xde\x06\n\x11\x42\x61mlOutboundValue\x12\x38\n\nnull_value\x18\x02 \x01(\x0b\x32\".baml_bridge.cffi.v1.BamlValueNullH\x00\x12\x16\n\x0cstring_value\x18\x03 \x01(\tH\x00\x12\x13\n\tint_value\x18\x04 \x01(\x03H\x00\x12\x15\n\x0b\x66loat_value\x18\x05 \x01(\x01H\x00\x12\x14\n\nbool_value\x18\x06 \x01(\x08H\x00\x12:\n\x0b\x63lass_value\x18\x07 \x01(\x0b\x32#.baml_bridge.cffi.v1.BamlValueClassH\x00\x12\x38\n\nenum_value\x18\x08 \x01(\x0b\x32\".baml_bridge.cffi.v1.BamlValueEnumH\x00\x12>\n\rliteral_value\x18\t \x01(\x0b\x32%.baml_bridge.cffi.v1.BamlLiteralValueH\x00\x12\x38\n\nlist_value\x18\x0b \x01(\x0b\x32\".baml_bridge.cffi.v1.BamlValueListH\x00\x12\x36\n\tmap_value\x18\x0c \x01(\x0b\x32!.baml_bridge.cffi.v1.BamlValueMapH\x00\x12I\n\x13union_variant_value\x18\r \x01(\x0b\x32*.baml_bridge.cffi.v1.BamlValueUnionVariantH\x00\x12?\n\x0chandle_value\x18\x10 \x01(\x0b\x32\'.baml_bridge.cffi.v1.BamlOutboundHandleH\x00\x12:\n\x0bmedia_value\x18\x11 \x01(\x0b\x32#.baml_bridge.cffi.v1.BamlValueMediaH\x00\x12\x43\n\x10prompt_ast_value\x18\x12 \x01(\x0b\x32\'.baml_bridge.cffi.v1.BamlValuePromptAstH\x00\x12\x1a\n\x10uint8array_value\x18\x13 \x01(\x0cH\x00\x12\x16\n\x0c\x62igint_value\x18\x14 \x01(\tH\x00\x12/\n\x08ty_value\x18\x15 \x01(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTyH\x00\x42\x07\n\x05valueJ\x04\x08\x16\x10\x17R\x0cty_def_value\"\x84\x01\n\x12\x42\x61mlOutboundHandle\x12\x0b\n\x03key\x18\x01 \x01(\x04\x12\x38\n\x0bhandle_type\x18\x02 \x01(\x0e\x32#.baml_bridge.cffi.v1.BamlHandleType\x12\'\n\x02ty\x18\x03 \x01(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTy\"\x0f\n\rBamlValueNull\"v\n\rBamlValueList\x12.\n\titem_type\x18\x01 \x01(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTy\x12\x35\n\x05items\x18\x02 \x03(\x0b\x32&.baml_bridge.cffi.v1.BamlOutboundValue\"Z\n\x14\x42\x61mlOutboundMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x35\n\x05value\x18\x02 \x01(\x0b\x32&.baml_bridge.cffi.v1.BamlOutboundValue\"\xaa\x01\n\x0c\x42\x61mlValueMap\x12-\n\x08key_type\x18\x01 \x01(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTy\x12/\n\nvalue_type\x18\x02 \x01(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTy\x12:\n\x07\x65ntries\x18\x03 \x03(\x0b\x32).baml_bridge.cffi.v1.BamlOutboundMapEntry\"\x89\x01\n\x0e\x42\x61mlValueClass\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x39\n\x06\x66ields\x18\x02 \x03(\x0b\x32).baml_bridge.cffi.v1.BamlOutboundMapEntry\x12.\n\ttype_args\x18\x03 \x03(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTy\"@\n\rBamlValueEnum\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x12\n\nis_dynamic\x18\x03 \x01(\x08\"\x9b\x02\n\x15\x42\x61mlValueUnionVariant\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0bis_optional\x18\x02 \x01(\x08\x12\x19\n\x11is_single_pattern\x18\x03 \x01(\x08\x12.\n\tself_type\x18\x04 \x01(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTy\x12\x19\n\x11value_option_name\x18\x05 \x01(\t\x12\x35\n\x05value\x18\x06 \x01(\x0b\x32&.baml_bridge.cffi.v1.BamlOutboundValue\x12\"\n\x15selected_option_index\x18\x08 \x01(\rH\x00\x88\x01\x01\x42\x18\n\x16_selected_option_indexJ\x04\x08\x07\x10\x08\"\xe7\x01\n\x0e\x42\x61mlValueMedia\x12\x31\n\x05media\x18\x01 \x01(\x0e\x32\".baml_bridge.cffi.v1.MediaTypeEnum\x12\x16\n\tmime_type\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\r\n\x03url\x18\x03 \x01(\tH\x00\x12\x10\n\x06\x62\x61se64\x18\x04 \x01(\tH\x00\x12\x46\n\x0c\x66ile_content\x18\x06 \x01(\x0b\x32..baml_bridge.cffi.v1.BamlValueMediaFileContentH\x00\x42\x07\n\x05valueB\x0c\n\n_mime_typeJ\x04\x08\x05\x10\x06R\x04\x66ile\"9\n\x19\x42\x61mlValueMediaFileContent\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0e\n\x06\x62\x61se64\x18\x02 \x01(\t\"\xe6\x01\n\x12\x42\x61mlValuePromptAst\x12?\n\x06simple\x18\x01 \x01(\x0b\x32-.baml_bridge.cffi.v1.BamlValuePromptAstSimpleH\x00\x12\x41\n\x07message\x18\x02 \x01(\x0b\x32..baml_bridge.cffi.v1.BamlValuePromptAstMessageH\x00\x12\x43\n\x08multiple\x18\x03 \x01(\x0b\x32/.baml_bridge.cffi.v1.BamlValuePromptAstMultipleH\x00\x42\x07\n\x05value\"\x83\x01\n\x19\x42\x61mlValuePromptAstMessage\x12\x0c\n\x04role\x18\x01 \x01(\t\x12>\n\x07\x63ontent\x18\x02 \x01(\x0b\x32-.baml_bridge.cffi.v1.BamlValuePromptAstSimple\x12\x18\n\x10metadata_as_json\x18\x03 \x01(\t\"T\n\x1a\x42\x61mlValuePromptAstMultiple\x12\x36\n\x05items\x18\x01 \x03(\x0b\x32\'.baml_bridge.cffi.v1.BamlValuePromptAst\"\xb6\x01\n\x18\x42\x61mlValuePromptAstSimple\x12\x10\n\x06string\x18\x01 \x01(\tH\x00\x12\x34\n\x05media\x18\x02 \x01(\x0b\x32#.baml_bridge.cffi.v1.BamlValueMediaH\x00\x12I\n\x08multiple\x18\x03 \x01(\x0b\x32\x35.baml_bridge.cffi.v1.BamlValuePromptAstSimpleMultipleH\x00\x42\x07\n\x05value\"`\n BamlValuePromptAstSimpleMultiple\x12<\n\x05items\x18\x01 \x03(\x0b\x32-.baml_bridge.cffi.v1.BamlValuePromptAstSimple\"B\n\x0e\x42\x61mlToHostCall\x12\x30\n\x04\x61rgs\x18\x01 \x03(\x0b\x32\".baml_bridge.cffi.v1.BamlToHostArg\"\xec\x01\n\x0eHostInvocation\x12\x16\n\x0ehost_value_key\x18\x01 \x01(\x04\x12\x13\n\x0b\x63\x61llback_id\x18\x02 \x01(\r\x12\x18\n\x10\x61pplication_args\x18\x03 \x01(\x0c\x12\x17\n\x0f\x65\x66\x66\x65\x63tive_state\x18\x04 \x01(\x04\x12\x18\n\x10host_environment\x18\x05 \x01(\x04\x12\x36\n\x06\x63\x61ncel\x18\x06 \x01(\x0b\x32&.baml_bridge.cffi.v1.BamlOutboundValue\x12\x18\n\x0b\x64\x65\x61\x64line_ns\x18\x07 \x01(\x04H\x00\x88\x01\x01\x42\x0e\n\x0c_deadline_ns\"q\n\rBamlToHostArg\x12\x35\n\x05value\x18\x01 \x01(\x0b\x32&.baml_bridge.cffi.v1.BamlOutboundValue\x12\x10\n\x08\x61rg_name\x18\x02 \x01(\t\x12\x17\n\x0fis_optional_arg\x18\x03 \x01(\x08\"\x8f\x01\n\x10\x42\x61mlLiteralValue\x12\x16\n\x0cstring_value\x18\x01 \x01(\tH\x00\x12\x13\n\tint_value\x18\x02 \x01(\x03H\x00\x12\x14\n\nbool_value\x18\x03 \x01(\x08H\x00\x12\x16\n\x0c\x62igint_value\x18\x04 \x01(\tH\x00\x12\x15\n\x0b\x66loat_value\x18\x05 \x01(\tH\x00\x42\t\n\x07literal*`\n\rMediaTypeEnum\x12\x1a\n\x16MEDIA_TYPE_UNSPECIFIED\x10\x00\x12\t\n\x05IMAGE\x10\x01\x12\t\n\x05\x41UDIO\x10\x02\x12\x07\n\x03PDF\x10\x03\x12\t\n\x05VIDEO\x10\x04\x12\t\n\x05OTHER\x10\x05\x42\nH\x03Z\x06./cffib\x06proto3" + +pool = ::Google::Protobuf::DescriptorPool.generated_pool +pool.add_serialized_file(descriptor_data) + +module BamlBridge + module Cffi + module V1 + BamlOutboundResult = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlOutboundResult").msgclass + BamlOutboundError = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlOutboundError").msgclass + BamlOutboundPanic = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlOutboundPanic").msgclass + BamlOutboundValue = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlOutboundValue").msgclass + BamlOutboundHandle = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlOutboundHandle").msgclass + BamlValueNull = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlValueNull").msgclass + BamlValueList = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlValueList").msgclass + BamlOutboundMapEntry = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlOutboundMapEntry").msgclass + BamlValueMap = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlValueMap").msgclass + BamlValueClass = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlValueClass").msgclass + BamlValueEnum = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlValueEnum").msgclass + BamlValueUnionVariant = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlValueUnionVariant").msgclass + BamlValueMedia = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlValueMedia").msgclass + BamlValueMediaFileContent = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlValueMediaFileContent").msgclass + BamlValuePromptAst = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlValuePromptAst").msgclass + BamlValuePromptAstMessage = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlValuePromptAstMessage").msgclass + BamlValuePromptAstMultiple = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlValuePromptAstMultiple").msgclass + BamlValuePromptAstSimple = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlValuePromptAstSimple").msgclass + BamlValuePromptAstSimpleMultiple = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlValuePromptAstSimpleMultiple").msgclass + BamlToHostCall = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlToHostCall").msgclass + HostInvocation = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.HostInvocation").msgclass + BamlToHostArg = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlToHostArg").msgclass + BamlLiteralValue = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlLiteralValue").msgclass + MediaTypeEnum = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.MediaTypeEnum").enummodule + end + end +end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml_bridge/cffi/v1/baml_type_pb.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml_bridge/cffi/v1/baml_type_pb.rb new file mode 100644 index 00000000000..b64279100d4 --- /dev/null +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml_bridge/cffi/v1/baml_type_pb.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: baml_bridge/cffi/v1/baml_type.proto + +require 'google/protobuf' + + +descriptor_data = "\n#baml_bridge/cffi/v1/baml_type.proto\x12\x13\x62\x61ml_bridge.cffi.v1\"\xfd\t\n\x06\x42\x61mlTy\x12\x39\n\tprimitive\x18\x01 \x01(\x0b\x32$.baml_bridge.cffi.v1.BamlTyPrimitiveH\x00\x12\x34\n\x08\x63lass_ty\x18\x02 \x01(\x0b\x32 .baml_bridge.cffi.v1.BamlTyClassH\x00\x12/\n\x04\x65num\x18\x03 \x01(\x0b\x32\x1f.baml_bridge.cffi.v1.BamlTyEnumH\x00\x12/\n\x04list\x18\x04 \x01(\x0b\x32\x1f.baml_bridge.cffi.v1.BamlTyListH\x00\x12-\n\x03map\x18\x05 \x01(\x0b\x32\x1e.baml_bridge.cffi.v1.BamlTyMapH\x00\x12\x37\n\x08optional\x18\x06 \x01(\x0b\x32#.baml_bridge.cffi.v1.BamlTyOptionalH\x00\x12\x31\n\x05union\x18\x07 \x01(\x0b\x32 .baml_bridge.cffi.v1.BamlTyUnionH\x00\x12\x35\n\x07literal\x18\x08 \x01(\x0b\x32\".baml_bridge.cffi.v1.BamlTyLiteralH\x00\x12:\n\ntype_alias\x18\t \x01(\x0b\x32$.baml_bridge.cffi.v1.BamlTyTypeAliasH\x00\x12\x35\n\x07unknown\x18\n \x01(\x0b\x32\".baml_bridge.cffi.v1.BamlTyUnknownH\x00\x12\x31\n\x05media\x18\x0b \x01(\x0b\x32 .baml_bridge.cffi.v1.BamlTyMediaH\x00\x12\x39\n\tinterface\x18\x0c \x01(\x0b\x32$.baml_bridge.cffi.v1.BamlTyInterfaceH\x00\x12>\n\x0c\x65num_variant\x18\r \x01(\x0b\x32&.baml_bridge.cffi.v1.BamlTyEnumVariantH\x00\x12\x37\n\x08\x66unction\x18\x0e \x01(\x0b\x32#.baml_bridge.cffi.v1.BamlTyFunctionH\x00\x12\x33\n\x06\x66uture\x18\x0f \x01(\x0b\x32!.baml_bridge.cffi.v1.BamlTyFutureH\x00\x12\x38\n\trust_type\x18\x10 \x01(\x0b\x32#.baml_bridge.cffi.v1.BamlTyRustTypeH\x00\x12\x38\n\tmeta_type\x18\x11 \x01(\x0b\x32#.baml_bridge.cffi.v1.BamlTyMetaTypeH\x00\x12\x37\n\x08resource\x18\x12 \x01(\x0b\x32#.baml_bridge.cffi.v1.BamlTyResourceH\x00\x12:\n\nprompt_ast\x18\x13 \x01(\x0b\x32$.baml_bridge.cffi.v1.BamlTyPromptAstH\x00\x12\x36\n\x08type_var\x18\x16 \x01(\x0b\x32\".baml_bridge.cffi.v1.BamlTyTypeVarH\x00\x12Y\n\x1a\x61ssociated_type_projection\x18\x17 \x01(\x0b\x32\x33.baml_bridge.cffi.v1.BamlTyAssociatedTypeProjectionH\x00\x12\x31\n\x05never\x18\x18 \x01(\x0b\x32 .baml_bridge.cffi.v1.BamlTyNeverH\x00\x42\x04\n\x02tyJ\x04\x08\x14\x10\x15R\x04void\"I\n\x0f\x42\x61mlTyPrimitive\x12\x36\n\x04kind\x18\x01 \x01(\x0e\x32(.baml_bridge.cffi.v1.BamlTyPrimitiveKind\"\x80\x01\n\x0b\x42\x61mlTyClass\x12\x0e\n\x04name\x18\x01 \x01(\tH\x00\x12\x0e\n\x04live\x18\x03 \x01(\x04H\x00\x12\x19\n\x0f\x61nonymous_label\x18\x04 \x01(\tH\x00\x12.\n\ttype_args\x18\x02 \x03(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTyB\x06\n\x04head\"\x84\x01\n\x0f\x42\x61mlTyTypeAlias\x12\x0e\n\x04name\x18\x01 \x01(\tH\x00\x12\x0e\n\x04live\x18\x03 \x01(\x04H\x00\x12\x19\n\x0f\x61nonymous_label\x18\x04 \x01(\tH\x00\x12.\n\ttype_args\x18\x02 \x03(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTyB\x06\n\x04head\"O\n\nBamlTyEnum\x12\x0e\n\x04name\x18\x01 \x01(\tH\x00\x12\x0e\n\x04live\x18\x02 \x01(\x04H\x00\x12\x19\n\x0f\x61nonymous_label\x18\x03 \x01(\tH\x00\x42\x06\n\x04head\"7\n\nBamlTyList\x12)\n\x04item\x18\x01 \x01(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTy\"a\n\tBamlTyMap\x12(\n\x03key\x18\x01 \x01(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTy\x12*\n\x05value\x18\x02 \x01(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTy\"<\n\x0e\x42\x61mlTyOptional\x12*\n\x05inner\x18\x01 \x01(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTy\";\n\x0b\x42\x61mlTyUnion\x12,\n\x07options\x18\x01 \x03(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTy\"\x0f\n\rBamlTyUnknown\"\x8c\x01\n\rBamlTyLiteral\x12\x16\n\x0cstring_value\x18\x01 \x01(\tH\x00\x12\x13\n\tint_value\x18\x02 \x01(\x03H\x00\x12\x14\n\nbool_value\x18\x03 \x01(\x08H\x00\x12\x16\n\x0c\x62igint_value\x18\x04 \x01(\tH\x00\x12\x15\n\x0b\x66loat_value\x18\x05 \x01(\tH\x00\x42\t\n\x07literal\"A\n\x0b\x42\x61mlTyMedia\x12\x32\n\x04kind\x18\x01 \x01(\x0e\x32$.baml_bridge.cffi.v1.BamlTyMediaKind\"\xc4\x01\n\x0f\x42\x61mlTyInterface\x12\x0e\n\x04name\x18\x01 \x01(\tH\x00\x12\x0e\n\x04live\x18\x04 \x01(\x04H\x00\x12\x19\n\x0f\x61nonymous_label\x18\x05 \x01(\tH\x00\x12.\n\ttype_args\x18\x02 \x03(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTy\x12>\n\x08\x62indings\x18\x03 \x03(\x0b\x32,.baml_bridge.cffi.v1.BamlTyAssociatedBindingB\x06\n\x04head\"P\n\x17\x42\x61mlTyAssociatedBinding\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\'\n\x02ty\x18\x02 \x01(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTy\"g\n\x11\x42\x61mlTyEnumVariant\x12\x0e\n\x04name\x18\x01 \x01(\tH\x00\x12\x0e\n\x04live\x18\x03 \x01(\x04H\x00\x12\x19\n\x0f\x61nonymous_label\x18\x04 \x01(\tH\x00\x12\x0f\n\x07variant\x18\x02 \x01(\tB\x06\n\x04head\"\x96\x01\n\x13\x42\x61mlTyFunctionParam\x12\x11\n\x04name\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\'\n\x02ty\x18\x02 \x01(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTy\x12:\n\x04mode\x18\x03 \x01(\x0e\x32,.baml_bridge.cffi.v1.BamlTyFunctionParamModeB\x07\n\x05_name\"\xbf\x01\n\x0e\x42\x61mlTyFunction\x12\x16\n\x0egeneric_params\x18\x01 \x03(\t\x12\x38\n\x06params\x18\x03 \x03(\x0b\x32(.baml_bridge.cffi.v1.BamlTyFunctionParam\x12(\n\x03ret\x18\x04 \x01(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTy\x12+\n\x06throws\x18\x05 \x01(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTyJ\x04\x08\x02\x10\x03\"f\n\x0c\x42\x61mlTyFuture\x12*\n\x05value\x18\x01 \x01(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTy\x12*\n\x05\x65rror\x18\x02 \x01(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTy\"\x10\n\x0e\x42\x61mlTyRustType\"\x10\n\x0e\x42\x61mlTyMetaType\"\x10\n\x0e\x42\x61mlTyResource\"\x11\n\x0f\x42\x61mlTyPromptAst\",\n\rBamlTyTypeVar\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05index\x18\x02 \x01(\r\"\x8b\x01\n\x1e\x42\x61mlTyAssociatedTypeProjection\x12)\n\x04\x62\x61se\x18\x01 \x01(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTy\x12.\n\tinterface\x18\x02 \x01(\x0b\x32\x1b.baml_bridge.cffi.v1.BamlTy\x12\x0e\n\x06member\x18\x03 \x01(\t\"\r\n\x0b\x42\x61mlTyNever*\x81\x02\n\x13\x42\x61mlTyPrimitiveKind\x12!\n\x1d\x42\x41ML_TY_PRIMITIVE_UNSPECIFIED\x10\x00\x12\x1c\n\x18\x42\x41ML_TY_PRIMITIVE_STRING\x10\x01\x12\x19\n\x15\x42\x41ML_TY_PRIMITIVE_INT\x10\x02\x12\x1b\n\x17\x42\x41ML_TY_PRIMITIVE_FLOAT\x10\x03\x12\x1a\n\x16\x42\x41ML_TY_PRIMITIVE_BOOL\x10\x04\x12\x1a\n\x16\x42\x41ML_TY_PRIMITIVE_NULL\x10\x05\x12\x1b\n\x17\x42\x41ML_TY_PRIMITIVE_BYTES\x10\x06\x12\x1c\n\x18\x42\x41ML_TY_PRIMITIVE_BIGINT\x10\x07*\xcb\x01\n\x0f\x42\x61mlTyMediaKind\x12\"\n\x1e\x42\x41ML_TY_MEDIA_KIND_UNSPECIFIED\x10\x00\x12\x1c\n\x18\x42\x41ML_TY_MEDIA_KIND_IMAGE\x10\x01\x12\x1c\n\x18\x42\x41ML_TY_MEDIA_KIND_AUDIO\x10\x02\x12\x1c\n\x18\x42\x41ML_TY_MEDIA_KIND_VIDEO\x10\x03\x12\x1a\n\x16\x42\x41ML_TY_MEDIA_KIND_PDF\x10\x04\x12\x1e\n\x1a\x42\x41ML_TY_MEDIA_KIND_GENERIC\x10\x05*\x9a\x01\n\x17\x42\x61mlTyFunctionParamMode\x12+\n\'BAML_TY_FUNCTION_PARAM_MODE_UNSPECIFIED\x10\x00\x12(\n$BAML_TY_FUNCTION_PARAM_MODE_REQUIRED\x10\x01\x12(\n$BAML_TY_FUNCTION_PARAM_MODE_OPTIONAL\x10\x02\x42\nH\x03Z\x06./cffib\x06proto3" + +pool = ::Google::Protobuf::DescriptorPool.generated_pool +pool.add_serialized_file(descriptor_data) + +module BamlBridge + module Cffi + module V1 + BamlTy = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTy").msgclass + BamlTyPrimitive = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyPrimitive").msgclass + BamlTyClass = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyClass").msgclass + BamlTyTypeAlias = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyTypeAlias").msgclass + BamlTyEnum = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyEnum").msgclass + BamlTyList = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyList").msgclass + BamlTyMap = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyMap").msgclass + BamlTyOptional = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyOptional").msgclass + BamlTyUnion = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyUnion").msgclass + BamlTyUnknown = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyUnknown").msgclass + BamlTyLiteral = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyLiteral").msgclass + BamlTyMedia = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyMedia").msgclass + BamlTyInterface = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyInterface").msgclass + BamlTyAssociatedBinding = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyAssociatedBinding").msgclass + BamlTyEnumVariant = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyEnumVariant").msgclass + BamlTyFunctionParam = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyFunctionParam").msgclass + BamlTyFunction = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyFunction").msgclass + BamlTyFuture = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyFuture").msgclass + BamlTyRustType = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyRustType").msgclass + BamlTyMetaType = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyMetaType").msgclass + BamlTyResource = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyResource").msgclass + BamlTyPromptAst = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyPromptAst").msgclass + BamlTyTypeVar = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyTypeVar").msgclass + BamlTyAssociatedTypeProjection = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyAssociatedTypeProjection").msgclass + BamlTyNever = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyNever").msgclass + BamlTyPrimitiveKind = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyPrimitiveKind").enummodule + BamlTyMediaKind = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyMediaKind").enummodule + BamlTyFunctionParamMode = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("baml_bridge.cffi.v1.BamlTyFunctionParamMode").enummodule + end + end +end diff --git a/baml_language/sdks/ruby/bridge_ruby/rbi/baml.rbi b/baml_language/sdks/ruby/bridge_ruby/rbi/baml.rbi new file mode 100644 index 00000000000..8c44cad12d0 --- /dev/null +++ b/baml_language/sdks/ruby/bridge_ruby/rbi/baml.rbi @@ -0,0 +1,133 @@ +# typed: strict +# frozen_string_literal: true + +# Contract between generated `baml_sdk/` code and the Ruby runtime. +# +# Generated code may call only what is declared here. The runtime implements it. +# The Sorbet static gate type-checks generated code against this file, so a +# mismatch between generator and runtime fails the gate rather than a test run. +# +# Later changes extend this file; they do not rename or remove anything in it. + +module Baml + extend T::Sig + + module ValueEquality + extend T::Sig + sig { params(other: T.untyped).returns(T::Boolean) } + def ==(other); end + sig { params(other: T.untyped).returns(T::Boolean) } + def eql?(other); end + sig { returns(Integer) } + def hash; end + end + + # Raised by Call#wait(timeout:) when the timeout passes. The call keeps running. + class WaitTimeout < StandardError; end + + class Error < StandardError + extend T::Sig + sig { params(value: T.untyped, type_name: T.nilable(String)).void } + def initialize(value, type_name: nil); end + sig { returns(T.nilable(String)) } + def type_name; end + end + class Panic < Exception + extend T::Sig + sig { params(value: T.untyped, type_name: T.nilable(String)).void } + def initialize(value, type_name: nil); end + sig { returns(T.nilable(String)) } + def type_name; end + end + + # The promise every BAML call returns. Sync generated methods are `call_async(...).wait`. + class Call + extend T::Sig + extend T::Generic + Value = type_member + + # Block until the outcome is known, then return the decoded value or raise + # the decoded error. Repeated and concurrent waits see the same outcome. + # With a timeout (seconds), raise Baml::WaitTimeout and leave the call running. + sig { params(timeout: T.nilable(Numeric)).returns(Value) } + def wait(timeout: nil); end + + # Ask the engine to cancel. True when the request was accepted, not when + # cancellation won; the eventual outcome still comes from the engine. + sig { returns(T::Boolean) } + def cancel; end + + sig { returns(T::Boolean) } + def done?; end + end + + # An owned engine handle. Released by #close or when garbage-collected. + # Sending a handle to BAML clones it first, so the Ruby object stays valid. + class Handle + extend T::Sig + sig { void } + def close; end + end + + module Bridge + extend T::Sig + + class Error < StandardError; end + class UnsupportedTypeError < Error; end + + # Type descriptors are frozen arrays mirroring the generator's Ty. Grammar: + # [:int] [:float] [:string] [:bool] [:null] [:never] [:handle] + # [:literal, value] value: String, Integer, Float, true or false + # [:class, "pkg.ns.Name", [type_args...]] + # [:enum, "pkg.ns.Name"] [:enum_variant, "pkg.ns.Name", "Variant"] + # [:unknown] [:interface, "pkg.ns.Name"] + # [:list, d] [:map, [:string], value] [:union, [members...]] + # [:function, [[name_or_nil, d, optional?], ...], ret, throws] + # [:other] unsupported type + # Values are planned against these slots before any handles are cloned. + # Enum variants and callable values are reserved for later runtime support. + Descriptor = T.type_alias { T::Array[T.untyped] } + + # Called once when the generated package loads. Registering a different + # program in the same process raises. + sig { params(bytecode: String, metadata: T.nilable(String)).void } + def self.register_program(bytecode, metadata = nil); end + + # Load the library and initialize the registered program now, instead of + # on the first call. Calling it again is a no-op. + sig { void } + def self.initialize!; end + + # fields: wire field name => [Ruby field name, descriptor] + sig do + params( + wire: String, + klass: T.class_of(T::Struct), + fields: T::Hash[String, [Symbol, Descriptor]] + ).void + end + def self.define_class(wire, klass, fields:); end + + sig { params(wire: String, klass: T.class_of(T::Enum)).void } + def self.define_enum(wire, klass); end + + # params: [wire name, descriptor] in declaration order. + # Instance methods include their receiver as the first param, named "self". + sig do + params( + wire: String, + params: T::Array[[String, Descriptor]] + ).void + end + def self.define_function(wire, params:); end + + # Start a call. args: wire parameter name => Ruby value; omitted keys are not sent. + sig do + params( + wire: String, + args: T::Hash[String, T.untyped] + ).returns(::Baml::Call[T.untyped]) + end + def self.call_async(wire, args); end + end +end diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/Cargo.toml b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/Cargo.toml new file mode 100644 index 00000000000..fd25355a76c --- /dev/null +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "sdkgen_ruby_sorbet" +version = { workspace = true } +publish = false +authors = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +homepage = { workspace = true } +documentation = { workspace = true } +repository = { workspace = true } +license = { workspace = true } + +[dependencies] +baml_base = { workspace = true } +baml_sdkgen_types = { workspace = true } +baml_type = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/descriptors.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/descriptors.rs new file mode 100644 index 00000000000..73f17518b62 --- /dev/null +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/descriptors.rs @@ -0,0 +1,88 @@ +use baml_base::Literal; +use baml_sdkgen_types::{CodegenFunctionParamMode, Ty}; + +use crate::names::quote; + +/// Keep the wire shape independent of which Sorbet types are supported. +pub(crate) fn descriptor(ty: &Ty) -> String { + let body = match ty { + Ty::Int => ":int".into(), + Ty::Float => ":float".into(), + Ty::String => ":string".into(), + Ty::Bool => ":bool".into(), + Ty::Null => ":null".into(), + Ty::Never => ":never".into(), + Ty::RustType => ":handle".into(), + Ty::Literal(value, _) => format!( + ":literal, {}", + match value { + Literal::String(s) => quote(s), + Literal::Int(v) => v.to_string(), + Literal::Bigint(v) => v.to_string(), + Literal::Float(v) => v.clone(), + Literal::Bool(v) => v.to_string(), + } + ), + Ty::Class(name, args) => format!( + ":class, {}, {}", + quote(&name.to_string()), + descriptors(args) + ), + Ty::Enum(name) => format!(":enum, {}", quote(&name.to_string())), + Ty::EnumVariant(name, variant) => format!( + ":enum_variant, {}, {}", + quote(&name.to_string()), + quote(variant.as_str()) + ), + Ty::Unknown => ":unknown".into(), + Ty::Interface(name, ..) => format!(":interface, {}", quote(&name.to_string())), + Ty::Function { + params, + ret, + throws, + .. + } => format!( + ":function, [{}].freeze, {}, {}", + params + .iter() + .map(|param| format!( + "[{}, {}, {}].freeze", + param + .name + .as_ref() + .map_or_else(|| "nil".into(), |name| quote(name.as_str())), + descriptor(¶m.ty), + param.mode == CodegenFunctionParamMode::Optional + )) + .collect::>() + .join(", "), + descriptor(ret), + descriptor(throws) + ), + Ty::List(inner) => format!(":list, {}", descriptor(inner)), + Ty::Map { key, value } if matches!(key.as_ref(), Ty::String) => { + format!(":map, {}, {}", descriptor(key), descriptor(value)) + } + Ty::Union(items) => { + format!(":union, {}", descriptors(items)) + } + Ty::Type + | Ty::Resource + | Ty::PromptAst + | Ty::Future(..) + | Ty::Bigint + | Ty::Uint8Array + | Ty::Media(..) + | Ty::TypeAlias(..) + | Ty::TypeVar(..) + | Ty::Map { .. } => ":other".into(), + }; + format!("[{body}].freeze") +} + +fn descriptors(items: &[Ty]) -> String { + format!( + "[{}].freeze", + items.iter().map(descriptor).collect::>().join(", ") + ) +} diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs new file mode 100644 index 00000000000..ceac008f7da --- /dev/null +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs @@ -0,0 +1,218 @@ +use std::{collections::BTreeSet, fmt::Write as _}; + +use baml_sdkgen_types::{Class, Enum, Function, FunctionArgument, Name, NamingConvention}; + +use crate::{ + descriptors::descriptor, + names::{NameTable, callable_parts, identifier, parameter, quote, variant_constant}, + types::ruby_type, +}; + +pub(crate) struct Emitter<'a> { + pub(crate) supported: &'a BTreeSet, + pub(crate) naming: NamingConvention, + pub(crate) names: NameTable, + pub(crate) registry: String, + pub(crate) shells: String, + pub(crate) skipped: BTreeSet, +} + +impl Emitter<'_> { + pub(crate) fn class(&mut self, name: &Name, class: &Class, body: &mut String) { + let scope = self.names.qualified(name); + self.shell(name, "Struct"); + writeln!( + body, + "class {} < ::T::Struct\n extend ::T::Sig\n include ::Baml::ValueEquality", + scope.rsplit("::").next().unwrap() + ) + .unwrap(); + let mut fields = Vec::new(); + for property in &class.properties { + let field = self.names.claim( + &scope, + &identifier(property.name.as_str(), self.naming), + &format!("field {name}.{}", property.name), + ); + writeln!( + body, + " const :{field}, {}", + ruby_type(&property.ty, self.supported, &self.names).unwrap() + ) + .unwrap(); + fields.push(format!( + "{} => [:{field}, {}]", + quote(property.name.as_str()), + descriptor(&property.ty) + )); + } + writeln!( + self.registry, + "::Baml::Bridge.define_class({}, ::{scope}, fields: {{{}}})", + quote(&name.to_string()), + fields.join(", ") + ) + .unwrap(); + for (instance, methods) in [ + (false, &class.static_methods), + (true, &class.instance_methods), + ] { + for method in methods { + let mut source = String::new(); + self.function( + &scope, + &format!("{name}.{}", method.name), + method, + instance.then_some(name), + &mut source, + ); + for line in source.lines() { + writeln!(body, " {line}").unwrap(); + } + } + } + body.push_str("end\n"); + } + + pub(crate) fn enum_(&mut self, name: &Name, enum_: &Enum, body: &mut String) { + self.shell(name, "Enum"); + let scope = self.names.qualified(name); + writeln!( + body, + "class {} < ::T::Enum\n enums do", + scope.rsplit("::").next().unwrap() + ) + .unwrap(); + for variant in &enum_.variants { + let ruby = self.names.claim( + &scope, + &variant_constant(variant.name.as_str()), + &format!("{name}.{}", variant.name), + ); + writeln!(body, " {ruby} = new({})", quote(&variant.value)).unwrap(); + } + body.push_str(" end\nend\n"); + writeln!( + self.registry, + "::Baml::Bridge.define_enum({}, ::{})", + quote(&name.to_string()), + scope + ) + .unwrap(); + } + + fn shell(&mut self, name: &Name, kind: &str) { + writeln!( + self.shells, + "{}.const_set(:{}, Class.new(::T::{kind}))", + self.names.module_name(name), + self.names.qualified(name).rsplit("::").next().unwrap() + ) + .unwrap(); + } + + pub(crate) fn function( + &mut self, + scope: &str, + wire: &str, + function: &Function, + receiver: Option<&Name>, + body: &mut String, + ) { + if wire.contains('$') + || !function.generic_params.is_empty() + || !function.watchers.is_empty() + || ruby_type(&function.return_type, self.supported, &self.names).is_none() + || function + .arguments + .iter() + .filter(|a| !omit_injected_argument(a, self.supported, &self.names)) + .any(|a| { + a.default.is_some() || ruby_type(&a.ty, self.supported, &self.names).is_none() + }) + { + self.skipped.insert(wire.to_owned()); + return; + } + let result = ruby_type(&function.return_type, self.supported, &self.names).unwrap(); + let argument_types: Vec<_> = function + .arguments + .iter() + .map(|a| ruby_type(&a.ty, self.supported, &self.names)) + .collect(); + let (base, suffix) = callable_parts(function.name.as_str()); + let base = identifier(base, self.naming); + // Instance and singleton methods have separate Ruby scopes. Fields share + // the instance scope, so an emitted method cannot overwrite a reader. + let name_scope = if receiver.is_some() { + scope.to_owned() + } else { + format!("{scope}.self") + }; + let base = self.names.callable(&name_scope, &base, wire); + let method = format!("{base}{suffix}"); + let mut params = Vec::new(); + let mut required = Vec::new(); + let mut arguments = Vec::new(); + let mut descriptors = Vec::new(); + let mut used = BTreeSet::from(["_baml".into(), "_types".into()]); + if let Some(class) = receiver { + arguments.push("\"self\" => self".into()); + descriptors.push(format!( + "[\"self\", {}]", + descriptor(&baml_sdkgen_types::Ty::Class(class.clone(), Box::new([]))) + )); + } + for (argument, argument_type) in function + .arguments + .iter() + .zip(argument_types) + .filter(|(a, _)| !omit_injected_argument(a, self.supported, &self.names)) + { + let arg = parameter(argument.name.as_str(), self.naming, &mut used); + arguments.push(format!("{} => {arg}", quote(argument.name.as_str()))); + let ty = argument_type.unwrap(); + params.push(format!("{arg}: {ty}")); + descriptors.push(format!( + "[{}, {}]", + quote(argument.name.as_str()), + descriptor(&argument.ty) + )); + required.push(arg); + } + writeln!( + self.registry, + "::Baml::Bridge.define_function({}, params: [{}])", + quote(wire), + descriptors.join(", ") + ) + .unwrap(); + for asynchronous in [false, true] { + let method = format!("{method}{}", if asynchronous { "_async" } else { "" }); + let returns = if asynchronous { + format!("::Baml::Call[{result}]") + } else { + result.clone() + }; + let target = if receiver.is_some() { "" } else { "self." }; + let signature = if params.is_empty() { + String::new() + } else { + format!("params({}).", params.join(", ")) + }; + writeln!(body, "sig {{ {signature}returns({returns}) }}\ndef {target}{method}({})\n ::Baml::Bridge.call_async({}, {{{}}}){}\nend", required.join(", "), quote(wire), arguments.join(", "), if asynchronous { "" } else { ".wait" }).unwrap(); + } + } +} + +// Leave unsupported compiler-injected options to the engine's default, without +// relaxing the supported-type requirement for user-declared arguments. +fn omit_injected_argument( + argument: &FunctionArgument, + supported: &BTreeSet, + names: &NameTable, +) -> bool { + argument.injected + && argument.default.is_some() + && ruby_type(&argument.ty, supported, names).is_none() +} diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/lib.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/lib.rs new file mode 100644 index 00000000000..84bdb99ea3e --- /dev/null +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/lib.rs @@ -0,0 +1,135 @@ +//! Ruby/Sorbet declarations and registrations for the whole BAML symbol pool. +mod descriptors; +mod emit; +mod names; +mod package; +mod types; + +use std::{ + collections::{BTreeMap, BTreeSet, HashMap}, + path::PathBuf, +}; + +use baml_sdkgen_types::{Name, NamingConvention, SkipWarning, Symbol, SymbolPool}; +use emit::Emitter; +use names::NameTable; +use types::ruby_type; + +pub fn to_source_code_with_bytecode( + pool: &SymbolPool, + bytecode: &[u8], + naming: NamingConvention, + metadata: Option<&str>, +) -> HashMap { + to_source_code_with_bytecode_and_skipped(pool, bytecode, naming, metadata).0 +} + +/// Reports omitted declarations, including methods, in the CLI's shared format. +pub fn to_source_code_with_bytecode_and_warnings( + pool: &SymbolPool, + bytecode: &[u8], + naming: NamingConvention, + metadata: Option<&str>, +) -> (HashMap, Vec) { + let (files, skipped) = + to_source_code_with_bytecode_and_skipped(pool, bytecode, naming, metadata); + let warnings = skipped + .into_iter() + .map(|fqn| { + let local = pool.keys().any(|name| { + name.is_local() && (fqn == name.to_string() || fqn.starts_with(&format!("{name}."))) + }); + SkipWarning { + fqn, + local, + reason: "declaration uses a type or callable feature unsupported by Ruby/Sorbet" + .into(), + } + }) + .collect(); + (files, warnings) +} + +/// Also returns sorted BAML names omitted from the generated surface. +pub fn to_source_code_with_bytecode_and_skipped( + pool: &SymbolPool, + bytecode: &[u8], + naming: NamingConvention, + metadata: Option<&str>, +) -> (HashMap, Vec) { + let names = NameTable::build(pool, naming); + let symbols: BTreeMap<_, _> = pool.iter().collect(); + let mut supported: BTreeSet = symbols + .iter() + .filter_map(|(n, s)| match s { + Symbol::Class(class) if class.crossing == baml_sdkgen_types::ClassCrossing::Prompt => { + None + } + Symbol::Class(class) if class.generic_params.is_empty() => Some((*n).clone()), + Symbol::Enum(_) => Some((*n).clone()), + _ => None, + }) + .collect(); + // Remove declarations referring to omitted types, including transitive edges. + loop { + let rejected: Vec<_> = supported + .iter() + .filter(|n| match pool.get(*n) { + Some(Symbol::Class(c)) => c + .properties + .iter() + .any(|p| ruby_type(&p.ty, &supported, &names).is_none()), + _ => false, + }) + .cloned() + .collect(); + if rejected.is_empty() { + break; + } + for n in rejected { + supported.remove(&n); + } + } + let mut emitter = Emitter { + supported: &supported, + naming, + names, + registry: String::new(), + shells: String::new(), + skipped: BTreeSet::new(), + }; + let mut namespaces: BTreeMap = BTreeMap::new(); + for (name, symbol) in symbols { + let module = emitter.names.module_name(name); + let body = namespaces.entry(module.clone()).or_default(); + match symbol { + Symbol::Class(_) | Symbol::Enum(_) if supported.contains(name) => match symbol { + Symbol::Class(class) => emitter.class(name, class, body), + Symbol::Enum(enum_) => emitter.enum_(name, enum_, body), + _ => unreachable!(), + }, + Symbol::Function(function) => { + emitter.function(&module, &name.to_string(), function, None, body); + } + _ => { + emitter.skipped.insert(name.to_string()); + if let Symbol::Class(class) = symbol { + emitter.skipped.extend( + class + .static_methods + .iter() + .chain(&class.instance_methods) + .map(|m| format!("{name}.{}", m.name)), + ); + } + } + } + } + let files = package::emit(namespaces, &emitter, bytecode, metadata); + (files, emitter.skipped.into_iter().collect()) +} + +#[cfg(test)] +mod review_tests; +#[cfg(test)] +mod tests; diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/names.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/names.rs new file mode 100644 index 00000000000..6c071faed7d --- /dev/null +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/names.rs @@ -0,0 +1,609 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use baml_sdkgen_types::{Name, NamingConvention, Symbol, SymbolPool, namespace_segments}; + +/// Claims include every callable sibling, even when its type is not mapped yet. +#[derive(Default)] +pub(crate) struct NameTable { + used: BTreeSet<(String, String)>, + allocated: BTreeMap<(String, String), String>, + modules: BTreeMap, String>, +} + +impl NameTable { + pub(crate) fn build(pool: &SymbolPool, naming: NamingConvention) -> Self { + let mut table = Self::default(); + for reserved in ["BYTECODE", "METADATA"] { + table.used.insert(("BamlSdk".into(), reserved.into())); + } + let symbols: BTreeMap<_, _> = pool.iter().collect(); + // Namespace modules and type constants share Ruby's constant scope. + let mut children: BTreeMap, BTreeSet<(String, String)>> = BTreeMap::new(); + for (name, symbol) in &symbols { + let path = namespace_segments(name); + for depth in 0..path.len() { + children.entry(path[..depth].to_vec()).or_default().insert(( + path[depth].clone(), + format!("namespace {}", path[..=depth].join(".")), + )); + } + if !matches!(symbol, Symbol::Function(_)) { + children + .entry(path) + .or_default() + .insert((name.name().to_string(), name.to_string())); + } + } + table.modules.insert(vec![], "BamlSdk".into()); + for (parent, children) in children { + let scope = table.modules[&parent].clone(); + let mut children: Vec<_> = children.into_iter().collect(); + children.sort_by(|(a, a_wire), (b, b_wire)| { + (allocation_key(a), a_wire).cmp(&(allocation_key(b), b_wire)) + }); + for (raw, wire) in children { + let ruby = table.claim(&scope, &constant(&raw), &wire); + if wire.starts_with("namespace ") { + let mut path = parent.clone(); + path.push(raw); + table.modules.insert(path, format!("{scope}::{ruby}")); + } + } + } + let mut symbols: Vec<_> = symbols.into_iter().collect(); + symbols.sort_by_key(|(name, _)| (allocation_key(name.name().as_str()).0, name.to_string())); + for (name, symbol) in symbols { + match symbol { + Symbol::Class(class) => { + let scope = table.qualified(name); + let mut fields: Vec<_> = class.properties.iter().collect(); + fields.sort_by_key(|field| allocation_key(field.name.as_str())); + for field in fields { + table.claim( + &scope, + &identifier(field.name.as_str(), naming), + &format!("field {name}.{}", field.name), + ); + } + let mut methods: Vec<_> = class + .static_methods + .iter() + .map(|m| (false, m)) + .chain(class.instance_methods.iter().map(|m| (true, m))) + .collect(); + methods.sort_by_key(|(_, method)| allocation_key(method.name.as_str())); + for (instance, method) in methods { + let method_scope = if instance { + scope.clone() + } else { + format!("{scope}.self") + }; + table.callable( + &method_scope, + &callable_identifier( + callable_parts(method.name.as_str()).0, + naming, + !instance, + ), + &format!("{name}.{}", method.name), + ); + } + } + Symbol::Enum(enum_) => { + let scope = table.qualified(name); + let mut variants: Vec<_> = enum_.variants.iter().collect(); + variants.sort_by_key(|variant| allocation_key(variant.name.as_str())); + for variant in variants { + table.claim( + &scope, + &variant_constant(variant.name.as_str()), + &format!("{name}.{}", variant.name), + ); + } + } + Symbol::Function(function) => { + table.callable( + &format!("{}.self", table.module_name(name)), + &callable_identifier( + callable_parts(function.name.as_str()).0, + naming, + true, + ), + &name.to_string(), + ); + } + Symbol::TypeAlias(_) => {} + } + } + table + } + + pub(crate) fn claim(&mut self, scope: &str, ruby: &str, baml: &str) -> String { + let key = (scope.to_owned(), baml.to_owned()); + if let Some(allocated) = self.allocated.get(&key) { + return allocated.clone(); + } + let mut ruby = ruby.to_owned(); + while self.used.contains(&(scope.to_owned(), ruby.clone())) { + ruby.push('_'); + } + self.used.insert((scope.to_owned(), ruby.clone())); + self.allocated.insert(key, ruby.clone()); + ruby + } + + pub(crate) fn callable(&mut self, scope: &str, ruby: &str, wire: &str) -> String { + let (base, _) = callable_parts(wire); + let key = (scope.to_owned(), base.to_owned()); + if let Some(allocated) = self.allocated.get(&key) { + return allocated.clone(); + } + let suffixes = [ + "", + "_async", + "_stream", + "_stream_async", + "_spec", + "_spec_async", + ]; + let mut ruby = ruby.to_owned(); + // Fields are allocated first. Escape the whole family until every reader + // and callable spelling is free, including repeated underscore collisions. + while suffixes.iter().any(|suffix| { + self.used + .contains(&(scope.to_owned(), format!("{ruby}{suffix}"))) + }) { + ruby.push('_'); + } + for suffix in suffixes { + self.used + .insert((scope.to_owned(), format!("{ruby}{suffix}"))); + } + self.allocated.insert(key, ruby.clone()); + ruby + } + + pub(crate) fn module_name(&self, name: &Name) -> String { + self.modules + .get(&namespace_segments(name)) + .cloned() + .unwrap_or_else(|| module_name(name)) + } + + pub(crate) fn qualified(&self, name: &Name) -> String { + let module = self.module_name(name); + let ruby = self + .allocated + .get(&(module.clone(), name.to_string())) + .cloned() + .unwrap_or_else(|| constant(name.name().as_str())); + format!("{module}::{ruby}") + } +} + +// Match Python names.rs::allocate: legal authored identifiers first, then BAML +// identity. Ruby projection must not decide which BAML declaration wins. +fn allocation_key(raw: &str) -> (bool, &str) { + let python_keyword = [ + "False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", + "continue", "def", "del", "elif", "else", "except", "finally", "for", "from", "global", + "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return", + "try", "while", "with", "yield", + ] + .contains(&raw); + let mut chars = raw.chars(); + let legal = chars.next().is_some_and(|c| c == '_' || c.is_alphabetic()) + && chars.all(|c| c == '_' || c.is_alphanumeric()) + && !python_keyword; + (!legal, raw) +} + +pub(crate) fn callable_parts(name: &str) -> (&str, &str) { + for (wire, ruby) in [("@stream", "_stream"), ("@spec", "_spec")] { + if let Some(base) = name.strip_suffix(wire) { + return (base, ruby); + } + } + (name, "") +} + +pub(crate) fn module_name(name: &Name) -> String { + std::iter::once("BamlSdk".to_owned()) + .chain(namespace_segments(name).iter().map(|n| constant(n))) + .collect::>() + .join("::") +} + +pub(crate) fn constant(name: &str) -> String { + let mut capitalize = true; + let mut result = String::new(); + for c in name.chars() { + if c == '_' { + capitalize = true; + } else if capitalize { + result.extend(c.to_uppercase()); + capitalize = false; + } else { + result.push(c); + } + } + if name.starts_with('_') { + format!("Baml{result}") + } else { + result + } +} + +pub(crate) fn variant_constant(name: &str) -> String { + if name.starts_with('_') { + format!("Baml{}", variant_constant(name.trim_start_matches('_'))) + } else { + let mut chars = name.chars(); + chars.next().map_or_else(String::new, |first| { + first.to_uppercase().chain(chars).collect() + }) + } +} + +pub(crate) fn snake_case(name: &str) -> String { + project(name, NamingConvention::Language) +} + +fn project(name: &str, naming: NamingConvention) -> String { + let mut result = String::new(); + let chars: Vec<_> = name.chars().collect(); + for (i, c) in chars.iter().enumerate() { + if naming == NamingConvention::Language && c.is_uppercase() { + if i > 0 + && chars[i - 1] != '_' + && (chars[i - 1].is_lowercase() + || chars.get(i + 1).is_some_and(|c| c.is_lowercase())) + { + result.push('_'); + } + result.extend(c.to_lowercase()); + } else { + result.push(*c); + } + } + result +} + +const KEYWORDS: &[&str] = &[ + "BEGIN", + "END", + "alias", + "and", + "begin", + "break", + "case", + "class", + "def", + "defined", + "do", + "else", + "elsif", + "end", + "ensure", + "false", + "for", + "if", + "in", + "module", + "next", + "nil", + "not", + "or", + "redo", + "rescue", + "retry", + "return", + "self", + "super", + "then", + "true", + "undef", + "unless", + "until", + "when", + "while", + "yield", + "__FILE__", + "__LINE__", + "__ENCODING__", +]; + +// Generated once with Ruby 3.2.2, `require "bundler/setup"` and +// `require "sorbet-runtime"` (Bundler makes Kernel#gem public): +// Object.instance_methods.map(&:to_s).sort +// (Module.instance_methods | Class.instance_methods).map(&:to_s).sort +// T::Struct.singleton_methods.map(&:to_s).sort +// Class adds singleton callables such as allocate and superclass. +const OBJECT_METHODS: &[&str] = &[ + "!", + "!=", + "!~", + "<=>", + "==", + "===", + "__id__", + "__send__", + "class", + "clone", + "define_singleton_method", + "display", + "dup", + "enum_for", + "eql?", + "equal?", + "extend", + "freeze", + "frozen?", + "gem", + "hash", + "inspect", + "instance_eval", + "instance_exec", + "instance_of?", + "instance_variable_defined?", + "instance_variable_get", + "instance_variable_set", + "instance_variables", + "is_a?", + "itself", + "kind_of?", + "method", + "methods", + "nil?", + "object_id", + "pretty_inspect", + "pretty_print", + "pretty_print_cycle", + "pretty_print_inspect", + "pretty_print_instance_variables", + "private_methods", + "protected_methods", + "public_method", + "public_methods", + "public_send", + "remove_instance_variable", + "respond_to?", + "send", + "singleton_class", + "singleton_method", + "singleton_methods", + "tap", + "then", + "to_enum", + "to_s", + "yield_self", +]; +const MODULE_METHODS: &[&str] = &[ + "!", + "!=", + "!~", + "<", + "<=", + "<=>", + "==", + "===", + ">", + ">=", + "__id__", + "__send__", + "alias_method", + "allocate", + "ancestors", + "attached_object", + "attr", + "attr_accessor", + "attr_reader", + "attr_writer", + "autoload", + "autoload?", + "class", + "class_eval", + "class_exec", + "class_variable_defined?", + "class_variable_get", + "class_variable_set", + "class_variables", + "clone", + "const_defined?", + "const_get", + "const_missing", + "const_set", + "const_source_location", + "constants", + "define_method", + "define_singleton_method", + "deprecate_constant", + "display", + "dup", + "enum_for", + "eql?", + "equal?", + "extend", + "freeze", + "frozen?", + "gem", + "hash", + "include", + "include?", + "included_modules", + "inspect", + "instance_eval", + "instance_exec", + "instance_method", + "instance_methods", + "instance_of?", + "instance_variable_defined?", + "instance_variable_get", + "instance_variable_set", + "instance_variables", + "is_a?", + "itself", + "kind_of?", + "method", + "method_defined?", + "methods", + "module_eval", + "module_exec", + "name", + "new", + "nil?", + "object_id", + "prepend", + "pretty_inspect", + "pretty_print", + "pretty_print_cycle", + "pretty_print_inspect", + "pretty_print_instance_variables", + "private_class_method", + "private_constant", + "private_instance_methods", + "private_method_defined?", + "private_methods", + "protected_instance_methods", + "protected_method_defined?", + "protected_methods", + "public_class_method", + "public_constant", + "public_instance_method", + "public_instance_methods", + "public_method", + "public_method_defined?", + "public_methods", + "public_send", + "refinements", + "remove_class_variable", + "remove_instance_variable", + "remove_method", + "respond_to?", + "send", + "singleton_class", + "singleton_class?", + "singleton_method", + "singleton_methods", + "subclasses", + "superclass", + "tap", + "then", + "to_enum", + "to_s", + "undef_method", + "undefined_instance_methods", + "yield_self", +]; + +const STRUCT_METHODS: &[&str] = &[ + "const", + "decorator", + "decorator_class", + "extended", + "from_hash", + "from_hash!", + "included", + "inherited", + "plugin", + "plugins", + "prepended", + "prop", + "prop_by_serialized_forms", + "props", + "reload_decorator!", + "validate_prop_value", +]; + +pub(crate) fn identifier(name: &str, naming: NamingConvention) -> String { + callable_identifier(name, naming, false) +} + +fn callable_identifier(name: &str, naming: NamingConvention, singleton: bool) -> String { + let mut result = project(name, naming); + // Keywords retain their suffix (notably then_), even if Object defines them. + if KEYWORDS.contains(&result.as_str()) || ["_baml", "_types"].contains(&result.as_str()) { + result.push('_'); + return result; + } + // Keep lifecycle hooks and the original T::Struct/runtime protections. + if OBJECT_METHODS.contains(&result.as_str()) + || (singleton + && (MODULE_METHODS.contains(&result.as_str()) + || STRUCT_METHODS.contains(&result.as_str()))) + || [ + "method", + "methods", + "new", + "send", + "public_send", + "object_id", + "hash", + "freeze", + "dup", + "clone", + "inspect", + "to_s", + "to_h", + "initialize", + "instance_variable_get", + "instance_variable_set", + "define_method", + "const_get", + "extend", + "include", + "sig", + "props", + "serialize", + "deserialize", + "method_added", + "method_removed", + "method_undefined", + "singleton_method_added", + "singleton_method_removed", + "singleton_method_undefined", + "inherited", + "included", + "extended", + "prepended", + "const_added", + "const_missing", + "method_missing", + "respond_to_missing?", + "initialize_copy", + "initialize_clone", + "initialize_dup", + ] + .contains(&result.as_str()) + { + return format!("baml_{result}"); + } + result +} + +pub(crate) fn parameter( + name: &str, + naming: NamingConvention, + used: &mut BTreeSet, +) -> String { + let mut result = project(name, naming); + while KEYWORDS.contains(&result.as_str()) || used.contains(&result) { + result.push('_'); + } + used.insert(result.clone()); + result +} + +pub(crate) fn quote(value: &str) -> String { + let mut quoted = String::from("\""); + for ch in value.chars() { + match ch { + '\0' => quoted.push_str("\\u0000"), + '#' => quoted.push_str("\\#"), + '\'' => quoted.push(ch), + _ => quoted.extend(ch.escape_debug()), + } + } + quoted.push('"'); + quoted +} diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/package.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/package.rs new file mode 100644 index 00000000000..620014fad40 --- /dev/null +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/package.rs @@ -0,0 +1,107 @@ +use std::{ + collections::{BTreeMap, BTreeSet, HashMap}, + fmt::Write as _, + path::PathBuf, +}; + +use crate::{ + emit::Emitter, + names::{quote, snake_case}, +}; + +pub(crate) fn emit( + namespaces: BTreeMap, + emitter: &Emitter<'_>, + bytecode: &[u8], + metadata: Option<&str>, +) -> HashMap { + let mut root = String::from( + "# typed: strict\n# frozen_string_literal: true\nrequire \"baml/bridge\"\nrequire \"sorbet-runtime\"\nrequire_relative \"baml_sdk/__bytecode\"\nmodule BamlSdk\n extend ::T::Sig\n sig { returns(::NilClass) }\n def self.initialize!\n ::Baml::Bridge.initialize!\n nil\n end\nend\n", + ); + // Runtime shells preserve forward/recursive references. Create them dynamically + // so Sorbet sees each T::Struct/T::Enum declaration once, with all its fields; + // a normal empty T::Struct declaration synthesizes a zero-argument constructor. + let mut modules = BTreeSet::new(); + for module in namespaces.keys() { + let parts: Vec<_> = module.split("::").collect(); + for end in 2..=parts.len() { + modules.insert(parts[..end].join("::")); + } + } + for module in modules { + writeln!(root, "module {module}; end").unwrap(); + } + root.push_str(&emitter.shells); + let mut files = HashMap::new(); + let mut paths = BTreeSet::from(["__root".to_owned(), "__bytecode".to_owned()]); + for (module, body) in namespaces { + // Ruby constants never start with underscores, so namespaces cannot + // produce the reserved declarations, bytecode or vendor filenames. + // Vendored packages avoid `vendor/`: Tapioca's default Sorbet config + // ignores every directory with that name. + let mut relative = module.strip_prefix("BamlSdk::").map_or_else( + || "__root".to_owned(), + |namespace| { + namespace + .split("::") + .map(snake_case) + .map(|part| { + if part == "vendor" { + "__vendor".to_owned() + } else { + part + } + }) + .collect::>() + .join("/") + }, + ); + if module != "BamlSdk" { + while !paths.insert(relative.clone()) { + relative.push('_'); + } + } + let path = format!("baml_sdk/{relative}.rb"); + let body = body.lines().fold(String::new(), |mut out, line| { + writeln!(out, " {line}").unwrap(); + out + }); + insert( + &mut files, + &path, + format!( + "# typed: strict\n# frozen_string_literal: true\nmodule {module}\n extend ::T::Sig\n{body}end\n" + ), + ); + writeln!( + root, + "require_relative {}", + quote(path.strip_suffix(".rb").unwrap()) + ) + .unwrap(); + } + root.push_str("::Baml::Bridge.register_program(::BamlSdk::BYTECODE, ::BamlSdk::METADATA)\n"); + root.push_str(&emitter.registry); + insert(&mut files, "baml_sdk.rb", root); + let bytes = baml_sdkgen_types::embedded_bytecode_base64(bytecode); + let metadata = metadata.map_or_else(|| "nil".into(), |s| format!("{}.freeze", quote(s))); + insert( + &mut files, + "baml_sdk/__bytecode.rb", + format!( + "# typed: strict\n# frozen_string_literal: true\nmodule BamlSdk\n BYTECODE = ::T.let(\"{bytes}\".b.freeze, ::String)\n METADATA = ::T.let({metadata}, ::T.nilable(::String))\nend\n" + ), + ); + files +} + +fn insert(files: &mut HashMap, path: &str, source: String) { + match files.entry(PathBuf::from(path)) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(source); + } + std::collections::hash_map::Entry::Occupied(_) => { + panic!("duplicate Ruby output path: {path}"); + } + } +} diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/review_tests.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/review_tests.rs new file mode 100644 index 00000000000..add32e1d317 --- /dev/null +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/review_tests.rs @@ -0,0 +1,178 @@ +use super::*; +use baml_sdkgen_types::{Enum, EnumVariant, Function, Origin, Ty}; + +#[test] +fn prompt_return_is_skipped_with_a_warning() { + use baml_sdkgen_types::{Class, ClassCrossing}; + let prompt = Name::new("ai".into(), vec![], "Prompt".into()); + let origin = Origin { + source_file_path: "test.baml".into(), + span_start: 0, + }; + let function = Name::new("user".into(), vec![], "get_prompt".into()); + let pool = SymbolPool::from([ + ( + prompt.clone(), + Symbol::Class(Class { + name: prompt.clone(), + crossing: ClassCrossing::Prompt, + generic_params: vec![], + docstring: None, + properties: vec![], + static_methods: vec![], + instance_methods: vec![], + origin: origin.clone(), + }), + ), + ( + function, + Symbol::Function(Function { + name: "get_prompt".into(), + generic_params: vec![], + docstring: None, + arguments: vec![], + return_type: Ty::Class(prompt, Box::new([])), + throws: None, + watchers: vec![], + origin, + }), + ), + ]); + let (files, warnings) = + to_source_code_with_bytecode_and_warnings(&pool, &[], NamingConvention::Language, None); + assert!( + warnings + .iter() + .any(|warning| warning.fqn == "user.get_prompt" && warning.local) + ); + assert!( + files + .values() + .all(|source| !source.contains("def self.get_prompt")) + ); +} + +#[test] +fn skips_report_the_users_package_not_a_hardcoded_user_prefix() { + let mut pool = SymbolPool::new(); + // Even a project named my_app has local names spelled user in the pool. + for package in ["user", "dependency"] { + let name = Name::new(package.into(), vec![], "unsupported".into()); + pool.insert( + name, + Symbol::Function(Function { + name: "unsupported".into(), + generic_params: vec![], + docstring: None, + arguments: vec![], + return_type: Ty::Literal( + baml_base::Literal::Float("1.5".into()), + baml_type::Freshness::Regular, + ), + throws: None, + watchers: vec![], + origin: Origin { + source_file_path: "test.baml".into(), + span_start: 0, + }, + }), + ); + } + let (files, warnings) = to_source_code_with_bytecode_and_warnings( + &pool, + &[], + NamingConvention::Language, + Some("[package]\nname = \"my_app\"\n"), + ); + assert_eq!(warnings.len(), 2); + for warning in warnings { + assert_eq!(warning.local, warning.fqn.starts_with("user.")); + assert!(!warning.reason.is_empty()); + } + assert!( + files + .values() + .all(|source| !source.contains("def self.unsupported")) + ); +} + +#[test] +fn enum_variants_keep_underscores_and_escape_collisions() { + let name = Name::new("user".into(), vec![], "Choice".into()); + let pool = SymbolPool::from([( + name.clone(), + Symbol::Enum(Enum { + name, + docstring: None, + variants: [ + "UPDATE_QUANTITY", + "update_quantity", + "_private", + "BamlPrivate", + "_update_quantity", + "BamlUpdate_quantity", + ] + .into_iter() + .map(|raw| EnumVariant { + name: raw.into(), + value: raw.into(), + docstring: None, + }) + .collect(), + origin: Origin { + source_file_path: "test.baml".into(), + span_start: 0, + }, + }), + )]); + let files = to_source_code_with_bytecode(&pool, &[], NamingConvention::Language, None); + let source = &files[&PathBuf::from("baml_sdk/__root.rb")]; + for expected in [ + "UPDATE_QUANTITY = new(\"UPDATE_QUANTITY\")", + "Update_quantity = new(\"update_quantity\")", + "BamlPrivate = new(\"BamlPrivate\")", + "BamlPrivate_ = new(\"_private\")", + "BamlUpdate_quantity = new(\"BamlUpdate_quantity\")", + "BamlUpdate_quantity_ = new(\"_update_quantity\")", + ] { + assert!(source.contains(expected), "{source}"); + } +} + +#[test] +fn package_constants_are_reserved_and_vendor_is_escaped_at_every_depth() { + let mut pool = SymbolPool::new(); + for (namespace, leaf) in [ + (vec![], "BYTECODE"), + (vec![], "METADATA"), + (vec!["nested", "vendor"], "Choice"), + ] { + let name = Name::new( + "user".into(), + namespace.into_iter().map(Into::into).collect(), + leaf.into(), + ); + pool.insert( + name.clone(), + Symbol::Enum(Enum { + name, + docstring: None, + variants: vec![], + origin: Origin { + source_file_path: "test.baml".into(), + span_start: 0, + }, + }), + ); + } + let files = to_source_code_with_bytecode(&pool, &[], NamingConvention::Language, None); + let root_types = &files[&PathBuf::from("baml_sdk/__root.rb")]; + assert!(root_types.contains("class BYTECODE_ <"), "{root_types}"); + assert!(root_types.contains("class METADATA_ <"), "{root_types}"); + assert!(files.contains_key(&PathBuf::from("baml_sdk/nested/__vendor.rb"))); + assert!( + files + .keys() + .all(|path| !path.components().any(|part| part.as_os_str() == "vendor")) + ); +} diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs new file mode 100644 index 00000000000..f09734fa4e1 --- /dev/null +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs @@ -0,0 +1,443 @@ +use super::*; +use crate::names::{constant, identifier}; +use baml_sdkgen_types::Ty; + +#[test] +fn abi5_descriptors_keep_slots_and_callable_signatures() { + use baml_sdkgen_types::{CallableParam, CodegenFunctionParamMode}; + + let name = Name::new("user".into(), vec![], "Choice".into()); + assert_eq!(descriptors::descriptor(&Ty::Unknown), "[:unknown].freeze"); + assert_eq!( + descriptors::descriptor(&Ty::Interface(name.clone(), Box::new([]), Box::new([]))), + "[:interface, \"user.Choice\"].freeze" + ); + assert_eq!( + descriptors::descriptor(&Ty::EnumVariant(name.clone(), "Red".into())), + "[:enum_variant, \"user.Choice\", \"Red\"].freeze" + ); + assert_eq!( + descriptors::descriptor(&Ty::Class(name.clone(), Box::new([Ty::Int]))), + "[:class, \"user.Choice\", [[:int].freeze].freeze].freeze" + ); + assert_eq!( + descriptors::descriptor(&Ty::Union(Box::new([Ty::Int, Ty::String, Ty::Null]))), + "[:union, [[:int].freeze, [:string].freeze, [:null].freeze].freeze].freeze" + ); + let callable = Ty::Function { + params: Box::new([ + CallableParam::required(Some("value".into()), Ty::Int), + CallableParam { + name: None, + ty: Ty::String, + mode: CodegenFunctionParamMode::Optional, + }, + ]), + ret: Box::new(Ty::Float), + throws: Box::new(Ty::Enum(name)), + }; + assert_eq!( + descriptors::descriptor(&callable), + "[:function, [[\"value\", [:int].freeze, false].freeze, [nil, [:string].freeze, true].freeze].freeze, [:float].freeze, [:enum, \"user.Choice\"].freeze].freeze" + ); + assert_eq!(descriptors::descriptor(&Ty::Type), "[:other].freeze"); +} + +#[test] +fn ruby_names_and_container_types() { + assert_eq!(crate::names::quote("\0\u{31}"), "\"\\u00001\""); + assert_eq!(crate::names::quote(r"\01"), r#""\\01""#); + assert_eq!(crate::names::quote("\"#{x}\n'"), "\"\\\"\\#{x}\\n'\""); + assert_eq!( + crate::names::variant_constant("UPDATE_QUANTITY"), + "UPDATE_QUANTITY" + ); + assert_eq!( + crate::names::variant_constant("update_quantity"), + "Update_quantity" + ); + assert_eq!(crate::names::variant_constant("_private"), "BamlPrivate"); + assert_eq!( + ruby_type( + &Ty::Literal( + baml_base::Literal::Float("1.5".into()), + baml_type::Freshness::Regular + ), + &BTreeSet::new(), + &NameTable::default(), + ), + None + ); + assert_eq!(identifier("class", NamingConvention::Language), "class_"); + assert_eq!( + identifier("HTTPCall", NamingConvention::Language), + "http_call" + ); + assert_eq!( + identifier("SomeCall", NamingConvention::PreserveCase), + "SomeCall" + ); + assert_eq!(constant("throws_test"), "ThrowsTest"); + assert_eq!( + identifier("method", NamingConvention::Language), + "baml_method" + ); + assert_eq!(identifier("sig", NamingConvention::Language), "baml_sig"); + let mut names = NameTable::default(); + assert_eq!( + names.claim("BamlSdk", "hello_world", "user.HelloWorld"), + "hello_world" + ); + assert_eq!( + names.claim("BamlSdk", "hello_world", "user.HelloWorld"), + "hello_world" + ); + assert_eq!( + names.claim("BamlSdk", "hello_world", "user.hello_world"), + "hello_world_" + ); + assert_eq!( + names.claim("BamlSdk", "hello_world_", "user.hello_world_"), + "hello_world__" + ); + assert_eq!( + names.claim("BamlSdk::Other", "hello_world", "user.other.hello_world"), + "hello_world" + ); + assert_eq!(constant("StreamE2ECollectResult"), "StreamE2ECollectResult"); + assert_eq!(constant("_private"), "BamlPrivate"); + assert_eq!( + ruby_type(&Ty::Never, &BTreeSet::new(), &names).as_deref(), + Some("::T.noreturn") + ); + let string = Ty::String; + let optional = Ty::Union(Box::new([string.clone(), Ty::Null])); + let map = Ty::Map { + key: Box::new(string), + value: Box::new(Ty::List(Box::new(optional))), + }; + assert_eq!( + ruby_type(&map, &BTreeSet::new(), &names).as_deref(), + Some("::T::Hash[::String, ::T::Array[::T.nilable(::String)]]") + ); + assert_eq!(ruby_type(&Ty::Unknown, &BTreeSet::new(), &names), None); + let parameter = Ty::TypeVar(baml_sdkgen_types::ParamTy::new(1, "foo_bar".into())); + assert_eq!(ruby_type(¶meter, &BTreeSet::new(), &names), None); +} + +#[test] +fn colliding_constants_and_namespaces_keep_wire_identities() { + use baml_sdkgen_types::{Enum, EnumVariant, Origin}; + + let declarations: Vec<_> = [ + (vec![], "fooBar"), + (vec!["fooBar"], "fooBar"), + (vec!["fooBar"], "foo_bar"), + (vec!["foo_bar"], "Choice"), + ] + .into_iter() + .map(|(namespace, leaf)| { + let name = Name::new( + "user".into(), + namespace.into_iter().map(Into::into).collect(), + leaf.into(), + ); + let symbol = Symbol::Enum(Enum { + name: name.clone(), + docstring: None, + // Deliberately not in allocation order. + variants: ["foo_bar", "fooBar", "foo__bar"] + .into_iter() + .map(|raw| EnumVariant { + name: raw.into(), + docstring: None, + value: raw.into(), + }) + .collect(), + origin: Origin { + source_file_path: "names.baml".into(), + span_start: 0, + }, + }); + (name, symbol) + }) + .collect(); + let pool: SymbolPool = declarations.iter().cloned().collect(); + let names = NameTable::build(&pool, NamingConvention::Language); + let expected = [ + "BamlSdk::FooBar_", + "BamlSdk::FooBar::FooBar", + "BamlSdk::FooBar::FooBar_", + "BamlSdk::FooBar__::Choice", + ]; + for ((name, _), expected) in declarations.iter().zip(expected) { + assert_eq!(names.qualified(name), expected); + assert_eq!( + ruby_type( + &Ty::Enum(name.clone()), + &pool.keys().cloned().collect(), + &names + ), + Some(format!("::{expected}")) + ); + } + let generate = |pool: &SymbolPool| { + to_source_code_with_bytecode_and_skipped(pool, &[], NamingConvention::Language, None) + }; + let (files, skipped) = generate(&pool); + assert!(skipped.is_empty()); + assert_eq!( + (files.clone(), skipped), + generate(&declarations.into_iter().rev().collect()) + ); + let registry = &files[&PathBuf::from("baml_sdk.rb")]; + assert!(registry.contains("define_enum(\"user.fooBar.foo_bar\", ::BamlSdk::FooBar::FooBar_)")); + let source = &files[&PathBuf::from("baml_sdk/foo_bar.rb")]; + for expected in [ + "FooBar = new(\"fooBar\")", + "Foo__bar = new(\"foo__bar\")", + "Foo_bar = new(\"foo_bar\")", + ] { + assert!(source.contains(expected), "{source}"); + } +} + +fn namespace_pool(namespaces: &[&str]) -> SymbolPool { + namespaces + .iter() + .map(|namespace| { + let path = if namespace.is_empty() { + vec![] + } else { + vec![(*namespace).into()] + }; + let name = Name::new("user".into(), path, "Choice".into()); + let symbol = Symbol::Enum(baml_sdkgen_types::Enum { + name: name.clone(), + docstring: None, + variants: vec![], + origin: baml_sdkgen_types::Origin { + source_file_path: "paths.baml".into(), + span_start: 0, + }, + }); + (name, symbol) + }) + .collect() +} + +#[test] +fn namespace_paths_are_snake_case_and_cannot_replace_package_files() { + let pool = namespace_pool(&["", "root", "bytecode", "throws_test", "throwstest"]); + let files = to_source_code_with_bytecode(&pool, &[42], NamingConvention::Language, None); + for namespace in ["root", "bytecode", "throws_test", "throwstest"] { + let source = &files[&PathBuf::from(format!("baml_sdk/{namespace}.rb"))]; + assert!(source.contains("class Choice"), "{source}"); + } + assert!(files[&PathBuf::from("baml_sdk/__root.rb")].contains("class Choice")); + assert!(files[&PathBuf::from("baml_sdk/__bytecode.rb")].contains("BYTECODE =")); + assert_eq!(files.len(), pool.len() + 2); +} + +#[test] +fn vendored_packages_avoid_directories_tapioca_ignores() { + let name = Name::new("anthropic".into(), vec![], "Choice".into()); + let symbol = Symbol::Enum(baml_sdkgen_types::Enum { + name: name.clone(), + docstring: None, + variants: vec![], + origin: baml_sdkgen_types::Origin { + source_file_path: "anthropic.baml".into(), + span_start: 0, + }, + }); + let pool: SymbolPool = [(name, symbol)].into_iter().collect(); + let files = to_source_code_with_bytecode(&pool, &[], NamingConvention::Language, None); + let source = &files[&PathBuf::from("baml_sdk/__vendor/anthropic.rb")]; + assert!( + source.contains("module BamlSdk::Vendor::Anthropic"), + "{source}" + ); + assert!( + files[&PathBuf::from("baml_sdk.rb")] + .contains("require_relative \"baml_sdk/__vendor/anthropic\"") + ); +} + +#[test] +fn duplicate_namespace_paths_get_distinct_files_and_requires() { + let pool = namespace_pool(&["FooBar", "FOOBar", "foo_bar_"]); + let files = to_source_code_with_bytecode(&pool, &[], NamingConvention::Language, None); + assert_eq!(files.len(), pool.len() + 2); + let root = &files[&PathBuf::from("baml_sdk.rb")]; + for path in files.keys().filter(|path| { + path.to_string_lossy().starts_with("baml_sdk/") + && path.to_string_lossy() != "baml_sdk/__bytecode.rb" + }) { + assert!(root.contains(&format!("require_relative {:?}", path.with_extension("")))); + assert!(files[path].contains("class Choice")); + } +} + +#[test] +fn unsupported_injected_defaults_are_omitted_without_dropping_user_arguments() { + use baml_sdkgen_types::{Function, FunctionArgument, FunctionArgumentDefault, Origin}; + + let function = Function { + name: "extract".into(), + generic_params: vec![], + docstring: None, + arguments: vec![ + FunctionArgument { + name: "text".into(), + docstring: None, + ty: Ty::String, + default: None, + injected: false, + }, + FunctionArgument { + // Deliberately not named on_event: only the marker matters. + name: "callback".into(), + docstring: None, + ty: Ty::Unknown, + default: Some(FunctionArgumentDefault::Null), + injected: true, + }, + ], + return_type: Ty::String, + throws: None, + watchers: vec![], + origin: Origin { + source_file_path: "test.baml".into(), + span_start: 0, + }, + }; + let name = Name::new("user".into(), vec![], "extract".into()); + let generate = |function| { + to_source_code_with_bytecode_and_skipped( + &SymbolPool::from([(name.clone(), Symbol::Function(function))]), + &[], + NamingConvention::Language, + None, + ) + }; + let (files, skipped) = generate(function.clone()); + let source = &files[&PathBuf::from("baml_sdk/__root.rb")]; + assert!(skipped.is_empty()); + assert!( + source.contains("sig { params(text: ::String).returns(::String) }"), + "{source}" + ); + assert!(source.contains("def self.extract(text)"), "{source}"); + assert!( + source.contains("::Baml::Bridge.call_async(\"user.extract\", {\"text\" => text}).wait"), + "{source}" + ); + assert!(!source.contains("callback"), "{source}"); + + let mut user_callback = function.clone(); + user_callback.arguments[1].injected = false; + user_callback.arguments[1].name = "on_event".into(); + let (files, skipped) = generate(user_callback); + assert_eq!(skipped, ["user.extract"]); + assert!(!files[&PathBuf::from("baml_sdk/__root.rb")].contains("def self.extract")); + + let mut required_callback = function.clone(); + required_callback.arguments[1].default = None; + assert_eq!(generate(required_callback).1, ["user.extract"]); + + // Defaulted callables are omitted, even when their parameter types are supported. + for injected in [false, true] { + let mut supported_default = function.clone(); + supported_default.arguments[1].injected = injected; + supported_default.arguments[1].ty = Ty::String; + let (files, skipped) = generate(supported_default); + assert_eq!(skipped, ["user.extract"]); + assert!(!files[&PathBuf::from("baml_sdk/__root.rb")].contains("def self.extract")); + assert!(!files[&PathBuf::from("baml_sdk.rb")].contains("define_function")); + } + + // Generic declarations are omitted even when their type parameters are unused. + let mut generic = function.clone(); + generic.generic_params.push("T".into()); + assert_eq!(generate(generic).1, ["user.extract"]); + let class_name = Name::new("user".into(), vec![], "Box".into()); + let pool = SymbolPool::from([( + class_name.clone(), + Symbol::Class(baml_sdkgen_types::Class { + crossing: baml_sdkgen_types::ClassCrossing::Fields, + name: class_name.clone(), + generic_params: vec!["T".into()], + docstring: None, + properties: vec![], + static_methods: vec![function.clone()], + instance_methods: vec![], + origin: function.origin.clone(), + }), + )]); + let (files, skipped) = + to_source_code_with_bytecode_and_skipped(&pool, &[], NamingConvention::Language, None); + assert_eq!(skipped, ["user.Box", "user.Box.extract"]); + assert!(!files[&PathBuf::from("baml_sdk.rb")].contains("define_class")); + assert!(!files[&PathBuf::from("baml_sdk/__root.rb")].contains("class Box")); + let mut concrete_generic = function.clone(); + concrete_generic.return_type = Ty::Class(class_name, Box::new([Ty::Int])); + assert_eq!(generate(concrete_generic).1, ["user.extract"]); + + let mut no_args = function.clone(); + no_args.arguments.clear(); + let (files, skipped) = generate(no_args); + assert!(skipped.is_empty()); + let source = &files[&PathBuf::from("baml_sdk/__root.rb")]; + assert!(source.contains("sig { returns(::String) }\n def self.extract()")); + assert!(source.contains("sig { returns(::Baml::Call[::String]) }\n def self.extract_async()")); + + // Nullable alone does not make a parameter optional. + let mut nullable = function; + nullable.arguments.truncate(1); + nullable.arguments[0].ty = Ty::Union(vec![Ty::String, Ty::Null].into_boxed_slice()); + let (files, skipped) = generate(nullable); + assert!(skipped.is_empty()); + let source = &files[&PathBuf::from("baml_sdk/__root.rb")]; + assert!(source.contains("def self.extract(text)")); + assert!(source.contains("text: ::T.nilable(::String)")); + assert!( + files[&PathBuf::from("baml_sdk.rb")] + .contains("[\"text\", [:union, [[:string].freeze, [:null].freeze].freeze].freeze]") + ); + assert!(!source.contains("_types")); + assert!(!source.contains("_baml")); + assert!(!files[&PathBuf::from("baml_sdk.rb")].contains("type_params")); + for asynchronous in [false, true] { + assert!(source.contains(&format!( + "def self.extract{}(text)", + if asynchronous { "_async" } else { "" } + ))); + } +} + +#[test] +fn bytecode_is_binary_and_generation_is_stable() { + let first = to_source_code_with_bytecode( + &SymbolPool::new(), + &[0, 255, 34, 92], + NamingConvention::Language, + None, + ); + let second = to_source_code_with_bytecode( + &SymbolPool::new(), + &[0, 255, 34, 92], + NamingConvention::Language, + None, + ); + assert_eq!(first, second); + let embedded = baml_sdkgen_types::embedded_bytecode_base64(&[0, 255, 34, 92]); + assert_eq!( + first[&PathBuf::from("baml_sdk/__bytecode.rb")], + format!( + "# typed: strict\n# frozen_string_literal: true\nmodule BamlSdk\n BYTECODE = ::T.let(\"{embedded}\".b.freeze, ::String)\n METADATA = ::T.let(nil, ::T.nilable(::String))\nend\n" + ) + ); + assert!(embedded.is_ascii() && !embedded.contains(['\n', '\r'])); + assert!(first[&PathBuf::from("baml_sdk.rb")].contains("::Baml::Bridge.initialize!\n")); +} diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/types.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/types.rs new file mode 100644 index 00000000000..1f7594f8a41 --- /dev/null +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/types.rs @@ -0,0 +1,40 @@ +use std::collections::BTreeSet; + +use baml_sdkgen_types::{Name, Ty}; + +use crate::names::NameTable; + +pub(crate) fn ruby_type(ty: &Ty, supported: &BTreeSet, names: &NameTable) -> Option { + Some(match ty { + // The pool also defines baml.String and baml.Float; do not resolve to those wrappers. + Ty::Literal(baml_base::Literal::String(_), ..) => "::String".into(), + Ty::Literal(baml_base::Literal::Int(_), ..) => "::Integer".into(), + Ty::Literal(baml_base::Literal::Float(_), ..) => return None, + Ty::Literal(baml_base::Literal::Bool(_), ..) => "::T::Boolean".into(), + Ty::String => "::String".into(), + Ty::Int => "::Integer".into(), + Ty::Float => "::Float".into(), + Ty::Bool => "::T::Boolean".into(), + Ty::Null => "::NilClass".into(), + Ty::RustType => "::Baml::Handle".into(), + Ty::Never => "::T.noreturn".into(), + Ty::List(t) => format!("::T::Array[{}]", ruby_type(t, supported, names)?), + Ty::Map { key, value } if matches!(key.as_ref(), Ty::String) => { + format!( + "::T::Hash[::String, {}]", + ruby_type(value, supported, names)? + ) + } + Ty::Class(n, args) if args.is_empty() && supported.contains(n) => { + format!("::{}", names.qualified(n)) + } + Ty::Enum(n) if supported.contains(n) => format!("::{}", names.qualified(n)), + Ty::Union(items) => match items.as_ref() { + [Ty::Null, inner] | [inner, Ty::Null] => { + format!("::T.nilable({})", ruby_type(inner, supported, names)?) + } + _ => return None, + }, + _ => return None, + }) +} diff --git a/tools/sdk-parity-lint/README.md b/tools/sdk-parity-lint/README.md index d8f71636482..a293dd74e9e 100644 --- a/tools/sdk-parity-lint/README.md +++ b/tools/sdk-parity-lint/README.md @@ -37,7 +37,7 @@ def test_optional_args_python_unset_and_none_differ_in_one_call(): ... ``` -Use `//` in C++, C#, Go, Java, Rust, Swift, and TypeScript. The reason after the colon is required. The annotation applies to its canonical test ID across the matrix: environments where the declaration is currently absent are waived, while present declarations remain required by the baseline ratchet. +Use `#` in Python and Ruby, and `//` in C++, C#, Go, Java, Rust, Swift, and TypeScript. The reason after the colon is required. The annotation applies to its canonical test ID across the matrix: environments where the declaration is currently absent are waived, while present declarations remain required by the baseline ratchet. ## Test diff --git a/tools/sdk-parity-lint/ast-grep-rules/ruby.yml b/tools/sdk-parity-lint/ast-grep-rules/ruby.yml new file mode 100644 index 00000000000..ab2314d0a54 --- /dev/null +++ b/tools/sdk-parity-lint/ast-grep-rules/ruby.yml @@ -0,0 +1,23 @@ +id: ruby-test +language: Ruby +rule: + pattern: 'def $NAME $$$BODY end' +constraints: + NAME: + regex: '^test_[A-Za-z0-9_]+$' +--- +id: ruby-audit-test +language: Ruby +rule: + all: + - kind: method + - has: + field: name + regex: '^test[A-Za-z0-9_]*[!?]?$' +--- +id: sdk-parity-lint-skip +language: Ruby +rule: + all: + - kind: comment + - regex: 'SDK_PARITY_LINT\(skip\):' diff --git a/tools/sdk-parity-lint/baml_src/annotations.baml b/tools/sdk-parity-lint/baml_src/annotations.baml index 1f235a50787..a006a0b2d86 100644 --- a/tools/sdk-parity-lint/baml_src/annotations.baml +++ b/tools/sdk-parity-lint/baml_src/annotations.baml @@ -33,6 +33,15 @@ function line_is_annotation_layout(language: string, line: string) -> bool { true } else if (language == "python" || language == "java") { trimmed.starts_with("@") + } else if (language == "ruby") { + if (!trimmed.starts_with("#")) { + return false; + } + let comment = trimmed.slice(1, trimmed.length()).trim(); + if (comment.starts_with("def ") || comment.starts_with("def\t")) { + return !comment.slice(3, comment.length()).trim().starts_with("test"); + } + true } else if (language == "rust" || language == "csharp") { trimmed.starts_with("#[") && trimmed.ends_with("]") } else { diff --git a/tools/sdk-parity-lint/baml_src/ast_grep.baml b/tools/sdk-parity-lint/baml_src/ast_grep.baml index 0da98a06bfc..704a64ea424 100644 --- a/tools/sdk-parity-lint/baml_src/ast_grep.baml +++ b/tools/sdk-parity-lint/baml_src/ast_grep.baml @@ -128,9 +128,9 @@ function scan_source(repo_root: string, config: SourceConfig) -> SourceScan { } function scan_all_sources(repo_root: string, configs: SourceConfig[]) -> SourceScan[] { - if (configs.length() != 8) { + if (configs.length() != 9) { throw baml.errors.InvalidArgument { - message: `expected eight SDK source configurations, got ${configs.length()}`, + message: `expected nine SDK source configurations, got ${configs.length()}`, }; } let python = spawn { scan_source(repo_root, configs[0]) }; @@ -141,6 +141,7 @@ function scan_all_sources(repo_root: string, configs: SourceConfig[]) -> SourceS let go = spawn { scan_source(repo_root, configs[5]) }; let java = spawn { scan_source(repo_root, configs[6]) }; let swift = spawn { scan_source(repo_root, configs[7]) }; + let ruby = spawn { scan_source(repo_root, configs[8]) }; [ await python, await typescript, @@ -150,6 +151,7 @@ function scan_all_sources(repo_root: string, configs: SourceConfig[]) -> SourceS await go, await java, await swift, + await ruby, ] } diff --git a/tools/sdk-parity-lint/baml_src/config.baml b/tools/sdk-parity-lint/baml_src/config.baml index f0841893efd..90eeb29e5d0 100644 --- a/tools/sdk-parity-lint/baml_src/config.baml +++ b/tools/sdk-parity-lint/baml_src/config.baml @@ -10,6 +10,7 @@ function sdk_environments() -> string[] { "go", "java", "swift", + "ruby_sorbet", ] } @@ -144,6 +145,17 @@ function source_configs(repo_root: string) -> SourceConfig[] { "NAME", "test_", ), + make_source_config( + "ruby", + "ruby_sorbet", + "baml_language/sdk_tests/crates/ruby_sorbet", + `${rules_root}/ruby.yml`, + "**/customizable/**/*.rb", + ".rb", + ["ruby-test"], + "NAME", + "test_", + ), ] } diff --git a/tools/sdk-parity-lint/baml_src/normalize.baml b/tools/sdk-parity-lint/baml_src/normalize.baml index 19dec69b37f..65a5137e384 100644 --- a/tools/sdk-parity-lint/baml_src/normalize.baml +++ b/tools/sdk-parity-lint/baml_src/normalize.baml @@ -259,6 +259,7 @@ function audit_rule_ids(language: string) -> string[] { || language == "java" || language == "swift" || language == "csharp" + || language == "ruby" ) { [`${language}-audit-test`] } else { diff --git a/tools/sdk-parity-lint/baml_src/tests.baml b/tools/sdk-parity-lint/baml_src/tests.baml index 5d7d5592037..8277ad08d48 100644 --- a/tools/sdk-parity-lint/baml_src/tests.baml +++ b/tools/sdk-parity-lint/baml_src/tests.baml @@ -49,6 +49,7 @@ test "ast-grep rules find framework tests and ignore ordinary helpers" { let go = scan_source(repo_root, fixture_config(repo_root, "go")); let java = scan_source(repo_root, fixture_config(repo_root, "java")); let swift = scan_source(repo_root, fixture_config(repo_root, "swift")); + let ruby = scan_source(repo_root, fixture_config(repo_root, "ruby_sorbet")); validate_audit_coverage(python); validate_audit_coverage(typescript); validate_audit_coverage(cpp); @@ -57,6 +58,7 @@ test "ast-grep rules find framework tests and ignore ordinary helpers" { validate_audit_coverage(go); validate_audit_coverage(java); validate_audit_coverage(swift); + validate_audit_coverage(ruby); assert.equal(declaration_count(python), 2); assert.equal(declaration_count(typescript), 8); assert.equal(declaration_count(cpp), 1); @@ -64,7 +66,8 @@ test "ast-grep rules find framework tests and ignore ordinary helpers" { assert.equal(declaration_count(rust), 4); assert.equal(declaration_count(go), 1); assert.equal(declaration_count(java), 2); - assert.equal(declaration_count(swift), 1) + assert.equal(declaration_count(swift), 1); + assert.equal(declaration_count(ruby), 2) } test "audit coverage rejects unsupported TypeScript declaration forms" { @@ -132,6 +135,78 @@ test "normalization derives category IDs and associates an annotation" { assert.equal(annotations[0].reason, "fixture-only behavior") } +test "Ruby normalization derives category IDs and associates a hash comment annotation" { + let repo_root = "."; + let scan = scan_source(repo_root, fixture_config(repo_root, "ruby_sorbet")); + let tests = discover_tests([scan]); + let annotations = build_annotation_configs(repo_root, [scan], tests); + let ids = tests.map((test_case) -> { + test_case.sdk_test_id + }); + ids.sort_by_key((sdk_test_id) -> { + sdk_test_id + }); + assert.equal(ids, ["fixture/sync_case", "fixture/with_parameters_case"]); + assert.equal(annotations.length(), 1); + assert.equal(annotations[0].sdk_test_id, "fixture/sync_case"); + assert.equal(annotations[0].reason, "fixture-only behavior"); + assert.equal(line_is_annotation_layout("ruby", " # comment"), true); + assert.equal(line_is_annotation_layout("ruby", " @instance_variable = 1"), false) +} + +test "Ruby skip cannot cross a commented-out test declaration" { + assert.equal(line_is_annotation_layout("ruby", "# def test_disabled"), false); + assert.equal(line_is_annotation_layout("ruby", "# def test_disabled!"), false); + let base = fixture_config(".", "ruby_sorbet"); + let config = make_source_config( + base.language, base.source_directory, `${base.source_root}-adjacency`, base.rule_file, + base.file_glob, base.suffix, base.declaration_rule_ids, + base.raw_name_field, base.prefix, + ); + let scan = scan_source(".", config); + let tests = discover_tests([scan]); + assert.equal(tests.length(), 1); + { + let _ = build_annotation_configs(".", [scan], tests); + baml.sys.panic("expected skip adjacency rejection") + } catch (e) { + baml.errors.ParseError => assert.equal(e.message.includes("is not immediately before"), true) + } +} + +test "Ruby audit detects predicates bangs and endless definitions" { + let base = fixture_config(".", "ruby_sorbet"); + let config = make_source_config( + base.language, base.source_directory, `${base.source_root}-audit`, base.rule_file, + base.file_glob, base.suffix, base.declaration_rule_ids, + base.raw_name_field, base.prefix, + ); + let scan = scan_source(".", config); + let audits = scan.matches.filter((ast_match: AstMatch) -> bool { + ast_match.ruleId == "ruby-audit-test" + }); + assert.equal(audits.length(), 3); + for (let ast_match in audits) { + { + validate_audit_coverage(SourceScan { config: config, matches: [ast_match] }); + baml.sys.panic("expected unsupported Ruby method rejection") + } catch (e) { + baml.errors.ParseError => null + } + } +} + +test "audit coverage rejects Ruby methods without canonical test IDs" { + let config = fixture_config(".", "ruby_sorbet"); + let scan = SourceScan { config: config, matches: [fixture_ast_match("ruby-audit-test", 0, 24)] }; + { + validate_audit_coverage(scan); + baml.sys.panic("expected noncanonical Ruby test declaration rejection") + } catch (e) { + baml.errors.ParseError => null + } +} + test "dynamic TypeScript titles are rejected instead of undercounted" { { let _ = decode_typescript_id("`${prefix}_dynamic_case`", "fixture/customizable/sample.test.ts", 1); @@ -328,7 +403,7 @@ test "baseline report round trips escaped Markdown cells" { let row = fixture_parity_row( "type_shapes/round_trip", sdk_envs, - [true, false, true, false, true, false, true, false, true, false], + [true, false, true, false, true, false, true, false, true, false, true], ["python_pydantic2", "typescript_node"], "reason with a | pipe and \\ slash", ); @@ -416,5 +491,6 @@ test "TypeScript runtime membership narrows only TypeScript requirements" { assert.equal(array_contains(required, "typescript_node"), false); assert.equal(array_contains(required, "typescript_web_chromium"), true); assert.equal(array_contains(required, "typescript_web_cloudflare_workers"), true); - assert.equal(array_contains(required, "python_pydantic2"), true) + assert.equal(array_contains(required, "python_pydantic2"), true); + assert.equal(array_contains(required, "ruby_sorbet"), true) } diff --git a/tools/sdk-parity-lint/test-fixtures/ruby-adjacency/fixture/customizable/test_layout.rb b/tools/sdk-parity-lint/test-fixtures/ruby-adjacency/fixture/customizable/test_layout.rb new file mode 100644 index 00000000000..984b7b31bf3 --- /dev/null +++ b/tools/sdk-parity-lint/test-fixtures/ruby-adjacency/fixture/customizable/test_layout.rb @@ -0,0 +1,6 @@ +# SDK_PARITY_LINT(skip): must not attach to the next live test +# def test_fixture_disabled +# end + +def test_fixture_live +end diff --git a/tools/sdk-parity-lint/test-fixtures/ruby-audit/fixture/customizable/test_unsupported.rb b/tools/sdk-parity-lint/test-fixtures/ruby-audit/fixture/customizable/test_unsupported.rb new file mode 100644 index 00000000000..e94200c02d3 --- /dev/null +++ b/tools/sdk-parity-lint/test-fixtures/ruby-audit/fixture/customizable/test_unsupported.rb @@ -0,0 +1,11 @@ +class UnsupportedFormsTest + def test_predicate? + true + end + + def test_bang! + true + end + + def test_endless = true +end diff --git a/tools/sdk-parity-lint/test-fixtures/ruby/fixture/customizable/test_sample.rb b/tools/sdk-parity-lint/test-fixtures/ruby/fixture/customizable/test_sample.rb new file mode 100644 index 00000000000..ce2d5efdb6c --- /dev/null +++ b/tools/sdk-parity-lint/test-fixtures/ruby/fixture/customizable/test_sample.rb @@ -0,0 +1,14 @@ +class SampleTest + def helper + nil + end + + def test_with_parameters_case(value = nil) + value + end + + # SDK_PARITY_LINT(skip): fixture-only behavior + def test_sync_case + nil + end +end From 4a6b961685359b6fdfe36001cdf4b51dcd08d633 Mon Sep 17 00:00:00 2001 From: Ryan Mazzolini Date: Mon, 2 Nov 2026 14:59:50 +0000 Subject: [PATCH 2/7] feat(ruby): add bigint, bytes, media, union and alias values, and docstrings The foundation encodes null, primitives, single literals, classes, enums, lists, string-keyed maps and optionals. Declarations using any other value kind were skipped. This adds bigints, bytes, media values (Baml::Image, Audio, Video and Pdf, sent as content), general unions, literal unions, enum-variant types, type aliases and non-string map keys. A Ruby String is sent as bytes only where the parameter is declared bytes; in a string-or-bytes union a binary String selects bytes. Recursive aliases widen only their recursive back-edge to T.untyped, because Sorbet cannot express them. Float parameters and fields accept Integer, matching Python. Generated code now carries docstrings and Raises sections. Under ABI 5 these kinds go through the walker too: bigints keep their kind, including literals such as 42n; recursive aliases are stated by name with a cycle guard; and only string map keys can be sent, as the bridge requires. Tests: ports the shared type_shapes, docstrings_etc and raises tests; 112 nextest targets pass and Ruby declares 132 shared SDK tests. Developed with AI assistance. --- .../sdk_tests/crates/parity_analysis.md | 216 ++++++------- .../docstrings_etc/customizable/test_main.rb | 55 ++++ .../customizable/test_raises.rb | 55 ++++ .../llm_functions/customizable/test_import.rb | 9 + .../sdk_tests/crates/ruby_sorbet/src/lib.rs | 2 +- .../ruby_sorbet/test/bridge_encode_test.rb | 284 +++++++++++++++++- .../type_shapes/customizable/test_aliases.rb | 21 ++ .../customizable/test_class_refs.rb | 22 ++ .../customizable/test_complex_models.rb | 50 +++ .../type_shapes/customizable/test_enums.rb | 29 ++ .../customizable/test_forward_refs.rb | 24 ++ .../type_shapes/customizable/test_handles.rb | 73 +++++ .../type_shapes/customizable/test_lists.rb | 28 ++ .../type_shapes/customizable/test_main.rb | 29 ++ .../type_shapes/customizable/test_maps.rb | 24 ++ .../type_shapes/customizable/test_media.rb | 85 ++++++ .../type_shapes/customizable/test_optional.rb | 34 +++ .../customizable/test_primitives.rb | 90 ++++++ .../customizable/test_recursion.rb | 51 ++++ .../type_shapes/customizable/test_routing.rb | 56 ++++ .../customizable/test_symbol_collisions.rb | 33 ++ .../type_shapes/customizable/test_unions.rb | 44 +++ .../customizable/typed_consumer.rb | 16 + .../ref-ruby-state-of-completeness.md | 54 ++-- .../sdks/ruby/bridge_ruby/lib/baml/bridge.rb | 8 +- .../lib/baml/bridge/encode/commit.rb | 18 ++ .../lib/baml/bridge/encode/plan.rb | 58 ++-- .../lib/baml/bridge/encode/read.rb | 7 + .../lib/baml/bridge/encode/rules.rb | 9 +- .../lib/baml/bridge/encode/slot.rb | 190 +++++++++--- .../bridge_ruby/lib/baml/bridge/protocol.rb | 84 +++++- .../sdks/ruby/bridge_ruby/lib/baml/media.rb | 51 ++++ .../sdks/ruby/bridge_ruby/rbi/baml.rbi | 43 ++- .../sdkgen_ruby_sorbet/src/descriptors.rs | 29 +- .../sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs | 145 ++++++++- .../sdks/ruby/sdkgen_ruby_sorbet/src/lib.rs | 16 +- .../ruby/sdkgen_ruby_sorbet/src/package.rs | 1 + .../sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs | 123 ++++++++ .../sdks/ruby/sdkgen_ruby_sorbet/src/types.rs | 114 ++++++- 39 files changed, 2043 insertions(+), 237 deletions(-) create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/docstrings_etc/customizable/test_main.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_raises.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_import.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_aliases.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_class_refs.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_complex_models.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_enums.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_forward_refs.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_handles.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_lists.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_main.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_maps.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_media.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_optional.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_recursion.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_routing.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_symbol_collisions.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_unions.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/typed_consumer.rb create mode 100644 baml_language/sdks/ruby/bridge_ruby/lib/baml/media.rb diff --git a/baml_language/sdk_tests/crates/parity_analysis.md b/baml_language/sdk_tests/crates/parity_analysis.md index 35965594e5b..dc9c6c1cdf8 100644 --- a/baml_language/sdk_tests/crates/parity_analysis.md +++ b/baml_language/sdk_tests/crates/parity_analysis.md @@ -2,9 +2,9 @@ This report inventories checked-in test declarations. It does not report whether tests passed. -Distinct exact test IDs: 983. IDs with complete required parity: 234. Required gaps: 5865. +Distinct exact test IDs: 983. IDs with complete required parity: 234. Required gaps: 5760. -Baseline ratchet: UNCHANGED. Required gaps: 5865 (baseline: 5865). Present declarations: 2639 (baseline: 2639). Newly missing required pairs: 0. Resolved baseline gaps: 0. Weakened requirements: 0. +Baseline ratchet: UNCHANGED. Required gaps: 5760 (baseline: 5760). Present declarations: 2744 (baseline: 2744). Newly missing required pairs: 0. Resolved baseline gaps: 0. Weakened requirements: 0. ## Python-baselined parity @@ -22,7 +22,7 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | go | 21 / 457 | 4.6% | | java | 307 / 457 | 67.2% | | swift | 191 / 457 | 41.8% | -| ruby_sorbet | 27 / 457 | 5.9% | +| ruby_sorbet | 132 / 457 | 28.9% | | Test case | python_pydantic2 | typescript_node | typescript_web_chromium | typescript_web_cloudflare_workers | cpp | csharp | rust | go | java | swift | ruby_sorbet | Required in | Reason | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | @@ -31,16 +31,16 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | docstrings_etc/enum_summary_only_omits_member_comments | - | - | - | - | - | - | - | y | - | - | - | all | | | docstrings_etc/go_codegen_function_doc_comment | - | - | - | - | - | - | - | y | - | - | - | all | | | docstrings_etc/imports | - | - | - | - | - | - | - | y | - | - | - | all | | -| docstrings_etc/main_class_doc_summary_and_attributes_section | y | y | - | - | y | - | y | - | y | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| docstrings_etc/main_class_doc_summary_and_attributes_section | y | y | - | - | y | - | y | - | y | - | y | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | | docstrings_etc/main_class_doc_summary_present | - | - | - | - | - | - | - | - | - | y | - | all | | | docstrings_etc/main_enum_and_variant_docs_attached | - | - | - | - | - | - | - | - | - | y | - | all | | -| docstrings_etc/main_enum_doc_summary_and_members_section | y | y | - | - | y | - | y | - | y | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | -| docstrings_etc/main_enum_summary_only_omits_members_section | y | - | - | - | y | - | y | - | y | - | - | all | | +| docstrings_etc/main_enum_doc_summary_and_members_section | y | y | - | - | y | - | y | - | y | - | y | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| docstrings_etc/main_enum_summary_only_omits_members_section | y | - | - | - | y | - | y | - | y | - | y | all | | | docstrings_etc/main_field_docs_attached | - | - | - | - | - | - | - | - | - | y | - | all | | -| docstrings_etc/main_imports_symbols_reachable | y | y | y | y | - | - | y | - | y | y | - | all | | +| docstrings_etc/main_imports_symbols_reachable | y | y | y | y | - | - | y | - | y | y | y | all | | | docstrings_etc/main_multi_line_class_doc_preserved | - | - | - | - | - | - | - | - | - | y | - | all | | -| docstrings_etc/main_no_inline_field_or_variant_doc_artifacts | y | y | - | - | y | - | y | - | y | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | -| docstrings_etc/main_undocumented_field_listed_as_bare_name_under_attributes | y | - | - | - | y | - | y | - | y | - | - | all | | +| docstrings_etc/main_no_inline_field_or_variant_doc_artifacts | y | y | - | - | y | - | y | - | y | - | y | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| docstrings_etc/main_undocumented_field_listed_as_bare_name_under_attributes | y | - | - | - | y | - | y | - | y | - | y | all | | | docstrings_etc/no_inline_field_or_variant_doc_artifacts | - | - | - | - | - | - | - | y | - | - | - | all | | | docstrings_etc/undocumented_field_has_no_doc_artifact | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/ambient_context_survives_await_rust_only | - | - | - | - | - | - | y | - | - | - | - | rust | Rust task scopes and lazy future cancellation are specific to Rust | @@ -506,16 +506,16 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/promise_callback_preserves_async_local_storage_across_suspension_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | | function_calls/promise_callback_sync_reentry_rejects_host_callback_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | | function_calls/provider_error_preserves_native_error_metadata | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | exercises Python BamlError and generated Pydantic payloads | -| function_calls/raises_async_sibling_also_has_raises | y | - | - | - | y | - | y | - | y | - | - | all | | +| function_calls/raises_async_sibling_also_has_raises | y | - | - | - | y | - | y | - | y | - | y | all | | | function_calls/raises_imports | - | - | - | - | - | - | y | - | - | - | - | all | | -| function_calls/raises_imports_symbols_reachable | y | - | - | - | y | - | - | - | y | - | - | all | | -| function_calls/raises_inferred_contract_without_clause_still_raises | y | - | - | - | y | - | y | - | y | - | - | all | | -| function_calls/raises_method_raises_block_in_pyi | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/raises_imports_symbols_reachable | y | - | - | - | y | - | - | - | y | - | y | all | | +| function_calls/raises_inferred_contract_without_clause_still_raises | y | - | - | - | y | - | y | - | y | - | y | all | | +| function_calls/raises_method_raises_block_in_pyi | y | - | - | - | - | - | y | - | y | - | y | all | | | function_calls/raises_method_raises_blocks | - | - | - | - | y | - | - | - | - | - | - | all | | -| function_calls/raises_non_throwing_function_has_no_raises_block | y | - | - | - | y | - | y | - | y | - | - | all | | -| function_calls/raises_single_throws | y | - | - | - | y | - | y | - | y | - | - | all | | -| function_calls/raises_summary_precedes_raises_block | y | - | - | - | y | - | y | - | y | - | - | all | | -| function_calls/raises_union_throws_lists_all_names | y | - | - | - | y | - | y | - | y | - | - | all | | +| function_calls/raises_non_throwing_function_has_no_raises_block | y | - | - | - | y | - | y | - | y | - | y | all | | +| function_calls/raises_single_throws | y | - | - | - | y | - | y | - | y | - | y | all | | +| function_calls/raises_summary_precedes_raises_block | y | - | - | - | y | - | y | - | y | - | y | all | | +| function_calls/raises_union_throws_lists_all_names | y | - | - | - | y | - | y | - | y | - | y | all | | | function_calls/reflect_facade_builds_runtime_declarations_and_decodes_their_instances | - | - | - | - | - | - | - | y | - | - | - | go | Go's hand-written reflect facade; the host_reflect fixture that would cover it does not compile | | function_calls/reflected_type_closed_dynamic_unions_and_callback | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/reflected_type_composes_through_optional_containers_and_classes | - | - | - | - | - | - | - | y | - | - | - | all | | @@ -683,7 +683,7 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | llm_functions/main_namespaces_reachable_via_explicit_import | y | - | - | - | - | - | y | - | y | - | - | all | | | llm_functions/main_nullable_model_field_can_be_omitted | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | pins the Python generator's nullable-field `= None` default | | llm_functions/main_replay_server_namespace_bindings | y | - | - | - | - | - | y | - | y | - | y | all | | -| llm_functions/main_root_imports_cleanly | y | y | y | y | - | - | y | - | y | - | - | all | | +| llm_functions/main_root_imports_cleanly | y | y | y | y | - | - | y | - | y | - | y | all | | | llm_functions/main_spec_replaces_prompt_parse_and_request_companions | - | - | - | - | - | - | y | - | - | - | - | rust | validates Rust's generated FunctionSpec prompt, parse, and request surface | | llm_functions/main_streaming_extract_factory_bindings | y | - | - | - | - | - | y | - | y | - | - | all | | | llm_functions/main_streaming_extract_operation_bindings | y | - | - | - | - | - | - | - | y | - | - | python_pydantic2, java | pins Python and Java flat streaming operation binding spellings | @@ -723,10 +723,10 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | package_edges/compile_llm_projection_default_overrides | - | - | - | - | - | - | - | y | - | - | - | go | pins Go generator options for authored Spec and Stream defaults. | | type_shapes/alias_container_composition_and_defaults | - | - | - | - | - | - | - | y | - | - | - | all | | | type_shapes/alias_package_scope_collisions_compile_and_run | - | - | - | - | - | - | - | y | - | - | - | all | | -| type_shapes/aliases_round_trip_alias_container | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/aliases_round_trip_alias_container | y | y | y | y | y | - | y | - | y | y | y | all | | | type_shapes/aliases_round_trip_maybe_rec | - | - | - | - | y | - | - | - | - | - | - | all | | -| type_shapes/aliases_round_trip_rec_list | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/aliases_round_trip_string_list | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/aliases_round_trip_rec_list | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/aliases_round_trip_string_list | y | y | y | y | y | - | y | - | y | y | y | all | | | type_shapes/bridge_handles_media_clones_ordinary_owners_and_wire_keys_independently | - | y | y | y | - | - | - | - | - | - | - | all | | | type_shapes/bridge_handles_media_clones_ownership_through_to_handle_and_from_handle | - | y | y | y | - | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | exercises TypeScript bridge media handle ownership APIs | | type_shapes/bridge_handles_media_constructs_url_file_and_base64_descriptors | - | y | y | y | - | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | exercises TypeScript bridge media descriptor APIs | @@ -736,14 +736,14 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | type_shapes/bridge_handles_media_rejects_an_invalid_ordinary_key_only_when_an_operation_resolves_it | - | y | y | y | - | - | - | - | - | - | - | all | | | type_shapes/bridge_surface_exports_the_same_runtime_values_in_node_browsers_and_workers | - | y | y | y | - | - | - | - | - | - | - | all | | | type_shapes/bridge_surface_preserves_the_public_constructor_names | - | y | y | y | - | - | - | - | - | - | - | all | | -| type_shapes/class_refs_make_outer | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/class_refs_make_outer | y | y | y | y | y | - | y | - | y | y | y | all | | | type_shapes/class_refs_round_trip_async_by_value_or_reference_rust_only | - | - | - | - | - | - | y | - | - | - | - | rust | Rust class parameters take `impl Borrow`, so callers pass a value or a reference | | type_shapes/class_refs_round_trip_by_value_or_reference_rust_only | - | - | - | - | - | - | y | - | - | - | - | rust | Rust class parameters take `impl Borrow`, so callers pass a value or a reference | -| type_shapes/class_refs_round_trip_inner | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/class_refs_round_trip_outer | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/class_refs_round_trip_inner | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/class_refs_round_trip_outer | y | y | y | y | y | - | y | - | y | y | y | all | | | type_shapes/closed_union_zero_value_returns_an_input_error | - | - | - | - | - | - | - | y | - | - | - | all | | | type_shapes/complex_models_round_trip_complex_profile_accepts_plain_object_literals_no_class_constructors | - | y | y | y | - | - | - | - | - | - | - | all | | -| type_shapes/complex_models_round_trip_complex_profile_preserves_deeply_nested_mixed_shape_class | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/complex_models_round_trip_complex_profile_preserves_deeply_nested_mixed_shape_class | y | y | y | y | y | - | y | - | y | y | y | all | | | type_shapes/container_recursive_class_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | | type_shapes/declared_enum_functions_and_class_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | | type_shapes/deep_namespace_thing_reachable | - | - | - | - | - | - | - | y | - | - | - | all | | @@ -756,17 +756,17 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | type_shapes/dynamic_union_rejects_unserializable_go_values_in_bridge | - | - | - | - | - | - | - | y | - | - | - | all | | | type_shapes/enum_composition_matrix_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | | type_shapes/enum_package_scope_collisions_compile_and_run | - | - | - | - | - | - | - | y | - | - | - | all | | -| type_shapes/enums_pick_positive | y | y | y | y | y | - | y | - | y | - | - | all | | -| type_shapes/enums_pick_sentiment | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/enums_round_trip_enums | y | y | y | y | y | - | y | - | y | - | - | all | | -| type_shapes/enums_round_trip_sentiment | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/enums_round_trip_sentiment_positive | y | y | y | y | y | - | y | - | y | - | - | all | | +| type_shapes/enums_pick_positive | y | y | y | y | y | - | y | - | y | - | y | all | | +| type_shapes/enums_pick_sentiment | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/enums_round_trip_enums | y | y | y | y | y | - | y | - | y | - | y | all | | +| type_shapes/enums_round_trip_sentiment | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/enums_round_trip_sentiment_positive | y | y | y | y | y | - | y | - | y | - | y | all | | | type_shapes/every_supported_leaf_through_lists_and_maps | - | - | - | - | - | - | - | y | - | - | - | all | | | type_shapes/forward_refs_round_trip_g_node_int | y | y | y | y | - | - | y | - | y | - | - | all | | | type_shapes/forward_refs_round_trip_node_symbol_exists | - | - | - | - | y | - | - | - | - | y | - | all | | -| type_shapes/forward_refs_round_trip_other | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/forward_refs_round_trip_rec_list | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/forward_refs_round_trip_rec_list_with_other | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/forward_refs_round_trip_other | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/forward_refs_round_trip_rec_list | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/forward_refs_round_trip_rec_list_with_other | y | y | y | y | y | - | y | - | y | y | y | all | | | type_shapes/generic_generic | y | y | y | y | - | - | y | - | y | y | - | all | | | type_shapes/generic_generic_wrapper_get_value | y | - | - | - | - | - | y | - | y | y | - | all | | | type_shapes/generic_wrapper_get_value | - | - | - | - | - | - | - | y | - | - | - | all | | @@ -777,10 +777,10 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | type_shapes/generics_round_trip_nested_generics | y | y | y | y | - | - | y | - | y | y | - | all | | | type_shapes/generics_round_trip_wrapper_int | y | y | y | y | - | - | y | - | y | y | - | all | | | type_shapes/handles_baml_fs_open_returns_a_typed_file_handle | - | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | -| type_shapes/handles_file_cursor_state_persists_across_calls | y | y | - | - | - | - | y | - | y | y | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| type_shapes/handles_file_cursor_state_persists_across_calls | y | y | - | - | - | - | y | - | y | y | y | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | | type_shapes/handles_http_get_response_fields_and_methods | y | y | - | - | - | - | y | - | y | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | -| type_shapes/handles_image_from_base64_roundtrips_payload | y | y | y | y | - | - | y | - | y | y | - | all | | -| type_shapes/handles_open_file_returns_file_handle | y | - | - | - | - | - | y | - | y | y | - | all | | +| type_shapes/handles_image_from_base64_roundtrips_payload | y | y | y | y | - | - | y | - | y | y | y | all | | +| type_shapes/handles_open_file_returns_file_handle | y | - | - | - | - | - | y | - | y | y | y | all | | | type_shapes/host_created_media_round_trips_through_optional_list | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | validates Python host-created media wrapper encoding | | type_shapes/host_from_file_reads_now_and_keeps_the_base_name | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | validates the Python media wrapper's from_file, which is BAML's from_file | | type_shapes/json_interchange_both_reject_what_baml_rejects | - | - | - | - | - | - | y | - | - | - | - | rust | validates the Rust SDK's optional serde impls | @@ -790,11 +790,11 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | type_shapes/json_interchange_serialize_matches_baml | - | - | - | - | - | - | y | - | - | - | - | rust | validates the Rust SDK's optional serde impls | | type_shapes/json_interchange_serialize_matches_baml_outside_a_class | - | - | - | - | - | - | y | - | - | - | - | rust | validates the Rust SDK's optional serde impls | | type_shapes/json_interchange_unions_take_the_first_arm_that_fits | - | - | - | - | - | - | y | - | - | - | - | rust | validates the Rust SDK's optional serde impls | -| type_shapes/lists_round_trip_empty_list | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/lists_round_trip_ints | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/lists_round_trip_list_container | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/lists_round_trip_optional_strings | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/lists_round_trip_union_list | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/lists_round_trip_empty_list | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/lists_round_trip_ints | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/lists_round_trip_list_container | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/lists_round_trip_optional_strings | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/lists_round_trip_union_list | y | y | y | y | y | - | y | - | y | y | y | all | | | type_shapes/literals_literal_values_convert_implicitly | - | - | - | - | y | - | - | - | - | - | - | all | | | type_shapes/literals_return_literals | y | y | y | y | y | - | y | - | y | y | y | all | | | type_shapes/literals_round_trip_flag_mixed_literal_union | y | - | - | - | y | - | - | - | - | - | - | all | | @@ -805,49 +805,49 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | type_shapes/literals_round_trip_literal_true | y | y | y | y | y | - | y | - | y | y | y | all | | | type_shapes/literals_round_trip_literals | y | y | y | y | y | - | y | - | y | y | y | all | | | type_shapes/lorem_resume_reachable | - | - | - | - | - | - | - | y | - | - | - | all | | -| type_shapes/main_all_namespaces_reachable | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/main_deep_namespace_thing_reachable | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/main_all_namespaces_reachable | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/main_deep_namespace_thing_reachable | y | y | y | y | y | - | y | - | y | y | y | all | | | type_shapes/main_generated_models_ignore_extra_fields | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | validates Python Pydantic extra-field compatibility | -| type_shapes/main_lorem_resume_reachable | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/main_root_foo_reachable | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/main_root_imports_cleanly | y | y | y | y | - | - | y | - | y | - | - | all | | +| type_shapes/main_lorem_resume_reachable | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/main_root_foo_reachable | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/main_root_imports_cleanly | y | y | y | y | - | - | y | - | y | - | y | all | | | type_shapes/main_runtime_owned_builtin_leaves_expose_their_public_names | - | y | y | y | - | - | - | - | - | - | - | all | | | type_shapes/make_foo | - | - | - | - | - | - | - | y | - | - | - | all | | | type_shapes/make_outer | - | - | - | - | - | - | - | y | - | - | - | all | | | type_shapes/maps_round_trip_enum_keyed_map | - | y | y | y | - | - | - | - | - | - | - | all | | -| type_shapes/maps_round_trip_list_valued_map | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/maps_round_trip_list_valued_map | y | y | y | y | y | - | y | - | y | y | y | all | | | type_shapes/maps_round_trip_map_container | - | y | y | y | - | - | - | - | - | - | - | all | | -| type_shapes/maps_round_trip_resume | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/maps_round_trip_sentiment | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/maps_round_trip_simple_map | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/maps_round_trip_resume | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/maps_round_trip_sentiment | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/maps_round_trip_simple_map | y | y | y | y | y | - | y | - | y | y | y | all | | | type_shapes/media_constructors_and_accessors | - | - | - | - | - | - | - | y | - | - | - | all | | | type_shapes/media_from_file_reads_now_and_keeps_the_base_name | - | - | - | - | - | - | - | - | y | - | - | java | validates the Java media wrapper's from_file, which is BAML's from_file | | type_shapes/media_nested_optional_and_containers | - | - | - | - | - | - | - | y | - | - | - | all | | | type_shapes/media_return_and_round_trip_all_kinds | - | - | - | - | - | - | - | y | - | - | - | all | | -| type_shapes/media_return_audio | y | - | - | - | - | - | y | - | y | y | - | all | | +| type_shapes/media_return_audio | y | - | - | - | - | - | y | - | y | y | y | all | | | type_shapes/media_return_audio_preserves_url_and_mime | - | y | y | y | - | - | - | - | - | - | - | all | | -| type_shapes/media_return_image | y | - | - | - | - | - | y | - | y | y | - | all | | +| type_shapes/media_return_image | y | - | - | - | - | - | y | - | y | y | y | all | | | type_shapes/media_return_image_preserves_url_and_mime | - | y | y | y | - | - | - | - | - | - | - | all | | -| type_shapes/media_return_pdf | y | - | - | - | - | - | y | - | y | y | - | all | | +| type_shapes/media_return_pdf | y | - | - | - | - | - | y | - | y | y | y | all | | | type_shapes/media_return_pdf_preserves_url_and_mime | - | y | y | y | - | - | - | - | - | - | - | all | | -| type_shapes/media_return_video | y | - | - | - | - | - | y | - | y | y | - | all | | +| type_shapes/media_return_video | y | - | - | - | - | - | y | - | y | y | y | all | | | type_shapes/media_return_video_preserves_url_and_mime | - | y | y | y | - | - | - | - | - | - | - | all | | -| type_shapes/media_round_trip_all_media | y | y | y | y | y | - | - | y | y | y | - | all | | -| type_shapes/media_round_trip_audio | y | - | - | - | - | - | y | - | y | y | - | all | | +| type_shapes/media_round_trip_all_media | y | y | y | y | y | - | - | y | y | y | y | all | | +| type_shapes/media_round_trip_audio | y | - | - | - | - | - | y | - | y | y | y | all | | | type_shapes/media_round_trip_audio_preserves_url_and_mime | - | y | y | y | - | - | - | - | - | - | - | all | | -| type_shapes/media_round_trip_image | y | - | - | - | - | - | y | - | y | y | - | all | | +| type_shapes/media_round_trip_image | y | - | - | - | - | - | y | - | y | y | y | all | | | type_shapes/media_round_trip_image_can_reuse_the_same_wrapper | - | y | y | y | - | - | - | - | - | - | - | all | | | type_shapes/media_round_trip_image_list | - | - | - | - | - | - | - | - | - | y | - | all | | | type_shapes/media_round_trip_image_map | - | - | - | - | - | - | - | - | - | y | - | all | | | type_shapes/media_round_trip_image_or_audio | - | - | - | - | - | - | - | - | y | y | - | all | | -| type_shapes/media_round_trip_media | y | - | - | - | - | - | y | - | y | y | - | all | | -| type_shapes/media_round_trip_media_or_image_class | y | y | y | y | y | - | - | y | y | y | - | all | | +| type_shapes/media_round_trip_media | y | - | - | - | - | - | y | - | y | y | y | all | | +| type_shapes/media_round_trip_media_or_image_class | y | y | y | y | y | - | - | y | y | y | y | all | | | type_shapes/media_round_trip_media_preserves_all_four_media_fields | - | y | y | y | - | - | - | - | - | - | - | all | | | type_shapes/media_round_trip_media_shapes | - | - | - | - | - | - | - | - | - | y | - | all | | | type_shapes/media_round_trip_optional_image_list | - | - | - | - | - | - | - | - | - | y | - | all | | -| type_shapes/media_round_trip_pdf | y | - | - | - | - | - | y | - | y | y | - | all | | +| type_shapes/media_round_trip_pdf | y | - | - | - | - | - | y | - | y | y | y | all | | | type_shapes/media_round_trip_pdf_preserves_url_and_mime | - | y | y | y | - | - | - | - | - | - | - | all | | -| type_shapes/media_round_trip_video | y | - | - | - | - | - | y | - | y | y | - | all | | +| type_shapes/media_round_trip_video | y | - | - | - | - | - | y | - | y | y | y | all | | | type_shapes/media_round_trip_video_preserves_url_and_mime | - | y | y | y | - | - | - | - | - | - | - | all | | | type_shapes/media_uses_the_runtime_owned_wrapper_constructors | - | y | y | y | - | - | - | - | - | - | - | all | | | type_shapes/nested_class_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | @@ -861,11 +861,11 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | type_shapes/opaque_rust_type_round_trips_and_remains_reusable | - | - | - | - | - | - | - | y | - | - | - | all | | | type_shapes/optional_omitted_nullable_keys_read_as_null | - | y | y | y | - | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | TypeScript object literals and constructor inits may leave nullable keys out | | type_shapes/optional_omitted_required_key_is_rejected | - | y | y | y | - | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | only TypeScript has a type-level check that a required key is present | -| type_shapes/optional_round_trip_optional_container | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/optional_round_trip_optional_int | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/optional_round_trip_optional_resume | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/optional_round_trip_optional_union | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/optional_round_trip_resume | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/optional_round_trip_optional_container | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/optional_round_trip_optional_int | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/optional_round_trip_optional_resume | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/optional_round_trip_optional_union | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/optional_round_trip_resume | y | y | y | y | y | - | y | - | y | y | y | all | | | type_shapes/optional_top_level_round_trips | - | - | - | - | - | - | - | y | - | - | - | all | | | type_shapes/overlapping_list_arms_preserve_exact_kind_for_empty_and_nonempty_values | - | - | - | - | - | - | - | y | - | - | - | all | | | type_shapes/overlapping_literal_arms_round_trip_with_exact_kind | - | - | - | - | - | - | - | y | - | - | - | all | | @@ -874,29 +874,29 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | type_shapes/pick_sentiment | - | - | - | - | - | - | - | y | - | - | - | all | | | type_shapes/primitive_class_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | | type_shapes/primitive_literal_union_constructors_round_trip_with_exact_kind_and_value | - | - | - | - | - | - | - | y | - | - | - | all | | -| type_shapes/primitives_return_bigint | y | - | - | - | y | - | - | - | y | - | - | all | | -| type_shapes/primitives_return_bool | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/primitives_return_float | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/primitives_return_bigint | y | - | - | - | y | - | - | - | y | - | y | all | | +| type_shapes/primitives_return_bool | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/primitives_return_float | y | y | y | y | y | - | y | - | y | y | y | all | | | type_shapes/primitives_return_int | y | y | y | y | y | - | y | - | y | y | y | all | | -| type_shapes/primitives_return_null | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/primitives_return_string | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/primitives_round_trip_bigint | y | - | - | - | y | - | - | - | y | - | - | all | | -| type_shapes/primitives_round_trip_bool | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/primitives_round_trip_float | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/primitives_round_trip_float_accepts_int | y | - | - | - | y | - | y | - | y | - | - | all | | +| type_shapes/primitives_return_null | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/primitives_return_string | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/primitives_round_trip_bigint | y | - | - | - | y | - | - | - | y | - | y | all | | +| type_shapes/primitives_round_trip_bool | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/primitives_round_trip_float | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/primitives_round_trip_float_accepts_int | y | - | - | - | y | - | y | - | y | - | y | all | | | type_shapes/primitives_round_trip_float_refuses_an_int_on_the_wire | - | - | - | - | - | - | y | - | - | - | - | rust | only the low-level Rust entry point can put an int on the wire where a float is declared | -| type_shapes/primitives_round_trip_int | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/primitives_round_trip_int | y | y | y | y | y | - | y | - | y | y | y | all | | | type_shapes/primitives_round_trip_int_async | - | - | - | - | - | - | - | - | - | y | - | all | | -| type_shapes/primitives_round_trip_null | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/primitives_round_trip_primitives | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/primitives_round_trip_primitives_float_field_accepts_int | y | - | - | - | y | - | y | - | y | y | - | all | | -| type_shapes/primitives_round_trip_string | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/primitives_round_trip_uint8_array | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/recursion_int_binary_tree_deeper_than_the_wire_is_refused | y | y | y | y | y | - | y | y | y | y | - | all | | -| type_shapes/recursion_round_trip_int_binary_tree | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/recursion_round_trip_mutual_recursion | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/recursion_round_trip_scc_t1_t2_t3 | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/recursion_round_trip_scc_t4_t5_t6 | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/primitives_round_trip_null | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/primitives_round_trip_primitives | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/primitives_round_trip_primitives_float_field_accepts_int | y | - | - | - | y | - | y | - | y | y | y | all | | +| type_shapes/primitives_round_trip_string | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/primitives_round_trip_uint8_array | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/recursion_int_binary_tree_deeper_than_the_wire_is_refused | y | y | y | y | y | - | y | y | y | y | y | all | | +| type_shapes/recursion_round_trip_int_binary_tree | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/recursion_round_trip_mutual_recursion | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/recursion_round_trip_scc_t1_t2_t3 | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/recursion_round_trip_scc_t4_t5_t6 | y | y | y | y | y | - | y | - | y | y | y | all | | | type_shapes/required_container_round_trips | - | - | - | - | - | - | - | y | - | - | - | all | | | type_shapes/return_bool | - | - | - | - | - | - | - | y | - | - | - | all | | | type_shapes/return_float | - | - | - | - | - | - | - | y | - | - | - | all | | @@ -975,22 +975,22 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | type_shapes/round_trip_union_list | - | - | - | - | - | - | - | y | - | - | - | all | | | type_shapes/round_trip_union_t | - | - | - | - | - | - | - | y | - | - | - | all | | | type_shapes/round_trip_wrapper_int | - | - | - | - | - | - | - | y | - | - | - | all | | -| type_shapes/routing_make_foo | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/routing_round_trip_deep_thing_from_a | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/routing_round_trip_deep_thing_from_lorem | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/routing_round_trip_foo | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/routing_round_trip_lorem_resume_from_ipsum | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/routing_round_trip_resume | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/routing_round_trip_resume_or_http_response | y | y | y | y | - | - | - | - | y | y | - | all | | -| type_shapes/routing_round_trip_root_foo | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/routing_round_trip_root_foo_from_ab | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/routing_round_trip_thing_from_ab | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/routing_make_foo | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/routing_round_trip_deep_thing_from_a | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/routing_round_trip_deep_thing_from_lorem | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/routing_round_trip_foo | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/routing_round_trip_lorem_resume_from_ipsum | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/routing_round_trip_resume | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/routing_round_trip_resume_or_http_response | y | y | y | y | - | - | - | - | y | y | y | all | | +| type_shapes/routing_round_trip_root_foo | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/routing_round_trip_root_foo_from_ab | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/routing_round_trip_thing_from_ab | y | y | y | y | y | - | y | - | y | y | y | all | | | type_shapes/supported_namespaces_reachable | - | - | - | - | - | - | - | y | - | - | - | all | | -| type_shapes/symbol_collisions_round_trip_deep | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/symbol_collisions_round_trip_fizz_buzz_foo_bar | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/symbol_collisions_round_trip_fizz_foo_bar | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/symbol_collisions_round_trip_foo_bar | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/symbol_collisions_round_trip_ipsum | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/symbol_collisions_round_trip_deep | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/symbol_collisions_round_trip_fizz_buzz_foo_bar | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/symbol_collisions_round_trip_fizz_foo_bar | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/symbol_collisions_round_trip_foo_bar | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/symbol_collisions_round_trip_ipsum | y | y | y | y | y | - | y | - | y | y | y | all | | | type_shapes/transparent_alias_functions_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | | type_shapes/type_alias_declared_before_classes_is_importable | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | validates Python-specific generated type alias ordering | | type_shapes/typemap_is_installed_during_root_module_evaluation | - | y | y | y | - | - | - | - | - | - | - | all | | @@ -999,13 +999,13 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | type_shapes/union_aliases_are_transparent_and_flatten_before_thresholding | - | - | - | - | - | - | - | y | - | - | - | all | | | type_shapes/unions_consumption_surfaces | - | - | - | - | - | - | - | - | - | y | - | all | | | type_shapes/unions_match_dispatches_by_type | - | - | - | - | y | - | - | - | - | - | - | all | | -| type_shapes/unions_round_trip_dedup | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/unions_round_trip_null_to_end | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/unions_round_trip_optional_plus_null | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/unions_round_trip_singleton_unwrap | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/unions_round_trip_str_or_int_list | y | - | - | - | y | - | y | - | y | - | - | all | | -| type_shapes/unions_round_trip_t | y | y | y | y | y | - | y | - | y | y | - | all | | -| type_shapes/unions_round_trip_union_container | y | y | y | y | y | - | y | - | y | y | - | all | | +| type_shapes/unions_round_trip_dedup | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/unions_round_trip_null_to_end | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/unions_round_trip_optional_plus_null | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/unions_round_trip_singleton_unwrap | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/unions_round_trip_str_or_int_list | y | - | - | - | y | - | y | - | y | - | y | all | | +| type_shapes/unions_round_trip_t | y | y | y | y | y | - | y | - | y | y | y | all | | +| type_shapes/unions_round_trip_union_container | y | y | y | y | y | - | y | - | y | y | y | all | | | type_shapes/unions_union_is_a_plain_std_variant | - | - | - | - | y | - | - | - | - | - | - | all | | | type_shapes/void_no_op | y | y | y | y | y | - | y | - | y | y | y | all | | | unsupported_only/compile_unsupported_only_package_compiles | - | - | - | - | - | - | - | y | - | - | - | all | | diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/docstrings_etc/customizable/test_main.rb b/baml_language/sdk_tests/crates/ruby_sorbet/docstrings_etc/customizable/test_main.rb new file mode 100644 index 00000000000..de11f4667b5 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/docstrings_etc/customizable/test_main.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" + +class MainTest < Minitest::Test + def source + File.read(File.join(__dir__, "baml_sdk/docs.rb")) + end + + # Ruby comments, like Java's Javadoc, are a source-only documentation surface. + def doc_for(name) + lines = source.split("class #{name} <", 2).first.lines + lines.pop while lines.last&.strip == "" + lines.reverse.take_while { |line| line.lstrip.start_with?("#") } + .reverse.map { |line| line.sub(/^\s*# ?/, "").rstrip }.join("\n") + end + + def test_main_imports_symbols_reachable + [BamlSdk::Docs::Doc, BamlSdk::Docs::Note, BamlSdk::Docs::Priority, BamlSdk::Docs::Sentiment].each do |symbol| + assert_kind_of Class, symbol + end + end + + def test_main_class_doc_summary_and_attributes_section + assert_equal "A document with a title and an optional body.\n\nAttributes:\n" \ + " title: Title shown in lists and search results.\n body: Free-form body text.", doc_for("Doc") + end + + def test_main_undocumented_field_listed_as_bare_name_under_attributes + doc = doc_for("Note") + assert doc.start_with?("A multi-line summary.\nContinuation line"), doc + assert_includes doc, "\n\nAttributes:\n id: Stable identifier" + assert doc.end_with?("\n text"), doc + end + + def test_main_enum_doc_summary_and_members_section + assert_equal "Sentiment labels surfaced by the model.\n\nMembers:\n" \ + " HAPPY: Smiling face.\n SAD: Frowning face.\n NEUTRAL", doc_for("Sentiment") + end + + def test_main_enum_summary_only_omits_members_section + doc = doc_for("Priority") + assert_equal "Pin the \"summary only, no member rollup\" case: this enum has a\n" \ + "class-level `///` but every variant is bare.", doc + refute_includes doc, "Members:" + assert_equal %w[HIGH LOW MEDIUM], BamlSdk::Docs::Priority.values.map(&:serialize).sort + end + + def test_main_no_inline_field_or_variant_doc_artifacts + refute_includes source, "# Title shown in lists" + refute_includes source, "# Smiling face" + refute_match(/(?:const :|= new\().*#/, source) + assert_equal 4, source.scan(/(?:^\s*#.*\n)+(?=\s*class )/).length + end +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_raises.rb b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_raises.rb new file mode 100644 index 00000000000..30643a42f1a --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_raises.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" + +class RaisesTest < Minitest::Test + def doc_for(marker) + source = File.read(File.join(__dir__, "baml_sdk/raises_test.rb")) + assert_includes source, marker + lines = source.split(marker, 2).first.lines + lines.pop while lines.last && (lines.last.strip.empty? || lines.last.lstrip.start_with?("sig ")) + lines.reverse.take_while { |line| line.lstrip.start_with?("#") } + .reverse.map { |line| line.sub(/^\s*# ?/, "").rstrip }.join("\n") + end + + def test_raises_imports_symbols_reachable + assert_kind_of Class, BamlSdk::RaisesTest::DocLoader + %i[inferred_throw load_doc pure_len reparse].each { |name| assert_respond_to BamlSdk::RaisesTest, name } + end + + def test_raises_union_throws_lists_all_names + assert doc_for("def self.load_doc(").end_with?("Raises:\n ParseError, TimeoutError") + end + + def test_raises_async_sibling_also_has_raises + assert doc_for("def self.load_doc_async(").end_with?("Raises:\n ParseError, TimeoutError") + end + + def test_raises_single_throws + assert doc_for("def self.reparse(").end_with?("Raises:\n ParseError") + end + + def test_raises_summary_precedes_raises_block + doc = doc_for("def self.load_doc(") + assert doc.start_with?("Load a document from a path."), doc + assert_includes doc, "\n\nRaises:\n" + end + + def test_raises_inferred_contract_without_clause_still_raises + assert doc_for("def self.inferred_throw(").end_with?("Raises:\n ParseError") + end + + def test_raises_non_throwing_function_has_no_raises_block + refute_includes doc_for("def self.pure_len("), "Raises:" + end + + def test_raises_method_raises_block_in_pyi + # Ruby has no separate stub: both method comments live in generated source. + load = doc_for("def load(") + assert_includes load, "Raises:" + assert_includes load, "ParseError" + create = doc_for("def self.create(") + assert_includes create, "Raises:" + assert_includes create, "TimeoutError" + end +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_import.rb b/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_import.rb new file mode 100644 index 00000000000..00d9975fcb5 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_import.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" + +class ImportTest < Minitest::Test + def test_main_root_imports_cleanly + assert_kind_of Module, BamlSdk + end +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/src/lib.rs b/baml_language/sdk_tests/crates/ruby_sorbet/src/lib.rs index 751d11e6d2c..f7dfd9c76cf 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/src/lib.rs +++ b/baml_language/sdk_tests/crates/ruby_sorbet/src/lib.rs @@ -1,6 +1,6 @@ #[cfg(test)] sdk_test_harness_runner::ruby_sorbet::test_suite! { - fixture docstrings_etc { minitest: later, sorbet: later } + fixture docstrings_etc { minitest: on, sorbet: on } fixture function_calls { minitest: on, sorbet: on } fixture llm_functions { minitest: on, sorbet: on } fixture type_shapes { minitest: on, sorbet: on } diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_encode_test.rb b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_encode_test.rb index 7afa176a0b6..20ded340f56 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_encode_test.rb +++ b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_encode_test.rb @@ -23,11 +23,32 @@ class Registry def initialize @types = {} @fields = {} + @aliases = {} + @normalized_aliases = {} + @crossings = {} end - def define(type, name, fields = nil) + def define(type, name, fields = nil, crossing: :fields) @types[type] = [name, fields] @fields[name] = fields + @crossings[name] = crossing + end + + def class_crossing(name) + @crossings.fetch(name, :fields) + end + + def define_alias(name, body, recursive: false) + @aliases[name] = [body, recursive] + @normalized_aliases.clear + end + + def normalized_alias(name) + @normalized_aliases[name] ||= yield @aliases[name] + end + + def alias_definition(name) + @aliases[name] end def encoding_type(type) @@ -93,6 +114,101 @@ def test_string_slots_validate_binary_utf8_values_and_keys assert_raises(TypeError) { encode({ "\xff".b => 1 }, slot) } end + def test_alias_normalization_is_cached_per_registry_and_invalidated_on_definition + @registry.define_alias("user.A", [:list, [:int]]) + @registry.define_alias("user.B", [:map, [:string], [:alias, "user.A"]]) + first = Encode::Slot.stated_form([:alias, "user.B"], @registry) + assert_same first, Encode::Slot.stated_form([:alias, "user.B"], @registry) + @registry.define_alias("user.A", [:string]) + assert_equal [:map, [:string], [:string]], Encode::Slot.stated_form([:alias, "user.B"], @registry) + other = Registry.new + other.define_alias("user.B", [:bool]) + assert_equal [:bool], Encode::Slot.stated_form([:alias, "user.B"], other) + end + + def test_alias_cache_does_not_publish_a_normalization_from_before_redefinition + name = "user.CacheRace" + Protocol.define_alias(name, [:int]) + entered, resume = Queue.new, Queue.new + normalizer = Thread.new do + Protocol.normalized_alias(name) do |definition| + entered << true + resume.pop + definition.first + end + end + Timeout.timeout(5) { entered.pop } + Protocol.define_alias(name, [:string]) + resume << true + assert_equal [:int], normalizer.value + assert_equal [:string], Encode::Slot.stated_form([:alias, name], Protocol) + ensure + resume << true if resume + normalizer&.join(5) + end + + def test_expanding_aliases_spend_the_planning_budget_even_with_a_warm_cache + @registry.define_alias("user.A0", [:int]) + 1.upto(14) do |index| + previous = [:alias, "user.A#{index - 1}"] + @registry.define_alias("user.A#{index}", union([:list, previous], [:map, [:string], previous])) + end + tree = Encode::Read.new(@registry).read([], "alias") + 2.times do + Timeout.timeout(1) do + error = assert_raises(Encode::Fault) { Encode::Plan.new(@registry, budget: 10).plan(tree, [:alias, "user.A14"]) } + assert_includes error.message, "planning-step budget" + end + end + # Prime a smaller alias, then ensure cache hits cannot evade later union work. + Encode::Slot.stated_form([:alias, "user.A6"], @registry) + Timeout.timeout(1) do + assert_raises(Encode::Fault) { Encode::Plan.new(@registry, budget: 10).plan(tree, [:alias, "user.A6"]) } + end + end + + def expanding_aliases(depth) + @registry.define_alias("user.Deep0", [:int]) + 1.upto(depth) do |index| + previous = [:alias, "user.Deep#{index - 1}"] + @registry.define_alias("user.Deep#{index}", union([:list, previous], [:map, [:string], previous])) + end + end + + def plan_alias_or_refuse(tree, slot) + assert_equal :list, Encode::Plan.new(@registry).plan(tree, slot).kind + rescue Encode::Fault => error + assert_includes error.message, "planning-step budget" + end + + def test_default_budget_bounds_direct_aliases_and_warm_cache_repeats + expanding_aliases(16) + tree = Encode::Read.new(@registry).read([], "alias") + 2.times do + Timeout.timeout(0.75) do + assert_equal :list, Encode::Plan.new(@registry).plan(tree, [:alias, "user.Deep14"]).kind + plan_alias_or_refuse(tree, [:alias, "user.Deep16"]) + end + Timeout.timeout(0.1) do + error = assert_raises(Encode::Fault) { Encode::Plan.new(@registry, budget: 10).plan(tree, [:alias, "user.Deep14"]) } + assert_includes error.message, "planning-step budget" + end + end + end + + def test_class_field_alias_normalization_spends_the_budget + expanding_aliases(18) + klass = Class.new(Struct.new(:items)) + @registry.define(klass, "user.DeepField", { "items" => [:items, [:alias, "user.Deep18"]] }) + tree = Encode::Read.new(@registry).read(klass.new([]), "field") + 2.times do + Timeout.timeout(0.1) do + error = assert_raises(Encode::Fault) { Encode::Plan.new(@registry, budget: 10).plan(tree, [:class, "user.DeepField", []]) } + assert_includes error.message, "planning-step budget" + end + end + end + def test_scalars_follow_the_slot_and_the_hosts_rules [[nil, :null, nil], [true, :bool, :bool_value], [false, :bool, :bool_value], [7, :int, :int_value], [2.5, :float, :float_value], ["text", :string, :string_value]].each do |value, kind, arm| @@ -139,6 +255,154 @@ def test_literals_are_matched_exactly assert_equal :int_value, encode(1, union([:literal, 1.0], [:literal, 1])).value end + def test_bigint_literal_types_are_decimal_while_values_stay_hex + [42, -42, 2**80].each do |value| + slot = [:literal, :bigint, value] + wire = Encode::Slot.to_wire(slot) + assert_equal value.to_s(10), wire.literal.bigint_value + assert_equal slot, Encode::Slot.from_wire(Wire::BamlTy.new(literal: { bigint_value: value.to_s(10) })) + assert_equal value.to_s(16), encode(value, slot).bigint_value + assert_equal value, Protocol.decode_value(Wire::BamlOutboundValue.new(literal_value: { bigint_value: value.to_s(16) })) + end + end + + def test_media_equality_includes_kind_and_every_content_field + image = Baml::Image.from_url("https://example.com/image", mime_type: "image/png") + same = Baml::Image.from_url("https://example.com/image", mime_type: "image/png") + assert_equal image, same + assert image.eql?(same) + assert_equal image.hash, same.hash + assert_equal 1, { image => 1 }[same] + [Baml::Audio.from_url(image.url, mime_type: image.mime_type), + Baml::Image.from_url(image.url), Baml::Image.from_url("other", mime_type: image.mime_type), + Baml::Image.from_base64("YQ=="), Baml::Image.from_file_content("image.png", "YQ==")].each do |other| + refute_equal image, other + refute image.eql?(other) + end + refute_equal Baml::Image.from_base64("YQ=="), Baml::Image.from_base64("Yg==") + refute_equal Baml::Image.from_file_content("a.png", "YQ=="), Baml::Image.from_file_content("b.png", "YQ==") + end + + def test_bigints_bytes_and_literal_kinds_follow_the_slot + [7, 2**62, -(2**80)].each do |value| + result = encode(value, [:bigint]) + assert_equal value.to_s(16), result.bigint_value + assert_nil result.value_type + end + assert_equal :bigint_value, encode(2**62).value + assert_equal :int_value, encode(7, union([:bigint], [:int])).value + assert_equal "2a", encode(42, [:literal, :bigint, 42]).bigint_value + assert_raises(TypeError) { encode(43, [:literal, :bigint, 42]) } + assert_equal :int_value, encode(42, union([:literal, :bigint, 42], [:literal, 42])).value + assert_equal :uint8array_value, encode("ab".b).value + assert_equal :string_value, encode("ab".b, [:string]).value + assert_equal :uint8array_value, encode("ab", [:bytes]).value + assert_equal :string_value, encode("ab", union([:bytes], [:string])).value + assert_equal :uint8array_value, encode("ab".b, union([:string], [:bytes])).value + assert_equal [:list, [:bytes]], type_of(["ab".b]) + end + + def test_bytes_convert_to_text_only_when_valid_utf8_including_map_keys + assert_raises(Encode::Refusal) { encode("\xff".b, [:string]) } + assert_raises(Encode::Refusal) { encode({ "\xff".b => 1 }) } + assert_equal "café", encode("café".b, [:string]).string_value + [[:unknown], [:map, [:string], [:int]]].each do |slot| + result = encode({ "café".b => 1 }, slot) + assert_equal "café", result.map_value.entries.first.string_key + assert_equal Encoding::UTF_8, result.map_value.entries.first.string_key.encoding + end + assert_equal :uint8array_value, encode("\xff".b).value + end + + def test_binary_strings_do_not_convert_to_enums + assert_raises(Encode::Refusal) { encode("red".b, [:enum, "user.Color"]) } + assert_equal :string_value, encode("red".b, union([:enum, "user.Color"], [:string])).value + end + + def test_enum_variants_admit_only_the_named_variant + [Color::Red, "red"].each do |value| + assert_equal "red", encode(value, [:enum_variant, "user.Color", "red"]).enum_value.value + assert_raises(TypeError) { encode(value, [:enum_variant, "user.Color", "blue"]) } + assert_equal "red", encode(value, union([:enum_variant, "user.Color", "blue"], [:enum_variant, "user.Color", "red"])).enum_value.value + end + end + + def test_recursive_aliases_are_judged_by_their_bodies_and_terminate + rec = [:alias, "user.Rec"] + @registry.define_alias("user.Rec", union([:int], [:list, rec]), recursive: true) + result = encode([1, [2]], rec) + assert_equal [:list, rec], Encode::Slot.from_wire(result.value_type) + assert_equal [:list, rec], Encode::Slot.from_wire(result.list_value.values.last.value_type) + assert_raises(TypeError) { encode(["not an int"], rec) } + @registry.define_alias("user.Ints", [:list, [:int]]) + assert_equal [:list, [:int]], type_of([1], [:alias, "user.Ints"]) + @registry.define_alias("user.Number", [:int]) + assert_equal [:list, [:int]], Encode::Slot.stated_form([:list, [:alias, "user.Number"]], @registry) + + a, b = [:alias, "user.A"], [:alias, "user.B"] + @registry.define_alias("user.A", b, recursive: true) + @registry.define_alias("user.B", a, recursive: true) + assert_raises(TypeError) { encode(1, a) } + @registry.define_alias("user.A", union(b, [:list, a]), recursive: true) + assert_equal [:list, a], type_of([[]], a) + assert_raises(TypeError) { encode([1], a) } + @registry.define_alias("user.A", b) + @registry.define_alias("user.B", a) + assert_raises(Encode::Fault) { encode(1, a) } + end + + def test_default_budget_bounds_overlapping_mutually_recursive_alias_trials + a, b = [:alias, "user.A"], [:alias, "user.B"] + @registry.define_alias("user.A", union([:list, a], [:list, b], [:int]), recursive: true) + @registry.define_alias("user.B", union([:list, a], [:list, b], [:string]), recursive: true) + tree = Encode::Read.new(@registry).read(30.times.reduce(1) { |value, _| [value] }, "recursive") + error = Timeout.timeout(15) do + assert_raises(Encode::Fault) { Encode::Plan.new(@registry).plan(tree, a) } + end + assert_includes error.message, "planning-step budget" + end + + def test_overlapping_mutually_recursive_alias_trials_share_the_planning_budget + a, b = [:alias, "user.A"], [:alias, "user.B"] + @registry.define_alias("user.A", union([:list, a], [:list, b], [:int]), recursive: true) + @registry.define_alias("user.B", union([:list, a], [:list, b], [:string]), recursive: true) + tree = Encode::Read.new(@registry).read(8.times.reduce(1) { |value, _| [value] }, "recursive") + error = assert_raises(TypeError) { Encode::Plan.new(@registry, budget: 100).plan(tree, a) } + assert_includes error.message, "planning-step budget" + error = assert_raises(TypeError) { encode([[1]], a) } + assert_includes error.message, "fits more than one" + end + + def test_leaves_fit_their_kind_any_open_slot_and_open_union_members + @registry.define(Class.new, "user.ImageWrapper", {}, crossing: :media_image) + image = Baml::Image.from_url("https://example.com/asset", mime_type: "image/png") + [[:media, :image], [:media, :generic], [:class, "user.ImageWrapper", []], + [:unknown], [:interface, "user.I"], union([:int], [:media, :image]), union([:int], [:unknown])].each do |slot| + result = encode(image, slot) + assert_nil result.value_type + assert_equal :IMAGE, result.media_value.media + assert_equal image.url, result.media_value.url + end + assert_raises(TypeError) { encode(image, [:media, :audio]) } + assert_raises(TypeError) { encode(image, [:class, "user.ImageWrapper", [[:int]]]) } + assert_equal "scan.pdf", encode(Baml::Pdf.from_file_content("/tmp/scan.pdf", "JVBERg==")).media_value.file_content.name + assert_equal "YWJj", encode(Baml::Audio.from_base64("YWJj")).media_value.base64 + opaque = Class.new(Struct.new(:value)) + @registry.define(opaque, "user.Opaque", { "value" => [:value, [:int]] }, crossing: :opaque) + error = assert_raises(TypeError) { encode(opaque.new(1)) } + assert_includes error.message, "built only by BAML" + api = Api.new + handle = Baml::Handle.new(api, 41, :ADT_TAGGED_HEAP_HANDLE) + scope = Baml::Bridge::EncodingScope.new + [[:int], [:unknown], union([:int], [:string])].each do |slot| + assert_equal 41, encode(handle, slot, scope: scope).handle.key + end + assert_equal [41, 41, 41], api.cloned + ensure + scope&.close + handle&.close + end + def test_unions_rank_identity_over_natural_shape_over_conversion assert_equal :int_value, encode(5, union([:float], [:int])).value assert_equal :float_value, encode(5, union([:float], [:string])).value @@ -175,6 +439,14 @@ def test_distinct_mixed_scalar_lists_plan_in_linear_time assert_equal 4_000, planned.ty[1][1].length end + def test_canonical_union_order_does_not_depend_on_array_sharing + int = [:int] + left = union([:map, int, int], [:map, int, [:string]]) + right = union([:map, [:int], [:string]], [:map, [:int], [:int]]) + assert Encode::Slot.equivalent?(left, right) + assert_equal left, Encode::Slot.union([left, right]) + end + def test_type_identity_recurses_through_every_composite_and_function_part left = union([:int], union([:string], [:int])) right = union([:string], [:int]) @@ -256,6 +528,14 @@ def test_map_keys_are_strings_and_errors_carry_the_entry assert_raises(TypeError) { encode({ "red" => 1 }, [:map, [:enum, "user.Color"], [:int]]) } end + def test_outbound_map_keys_keep_their_existing_decode_types + [["7", [:int], 7], ["18446744073709551616", [:bigint], 2**64], + ["true", [:bool], true], ["false", [:literal, false], false], + ["42", [:literal, :bigint, 42], 42], ["text", [:literal, "text"], "text"]].each do |key, ty, expected| + assert_equal expected, Protocol.decode_map_key(key, Encode::Slot.to_wire(ty)) + end + end + def test_a_value_the_wire_cannot_carry_is_refused_before_anything_is_created api = Api.new handle = Baml::Handle.new(api, 41, :ADT_TAGGED_HEAP_HANDLE) @@ -366,6 +646,8 @@ def test_complete_host_call_uses_the_abi5_signature def test_type_descriptors_round_trip_and_refuse_nonstatic_heads slots = [[:null], [:never], [:unknown], [:enum, "user.Color"], [:interface, "user.I"], + [:bigint], [:bytes], [:media, :image], [:media, :generic], [:literal, :bigint, 42], + [:enum_variant, "user.Color", "red"], [:alias, "user.Rec"], [:literal, 3], [:literal, true], [:literal, "x"], [:class, "user.Box", [[:int]]], [:map, [:string], [:list, union([:int], [:null])]]] slots.each { |slot| assert_equal slot, Encode::Slot.from_wire(Encode::Slot.to_wire(slot)) } diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_aliases.rb b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_aliases.rb new file mode 100644 index 00000000000..f214d0acbb2 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_aliases.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class AliasesTest < Minitest::Test + def test_aliases_round_trip_string_list + assert_equal ["a", "b"], BamlSdk::Aliases.round_trip_string_list(["a", "b"]) + end + + def test_aliases_round_trip_rec_list + assert_equal 1, BamlSdk::Aliases.round_trip_rec_list(1) + assert_equal [1, [2, 3]], BamlSdk::Aliases.round_trip_rec_list([1, [2, 3]]) + end + + def test_aliases_round_trip_alias_container + c = BamlSdk::Aliases::AliasContainer.new(list_field: ["x"], rec_field: [1, [2]]) + assert_equal c, BamlSdk::Aliases.round_trip_alias_container(c) + end + +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_class_refs.rb b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_class_refs.rb new file mode 100644 index 00000000000..616a1a2ac62 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_class_refs.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class ClassRefsTest < Minitest::Test + def test_class_refs_make_outer + o = BamlSdk::ClassRefs.make_outer(5) + assert_equal 5, o.inner.value + end + + def test_class_refs_round_trip_inner + i = BamlSdk::ClassRefs::Inner.new(value: 3) + assert_equal i, BamlSdk::ClassRefs.round_trip_inner(i) + end + + def test_class_refs_round_trip_outer + o = BamlSdk::ClassRefs::Outer.new(inner: BamlSdk::ClassRefs::Inner.new(value: 9)) + assert_equal o, BamlSdk::ClassRefs.round_trip_outer(o) + end + +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_complex_models.rb b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_complex_models.rb new file mode 100644 index 00000000000..32543acc94a --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_complex_models.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class ComplexModelsTest < Minitest::Test + Models = BamlSdk::ComplexModels + + def test_complex_models_round_trip_complex_profile_preserves_deeply_nested_mixed_shape_class + home = Models::PostalAddress.new( + line1: "1 Compiler Way", line2: nil, city: "San Francisco", region: "CA", postal_code: "94107", + location: Models::GeoPoint.new(lat: 37.7749, lng: -122.4194) + ) + office = Models::PostalAddress.new( + line1: "200 Type Lane", line2: "Suite 42", city: "Oakland", region: "CA", postal_code: "94612", location: nil + ) + email = Models::ContactMethod.new(label: "email", value: "ada@example.com", verified: true) + phone = Models::ContactMethod.new(label: "phone", value: "+1-555-0100", verified: false) + invoice = Models::Invoice.new( + id: "inv-001", status: "sent", + items: [Models::LineItem.new( + sku: "sdk-pro", quantity: 2, unit_price: 19.5, tags: ["sdk", "typescript"], + attributes: { "language" => "ts", "support" => "priority" } + )], + payment: Models::CardPayment.new(brand: "visa", last4: "4242", billing_address: home), + notes: "first invoice" + ) + wire_invoice = Models::Invoice.new( + id: "inv-002", status: "paid", + items: [Models::LineItem.new( + sku: "sdk-enterprise", quantity: 1, unit_price: 250, tags: ["sdk", "enterprise"], + attributes: { "language" => "python", "term" => "annual" } + )], + payment: Models::WirePayment.new(bank_name: "Boundary Bank", routing_code: "110000000", reference: nil), + notes: nil + ) + profile = Models::ComplexProfile.new( + id: "profile-001", tier: Models::AccountTier::Enterprise, + owner: Models::ProfileOwner.new(name: "Ada Lovelace", primary_contact: email, backup_contacts: [phone]), + addresses: [home, office], invoices: [invoice, wire_invoice], + audit_trail: [ + Models::AuditEvent.new(actor: "system", action: "created", context: { "source" => "fixture" }), + Models::AuditEvent.new(actor: "reviewer", action: "approved", context: { "level" => "2", "region" => "us" }) + ], + metadata: { "cohort" => "beta", "owner_kind" => "internal" }, featured: invoice, + flags: [7, "manual-review", true] + ) + assert_equal profile, Models.round_trip_complex_profile(profile) + end +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_enums.rb b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_enums.rb new file mode 100644 index 00000000000..ba2fc56444b --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_enums.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class EnumsTest < Minitest::Test + def test_enums_pick_sentiment + assert_equal BamlSdk::Enums::Sentiment::Positive, BamlSdk::Enums.pick_sentiment(true) + assert_equal BamlSdk::Enums::Sentiment::Negative, BamlSdk::Enums.pick_sentiment(false) + end + + def test_enums_pick_positive + assert_equal BamlSdk::Enums::Sentiment::Positive, BamlSdk::Enums.pick_positive() + end + + def test_enums_round_trip_sentiment + assert_equal BamlSdk::Enums::Sentiment::Negative, BamlSdk::Enums.round_trip_sentiment(BamlSdk::Enums::Sentiment::Negative) + end + + def test_enums_round_trip_sentiment_positive + assert_equal BamlSdk::Enums::Sentiment::Positive, BamlSdk::Enums.round_trip_sentiment_positive(BamlSdk::Enums::Sentiment::Positive) + end + + def test_enums_round_trip_enums + e = BamlSdk::Enums::Enums.new(bare_enum: BamlSdk::Enums::Sentiment::Positive, variant_as_type: BamlSdk::Enums::Sentiment::Positive) + assert_equal e, BamlSdk::Enums.round_trip_enums(e) + end + +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_forward_refs.rb b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_forward_refs.rb new file mode 100644 index 00000000000..ba89626914c --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_forward_refs.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class ForwardRefsTest < Minitest::Test + def test_forward_refs_round_trip_other + # Node's required self-reference has no finite host value; check reachability. + assert_operator BamlSdk::ForwardRefs::Node, :<, T::Struct + assert_respond_to BamlSdk::ForwardRefs, :round_trip_node + o = BamlSdk::ForwardRefs::Other.new(v: 7) + assert_equal o, BamlSdk::ForwardRefs.round_trip_other(o) + end + + def test_forward_refs_round_trip_rec_list + assert_equal [1, [2, 3]], BamlSdk::ForwardRefs.round_trip_rec_list([1, [2, 3]]) + end + + def test_forward_refs_round_trip_rec_list_with_other + assert_equal 1, BamlSdk::ForwardRefs.round_trip_rec_list_with_other(1) + assert_equal [1, 2], BamlSdk::ForwardRefs.round_trip_rec_list_with_other([1, 2]) + end + +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_handles.rb b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_handles.rb new file mode 100644 index 00000000000..ac1db2cc448 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_handles.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") +require "tempfile" + +class HandlesTest < Minitest::Test + PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk" \ + "+M8AAAQEAQB9eIv5AAAAAElFTkSuQmCC" + + def test_handles_image_from_base64_roundtrips_payload + payload = PNG_B64.dup + image = Baml::Image.from_base64(payload, mime_type: "image/png") + payload.replace("changed") + assert image.frozen? + assert image.base64.frozen? + assert_equal "image/png", image.mime_type + assert_equal PNG_B64, image.base64 + result = BamlSdk::Media.round_trip_image(image) + assert_equal PNG_B64, result.base64 + assert_equal "image/png", BamlSdk::Media.image_mime_type(image) + assert_nil result.url + assert_nil result.name + + # File values own their contents, not a path or an engine capability. + Tempfile.create(["scan", ".pdf"]) do |file| + file.write("%PDF-1.7") + file.close + pdf = Baml::Pdf.from_file(file.path) + File.unlink(file.path) + assert_equal "JVBERi0xLjc=", pdf.base64 + assert_equal File.basename(file.path), pdf.name + assert_nil pdf.mime_type + decoded = BamlSdk::Media.round_trip_pdf(pdf) + assert_equal pdf.name, decoded.name + assert_equal pdf.base64, decoded.base64 + assert_equal "application/pdf", decoded.mime_type + assert_raises(Errno::ENOENT) { Baml::Pdf.from_file(file.path) } + end + audio = Baml::Audio.from_file_content("recording.webm", "YQ==") + assert_nil audio.mime_type + assert_equal "recording.webm", BamlSdk::Media.round_trip_audio(audio).name + video = Baml::Video.from_url("https://example.com/video.mp4", mime_type: "video/mp4") + assert_equal video.mime_type, BamlSdk::Media.round_trip_video(video).mime_type + end + + def test_handles_open_file_returns_file_handle + Tempfile.create("digits") do |file| + file.write("0123456789") + file.flush + value = BamlSdk::Baml::Fs.open(file.path, "r") + assert_instance_of BamlSdk::Baml::Fs::File, value + assert_nil value.close + end + end + + def test_handles_file_cursor_state_persists_across_calls + Tempfile.create("digits") do |file| + file.write("0123456789") + file.flush + value = BamlSdk::Baml::Fs.open(file.path, "r") + begin + assert_equal 3, value.seek_from("current", 3) + assert_equal 6, value.seek_from("current", 3) + assert_equal 0, value.seek_from("start", 0) + assert_equal 2, value.seek_from("current", 2) + assert_equal "23456789", value.text + ensure + assert_nil value.close + end + end + end +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_lists.rb b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_lists.rb new file mode 100644 index 00000000000..e5f770d6bc9 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_lists.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class ListsTest < Minitest::Test + def test_lists_round_trip_ints + assert_equal [1, 2, 3], BamlSdk::Lists.round_trip_ints([1, 2, 3]) + end + + def test_lists_round_trip_empty_list + assert_equal [], BamlSdk::Lists.round_trip_ints([]) + end + + def test_lists_round_trip_optional_strings + assert_equal ["a", nil, "b"], BamlSdk::Lists.round_trip_optional_strings(["a", nil, "b"]) + end + + def test_lists_round_trip_union_list + assert_equal [1, "two", 3], BamlSdk::Lists.round_trip_union_list([1, "two", 3]) + end + + def test_lists_round_trip_list_container + c = BamlSdk::Lists::ListContainer.new(ints: [1, 2], optional_strings: [nil, "z"], union_list: [1, "x"]) + assert_equal c, BamlSdk::Lists.round_trip_list_container(c) + end + +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_main.rb b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_main.rb new file mode 100644 index 00000000000..bf37413f451 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_main.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class MainTest < Minitest::Test + def test_main_root_imports_cleanly + assert_kind_of Module, BamlSdk + end + + def test_main_all_namespaces_reachable + %i[Primitives Media Enums Literals ClassRefs Aliases AliasesConsumer Optional + Lists Maps Unions Recursion Generics ForwardRefs ComplexModels Lorem A].each do |name| + assert_kind_of Module, BamlSdk.const_get(name, false) + end + end + + def test_main_root_foo_reachable + assert_operator BamlSdk::Foo, :<, T::Struct + end + + def test_main_lorem_resume_reachable + assert_operator BamlSdk::Lorem::Resume, :<, T::Struct + end + + def test_main_deep_namespace_thing_reachable + assert_operator BamlSdk::A::B::Thing, :<, T::Struct + end +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_maps.rb b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_maps.rb new file mode 100644 index 00000000000..262793842c7 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_maps.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class MapsTest < Minitest::Test + def test_maps_round_trip_simple_map + assert_equal({ "a" => 1, "b" => 2 }, BamlSdk::Maps.round_trip_simple_map({ "a" => 1, "b" => 2 })) + end + + def test_maps_round_trip_list_valued_map + assert_equal({ "k" => [1, 2] }, BamlSdk::Maps.round_trip_list_valued_map({ "k" => [1, 2] })) + end + + def test_maps_round_trip_sentiment + assert_equal BamlSdk::Maps::Sentiment::Positive, BamlSdk::Maps.round_trip_sentiment(BamlSdk::Maps::Sentiment::Positive) + end + + def test_maps_round_trip_resume + r = BamlSdk::Maps::Resume.new(name: "n") + assert_equal r, BamlSdk::Maps.round_trip_resume(r) + end + +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_media.rb b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_media.rb new file mode 100644 index 00000000000..a131704b3e4 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_media.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class MediaTest < Minitest::Test + URL = "https://example.com/asset" + + def test_media_return_image + image = BamlSdk::Media.return_image(URL, nil) + assert_instance_of Baml::Image, image + assert_equal URL, image.url + end + + def test_media_return_audio + audio = BamlSdk::Media.return_audio(URL, nil) + assert_instance_of Baml::Audio, audio + assert_equal URL, audio.url + end + + def test_media_return_video + video = BamlSdk::Media.return_video(URL, nil) + assert_instance_of Baml::Video, video + assert_equal URL, video.url + end + + def test_media_return_pdf + pdf = BamlSdk::Media.return_pdf(URL, nil) + assert_instance_of Baml::Pdf, pdf + assert_equal URL, pdf.url + end + + def test_media_round_trip_image + image = BamlSdk::Media.return_image(URL, nil) + assert_equal URL, BamlSdk::Media.round_trip_image(image).url + end + + def test_media_round_trip_audio + audio = BamlSdk::Media.return_audio(URL, nil) + assert_equal URL, BamlSdk::Media.round_trip_audio(audio).url + end + + def test_media_round_trip_video + video = BamlSdk::Media.return_video(URL, nil) + assert_equal URL, BamlSdk::Media.round_trip_video(video).url + end + + def test_media_round_trip_pdf + pdf = BamlSdk::Media.return_pdf(URL, nil) + assert_equal URL, BamlSdk::Media.round_trip_pdf(pdf).url + end + + def test_media_round_trip_all_media + [Baml::Image, Baml::Audio, Baml::Video, Baml::Pdf].each do |kind| + result = BamlSdk::Media.round_trip_all_media(kind.from_url(URL)) + assert_instance_of kind, result + assert_equal URL, result.url + end + end + + def test_media_round_trip_media_or_image_class + media = BamlSdk::Media.round_trip_media_or_image_class(Baml::Image.from_url(URL)) + assert_instance_of Baml::Image, media + assert_equal URL, media.url + labelled = BamlSdk::Media::Image.new(label: "cover") + assert_equal labelled, BamlSdk::Media.round_trip_media_or_image_class(labelled) + end + + def test_media_round_trip_media + media = BamlSdk::Media::Media.new( + image_field: BamlSdk::Media.return_image(URL, nil), + audio_field: BamlSdk::Media.return_audio(URL, nil), + video_field: BamlSdk::Media.return_video(URL, nil), + pdf_field: BamlSdk::Media.return_pdf(URL, nil) + ) + result = BamlSdk::Media.round_trip_media(media) + assert_equal media, result + assert media.eql?(result) + assert_equal media.hash, result.hash + assert_equal "found", { media => "found" }[result] + %i[image_field audio_field video_field pdf_field].each do |field| + assert_equal URL, result.public_send(field).url + end + end +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_optional.rb b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_optional.rb new file mode 100644 index 00000000000..b3dcb064eca --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_optional.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class OptionalTest < Minitest::Test + def test_optional_round_trip_optional_int + assert_equal 5, BamlSdk::Optional.round_trip_optional_int(5) + assert_nil BamlSdk::Optional.round_trip_optional_int(nil) + end + + def test_optional_round_trip_optional_resume + r = BamlSdk::Optional::Resume.new(name: "ada") + assert_equal r, BamlSdk::Optional.round_trip_optional_resume(r) + assert_nil BamlSdk::Optional.round_trip_optional_resume(nil) + end + + def test_optional_round_trip_optional_union + assert_equal 3, BamlSdk::Optional.round_trip_optional_union(3) + assert_equal "s", BamlSdk::Optional.round_trip_optional_union("s") + assert_nil BamlSdk::Optional.round_trip_optional_union(nil) + end + + def test_optional_round_trip_resume + r = BamlSdk::Optional::Resume.new(name: "grace") + assert_equal r, BamlSdk::Optional.round_trip_resume(r) + end + + def test_optional_round_trip_optional_container + c = BamlSdk::Optional::OptionalContainer.new(optional_int: nil, optional_class: BamlSdk::Optional::Resume.new(name: "x"), optional_union: "y") + assert_equal c, BamlSdk::Optional.round_trip_optional_container(c) + end + +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_primitives.rb b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_primitives.rb index bc73547dd1a..46a453a0cf8 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_primitives.rb +++ b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_primitives.rb @@ -8,4 +8,94 @@ def test_primitives_return_int assert_equal 42, BamlSdk::Primitives.return_int() end + def test_primitives_return_float + assert_equal 3.14, BamlSdk::Primitives.return_float() + end + + def test_primitives_return_string + assert_equal "hello", BamlSdk::Primitives.return_string() + end + + def test_primitives_return_bool + assert_equal true, BamlSdk::Primitives.return_bool() + end + + def test_primitives_return_bigint + assert_equal 12345678901234567890, BamlSdk::Primitives.return_bigint() + end + + def test_primitives_return_null + assert_nil BamlSdk::Primitives.return_null() + end + + def test_primitives_round_trip_int + assert_equal 7, BamlSdk::Primitives.round_trip_int(7) + end + + def test_primitives_round_trip_float + assert_equal 2.5, BamlSdk::Primitives.round_trip_float(2.5) + end + + def test_primitives_round_trip_float_accepts_int + result = BamlSdk::Primitives.round_trip_float(7) + assert_instance_of Float, result + assert_equal 7.0, result + end + + def test_primitives_round_trip_string + assert_equal "hi", BamlSdk::Primitives.round_trip_string("hi") + end + + def test_primitives_round_trip_bool + assert_equal false, BamlSdk::Primitives.round_trip_bool(false) + end + + def test_primitives_round_trip_bigint + big = 2**80 + assert_equal big, BamlSdk::Primitives.round_trip_bigint(big) + assert_equal -big, BamlSdk::Primitives.round_trip_bigint(-big) + assert_equal 7, BamlSdk::Primitives.round_trip_bigint(7) + assert_equal 42, BamlSdk::GoCodegen::PrimitiveEdges.round_trip_literal_bigint(42) + hex_boundary = 2**64 + assert_equal hex_boundary, BamlSdk::Primitives.round_trip_bigint(hex_boundary) + end + + def test_primitives_round_trip_null + assert_nil BamlSdk::Primitives.round_trip_null(nil) + end + + def test_primitives_round_trip_uint8_array + assert_equal "\x00\x01\x02".b, BamlSdk::Primitives.round_trip_uint8_array("\x00\x01\x02".b) + assert_equal Encoding::BINARY, BamlSdk::Primitives.round_trip_uint8_array("ab").encoding + + # Ruby has one String type: only binary strings choose bytes when both + # string and bytes occur in a union, including nested containers/aliases. + protocol = Baml::Bridge.const_get(:Protocol, false) + union = [:union, [[:string], [:bytes]]] + protocol.define_function("user.byte_choice", params: [["value", union]]) + wire = protocol.const_get(:Wire, false) + encode = ->(name, value) { wire::CallFunctionArgs.decode(protocol.encode_call(0, name, { "value" => value })).kwargs.first.value } + assert_equal :string_value, encode.call("user.byte_choice", "ab").value + assert_equal :uint8array_value, encode.call("user.byte_choice", "ab".b).value + protocol.define_alias("user.ByteChoice", union) + hint = [:map, [:string], [:list, [:alias, "user.ByteChoice"]]] + protocol.define_function("user.nested_bytes", params: [["value", hint]]) + values = encode.call("user.nested_bytes", { "k" => ["text", "bytes".b] }) + .map_value.entries.first.value.list_value.values + assert_equal [:string_value, :uint8array_value], values.map(&:value) + end + + def test_primitives_round_trip_primitives + p = BamlSdk::Primitives::Primitives.new(int_field: 1, float_field: 1.5, string_field: "s", bool_field: true, null_field: nil, uint8array_field: "\x61\x62".b) + assert_equal p, BamlSdk::Primitives.round_trip_primitives(p) + end + + def test_primitives_round_trip_primitives_float_field_accepts_int + p = BamlSdk::Primitives::Primitives.new(int_field: 1, float_field: 2, string_field: "s", bool_field: true, null_field: nil, uint8array_field: "\x61\x62".b) + assert_equal 2, p.float_field # Ruby accepts Integer input; the SDK widens it. + result = BamlSdk::Primitives.round_trip_primitives(p) + assert_equal 2.0, result.float_field + assert_instance_of Float, result.float_field + end + end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_recursion.rb b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_recursion.rb new file mode 100644 index 00000000000..4dabb25f3f8 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_recursion.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class RecursionTest < Minitest::Test + def test_recursion_round_trip_int_binary_tree + t = BamlSdk::Recursion::IntBinaryTree.new(value: 1, left: BamlSdk::Recursion::IntBinaryTree.new(value: 2, left: nil, right: nil), right: nil) + assert_equal t, BamlSdk::Recursion.round_trip_int_binary_tree(t) + end + + def test_recursion_int_binary_tree_deeper_than_the_wire_is_refused + build = ->(nodes) do + nodes.downto(1).reduce(nil) do |left, value| + BamlSdk::Recursion::IntBinaryTree.new(value: value, left: left, right: nil) + end + end + at_the_limit = build.call(32) + assert_equal at_the_limit, BamlSdk::Recursion.round_trip_int_binary_tree(at_the_limit) + [33, 1000].each do |nodes| + error = assert_raises(TypeError) { BamlSdk::Recursion.round_trip_int_binary_tree(build.call(nodes)) } + assert_includes error.message, "nested too deeply" + end + end + + def test_recursion_round_trip_mutual_recursion + a = BamlSdk::Recursion::A.new(b: BamlSdk::Recursion::B.new(a: nil)) + b = BamlSdk::Recursion::B.new(a: BamlSdk::Recursion::A.new(b: nil)) + assert_equal a, BamlSdk::Recursion.round_trip_a(a) + assert_equal b, BamlSdk::Recursion.round_trip_b(b) + end + + def test_recursion_round_trip_scc_t1_t2_t3 + t1 = BamlSdk::Recursion::T1.new(via2: BamlSdk::Recursion::T2.new(via1: nil, via3: nil), via3: nil) + t2 = BamlSdk::Recursion::T2.new(via1: nil, via3: BamlSdk::Recursion::T3.new(via1: nil, via2: nil)) + t3 = BamlSdk::Recursion::T3.new(via1: nil, via2: nil) + assert_equal t1, BamlSdk::Recursion.round_trip_t1(t1) + assert_equal t2, BamlSdk::Recursion.round_trip_t2(t2) + assert_equal t3, BamlSdk::Recursion.round_trip_t3(t3) + end + + def test_recursion_round_trip_scc_t4_t5_t6 + t4 = BamlSdk::Recursion::T4.new(via5: BamlSdk::Recursion::T5.new(via4: nil, via6: nil), via6: nil) + t5 = BamlSdk::Recursion::T5.new(via4: nil, via6: BamlSdk::Recursion::T6.new(via4: nil, via5: nil)) + t6 = BamlSdk::Recursion::T6.new(via4: nil, via5: nil) + assert_equal t4, BamlSdk::Recursion.round_trip_t4(t4) + assert_equal t5, BamlSdk::Recursion.round_trip_t5(t5) + assert_equal t6, BamlSdk::Recursion.round_trip_t6(t6) + end + +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_routing.rb b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_routing.rb new file mode 100644 index 00000000000..b7b59f930fd --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_routing.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class RoutingTest < Minitest::Test + def test_routing_make_foo + assert_equal 3, BamlSdk.make_foo(3).v + end + + def test_routing_round_trip_foo + f = BamlSdk::Foo.new(v: 10) + assert_equal f, BamlSdk.round_trip_foo(f) + end + + def test_routing_round_trip_thing_from_ab + t = BamlSdk::A::B::Thing.new(v: 1) + assert_equal t, BamlSdk::A::B.round_trip_thing_from_ab(t) + end + + def test_routing_round_trip_root_foo_from_ab + f = BamlSdk::Foo.new(v: 2) + assert_equal f, BamlSdk::A::B.round_trip_root_foo_from_ab(f) + end + + def test_routing_round_trip_deep_thing_from_a + t = BamlSdk::A::B::Thing.new(v: 4) + assert_equal t, BamlSdk::A.round_trip_deep_thing_from_a(t) + end + + def test_routing_round_trip_deep_thing_from_lorem + t = BamlSdk::A::B::Thing.new(v: 5) + assert_equal t, BamlSdk::Lorem.round_trip_deep_thing_from_lorem(t) + end + + def test_routing_round_trip_resume + r = BamlSdk::Lorem::Resume.new(name: "ada", email: nil) + assert_equal r, BamlSdk::Lorem.round_trip_resume(r) + end + + def test_routing_round_trip_root_foo + f = BamlSdk::Foo.new(v: 6) + assert_equal f, BamlSdk::Lorem.round_trip_root_foo(f) + end + + def test_routing_round_trip_lorem_resume_from_ipsum + r = BamlSdk::Lorem::Resume.new(name: "grace", email: "g@x.com") + assert_equal r, BamlSdk::Ipsum.round_trip_lorem_resume_from_ipsum(r) + end + + def test_routing_round_trip_resume_or_http_response + r = BamlSdk::Lorem::Resume.new(name: "lovelace", email: "a@x.com") + assert_equal r, BamlSdk::Lorem.round_trip_resume_or_http_response(r) + end + +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_symbol_collisions.rb b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_symbol_collisions.rb new file mode 100644 index 00000000000..35496a139ea --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_symbol_collisions.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class SymbolCollisionsTest < Minitest::Test + def test_symbol_collisions_round_trip_foo_bar + bar = BamlSdk::SymbolCollisions::Foo.make_foo_bar("hi", 2) + assert_equal bar, BamlSdk::SymbolCollisions::Foo.round_trip_foo_bar(bar) + end + + def test_symbol_collisions_round_trip_fizz_foo_bar + bar = BamlSdk::SymbolCollisions::Fizz::Foo.make_fizz_foo_bar("t", 1.5) + assert_equal bar, BamlSdk::SymbolCollisions::Fizz::Foo.round_trip_fizz_foo_bar(bar) + end + + def test_symbol_collisions_round_trip_fizz_buzz_foo_bar + bar = BamlSdk::SymbolCollisions::Fizz::Buzz::Foo.make_fizz_buzz_foo_bar("f", 2.5, true) + assert_equal bar, BamlSdk::SymbolCollisions::Fizz::Buzz::Foo.round_trip_fizz_buzz_foo_bar(bar) + end + + def test_symbol_collisions_round_trip_ipsum + ipsum = BamlSdk::SymbolCollisions::Lorem.make_ipsum(BamlSdk::SymbolCollisions::Foo.make_foo_bar("a", 1), BamlSdk::SymbolCollisions::Fizz::Foo.make_fizz_foo_bar("b", 2.0), BamlSdk::SymbolCollisions::Fizz::Buzz::Foo.make_fizz_buzz_foo_bar("c", 3.0, false)) + assert_equal ipsum, BamlSdk::SymbolCollisions::Lorem.round_trip_ipsum(ipsum) + end + + def test_symbol_collisions_round_trip_deep + ipsum = BamlSdk::SymbolCollisions::Lorem.make_ipsum(BamlSdk::SymbolCollisions::Foo.make_foo_bar("a", 1), BamlSdk::SymbolCollisions::Fizz::Foo.make_fizz_foo_bar("b", 2.0), BamlSdk::SymbolCollisions::Fizz::Buzz::Foo.make_fizz_buzz_foo_bar("c", 3.0, false)) + deep = BamlSdk::SymbolCollisions::A::B::C::D.make_deep(BamlSdk::SymbolCollisions::Foo.make_foo_bar("h", 9), BamlSdk::SymbolCollisions::Fizz::Foo.make_fizz_foo_bar("th", 4.0), BamlSdk::SymbolCollisions::Fizz::Buzz::Foo.make_fizz_buzz_foo_bar("fu", 5.0, true), ipsum) + assert_equal deep, BamlSdk::SymbolCollisions::A::B::C::D.round_trip_deep(deep) + end + +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_unions.rb b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_unions.rb new file mode 100644 index 00000000000..227d000b256 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_unions.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class UnionsTest < Minitest::Test + def test_unions_round_trip_null_to_end + assert_equal 1, BamlSdk::Unions.round_trip_null_to_end(1) + assert_equal "s", BamlSdk::Unions.round_trip_null_to_end("s") + assert_nil BamlSdk::Unions.round_trip_null_to_end(nil) + end + + def test_unions_round_trip_dedup + assert_equal 2, BamlSdk::Unions.round_trip_dedup(2) + assert_equal "x", BamlSdk::Unions.round_trip_dedup("x") + end + + def test_unions_round_trip_singleton_unwrap + assert_equal 7, BamlSdk::Unions.round_trip_singleton_unwrap(7) + end + + def test_unions_round_trip_optional_plus_null + assert_equal BamlSdk::Unions::T.new(v: 1), BamlSdk::Unions.round_trip_optional_plus_null(BamlSdk::Unions::T.new(v: 1)) + assert_equal "s", BamlSdk::Unions.round_trip_optional_plus_null("s") + assert_nil BamlSdk::Unions.round_trip_optional_plus_null(nil) + end + + def test_unions_round_trip_str_or_int_list + assert_equal [1, 2, 3], BamlSdk::Unions.round_trip_str_or_int_list([1, 2, 3]) + assert_equal ["a", "b"], BamlSdk::Unions.round_trip_str_or_int_list(["a", "b"]) + error = assert_raises(TypeError) { BamlSdk::Unions.round_trip_str_or_int_list([]) } + assert_includes error.message, "fits more than one" + end + + def test_unions_round_trip_t + assert_equal BamlSdk::Unions::T.new(v: 4), BamlSdk::Unions.round_trip_t(BamlSdk::Unions::T.new(v: 4)) + end + + def test_unions_round_trip_union_container + c = BamlSdk::Unions::UnionContainer.new(null_to_end: nil, dedup: "d", singleton_unwrap: 5, optional_plus_null: BamlSdk::Unions::T.new(v: 2)) + assert_equal c, BamlSdk::Unions.round_trip_union_container(c) + end + +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/typed_consumer.rb b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/typed_consumer.rb new file mode 100644 index 00000000000..b1f950c8984 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/typed_consumer.rb @@ -0,0 +1,16 @@ +# typed: strict +require "sorbet-runtime" +require_relative "baml_sdk" + +T.let(BamlSdk::Primitives.return_bigint, Integer) +T.let(BamlSdk::Primitives.round_trip_float(7), Float) +T.let(BamlSdk::Primitives.round_trip_uint8_array("bytes".b), String) +T.let(BamlSdk::Aliases.round_trip_string_list(["a"]), BamlSdk::Aliases::StringList) +T.let(BamlSdk::Aliases.round_trip_rec_list([1, [2]]), BamlSdk::Aliases::RecList) +T.let(BamlSdk::Unions.round_trip_dedup("s"), T.any(Integer, String)) +T.let(BamlSdk::Enums.pick_positive, BamlSdk::Enums::Sentiment) +image = Baml::Image.from_url("https://example.com/image.png") +T.let(BamlSdk::Media.round_trip_image(image), Baml::Image) +T.let(BamlSdk::Void.no_op, NilClass) +node = BamlSdk::Recursion::IntBinaryTree.new(value: 1, left: nil, right: nil) +T.let(BamlSdk::Recursion.round_trip_int_binary_tree(node), BamlSdk::Recursion::IntBinaryTree) diff --git a/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md b/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md index 47fa4fb0812..c08df0dd96d 100644 --- a/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md +++ b/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md @@ -4,11 +4,11 @@ repository: baml4 --- # State of BAML↔Ruby completeness -The Ruby/Sorbet SDK generates typed functions, classes and enums for user code and supported standard-library symbols. It runs sync and async calls through the V1 C bridge. The tables below separate tested support from remaining gaps. +The Ruby/Sorbet SDK generates typed functions, classes and enums for user code and supported standard-library symbols. It runs sync and async calls through the V1 C bridge, with media values. The tables below separate tested support from remaining gaps. ✅ means a named Ruby test proves the stated support, 🚧 means only part of the row is tested or supported, and ❌ means unsupported or not yet parity-verified. The Python column is copied from `ref-python-state-of-completeness.md` at commit `b74b83a3ab`, including its qualifications; it is not a claim about today's Python SDK. Links to `customizable/test_*.rb` identify real-engine tests; streaming tests use the keyless replay server. Fake-bridge tests are identified separately. -Validation: `cargo nextest run -p sdk_test_ruby_sorbet` passes all 11 enabled harness tests, including minitest and Sorbet checks for three shared fixtures, the fixture manifest and runtime checks. The `docstrings_etc` and `unsupported_only` fixtures' minitest and Sorbet gates are ignored because they have no test files. +Validation: `cargo nextest run -p sdk_test_ruby_sorbet` passes all 13 enabled harness tests, including minitest and Sorbet checks for four shared fixtures, the fixture manifest and runtime checks. The `unsupported_only` fixture's minitest and Sorbet gates are ignored because it has no test files. ## Function-call forms @@ -37,7 +37,7 @@ These rows describe what happens when a call returns, throws, or terminates. | Runtime behavior | Python | Ruby | Ruby outcome and evidence | | --- | --- | --- | --- | -| Normal return | ✅ | ✅ | Decodes the `ok` value: [`test_main_hello_world_returns_literal`][function-main], [`test_methods_on_classes_static_create_round_trips`][methods], [`test_streaming_e2e_stream_doc_collect_in_baml`][streaming]. | +| Normal return | ✅ | ✅ | Decodes the `ok` value: [`test_main_hello_world_returns_literal`][function-main], [`test_class_refs_round_trip_inner`][class-refs], [`test_streaming_e2e_stream_doc_collect_in_baml`][streaming]. | | BAML error | ✅ (docs-only) | 🚧 | Raises `Baml::Error < StandardError` with a message and `type_name`. The fake-bridge [`test_call_waiters_cache_outcomes_and_survive_interrupt_and_cancel`][bridge-call] checks a cached error. Decoded payloads and BAML traces are unsupported. | | BAML panic | ✅ | 🚧 | Raises `Baml::Panic < Exception`, not `StandardError`. Ordinary `rescue => e` does not catch it. Decoded payloads and BAML traces are unsupported. | | Host-callback error | ✅ | ❌ | No host-callback dispatch or exception round trip. | @@ -52,13 +52,13 @@ Gem packaging, clean-app installation and native-library cache lookup are unsupp ## Static typing -The [shared Ruby harness][static-gate] runs `srb tc` on each of the three enabled generated packages and its tests with Sorbet **0.6.13506**, pinned in a separate [test bundle][static-gemfile]. Every generated Ruby file, including bytecode and model declarations, uses `# typed: strict`. No SDK functions or models are recreated in test RBIs. +The [shared Ruby harness][static-gate] runs `srb tc` on each of the four enabled generated packages and its tests with Sorbet **0.6.13506**, pinned in a separate [test bundle][static-gemfile]. Every generated Ruby file, including bytecode and model declarations, uses `# typed: strict`. No SDK functions or models are recreated in test RBIs. -Strict consumers in the fixture overlays cover sync and async returns, stdlib methods, optionals, classes and enums. One negative consumer requires exactly one wrong-argument diagnostic (`7002`), so a regression to `T.untyped` fails the gate. +Strict consumers in the fixture overlays cover sync and async returns, stdlib methods, bigint, bytes, media, unions, aliases, classes and enums. One negative consumer requires exactly one wrong-argument diagnostic (`7002`), so a regression to `T.untyped` fails the gate. -The [boundary RBI][static-bridge] describes the runtime-facing API, including call helpers; it does not recreate generated SDK declarations. The bridge's decoded return remains dynamic, so this gate proves generated declarations and consumer checking, not the runtime's adherence to BAML return types. Real-engine and replay tests cover runtime behavior separately. +The [boundary RBI][static-bridge] describes the runtime-facing API, including call helpers; it does not recreate generated SDK declarations. The bridge's decoded return remains dynamic, so this gate proves generated declarations and consumer checking, not the runtime's adherence to BAML return types. Real-engine and replay tests cover runtime behavior separately, including recursion, forward references and symbol collisions. -Typing still has limits: single literal values use their Ruby primitive types, as checked by [`test_literals_return_literals`][literals]. Type aliases, literal unions and general unions are unsupported; declarations requiring them are not generated. Generated docstrings and `Raises` sections are unsupported. JSON has no special `T.untyped` mapping. Declarations requiring function types are not generated. Method signatures retain Sorbet's lazy validation. +Typing still has limits: literal values use their Ruby primitive types, recursive alias back-edges use `T.untyped`. JSON has no special `T.untyped` mapping. Declarations requiring function types are not generated. Method signatures retain Sorbet's lazy validation. The pinned checker runs on x86_64/arm64 macOS and x86_64/aarch64 glibc Linux. Windows, 32-bit Linux and musl hosts explicitly ignore the static test because no compatible binary is supplied; their runtime tests still run. Setup installs the checker under `target/ruby-sorbet-static-bundle` only on supported hosts, and installation failures there fail setup. @@ -68,23 +68,23 @@ Encode means Ruby → BAML; decode means BAML → Ruby. The generated-package te | Value kind | Python | Ruby | Ruby value and evidence | | --- | --- | --- | --- | -| Null | ✅ | ✅ | `nil`: [`test_void_no_op`][void]. The compiler represents both null and void/unit as the same type. | -| Bool | ✅ | 🚧 | `true` / `false`: [`test_stdlib_entrypoints_sysop_fs_exists_callable_as_entry_point`][stdlib] checks decode. Primitive round-trip parity is not verified. | -| Int (i64) | ✅ | 🚧 | `Integer` within i64: [`test_primitives_return_int`][primitives] checks decode. Primitive round-trip parity is not verified. | -| Bigint (outside i64) | ✅ | ❌ | No bigint encoding, decoding or generated type mapping. | -| Float | ✅ | 🚧 | `Float`; primitive round-trip parity is not verified. Generated inputs require `Float`, not `Integer`. | -| String | ✅ | ✅ | `String`: [`test_main_single_required_arg_round_trips`][function-main]. | -| Bytes | ✅ | ❌ | No bytes codec or generated type mapping. | -| List | ✅ | 🚧 | `Array`: [`test_streaming_e2e_stream_collect_in_baml`][streaming] checks decoded string lists. List round-trip parity is not verified. | -| Map | ✅ | 🚧 | String-keyed `Hash` values are supported but not parity-verified. Non-string keys are unsupported. | -| Enum | ✅ | 🚧 | Generated `T::Enum`: [`test_main_ipsum_sentiment_enum_shape`][llm-main] checks its shape. Enum-variant types and dynamic enums are unsupported. Enum round-trip parity is not verified. | -| Class | ✅ | ✅ | Generated `T::Struct`, registered by BAML type name: [`test_methods_on_classes_static_create_round_trips`][methods], [`test_methods_on_classes_instance_greet_with_arg_round_trips`][methods], [`test_streaming_e2e_stream_doc_collect_in_baml`][streaming]. | +| Null | ✅ | ✅ | `nil`: [`test_primitives_round_trip_null`][primitives]. Void returns also become `nil`: [`test_void_no_op`][void]. | +| Bool | ✅ | ✅ | `true` / `false`: [`test_primitives_round_trip_bool`][primitives]. | +| Int (Int63) | ✅ | ✅ | `Integer` within −2⁶²..2⁶²−1: [`test_primitives_round_trip_int`][primitives]. | +| Bigint | ✅ | ✅ | Arbitrary-precision `Integer`: [`test_primitives_round_trip_bigint`][primitives] covers positive and negative 2⁸⁰ and the 2⁶⁴ boundary; [`test_primitives_return_bigint`][primitives] checks decode. | +| Float | ✅ | ✅ | `Float`: [`test_primitives_round_trip_float`][primitives]. Integer inputs widen only when exactly representable (`2**53 + 1` raises `TypeError`): [`test_primitives_round_trip_float_accepts_int`][primitives]. | +| String | ✅ | ✅ | `String`: [`test_primitives_round_trip_string`][primitives]. | +| Bytes | ✅ | ✅ | Binary `String` is naturally bytes, including under open slots. Text strings convert to bytes in a bytes slot. When a union allows both string and bytes, binary encoding selects bytes: [`test_primitives_round_trip_uint8_array`][primitives] covers a real round trip and protocol selection through nested containers and aliases. | +| List | ✅ | ✅ | `Array`, including empty and mixed-element lists: [`test_lists_round_trip_ints`][lists], [`test_lists_round_trip_empty_list`][lists], [`test_lists_round_trip_union_list`][lists]. | +| Map | ✅ | ✅ | `Hash`; encoding refuses non-string keys with "map keys must be string": [`test_maps_round_trip_simple_map`][maps], [`test_maps_round_trip_list_valued_map`][maps]. | +| Enum | ✅ | ✅ | Generated `T::Enum`: [`test_enums_round_trip_sentiment`][enums], [`test_enums_round_trip_sentiment_positive`][enums]. Dynamic enums are unsupported. | +| Class | ✅ | ✅ | Generated `T::Struct`, registered by BAML type name: [`test_class_refs_round_trip_inner`][class-refs], [`test_streaming_e2e_stream_doc_collect_in_baml`][streaming]. | | Generic explicitly reified by BAML-known type | ✅ | ❌ | Generic classes and declarations requiring them are not generated. | | Generic implicitly reified by BAML-known type | ✅ | ❌ | Generic classes and declarations requiring them are not generated. | | Generic reified by host-only type | ❌ (need more rules for serializing Python-only types) | ❌ | No generic value support. | -| Union | ✅ (union metadata dropped) | ❌ | General unions are unsupported. Optionals remain supported as `T.nilable`, checked by [`test_main_lorem_streaming_doc_class_shape`][llm-main]. | +| Union | ✅ (union metadata dropped) | ✅ | Decodes to the Ruby variant value; metadata is dropped. Ambiguous inputs, such as `[]` for `int[] \| string[]`, raise `TypeError` instead of choosing an arm. Generated signatures use `T.any` / `T.nilable`: [`test_unions_round_trip_null_to_end`][unions], [`test_unions_round_trip_optional_plus_null`][unions], [`test_unions_round_trip_union_container`][unions]. | | BAML interface | ❌ | ❌ | No interface value support; affected declarations are not generated. | -| Media | ✅ | ❌ | No media values, codec or generated type mapping. | +| Media | ✅ | ✅ | Frozen value objects `Baml::Image`, `Audio`, `Video` and `Pdf`, not engine handles. URL round trips: [`test_media_round_trip_media`][media]. Base64, file contents, names, MIME types and local-file loading: [`test_handles_image_from_base64_roundtrips_payload`][handles]. | | Stream | ✅ | ❌ | No host stream wrapper; declarations requiring stream values are not generated. | | Host callable | ✅ | ❌ | No host-value registry or dispatch; declarations requiring function types are not generated. | | Host callable (async) | ✅ (works, but returned coroutine is driven to completion on new asyncio event loop) | ❌ | No host-callback dispatch. | @@ -92,9 +92,9 @@ Encode means Ruby → BAML; decode means BAML → Ruby. The generated-package te | BAML closure (closure / bound method / generic function / function) | ❌ | ❌ | No callable closure wrapper; declarations requiring function types are not generated. | | BAML type reference values | ❌ | ❌ | No first-class BAML type-value encoding or decoding. | | BAML type definition values | ❌ | ❌ | No type-definition values. | -| BAML `$rust_type` values: files, sockets, etc. | ✅ | 🚧 | Generated resource structs retain `Baml::Handle` fields. File, HTTP and socket values are not parity-verified. | +| BAML `$rust_type` values: files, sockets, etc. | ✅ | 🚧 | Generated resource structs retain `Baml::Handle` fields. Files preserve cursor state: [`test_handles_open_file_returns_file_handle`][handles], [`test_handles_file_cursor_state_persists_across_calls`][handles]. HTTP and socket values are not parity-verified. | | Native exception thrown by a host callback | ✅ | ❌ | No opaque host-value registry or exception round trip. | -| Unused SDK heap handles (`Handle`) | 🚧 | 🚧 | The fake-bridge [`test_result_handles_are_owned_before_wait_and_after_partial_decode`][bridge-call] checks result ownership and GC release. Real-engine handle ownership is not parity-verified. | +| Unused SDK heap handles (`Handle`) | 🚧 | 🚧 | Handles work inside tested resources: [`test_handles_file_cursor_state_persists_across_calls`][handles]. Standalone unused heap-handle ownership and GC release are not parity-verified. | | Unused SDK collector values (`Collector`) | 🚧 | ❌ | No collector wrapper or verified collector-value path. | | Unused SDK prompt values (`PromptAst`) | 🚧 | ❌ | No prompt-value support. | | BAML builtin type (`Future` / `UnscheduledFuture`) | ❌ | ❌ | No first-class engine future values. `Baml::Call` is the host call promise, not a BAML future value. | @@ -103,6 +103,7 @@ Encode means Ruby → BAML; decode means BAML → Ruby. The generated-package te [function-main]: ../../../sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_main.rb [streaming]: ../../../sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_streaming_e2e.rb +[class-refs]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_class_refs.rb [void]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_void.rb [bridge-call]: ../../../sdk_tests/crates/ruby_sorbet/test/bridge_call_test.rb [static-gemfile]: ../../../sdk_tests/crates/ruby_sorbet/test/static/Gemfile @@ -110,7 +111,10 @@ Encode means Ruby → BAML; decode means BAML → Ruby. The generated-package te [static-gate]: ../../../sdk_tests/harness_runner/src/ruby_sorbet.rs [methods]: ../../../sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_methods_on_classes.rb [primitives]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_primitives.rb -[literals]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_literals.rb -[llm-main]: ../../../sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_main.rb -[stdlib]: ../../../sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_stdlib_entrypoints.rb +[lists]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_lists.rb +[maps]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_maps.rb +[enums]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_enums.rb +[unions]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_unions.rb +[media]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_media.rb +[handles]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_handles.rb [loader]: ../../../sdk_tests/crates/ruby_sorbet/test/bridge_loader_test.rb diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge.rb index a72bf6dcaa3..11b4c22a375 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge.rb @@ -18,14 +18,18 @@ def register_program(bytecode, metadata = nil) @process_runtime.register_program(bytecode, metadata) end - def define_class(wire, klass, fields:) - Protocol.define_class(wire, klass, fields: fields) + def define_class(wire, klass, fields:, crossing: :fields) + Protocol.define_class(wire, klass, fields: fields, crossing: crossing) end def define_enum(wire, klass) Protocol.define_enum(wire, klass) end + def define_alias(wire, descriptor, recursive: false) + Protocol.define_alias(wire, descriptor, recursive: recursive) + end + def define_function(wire, params:) Protocol.define_function(wire, params: params) end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/commit.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/commit.rb index 662b9dae3ec..b54c8bb3472 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/commit.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/commit.rb @@ -12,6 +12,7 @@ def depth(node) statement = %i[list map class].include?(node.kind) ? 1 + Slot.wire_depth(Slot.to_wire(node.ty)) : 0 payload = case node.kind when :enum, :handle then 1 + when :media then node.value.name ? 2 : 1 when :list then 1 + (node.children.map { |child| 1 + depth(child) }.max || 0) when :map, :class then 1 + (node.children.map { |_, child| 2 + depth(child) }.max || 0) else 0 @@ -23,10 +24,27 @@ def check_depth!(depth, path, enclosing: ARGUMENT_DEPTH) raise Fault, "#{path}: nested too deeply" if enclosing + depth > WIRE_LIMIT end + def media(node) + value = node.value + fields = { media: node.ty[1].upcase } + fields[:mime_type] = value.mime_type if value.mime_type + if value.url + fields[:url] = value.url + elsif value.name + fields[:file_content] = Wire::BamlValueMediaFileContent.new(name: value.name, base64: value.base64) + else + fields[:base64] = value.base64 + end + Wire::BamlValueMedia.new(**fields) + end + def commit(node, scope: nil) fields = case node.kind when :null then {} when :int, :float, :bool, :string then { "#{node.kind}_value".to_sym => node.value } + when :bigint then { bigint_value: node.value.to_s(16) } + when :bytes then { uint8array_value: node.value.b } + when :media then { media_value: media(node) } when :enum { enum_value: Wire::InboundEnumValue.new(name: node.ty[1], value: node.value) } when :handle diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/plan.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/plan.rb index 1fe3f45cecb..7308671ffb2 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/plan.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/plan.rb @@ -12,11 +12,15 @@ class Plan def initialize(registry, budget: STEP_BUDGET) @registry = registry @remaining = budget + @work = ->(steps = 1) { spend(steps, @path || "value") } + @members = {}.compare_by_identity + @normalized = {}.compare_by_identity end def plan(tree, slot) + @path = tree.path spend(1, tree.path) - slot = Slot.unfold(slot) + slot = Slot.unfold(Slot.stated_form(slot, @registry, memo: @normalized, &@work), @registry, &@work) return choose(tree, slot[1]) if slot[0] == :union return node(tree, [:opaque]) if tree.kind == :handle if %i[unknown interface].include?(slot[0]) @@ -27,20 +31,21 @@ def plan(tree, slot) case slot[0] when :null return node(tree, [:null]) if tree.kind == :null - when :int, :float, :bool, :string + when :int, :bigint, :float, :bool, :string, :bytes if (fit = Rules.fit(tree.value, slot[0])) && scalar?(tree) return node(tree, [slot[0]], kind: slot[0], value: fit[1]) end when :literal - expected = slot[1] - kind = Rules.kind(expected) - if %i[int string bool].include?(kind) && scalar?(tree) && + expected = slot.last + kind = Slot.literal_kind(slot) + if %i[int bigint string bool].include?(kind) && scalar?(tree) && (fit = Rules.fit(tree.value, kind)) && fit[1] == expected return node(tree, [kind], kind: kind, value: fit[1]) end - when :enum + when :enum, :enum_variant if (tree.kind == :enum && tree.name == slot[1]) || tree.kind == :string if @registry.enum_values(slot[1]).include?(tree.value) + refuse(tree, "variant does not match #{slot[2]}") if slot[0] == :enum_variant && tree.value != slot[2] return node(tree, [:enum, slot[1]], kind: :enum) end refuse(tree, "unknown variant #{tree.value.inspect} of #{slot[1]}") @@ -59,6 +64,7 @@ def plan(tree, slot) when :class return instance(tree, slot) if tree.kind == :class && tree.name == slot[1] end + return node(tree, [:media, tree.name]) if tree.kind == :media && media_fits?(tree, slot) refuse(tree, "cannot encode #{tree.kind} as #{slot.inspect}") end @@ -70,7 +76,7 @@ def spend(steps, path) end def scalar?(tree) - %i[int float bool string].include?(tree.kind) || (tree.kind == :other && tree.value.is_a?(Integer)) + %i[int bigint float bool string bytes].include?(tree.kind) end def refuse(tree, message) @@ -85,7 +91,7 @@ def instance(tree, slot) args = slot[2] || [] fields = @registry.encoding_fields(slot[1]) children = tree.children.map do |name, child| - [name, plan(child, Slot.substitute(fields.fetch(name)[1], args))] + [name, plan(child, Slot.substitute(fields.fetch(name)[1], args, &@work))] end node(tree, [:class, slot[1], args], children: children) end @@ -93,8 +99,9 @@ def instance(tree, slot) def natural(tree) spend(1, tree.path) case tree.kind - when :null, :int, :float, :bool, :string + when :null, :int, :bigint, :float, :bool, :string, :bytes node(tree, [tree.kind]) + when :media then node(tree, [:media, tree.name]) when :enum unless @registry.enum_values(tree.name).include?(tree.value) refuse(tree, "unknown variant #{tree.value.inspect} of #{tree.name}") @@ -120,7 +127,7 @@ def natural(tree) end def map_key(key, slot) - unless key.kind == :string + unless %i[string bytes].include?(key.kind) refuse(key, "map keys must be string") end planned = plan(key, slot) @@ -128,21 +135,34 @@ def map_key(key, slot) planned.value end + def media_fits?(tree, slot) + kind = if slot[0] == :media + slot[1] + elsif slot[0] == :class && (slot[2] || []).empty? + @registry.class_crossing(slot[1]).to_s.delete_prefix("media_").to_sym + end + kind == :generic || kind == tree.name + end + def rank(tree, slot) + slot = Slot.unfold(slot, @registry, &@work) + return 0 if tree.kind == :media && media_fits?(tree, slot) + return 0 if tree.kind == :enum && %i[enum enum_variant].include?(slot[0]) && tree.name == slot[1] return 2 if %i[unknown interface].include?(slot[0]) || tree.kind == :handle return 0 if tree.kind == :null && slot[0] == :null return 0 if %i[class enum].include?(tree.kind) && tree.kind == slot[0] && tree.name == slot[1] return 1 if %i[list map].include?(tree.kind) && tree.kind == slot[0] - return 3 if tree.kind == :string && slot[0] == :enum + return 3 if tree.kind == :string && %i[enum enum_variant].include?(slot[0]) return unless scalar?(tree) - kind = slot[0] == :literal ? Rules.kind(slot[1]) : slot[0] + kind = slot[0] == :literal ? Slot.literal_kind(slot) : slot[0] fit = Rules.fit(tree.value, kind) fit && (fit[0] == :as_is ? 0 : 3) end def choose(tree, members) - members = Slot.union(members) + spend(1, tree.path) + members = @members[members] ||= Slot.union(Slot.members(members, @registry, &@work), &@work) return plan(tree, members) unless members[0] == :union ranked = members[1].filter_map do |member| @@ -155,7 +175,7 @@ def choose(tree, members) group.each do |_, member| begin candidate = plan(tree, member) - fits << candidate unless fits.any? { |seen| Slot.equivalent?(seen.ty, candidate.ty) } + fits << candidate unless fits.any? { |seen| Slot.equivalent?(seen.ty, candidate.ty, &@work) } rescue Fault raise rescue Refusal => error @@ -172,7 +192,7 @@ def choose(tree, members) rescue Refusal nil end - preferred = fits.find { |candidate| own && Slot.equivalent?(candidate.ty, own) } + preferred = fits.find { |candidate| own && Slot.equivalent?(candidate.ty, own, &@work) } return preferred if preferred refuse(tree, "fits more than one union member") @@ -186,7 +206,7 @@ def choose(tree, members) # types: [[1], ["a"]] is (int[] | string[])[], not (int | string)[][]. def join(nodes) distinct = {} - add = ->(ty) { distinct[Slot.canonical(ty)] ||= ty } + add = ->(ty) { distinct[Slot.canonical(ty, &@work)] ||= ty } nodes.each { |child| add.call(child.ty) unless open_type?(child.ty) } nodes.map! do |child| if open_type?(child.ty) @@ -199,7 +219,7 @@ def join(nodes) end child end - distinct.empty? ? [:hole] : Slot.union(distinct.values) + distinct.empty? ? [:hole] : Slot.union(distinct.values, &@work) end def open_type?(ty) @@ -219,7 +239,7 @@ def fills?(own, target) case own[0] when :list then fills?(own[1], target[1]) when :map then fills?(own[1], target[1]) && fills?(own[2], target[2]) - else Slot.equivalent?(own, target) + else Slot.equivalent?(own, target, &@work) end end @@ -230,7 +250,7 @@ def fill_type(ty) when :map then [:map, fill_type(ty[1]), fill_type(ty[2])] when :union members = ty[1].map { |item| fill_type(item) } - members.include?([:unknown]) ? [:unknown] : Slot.union(members) + members.include?([:unknown]) ? [:unknown] : Slot.union(members, &@work) else ty end end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/read.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/read.rb index 3194cb23b85..23e34006bf4 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/read.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/read.rb @@ -27,10 +27,17 @@ def read(value, path, depth = 1) :null elsif value.is_a?(::Baml::Handle) :handle + elsif value.is_a?(::Baml::Media) + name = { ::Baml::Image => :image, ::Baml::Audio => :audio, ::Baml::Video => :video, ::Baml::Pdf => :pdf } + .fetch(value.class) { raise Refusal, "#{path}: cannot encode media kind #{value.class}" } + :media elsif name && !fields value = value.serialize.dup.freeze :enum elsif name + unless @registry.class_crossing(name) == :fields + raise Refusal, "#{path}: #{name} is built only by BAML; pass the instance you received" + end children = fields.map do |wire, (ruby, _descriptor)| [wire, read(value.public_send(ruby), "#{path}.#{wire}", depth + 1)] end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/rules.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/rules.rb index 4181df51bfd..90bea7c8438 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/rules.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/rules.rb @@ -11,15 +11,14 @@ def kind(value) case value when nil then :null when true, false then :bool - when Integer then INT_RANGE.cover?(value) ? :int : :other + when Integer then INT_RANGE.cover?(value) ? :int : :bigint when Float then :float - when String then :string + when String then value.encoding == Encoding::BINARY ? :bytes : :string else :other end end - # The rank and converted payload come from the same rule. An integer - # outside Int63 can still fit float, but never travels as int or bigint. + # The rank and converted payload come from the same rule. def fit(value, slot) if slot == :string && value.is_a?(String) && value.encoding == Encoding::BINARY text = value.dup.force_encoding(Encoding::UTF_8) @@ -27,6 +26,8 @@ def fit(value, slot) end natural = kind(value) return [:as_is, value] if natural == slot && natural != :other + return [:converted, value] if natural == :int && slot == :bigint + return [:converted, value.b] if natural == :string && slot == :bytes return unless value.is_a?(Integer) && slot == :float float = value.to_f diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/slot.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/slot.rb index b4e75242960..1ec8e42a8a6 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/slot.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/slot.rb @@ -11,60 +11,168 @@ class Fault < Refusal; end module Slot module_function - def union(members) - members = flatten(members) - distinct = members.uniq { |item| canonical(item) } + def union(members, &work) + members = flatten(members, &work) + distinct = members.uniq { |item| canonical(item, &work) } distinct.length == 1 ? distinct.first : [:union, distinct] end - def flatten(members) - members.flat_map { |item| item[0] == :union ? flatten(item[1]) : [item] } + def flatten(members, &work) + members.flat_map do |item| + work&.call(1) + item[0] == :union ? flatten(item[1], &work) : [item] + end end - # Aliases are not supported by the foundation. Leave their heads intact - # rather than following an unguarded cycle; later ports add unfolding here. - def unfold(slot) - slot + def unfold(slot, registry = Protocol, &work) + return slot unless slot[0] == :alias + + seen = [] + while slot[0] == :alias && !seen.include?(slot[1]) + work&.call(1) + seen << slot[1] + definition = registry.alias_definition(slot[1]) + break unless definition + + slot = definition[0] + end + stated_form(slot, registry, &work) end - def stated_form(slot) - case slot[0] - when :union then union(slot[1].map { |item| stated_form(item) }) - when :list then [:list, stated_form(slot[1])] - when :map then [:map, stated_form(slot[1]), stated_form(slot[2])] - when :class then [:class, slot[1], (slot[2] || []).map { |item| stated_form(item) }] + # A repeated alias on one union path proves no membership. Containers + # end that path: their children are judged against the alias afresh. + def members(slots, registry, seen = [], &work) + slots.flat_map do |slot| + work&.call(1) + case slot[0] + when :alias + definition = registry.alias_definition(slot[1]) + if seen.include?(slot[1]) + [] + elsif definition + members([definition[0]], registry, seen + [slot[1]], &work) + else + [slot] + end + when :union then members(slot[1], registry, seen, &work) + else [slot] + end + end + end + + def stated_form(slot, registry = Protocol, seen = [], memo: {}.compare_by_identity, &work) + work&.call(1) + return memo[slot] if memo.key?(slot) + + state = ->(item) { stated_form(item, registry, seen, memo: memo, &work) } + memo[slot] = case slot[0] + when :alias + raise Fault, "cannot encode cyclic alias #{slot[1]}" if seen.include?(slot[1]) + + normalized = registry.normalized_alias(slot[1]) do |definition| + if definition && !definition[1] + stated_form(definition[0], registry, seen + [slot[1]], memo: memo, &work) + else + slot + end + end + charge(normalized, &work) + normalized + when :union then union(slot[1].map(&state), &work) + when :list then [:list, state.call(slot[1])] + when :map then [:map, state.call(slot[1]), state.call(slot[2])] + when :class then [:class, slot[1], (slot[2] || []).map(&state)] + when :function + [:function, slot[1].map { |name, ty, optional| [name, state.call(ty), optional] }, state.call(slot[2]), state.call(slot[3])] else slot end end + def literal_kind(slot) + slot.length == 3 ? slot[1] : Rules.kind(slot[1]) + end + # Hash keys use one spelling for every type, including unions nested # inside function signatures. Keep the first-seen spelling on the wire. - def canonical(slot) - case slot[0] + # Charge the expanded size before Ruby's native hashing and Marshal + # walks. Count shared subtrees once, but charge each occurrence. This + # also bounds cache hits without re-expanding a diamond-shaped alias. + def charge(value, &work) + return unless work + + visited = 0 + size = walk_size(value, {}.compare_by_identity) { visited += 1; work.call(1) } + work.call(size - visited) + end + + def walk_size(value, memo, &work) + work.call(1) + return memo[value] if memo.key?(value) + + memo[value] = 1 + children(value).sum { |child| walk_size(child, memo, &work) } + end + + def canonical(slot, &work) + charge(slot, &work) + canonical_form(slot, {}.compare_by_identity, {}) + end + + # Preserve sharing in canonical forms too. Without this memo, expanding + # each union's children again makes an alias DAG exponential in depth. + def canonical_form(slot, memo, intern) + return memo[slot] if memo.key?(slot) + + visit = ->(item) { canonical_form(item, memo, intern) } + result = case slot[0] when :union - members = flatten(slot[1]).map { |item| canonical(item) }.uniq.sort_by { |item| Marshal.dump(item) } + members = flatten(slot[1]).map(&visit).uniq.sort_by { |item| Marshal.dump(item) } members.length == 1 ? members.first : [:union, members] when :literal then [:literal, slot.length == 3 ? slot[1] : Rules.kind(slot.last), slot.last] - when :list then [:list, canonical(slot[1])] - when :map then [:map, canonical(slot[1]), canonical(slot[2])] - when :class then [:class, slot[1], (slot[2] || []).map { |item| canonical(item) }] + when :list then [:list, visit.call(slot[1])] + when :map then [:map, visit.call(slot[1]), visit.call(slot[2])] + when :class then [:class, slot[1], (slot[2] || []).map(&visit)] when :function - [:function, slot[1].map { |name, ty, optional| [name, canonical(ty), optional] }, canonical(slot[2]), canonical(slot[3])] + [:function, slot[1].map { |name, ty, optional| [name, visit.call(ty), optional] }, visit.call(slot[2]), visit.call(slot[3])] else slot end + # Marshal includes object references. Intern equal subtrees so sorting + # cannot depend on whether the input happened to share its arrays. + # Child IDs keep this table's hashing shallow, even for an alias DAG. + key = case result[0] + when :union then [:union, result[1].map(&:object_id)] + when :list then [:list, result[1].object_id] + when :map then [:map, result[1].object_id, result[2].object_id] + when :class then [:class, result[1], result[2].map(&:object_id)] + when :function + [:function, result[1].map { |name, ty, optional| [name, ty.object_id, optional] }, result[2].object_id, result[3].object_id] + else result + end + memo[slot] = intern[key] ||= result end - def equivalent?(left, right) - canonical(left) == canonical(right) + def equivalent?(left, right, &work) + canonical(left, &work) == canonical(right, &work) end - def substitute(slot, args) + def children(slot) + case slot[0] + when :list then [slot[1]] + when :map then [slot[1], slot[2]] + when :union then slot[1] + when :class then slot[2] || [] + when :function then slot[1].map { |param| param[1] } + [slot[2], slot[3]] + else [] + end + end + + def substitute(slot, args, &work) + work&.call(1) case slot[0] when :param then args.fetch(slot[1]) { [:other] } - when :list then [:list, substitute(slot[1], args)] - when :map then [:map, substitute(slot[1], args), substitute(slot[2], args)] - when :union then union(slot[1].map { |item| substitute(item, args) }) - when :class then [:class, slot[1], (slot[2] || []).map { |item| substitute(item, args) }] + when :list then [:list, substitute(slot[1], args, &work)] + when :map then [:map, substitute(slot[1], args, &work), substitute(slot[2], args, &work)] + when :union then union(slot[1].map { |item| substitute(item, args, &work) }, &work) + when :class then [:class, slot[1], (slot[2] || []).map { |item| substitute(item, args, &work) }] else slot end end @@ -73,8 +181,12 @@ def to_wire(slot) slot = stated_form(slot) kind, *args = slot fields = case kind - when :int, :float, :string, :bool, :null + when :int, :bigint, :float, :string, :bool, :null, :bytes { primitive: Wire::BamlTyPrimitive.new(kind: "BAML_TY_PRIMITIVE_#{kind.upcase}".to_sym) } + when :media + { media: Wire::BamlTyMedia.new(kind: "BAML_TY_MEDIA_KIND_#{args[0].upcase}".to_sym) } + when :alias then { type_alias: Wire::BamlTyTypeAlias.new(name: args[0]) } + when :enum_variant then { enum_variant: Wire::BamlTyEnumVariant.new(name: args[0], variant: args[1]) } when :unknown then { unknown: Wire::BamlTyUnknown.new } when :never then { never: Wire::BamlTyNever.new } when :class @@ -85,11 +197,12 @@ def to_wire(slot) when :map then { map: Wire::BamlTyMap.new(key: to_wire(args[0]), value: to_wire(args[1])) } when :union then { union: Wire::BamlTyUnion.new(options: args[0].map { |item| to_wire(item) }) } when :literal - literal_kind = Rules.kind(args[0]) - unless %i[int string bool].include?(literal_kind) + literal_kind = literal_kind(slot) + unless %i[int bigint string bool].include?(literal_kind) raise Fault, "cannot encode this literal type" end - { literal: Wire::BamlTyLiteral.new(**{ "#{literal_kind}_value".to_sym => args[0] }) } + value = literal_kind == :bigint ? slot.last.to_s(10) : slot.last + { literal: Wire::BamlTyLiteral.new(**{ "#{literal_kind}_value".to_sym => value }) } else raise Fault, "cannot encode type #{slot.inspect}" end Wire::BamlTy.new(**fields) @@ -105,11 +218,14 @@ def from_wire(ty) case ty.ty when :primitive kind = body.kind.to_s.delete_prefix("BAML_TY_PRIMITIVE_").downcase.to_sym - %i[int float string bool null].include?(kind) ? [kind] : [:other] + %i[int bigint float string bool null bytes].include?(kind) ? [kind] : [:other] when :unknown then [:unknown] when :never then [:never] when :class_ty then [:class, body.name, body.type_args.map { |item| from_wire(item) }] when :enum then [:enum, body.name] + when :enum_variant then [:enum_variant, body.name, body.variant] + when :type_alias then body.type_args.empty? ? [:alias, body.name] : [:other] + when :media then [:media, body.kind.to_s.delete_prefix("BAML_TY_MEDIA_KIND_").downcase.to_sym] when :interface body.type_args.empty? && body.bindings.empty? ? [:interface, body.name] : [:other] when :list then [:list, from_wire(body.item)] @@ -117,7 +233,11 @@ def from_wire(ty) when :optional then union([from_wire(body.inner), [:null]]) when :union then union(body.options.map { |item| from_wire(item) }) when :literal - %i[int_value string_value bool_value].include?(body.literal) ? [:literal, body.public_send(body.literal)] : [:other] + if body.literal == :bigint_value + [:literal, :bigint, Integer(body.bigint_value, 10)] + else + %i[int_value string_value bool_value].include?(body.literal) ? [:literal, body.public_send(body.literal)] : [:other] + end else [:other] end end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/protocol.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/protocol.rb index c35ede9193b..a02f3c9c237 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/protocol.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/protocol.rb @@ -2,6 +2,7 @@ require "baml_bridge/cffi/v1/baml_inbound_pb" require "baml_bridge/cffi/v1/baml_outbound_pb" +require_relative "../media" require_relative "encode/slot" require_relative "encode/rules" require_relative "encode/read" @@ -61,13 +62,19 @@ def close @fields = {} @functions = {} @enums = {} + @aliases = {} + @normalized_aliases = {} + @alias_mutex = Mutex.new + @alias_generation = 0 + @crossings = {} module_function # The generator owns Ruby naming, including renamed fields. No constant # lookup or naming conversion happens while decoding untrusted wire data. - def define_class(name, type, fields:) + def define_class(name, type, fields:, crossing: :fields) @types[name] = type @names[type] = name + @crossings[name] = crossing @fields[type] = fields.transform_values { |ruby, descriptor| [ruby, descriptor].freeze }.freeze end @@ -78,6 +85,35 @@ def define_enum(name, type) @enums[name] = type.values.map(&:serialize).freeze end + def define_alias(name, descriptor, recursive: false) + @alias_mutex.synchronize do + @aliases[name] = [descriptor, recursive].freeze + @alias_generation += 1 + @normalized_aliases.clear + end + end + + def normalized_alias(name) + definition, generation = @alias_mutex.synchronize do + return @normalized_aliases[name] if @normalized_aliases.key?(name) + + [@aliases[name], @alias_generation] + end + value = yield definition + @alias_mutex.synchronize do + @normalized_aliases[name] = value if generation == @alias_generation + end + value + end + + def alias_definition(name) + @alias_mutex.synchronize { @aliases[name] } + end + + def class_crossing(name) + @crossings.fetch(name, :fields) + end + def define_function(name, params:) @functions[name] = params.to_h.freeze end @@ -187,12 +223,32 @@ def decode_result(bytes, api = nil) captured&.close end + def decode_media(media) + type = { IMAGE: ::Baml::Image, AUDIO: ::Baml::Audio, VIDEO: ::Baml::Video, PDF: ::Baml::Pdf }[media.media] + raise UnsupportedTypeError, "BAML media kind #{media.media} is not supported" unless type + + mime = media.has_mime_type? ? media.mime_type : nil + case media.value + when :url then type.from_url(media.url, mime_type: mime) + when :base64 then type.from_base64(media.base64, mime_type: mime) + when :file_content + type.from_file_content(media.file_content.name, media.file_content.base64, mime_type: mime) + else raise Error, "BAML returned media with no content" + end + end + def decode_value(value, api = nil, handles: nil) raise Error, "BAML returned an empty value" unless value case value.value when :string_value, :int_value, :float_value, :bool_value value.public_send(value.value) + when :uint8array_value + value.uint8array_value.b + when :bigint_value + Integer(value.bigint_value, 16) + when :media_value + decode_media(value.media_value) when :handle_value handle = value.handle_value if %i[HOST_VALUE_CALLABLE HOST_VALUE_OPAQUE].include?(handle.handle_type) @@ -229,6 +285,8 @@ def decode_value(value, api = nil, handles: nil) literal.public_send(literal.literal) when :float_value Float(literal.float_value) + when :bigint_value + Integer(literal.bigint_value, 16) else raise UnsupportedTypeError, "BAML literal kind #{literal.literal.inspect} is not yet supported" end @@ -239,17 +297,41 @@ def decode_value(value, api = nil, handles: nil) def decode_map_key(key, type) case type&.ty + when :enum + registered_type(type.enum.name).deserialize(key) + when :enum_variant + registered_type(type.enum_variant.name).deserialize(key) + when :union, :type_alias, :never + # Compiler map keys (including aliases and literal unions) denote strings. + key when :primitive case type.primitive.kind when :BAML_TY_PRIMITIVE_STRING then key + when :BAML_TY_PRIMITIVE_INT, :BAML_TY_PRIMITIVE_BIGINT then Integer(key, 10) + when :BAML_TY_PRIMITIVE_BOOL then decode_bool_key(key) else raise UnsupportedTypeError, "BAML map key type #{type.primitive.kind} is not yet supported" end + when :literal + case type.literal.literal + when :string_value then key + when :int_value, :bigint_value then Integer(key, 10) + when :bool_value then decode_bool_key(key) + else + raise UnsupportedTypeError, "BAML map key literal #{type.literal.literal} is not yet supported" + end else raise UnsupportedTypeError, "BAML map key type #{type&.ty.inspect} is not yet supported" end end + def decode_bool_key(key) + return true if key == "true" + return false if key == "false" + + raise Error, "BAML returned an invalid bool map key #{key.inspect}" + end + def raise_failure(error_class, value, api = nil, handles: nil) while value&.value == :union_variant_value value = value.union_variant_value.value diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/media.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/media.rb new file mode 100644 index 00000000000..8d6e7114b57 --- /dev/null +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/media.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Baml + # Portable media content. Construction copies its strings so neither the + # caller nor an engine handle can mutate a value after it has been built. + class Media + attr_reader :url, :base64, :mime_type, :name + + def self.from_url(url, mime_type: nil) + new(url: url, mime_type: mime_type) + end + + def self.from_base64(base64, mime_type: nil) + new(base64: base64, mime_type: mime_type) + end + + def self.from_file(file, mime_type: nil) + from_file_content(file, [File.binread(file)].pack("m0"), mime_type: mime_type) + end + + def self.from_file_content(file, base64, mime_type: nil) + name = File.basename(file) + new(base64: base64, name: name, mime_type: mime_type) + end + + def initialize(url: nil, base64: "", mime_type: nil, name: nil) + @url, @base64, @mime_type, @name = [url, base64, mime_type, name].map do |value| + raise TypeError, "Media content must be a String" unless value.nil? || value.is_a?(String) + + value&.dup&.freeze + end + freeze + end + def ==(other) + other.instance_of?(self.class) && + [url, base64, mime_type, name] == [other.url, other.base64, other.mime_type, other.name] + end + alias eql? == + + def hash + [self.class, url, base64, mime_type, name].hash + end + + private_class_method :new + end + + class Image < Media; end + class Audio < Media; end + class Video < Media; end + class Pdf < Media; end +end diff --git a/baml_language/sdks/ruby/bridge_ruby/rbi/baml.rbi b/baml_language/sdks/ruby/bridge_ruby/rbi/baml.rbi index 8c44cad12d0..097f34861ec 100644 --- a/baml_language/sdks/ruby/bridge_ruby/rbi/baml.rbi +++ b/baml_language/sdks/ruby/bridge_ruby/rbi/baml.rbi @@ -69,6 +69,31 @@ module Baml def close; end end + # Frozen portable media values; file constructors read content immediately. + class Media + extend T::Sig + sig { params(url: String, mime_type: T.nilable(String)).returns(T.attached_class) } + def self.from_url(url, mime_type: nil); end + sig { params(base64: String, mime_type: T.nilable(String)).returns(T.attached_class) } + def self.from_base64(base64, mime_type: nil); end + sig { params(file: String, mime_type: T.nilable(String)).returns(T.attached_class) } + def self.from_file(file, mime_type: nil); end + sig { params(file: String, base64: String, mime_type: T.nilable(String)).returns(T.attached_class) } + def self.from_file_content(file, base64, mime_type: nil); end + sig { returns(T.nilable(String)) } + def url; end + sig { returns(String) } + def base64; end + sig { returns(T.nilable(String)) } + def mime_type; end + sig { returns(T.nilable(String)) } + def name; end + end + class Image < Media; end + class Audio < Media; end + class Video < Media; end + class Pdf < Media; end + module Bridge extend T::Sig @@ -76,16 +101,19 @@ module Baml class UnsupportedTypeError < Error; end # Type descriptors are frozen arrays mirroring the generator's Ty. Grammar: - # [:int] [:float] [:string] [:bool] [:null] [:never] [:handle] + # [:int] [:bigint] [:float] [:string] [:bool] [:null] [:bytes] [:never] [:handle] # [:literal, value] value: String, Integer, Float, true or false + # [:literal, :bigint, integer] preserves the bigint literal kind + # [:media, kind] kind: :image, :audio, :video, :pdf, :generic + # [:alias, "pkg.ns.Name"] # [:class, "pkg.ns.Name", [type_args...]] # [:enum, "pkg.ns.Name"] [:enum_variant, "pkg.ns.Name", "Variant"] # [:unknown] [:interface, "pkg.ns.Name"] - # [:list, d] [:map, [:string], value] [:union, [members...]] + # [:list, d] [:map, key, value] [:union, [members...]] # [:function, [[name_or_nil, d, optional?], ...], ret, throws] # [:other] unsupported type # Values are planned against these slots before any handles are cloned. - # Enum variants and callable values are reserved for later runtime support. + # Callable values are reserved for later runtime support. Descriptor = T.type_alias { T::Array[T.untyped] } # Called once when the generated package loads. Registering a different @@ -103,14 +131,19 @@ module Baml params( wire: String, klass: T.class_of(T::Struct), - fields: T::Hash[String, [Symbol, Descriptor]] + fields: T::Hash[String, [Symbol, Descriptor]], + crossing: Symbol ).void end - def self.define_class(wire, klass, fields:); end + def self.define_class(wire, klass, fields:, crossing: :fields); end sig { params(wire: String, klass: T.class_of(T::Enum)).void } def self.define_enum(wire, klass); end + # Non-recursive aliases state their bodies; recursive aliases state their names. + sig { params(wire: String, descriptor: Descriptor, recursive: T::Boolean).void } + def self.define_alias(wire, descriptor, recursive: false); end + # params: [wire name, descriptor] in declaration order. # Instance methods include their receiver as the first param, named "self". sig do diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/descriptors.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/descriptors.rs index 73f17518b62..cb1c20b15d2 100644 --- a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/descriptors.rs +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/descriptors.rs @@ -7,6 +7,16 @@ use crate::names::quote; pub(crate) fn descriptor(ty: &Ty) -> String { let body = match ty { Ty::Int => ":int".into(), + Ty::Bigint => ":bigint".into(), + Ty::Uint8Array => ":bytes".into(), + Ty::Media(kind) => format!( + ":media, :{}", + if matches!(kind, baml_base::MediaKind::Generic) { + "generic" + } else { + kind.tag_str() + } + ), Ty::Float => ":float".into(), Ty::String => ":string".into(), Ty::Bool => ":bool".into(), @@ -18,7 +28,8 @@ pub(crate) fn descriptor(ty: &Ty) -> String { match value { Literal::String(s) => quote(s), Literal::Int(v) => v.to_string(), - Literal::Bigint(v) => v.to_string(), + Literal::Bigint(v) => + format!(":bigint, ::Kernel.Integer({})", quote(&v.to_string())), Literal::Float(v) => v.clone(), Literal::Bool(v) => v.to_string(), } @@ -34,6 +45,7 @@ pub(crate) fn descriptor(ty: &Ty) -> String { quote(&name.to_string()), quote(variant.as_str()) ), + Ty::TypeAlias(name) => format!(":alias, {}", quote(&name.to_string())), Ty::Unknown => ":unknown".into(), Ty::Interface(name, ..) => format!(":interface, {}", quote(&name.to_string())), Ty::Function { @@ -60,22 +72,15 @@ pub(crate) fn descriptor(ty: &Ty) -> String { descriptor(throws) ), Ty::List(inner) => format!(":list, {}", descriptor(inner)), - Ty::Map { key, value } if matches!(key.as_ref(), Ty::String) => { + Ty::Map { key, value } => { format!(":map, {}, {}", descriptor(key), descriptor(value)) } Ty::Union(items) => { format!(":union, {}", descriptors(items)) } - Ty::Type - | Ty::Resource - | Ty::PromptAst - | Ty::Future(..) - | Ty::Bigint - | Ty::Uint8Array - | Ty::Media(..) - | Ty::TypeAlias(..) - | Ty::TypeVar(..) - | Ty::Map { .. } => ":other".into(), + Ty::Type | Ty::Resource | Ty::PromptAst | Ty::Future(..) | Ty::TypeVar(..) => { + ":other".into() + } }; format!("[{body}].freeze") } diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs index ceac008f7da..b58d51a21f6 100644 --- a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs @@ -1,19 +1,23 @@ use std::{collections::BTreeSet, fmt::Write as _}; -use baml_sdkgen_types::{Class, Enum, Function, FunctionArgument, Name, NamingConvention}; +use baml_sdkgen_types::{ + Class, Enum, Function, FunctionArgument, Name, NamingConvention, SymbolPool, Ty, TypeAlias, +}; use crate::{ descriptors::descriptor, names::{NameTable, callable_parts, identifier, parameter, quote, variant_constant}, - types::ruby_type, + types::{ruby_alias_type, ruby_input_type, ruby_type}, }; pub(crate) struct Emitter<'a> { + pub(crate) pool: &'a SymbolPool, pub(crate) supported: &'a BTreeSet, pub(crate) naming: NamingConvention, pub(crate) names: NameTable, pub(crate) registry: String, pub(crate) shells: String, + pub(crate) aliases: String, pub(crate) skipped: BTreeSet, } @@ -21,6 +25,17 @@ impl Emitter<'_> { pub(crate) fn class(&mut self, name: &Name, class: &Class, body: &mut String) { let scope = self.names.qualified(name); self.shell(name, "Struct"); + doc_comment( + body, + &member_doc( + class.docstring.as_deref(), + "Attributes", + class + .properties + .iter() + .map(|p| (p.name.as_str(), p.docstring.as_deref())), + ), + ); writeln!( body, "class {} < ::T::Struct\n extend ::T::Sig\n include ::Baml::ValueEquality", @@ -37,7 +52,7 @@ impl Emitter<'_> { writeln!( body, " const :{field}, {}", - ruby_type(&property.ty, self.supported, &self.names).unwrap() + ruby_input_type(&property.ty, self.supported, &self.names, self.pool).unwrap() ) .unwrap(); fields.push(format!( @@ -48,9 +63,24 @@ impl Emitter<'_> { } writeln!( self.registry, - "::Baml::Bridge.define_class({}, ::{scope}, fields: {{{}}})", + "::Baml::Bridge.define_class({}, ::{scope}, fields: {{{}}}, crossing: :{})", quote(&name.to_string()), - fields.join(", ") + fields.join(", "), + match class.crossing { + baml_type::ClassCrossing::Fields => "fields".into(), + baml_type::ClassCrossing::Opaque => "opaque".into(), + baml_type::ClassCrossing::Stream => "stream".into(), + baml_type::ClassCrossing::FunctionSpec => "function_spec".into(), + baml_type::ClassCrossing::Prompt => "prompt".into(), + baml_type::ClassCrossing::Media(kind) => format!( + "media_{}", + if matches!(kind, baml_base::MediaKind::Generic) { + "generic" + } else { + kind.tag_str() + } + ), + } ) .unwrap(); for (instance, methods) in [ @@ -77,6 +107,17 @@ impl Emitter<'_> { pub(crate) fn enum_(&mut self, name: &Name, enum_: &Enum, body: &mut String) { self.shell(name, "Enum"); let scope = self.names.qualified(name); + doc_comment( + body, + &member_doc( + enum_.docstring.as_deref(), + "Members", + enum_ + .variants + .iter() + .map(|v| (v.name.as_str(), v.docstring.as_deref())), + ), + ); writeln!( body, "class {} < ::T::Enum\n enums do", @@ -101,6 +142,30 @@ impl Emitter<'_> { .unwrap(); } + pub(crate) fn type_alias(&mut self, name: &Name, alias: &TypeAlias, pool: &SymbolPool) { + let ty = ruby_alias_type( + &Ty::TypeAlias(name.clone()), + pool, + self.supported, + &self.names, + ) + .unwrap(); + writeln!( + self.aliases, + "{} = ::T.type_alias {{ {ty} }}", + self.names.qualified(name) + ) + .unwrap(); + writeln!( + self.registry, + "::Baml::Bridge.define_alias({}, {}, recursive: {})", + quote(&name.to_string()), + descriptor(&alias.resolves_to), + alias.recursive + ) + .unwrap(); + } + fn shell(&mut self, name: &Name, kind: &str) { writeln!( self.shells, @@ -138,7 +203,7 @@ impl Emitter<'_> { let argument_types: Vec<_> = function .arguments .iter() - .map(|a| ruby_type(&a.ty, self.supported, &self.names)) + .map(|a| ruby_input_type(&a.ty, self.supported, &self.names, self.pool)) .collect(); let (base, suffix) = callable_parts(function.name.as_str()); let base = identifier(base, self.naming); @@ -195,6 +260,23 @@ impl Emitter<'_> { result.clone() }; let target = if receiver.is_some() { "" } else { "self." }; + let mut doc = function.docstring.clone().unwrap_or_default(); + if let Some(throws) = &function.throws { + let mut names = BTreeSet::new(); + throws_names(throws, &mut names); + if !names.is_empty() { + if !doc.is_empty() { + doc.push_str("\n\n"); + } + write!( + doc, + "Raises:\n {}", + names.into_iter().collect::>().join(", ") + ) + .unwrap(); + } + } + doc_comment(body, &doc); let signature = if params.is_empty() { String::new() } else { @@ -205,6 +287,57 @@ impl Emitter<'_> { } } +fn doc_comment(body: &mut String, doc: &str) { + for line in doc.lines() { + if line.is_empty() { + body.push_str("#\n"); + } else { + writeln!(body, "# {line}").unwrap(); + } + } +} + +fn member_doc<'a>( + summary: Option<&str>, + section: &str, + members: impl Iterator)>, +) -> String { + let mut doc = summary.unwrap_or_default().to_owned(); + let members: Vec<_> = members.collect(); + if members.iter().any(|(_, doc)| doc.is_some()) { + if !doc.is_empty() { + doc.push_str("\n\n"); + } + write!(doc, "{section}:").unwrap(); + for (name, comment) in members { + write!(doc, "\n {name}").unwrap(); + if let Some(comment) = comment { + let mut lines = comment.lines(); + if let Some(first) = lines.next() { + write!(doc, ": {first}").unwrap(); + } + for line in lines { + write!(doc, "\n {line}").unwrap(); + } + } + } + } + doc +} + +fn throws_names(ty: &Ty, names: &mut BTreeSet) { + match ty { + Ty::Union(items) => items.iter().for_each(|t| throws_names(t, names)), + Ty::Class(name, _) | Ty::Enum(name) | Ty::TypeAlias(name) => { + names.insert(name.name().to_string()); + } + Ty::Never => {} + _ => { + names.insert(ty.to_string()); + } + } +} + // Leave unsupported compiler-injected options to the engine's default, without // relaxing the supported-type requirement for user-declared arguments. fn omit_injected_argument( diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/lib.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/lib.rs index 84bdb99ea3e..9a1f2eafd62 100644 --- a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/lib.rs +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/lib.rs @@ -13,7 +13,7 @@ use std::{ use baml_sdkgen_types::{Name, NamingConvention, SkipWarning, Symbol, SymbolPool}; use emit::Emitter; use names::NameTable; -use types::ruby_type; +use types::{ruby_alias_type, ruby_type}; pub fn to_source_code_with_bytecode( pool: &SymbolPool, @@ -66,7 +66,7 @@ pub fn to_source_code_with_bytecode_and_skipped( None } Symbol::Class(class) if class.generic_params.is_empty() => Some((*n).clone()), - Symbol::Enum(_) => Some((*n).clone()), + Symbol::Enum(_) | Symbol::TypeAlias(_) => Some((*n).clone()), _ => None, }) .collect(); @@ -79,6 +79,13 @@ pub fn to_source_code_with_bytecode_and_skipped( .properties .iter() .any(|p| ruby_type(&p.ty, &supported, &names).is_none()), + Some(Symbol::TypeAlias(_)) => ruby_alias_type( + &baml_sdkgen_types::Ty::TypeAlias((*n).clone()), + pool, + &supported, + &names, + ) + .is_none(), _ => false, }) .cloned() @@ -91,11 +98,13 @@ pub fn to_source_code_with_bytecode_and_skipped( } } let mut emitter = Emitter { + pool, supported: &supported, naming, names, registry: String::new(), shells: String::new(), + aliases: String::new(), skipped: BTreeSet::new(), }; let mut namespaces: BTreeMap = BTreeMap::new(); @@ -108,6 +117,9 @@ pub fn to_source_code_with_bytecode_and_skipped( Symbol::Enum(enum_) => emitter.enum_(name, enum_, body), _ => unreachable!(), }, + Symbol::TypeAlias(alias) if supported.contains(name) => { + emitter.type_alias(name, alias, pool); + } Symbol::Function(function) => { emitter.function(&module, &name.to_string(), function, None, body); } diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/package.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/package.rs index 620014fad40..72542549328 100644 --- a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/package.rs +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/package.rs @@ -32,6 +32,7 @@ pub(crate) fn emit( writeln!(root, "module {module}; end").unwrap(); } root.push_str(&emitter.shells); + root.push_str(&emitter.aliases); let mut files = HashMap::new(); let mut paths = BTreeSet::from(["__root".to_owned(), "__bytecode".to_owned()]); for (module, body) in namespaces { diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs index f09734fa4e1..e88bc5fdd46 100644 --- a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs @@ -8,6 +8,13 @@ fn abi5_descriptors_keep_slots_and_callable_signatures() { let name = Name::new("user".into(), vec![], "Choice".into()); assert_eq!(descriptors::descriptor(&Ty::Unknown), "[:unknown].freeze"); + assert_eq!( + descriptors::descriptor(&Ty::Literal( + baml_base::Literal::Bigint(42.into()), + baml_type::Freshness::Regular + )), + "[:literal, :bigint, ::Kernel.Integer(\"42\")].freeze" + ); assert_eq!( descriptors::descriptor(&Ty::Interface(name.clone(), Box::new([]), Box::new([]))), "[:interface, \"user.Choice\"].freeze" @@ -120,6 +127,11 @@ fn ruby_names_and_container_types() { ruby_type(&map, &BTreeSet::new(), &names).as_deref(), Some("::T::Hash[::String, ::T::Array[::T.nilable(::String)]]") ); + let union = Ty::Union(Box::new([Ty::Int, Ty::Bool])); + assert_eq!( + ruby_type(&union, &BTreeSet::new(), &names).as_deref(), + Some("::T.any(::Integer, ::T::Boolean)") + ); assert_eq!(ruby_type(&Ty::Unknown, &BTreeSet::new(), &names), None); let parameter = Ty::TypeVar(baml_sdkgen_types::ParamTy::new(1, "foo_bar".into())); assert_eq!(ruby_type(¶meter, &BTreeSet::new(), &names), None); @@ -416,6 +428,117 @@ fn unsupported_injected_defaults_are_omitted_without_dropping_user_arguments() { } } +#[test] +fn classes_register_how_they_cross_the_bridge() { + use baml_sdkgen_types::{Class, ClassCrossing, Origin}; + + for (crossing, expected) in [ + (ClassCrossing::Fields, "fields"), + (ClassCrossing::Opaque, "opaque"), + (ClassCrossing::Stream, "stream"), + (ClassCrossing::FunctionSpec, "function_spec"), + (ClassCrossing::Prompt, "prompt"), + ( + ClassCrossing::Media(baml_base::MediaKind::Image), + "media_image", + ), + ( + ClassCrossing::Media(baml_base::MediaKind::Generic), + "media_generic", + ), + ] { + let name = Name::new("user".into(), vec![], "Boundary".into()); + let pool = SymbolPool::from([( + name.clone(), + Symbol::Class(Class { + name, + crossing, + generic_params: vec![], + docstring: None, + properties: vec![], + static_methods: vec![], + instance_methods: vec![], + origin: Origin { + source_file_path: "types.baml".into(), + span_start: 0, + }, + }), + )]); + let (files, warnings) = + to_source_code_with_bytecode_and_warnings(&pool, &[], NamingConvention::Language, None); + if expected == "prompt" { + assert_eq!(warnings.len(), 1); + assert_eq!(warnings[0].fqn, "user.Boundary"); + assert!(!files[&PathBuf::from("baml_sdk.rb")].contains("define_class")); + } else { + assert!(warnings.is_empty()); + assert!(files[&PathBuf::from("baml_sdk.rb")].contains(&format!( + "::Baml::Bridge.define_class(\"user.Boundary\", ::BamlSdk::Boundary, fields: {{}}, crossing: :{expected})" + ))); + } + } +} + +#[test] +fn aliases_preserve_descriptors_and_widen_only_recursive_back_edges() { + use baml_sdkgen_types::{Origin, TypeAlias}; + + let recursive = Name::new("user".into(), vec![], "Rec".into()); + let floats = Name::new("user".into(), vec![], "Floats".into()); + let pool = SymbolPool::from([ + ( + recursive.clone(), + Symbol::TypeAlias(TypeAlias { + name: recursive.clone(), + resolves_to: Ty::Union(Box::new([ + Ty::Int, + Ty::List(Box::new(Ty::TypeAlias(recursive))), + Ty::Uint8Array, + ])), + recursive: true, + origin: Origin { + source_file_path: "types.baml".into(), + span_start: 0, + }, + }), + ), + ( + floats.clone(), + Symbol::TypeAlias(TypeAlias { + name: floats.clone(), + resolves_to: Ty::List(Box::new(Ty::Float)), + recursive: false, + origin: Origin { + source_file_path: "types.baml".into(), + span_start: 1, + }, + }), + ), + ]); + let supported = pool.keys().cloned().collect(); + let names = NameTable::build(&pool, NamingConvention::Language); + let ty = Ty::TypeAlias(floats); + assert_eq!( + ruby_type(&ty, &supported, &names).as_deref(), + Some("::BamlSdk::Floats") + ); + assert_eq!( + types::ruby_input_type(&ty, &supported, &names, &pool).as_deref(), + Some("::T::Array[::T.any(::Float, ::Integer)]") + ); + let (files, skipped) = + to_source_code_with_bytecode_and_skipped(&pool, &[], NamingConvention::Language, None); + assert!(skipped.is_empty()); + let root = &files[&PathBuf::from("baml_sdk.rb")]; + assert!(root.contains("BamlSdk::Rec = ::T.type_alias { ::T.any(::Integer, ::T::Array[::T.untyped], ::String) }"), "{root}"); + assert!( + root.contains("BamlSdk::Floats = ::T.type_alias { ::T::Array[::Float] }"), + "{root}" + ); + assert!(root.contains("::Baml::Bridge.define_alias(\"user.Rec\", [:union, [[:int].freeze, [:list, [:alias, \"user.Rec\"].freeze].freeze, [:bytes].freeze].freeze].freeze, recursive: true)"), "{root}"); + assert!(root.contains("::Baml::Bridge.define_alias(\"user.Floats\", [:list, [:float].freeze].freeze, recursive: false)"), "{root}"); +} + #[test] fn bytecode_is_binary_and_generation_is_stable() { let first = to_source_code_with_bytecode( diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/types.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/types.rs index 1f7594f8a41..fa954d0bd57 100644 --- a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/types.rs +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/types.rs @@ -1,40 +1,122 @@ use std::collections::BTreeSet; -use baml_sdkgen_types::{Name, Ty}; +use baml_sdkgen_types::{Name, Symbol, SymbolPool, Ty}; use crate::names::NameTable; pub(crate) fn ruby_type(ty: &Ty, supported: &BTreeSet, names: &NameTable) -> Option { + render(ty, supported, names, false, None, &mut BTreeSet::new()) +} + +pub(crate) fn ruby_input_type( + ty: &Ty, + supported: &BTreeSet, + names: &NameTable, + pool: &SymbolPool, +) -> Option { + render(ty, supported, names, true, Some(pool), &mut BTreeSet::new()) +} + +pub(crate) fn ruby_alias_type( + ty: &Ty, + pool: &SymbolPool, + supported: &BTreeSet, + names: &NameTable, +) -> Option { + render( + ty, + supported, + names, + false, + Some(pool), + &mut BTreeSet::new(), + ) +} + +fn render( + ty: &Ty, + supported: &BTreeSet, + names: &NameTable, + input: bool, + aliases: Option<&SymbolPool>, + visiting: &mut BTreeSet, +) -> Option { + let mut child = |ty: &Ty| render(ty, supported, names, input, aliases, visiting); Some(match ty { // The pool also defines baml.String and baml.Float; do not resolve to those wrappers. Ty::Literal(baml_base::Literal::String(_), ..) => "::String".into(), - Ty::Literal(baml_base::Literal::Int(_), ..) => "::Integer".into(), + Ty::Literal(baml_base::Literal::Int(_) | baml_base::Literal::Bigint(_), ..) => { + "::Integer".into() + } Ty::Literal(baml_base::Literal::Float(_), ..) => return None, Ty::Literal(baml_base::Literal::Bool(_), ..) => "::T::Boolean".into(), Ty::String => "::String".into(), - Ty::Int => "::Integer".into(), + Ty::Int | Ty::Bigint => "::Integer".into(), + // Sorbet rejects Integer for Float; the SDK widens integer inputs. + Ty::Float if input => "::T.any(::Float, ::Integer)".into(), Ty::Float => "::Float".into(), + Ty::Uint8Array => "::String".into(), + Ty::Media(kind) => match kind { + baml_base::MediaKind::Image => "::Baml::Image", + baml_base::MediaKind::Audio => "::Baml::Audio", + baml_base::MediaKind::Video => "::Baml::Video", + baml_base::MediaKind::Pdf => "::Baml::Pdf", + baml_base::MediaKind::Generic => "::Baml::Media", + } + .into(), Ty::Bool => "::T::Boolean".into(), Ty::Null => "::NilClass".into(), Ty::RustType => "::Baml::Handle".into(), Ty::Never => "::T.noreturn".into(), - Ty::List(t) => format!("::T::Array[{}]", ruby_type(t, supported, names)?), - Ty::Map { key, value } if matches!(key.as_ref(), Ty::String) => { - format!( - "::T::Hash[::String, {}]", - ruby_type(value, supported, names)? - ) - } + Ty::List(t) => format!("::T::Array[{}]", child(t)?), + Ty::Map { key, value } => format!("::T::Hash[{}, {}]", child(key)?, child(value)?), Ty::Class(n, args) if args.is_empty() && supported.contains(n) => { format!("::{}", names.qualified(n)) } - Ty::Enum(n) if supported.contains(n) => format!("::{}", names.qualified(n)), - Ty::Union(items) => match items.as_ref() { - [Ty::Null, inner] | [inner, Ty::Null] => { - format!("::T.nilable({})", ruby_type(inner, supported, names)?) + Ty::Enum(n) | Ty::EnumVariant(n, _) if supported.contains(n) => { + format!("::{}", names.qualified(n)) + } + Ty::TypeAlias(n) if supported.contains(n) => { + if let Some(pool) = aliases { + // Sorbet cannot resolve recursive aliases. Widen only the back-edge; + // the runtime retains the exact recursive descriptor for encoding. + if !visiting.insert(n.clone()) { + return Some("::T.untyped".into()); + } + let Symbol::TypeAlias(alias) = pool.get(n)? else { + return None; + }; + let result = render( + &alias.resolves_to, + supported, + names, + input, + aliases, + visiting, + ); + visiting.remove(n); + result? + } else { + format!("::{}", names.qualified(n)) } - _ => return None, - }, + } + Ty::Union(items) => { + let mut types = items.iter().map(&mut child).collect::>>()?; + // Literal unions often project several members to the same Ruby type. + let mut seen = BTreeSet::new(); + types.retain(|t| seen.insert(t.clone())); + if types.iter().any(|t| t == "::T.untyped") { + return Some("::T.untyped".into()); + } + match types.as_slice() { + [] => "::T.noreturn".into(), + [only] => only.clone(), + [a, b] if a == "::NilClass" || b == "::NilClass" => { + format!("::T.nilable({})", if a == "::NilClass" { b } else { a }) + } + _ => format!("::T.any({})", types.join(", ")), + } + } _ => return None, }) } From 81a6533aecdec20f24ff94f4fe9e3affee19a58a Mon Sep 17 00:00:00 2001 From: Ryan Mazzolini Date: Mon, 2 Nov 2026 14:59:50 +0000 Subject: [PATCH 3/7] feat(ruby): add optional arguments, call options and typed errors Callables with defaulted arguments were skipped, calls could not be timed out or cancelled, and errors exposed only a type name and message. Defaulted parameters become keywords that default to Baml::UNSET, so BAML applies its own default; nil sends an explicit null. Every generated callable accepts `_baml: Baml::Options.new(timeout_ms:, cancel:)`. The timeout becomes an absolute deadline on the engine's clock, and cancel takes a generated CancelToken. Baml::Error now carries the decoded payload and BAML trace, engine cancellation raises Baml::CancelledError, a boundary TypeMismatch raises TypeError, and an exit panic ends the process with its exit code. Under ABI 5 the cancel token goes through the walker, omitted arguments are left out before encoding, and JSON nested deeper than the wire allows is refused with TypeError. Tests: ports the shared error, optional-argument, invocation, cancellation and JSON tests, and the HTTP response handle test, which needs Http.fetch's optional timeout; 112 nextest targets pass and Ruby declares 166 shared SDK tests. Developed with AI assistance. --- .../sdk_tests/crates/parity_analysis.md | 76 ++++++----- .../customizable/test_cancellation.rb | 89 +++++++++++++ .../customizable/test_errors.rb | 126 ++++++++++++++++++ .../customizable/test_invocation.rb | 70 ++++++++++ .../function_calls/customizable/test_json.rb | 35 +++++ .../customizable/test_optional_args.rb | 50 +++++++ .../customizable/typed_consumer.rb | 5 + .../ruby_sorbet/test/bridge_call_test.rb | 64 +++++++++ .../ruby_sorbet/test/bridge_encode_test.rb | 35 +++++ .../type_shapes/customizable/test_handles.rb | 23 ++++ .../ref-ruby-state-of-completeness.md | 28 ++-- .../sdks/ruby/bridge_ruby/lib/baml/bridge.rb | 5 +- .../bridge_ruby/lib/baml/bridge/errors.rb | 24 +++- .../bridge_ruby/lib/baml/bridge/native.rb | 9 ++ .../lib/baml/bridge/process_runtime.rb | 7 +- .../bridge_ruby/lib/baml/bridge/protocol.rb | 101 +++++++++++--- .../sdks/ruby/bridge_ruby/lib/baml/options.rb | 26 ++++ .../sdks/ruby/bridge_ruby/rbi/baml.rbi | 39 +++++- .../sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs | 36 +++-- .../sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs | 36 ++--- .../sdks/ruby/sdkgen_ruby_sorbet/src/types.rs | 2 + 21 files changed, 775 insertions(+), 111 deletions(-) create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_cancellation.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_errors.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_invocation.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_json.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_optional_args.rb create mode 100644 baml_language/sdks/ruby/bridge_ruby/lib/baml/options.rb diff --git a/baml_language/sdk_tests/crates/parity_analysis.md b/baml_language/sdk_tests/crates/parity_analysis.md index dc9c6c1cdf8..d9790d47b2b 100644 --- a/baml_language/sdk_tests/crates/parity_analysis.md +++ b/baml_language/sdk_tests/crates/parity_analysis.md @@ -2,9 +2,9 @@ This report inventories checked-in test declarations. It does not report whether tests passed. -Distinct exact test IDs: 983. IDs with complete required parity: 234. Required gaps: 5760. +Distinct exact test IDs: 985. IDs with complete required parity: 236. Required gaps: 5727. -Baseline ratchet: UNCHANGED. Required gaps: 5760 (baseline: 5760). Present declarations: 2744 (baseline: 2744). Newly missing required pairs: 0. Resolved baseline gaps: 0. Weakened requirements: 0. +Baseline ratchet: UNCHANGED. Required gaps: 5727 (baseline: 5727). Present declarations: 2780 (baseline: 2780). Newly missing required pairs: 0. Resolved baseline gaps: 0. Weakened requirements: 0. ## Python-baselined parity @@ -22,7 +22,7 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | go | 21 / 457 | 4.6% | | java | 307 / 457 | 67.2% | | swift | 191 / 457 | 41.8% | -| ruby_sorbet | 132 / 457 | 28.9% | +| ruby_sorbet | 166 / 457 | 36.3% | | Test case | python_pydantic2 | typescript_node | typescript_web_chromium | typescript_web_cloudflare_workers | cpp | csharp | rust | go | java | swift | ruby_sorbet | Required in | Reason | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | @@ -90,13 +90,13 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/callback_throws_caught_then_rethrown_value_is_the_replacement_union | - | - | - | - | - | - | y | - | - | - | - | rust | validates Rust-specific inferred callback error unions | | function_calls/callback_throws_rethrown_carries_the_effect_param_into_the_error_union | - | - | - | - | - | - | y | - | - | - | - | rust | validates Rust-specific inferred callback error unions | | function_calls/cancel_controls_with_cancelled_context_go_only | - | - | - | - | - | - | - | y | - | - | - | go | Go cancellation controls remain usable with a canceled context | -| function_calls/cancellation_async_call_returns_none | y | - | - | - | y | - | y | - | y | y | - | all | | +| function_calls/cancellation_async_call_returns_none | y | - | - | - | y | - | y | - | y | y | y | all | | | function_calls/cancellation_async_cancel_skips_later_step | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | drives Python asyncio cancellation (task.cancel / wait_for) | -| function_calls/cancellation_async_cancel_via_asyncio_timeout | y | - | - | - | - | - | y | - | y | - | - | all | | -| function_calls/cancellation_async_cancel_via_call_context | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/cancellation_async_cancel_via_asyncio_timeout | y | - | - | - | - | - | y | - | y | - | y | all | | +| function_calls/cancellation_async_cancel_via_call_context | y | - | - | - | - | - | y | - | y | - | y | all | | | function_calls/cancellation_async_cancel_via_future_cancel | - | - | - | - | y | - | - | - | - | - | - | all | | -| function_calls/cancellation_async_cancel_via_task_cancel | y | - | - | - | - | - | y | - | y | y | - | all | | -| function_calls/cancellation_async_cancel_via_task_group_sibling | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/cancellation_async_cancel_via_task_cancel | y | - | - | - | - | - | y | - | y | y | y | all | | +| function_calls/cancellation_async_cancel_via_task_group_sibling | y | - | - | - | - | - | y | - | y | - | y | all | | | function_calls/cancellation_async_cancel_via_timeout_race | - | - | - | - | - | - | - | - | - | y | - | all | | | function_calls/cancellation_can_pre_abort_async_baml_cancellation | - | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/cancellation_cancels_an_async_generated_free_function | - | y | y | y | - | - | - | - | - | - | - | all | | @@ -112,8 +112,8 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/cancellation_surfaces_async_cancellation_as_abort_error_with_baml_reason | - | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/cancellation_surfaces_cancellation_through_promise_all | - | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/cancellation_surfaces_sync_pre_aborted_cancellation_as_abort_error | - | y | y | y | - | - | - | - | - | - | - | all | | -| function_calls/cancellation_sync_call_returns_none | y | - | - | - | y | - | y | - | y | y | - | all | | -| function_calls/cancellation_sync_cancel_via_call_context | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/cancellation_sync_call_returns_none | y | - | - | - | y | - | y | - | y | y | y | all | | +| function_calls/cancellation_sync_cancel_via_call_context | y | - | - | - | - | - | y | - | y | - | y | all | | | function_calls/cancelled_waiter_does_not_end_host_execution | y | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/cancelled_waiter_keeps_host_context_through_cleanup_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python task unwinding and ContextVar semantics | | function_calls/cancelled_waiter_keeps_host_resource_until_promise_exit_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node AsyncResource destruction and AsyncLocalStorage | @@ -141,7 +141,7 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/coawait_uncaught_coroutine_exception_reaches_join | - | - | - | - | y | - | - | - | - | - | - | all | | | function_calls/completed_call_can_reuse_callback | y | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/completed_call_does_not_pin_reused_callback_to_old_context_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | -| function_calls/composite_token_observes_every_source | y | y | y | y | - | - | y | - | y | - | - | all | | +| function_calls/composite_token_observes_every_source | y | y | y | y | - | - | y | - | y | - | y | all | | | function_calls/concurrent_calls_isolate_async_local_storage_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | | function_calls/concurrent_calls_keep_callback_results_independent | y | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/concurrent_calls_share_callback_without_sharing_context_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | @@ -162,31 +162,32 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/dynamic_name_and_handle_accept_controls_async | - | - | - | - | - | - | y | - | - | - | - | all | | | function_calls/dynamic_type_bindings_async_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python dynamic type bindings accept Python classes and reflected BamlType handles | | function_calls/empty_class_self_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | -| function_calls/empty_controls | y | y | y | y | - | - | y | - | y | - | - | all | | +| function_calls/empty_controls | y | y | y | y | - | - | y | - | y | - | y | all | | | function_calls/empty_json_containers_round_trip | - | - | - | - | - | - | - | - | y | - | - | all | | | function_calls/encoding_time_counts_against_deadline_python_only | y | - | - | - | - | - | - | - | - | - | - | all | | | function_calls/error_call_cancellation_preserves_context_identity | - | - | - | - | - | - | - | y | - | - | - | all | | +| function_calls/error_message_prefers_detail_and_falls_back_to_the_type_name | - | - | - | - | - | - | - | - | - | - | y | ruby_sorbet | Ruby error messages use a detail field and never inspect a struct | | function_calls/error_string_is_non_empty | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/errors_async_sibling_throws_typed | - | - | - | - | y | - | - | - | - | - | - | all | | -| function_calls/errors_baml_error_carries_baml_trace | y | - | - | - | - | - | y | - | y | y | - | all | | -| function_calls/errors_baml_trace_spliced_into_python_traceback | y | - | - | - | - | - | y | - | y | - | - | all | | -| function_calls/errors_cancellation_surfaces_as_baml_panic | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/errors_baml_error_carries_baml_trace | y | - | - | - | - | - | y | - | y | y | y | all | | +| function_calls/errors_baml_trace_spliced_into_python_traceback | y | - | - | - | - | - | y | - | y | - | y | all | | +| function_calls/errors_cancellation_surfaces_as_baml_panic | y | - | - | - | - | - | y | - | y | - | y | all | | | function_calls/errors_clean_exit_terminates_process_with_code | - | - | - | - | - | - | y | - | - | - | - | all | | -| function_calls/errors_clean_exit_terminates_process_with_code_0 | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/errors_clean_exit_terminates_process_with_code_7 | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/errors_host_invalid_argument_wraps_baml_errors_invalid_argument | y | - | - | - | - | - | y | - | y | - | - | all | | -| function_calls/errors_stdlib_error_surfaces_as_baml_error | y | - | - | - | - | - | y | - | y | y | - | all | | +| function_calls/errors_clean_exit_terminates_process_with_code_0 | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/errors_clean_exit_terminates_process_with_code_7 | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/errors_host_invalid_argument_wraps_baml_errors_invalid_argument | y | - | - | - | - | - | y | - | y | - | y | all | | +| function_calls/errors_stdlib_error_surfaces_as_baml_error | y | - | - | - | - | - | y | - | y | y | y | all | | | function_calls/errors_stdlib_error_surfaces_typed | - | - | - | - | y | - | - | - | - | - | - | all | | -| function_calls/errors_str_is_non_empty | y | - | - | - | - | - | y | - | y | y | - | all | | +| function_calls/errors_str_is_non_empty | y | - | - | - | - | - | y | - | y | y | y | all | | | function_calls/errors_typed_throw_is_still_a_baml_error | - | - | - | - | y | - | - | - | - | - | - | all | | -| function_calls/errors_union_throws_preserves_class_name | y | y | y | y | y | - | y | - | y | y | - | all | | -| function_calls/errors_user_panic_surfaces_as_baml_panic | y | - | - | - | - | - | y | - | y | y | - | all | | -| function_calls/errors_user_throw_surfaces_declared_instance | y | - | - | - | y | - | y | - | y | y | - | all | | +| function_calls/errors_union_throws_preserves_class_name | y | y | y | y | y | - | y | - | y | y | y | all | | +| function_calls/errors_user_panic_surfaces_as_baml_panic | y | - | - | - | - | - | y | - | y | y | y | all | | +| function_calls/errors_user_throw_surfaces_declared_instance | y | - | - | - | y | - | y | - | y | y | y | all | | | function_calls/explicit_controls_are_applied | - | - | - | - | - | - | - | - | - | y | - | all | | | function_calls/explicit_controls_are_applied_async | - | - | - | - | - | - | - | - | - | y | - | all | | | function_calls/failed_admission_does_not_consume_reservation | - | - | - | - | - | - | y | - | y | - | - | all | | -| function_calls/four_call_forms | y | y | y | y | - | - | y | - | y | y | - | all | | -| function_calls/four_call_forms_async | y | y | y | y | - | - | y | - | y | y | - | all | | +| function_calls/four_call_forms | y | y | y | y | - | - | y | - | y | y | y | all | | +| function_calls/four_call_forms_async | y | y | y | y | - | - | y | - | y | y | y | all | | | function_calls/generic_calls_choose_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | | function_calls/generic_calls_consume_int_wrapper_baseline | y | - | - | - | - | - | y | - | y | y | - | all | | | function_calls/generic_calls_extract_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | @@ -416,7 +417,7 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/host_rejects_unsupported_configuration_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | covers unsupported Python generator/decorator shapes | | function_calls/host_rejects_unsupported_configuration_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | covers JS generator and marker configuration rejection | | function_calls/host_supplied_empty_and_null_bearing_json_containers_state_the_alias | - | - | - | - | - | - | - | - | - | y | - | all | | -| function_calls/host_supplied_json_supports_typed_narrowing | y | y | y | y | y | - | y | y | y | y | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, rust, go, java, swift | C# declares no function_calls suite (its native coverage is Rust-wrapped integration tests) | +| function_calls/host_supplied_json_supports_typed_narrowing | y | y | y | y | y | - | y | y | y | y | y | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, rust, go, java, swift, ruby_sorbet | C# declares no function_calls suite (its native coverage is Rust-wrapped integration tests) | | function_calls/host_thread_context_handoff_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | covers Python ContextVar handoff to a thread pool | | function_calls/instance_method_cancellation_returns_exact_context_error | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/instance_method_media_receiver_default_and_ownership_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | @@ -429,7 +430,7 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/invalid_controls_rejected | y | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/invalid_function_arguments_surface_baml_error | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/invalid_response_preserves_wire_diagnostics | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | exercises Python BamlError and generated Pydantic payloads | -| function_calls/invalid_timeout_rejected | y | y | y | y | - | - | y | - | y | - | - | all | | +| function_calls/invalid_timeout_rejected | y | y | y | y | - | - | y | - | y | - | y | all | | | function_calls/invocation_inheritance | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/invocation_inheritance_multi_layer_context_patch_inherits_and_restores | - | - | - | - | y | - | - | - | - | - | - | all | | | function_calls/invocation_lifecycle | - | - | - | - | - | - | - | y | - | - | - | all | | @@ -447,7 +448,7 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/json_bigint_round_trips_as_bigint | - | - | - | - | - | - | - | - | y | - | - | all | | | function_calls/json_nested_at_the_wire_limit_crosses_and_one_level_more_is_refused | - | - | - | - | - | - | - | - | y | y | - | java, swift | pins a typed SDK encoder's exact message count at the wire's limit | | function_calls/json_nested_deeper_than_the_wire_carries_is_refused_as_a_callback_result | y | y | y | y | y | - | y | y | y | y | - | all | | -| function_calls/json_nested_deeper_than_the_wire_carries_is_refused_as_an_argument | y | y | y | y | y | - | y | y | y | y | - | all | | +| function_calls/json_nested_deeper_than_the_wire_carries_is_refused_as_an_argument | y | y | y | y | y | - | y | y | y | y | y | all | | | function_calls/json_nested_to_the_wire_limit_crosses_and_one_level_more_does_not | - | - | - | - | y | - | - | - | - | - | - | cpp | pins the C++ encoder's exact wire measure at its cut. | | function_calls/json_returned_from_host_callback_supports_typed_narrowing | y | y | y | y | y | - | y | y | y | y | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, rust, go, java, swift | C# declares no function_calls suite (its native coverage is Rust-wrapped integration tests) | | function_calls/late_callback_keeps_async_local_storage_after_abort_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node callback Promises can outlive an aborted SDK waiter | @@ -466,7 +467,7 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/main_single_required_arg_round_trips | y | - | - | - | y | - | y | - | y | y | y | all | | | function_calls/method_generated_name_collisions_stay_callable | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/method_self_all_supported_positions_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | -| function_calls/methods_accept_controls | y | y | y | y | - | - | y | - | y | - | - | all | | +| function_calls/methods_accept_controls | y | y | y | y | - | - | y | - | y | - | y | all | | | function_calls/methods_accept_controls_async | y | y | y | y | - | - | y | - | - | - | - | all | | | function_calls/methods_on_classes_create_constructs_a_greeter_async_plus_sync | - | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/methods_on_classes_exposes_sync_plus_async_bindings_for_both_flavors | - | y | y | y | - | - | - | - | - | - | - | all | | @@ -484,16 +485,16 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/native_signal_cancels_without_cancelling_input_token_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node async carrier, native signals, and entry-time snapshots are specific to TypeScript | | function_calls/nil_host_callable_fails_before_dispatch | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/null_controls_preserve_inherited_context | y | y | y | y | - | - | - | - | - | - | - | all | | -| function_calls/omitted_argument_is_not_null | y | y | y | y | - | - | y | - | y | y | - | all | | -| function_calls/optional_args_async_samples | y | y | y | y | y | - | y | - | y | y | - | all | | +| function_calls/omitted_argument_is_not_null | y | y | y | y | - | - | y | - | y | y | y | all | | +| function_calls/optional_args_async_samples | y | y | y | y | y | - | y | - | y | y | y | all | | | function_calls/optional_args_bigint_in_i64_range | - | - | - | - | - | - | - | - | y | - | - | all | | | function_calls/optional_args_covers_static_and_instance_optional_args | - | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/optional_args_covers_the_runtime_matrix | - | y | y | y | - | - | - | - | - | - | - | all | | -| function_calls/optional_args_negative_runtime_cases_reject | y | - | - | - | - | - | y | - | y | - | - | all | | -| function_calls/optional_args_opt_box_method_matrix | y | - | - | - | y | - | y | - | y | y | - | all | | -| function_calls/optional_args_python_unset_and_none_differ_in_one_call | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/optional_args_negative_runtime_cases_reject | y | - | - | - | - | - | y | - | y | - | y | all | | +| function_calls/optional_args_opt_box_method_matrix | y | - | - | - | y | - | y | - | y | y | y | all | | +| function_calls/optional_args_python_unset_and_none_differ_in_one_call | y | - | - | - | - | - | y | - | y | - | y | all | | | function_calls/optional_args_rejects_invalid_runtime_calls_that_bypass_types | - | y | y | y | - | - | - | - | - | - | - | all | | -| function_calls/optional_args_runtime_matrix | y | - | - | - | y | - | y | y | y | y | - | all | | +| function_calls/optional_args_runtime_matrix | y | - | - | - | y | - | y | y | y | y | y | all | | | function_calls/optional_args_treats_undefined_as_omitted_and_keeps_null_distinct | - | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/optional_args_unset_and_null_differ_in_one_call | - | - | - | - | y | - | - | - | - | y | - | all | | | function_calls/options_snapshot_at_async_entry_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node async carrier, native signals, and entry-time snapshots are specific to TypeScript | @@ -502,6 +503,7 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/pre_aborted_call_does_not_dispatch_callback_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | | function_calls/pre_aborted_native_signal_does_not_enter_callback_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node async carrier, native signals, and entry-time snapshots are specific to TypeScript | | function_calls/pre_cancelled_call_does_not_enter_callback | y | y | y | y | - | - | y | - | y | - | - | all | | +| function_calls/primitive_thrown_error | - | - | - | - | - | - | - | - | - | - | y | ruby_sorbet | Ruby error wrapper for a thrown string | | function_calls/promise_all_failure_with_explicit_sibling_abort_preserves_error_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | | function_calls/promise_callback_preserves_async_local_storage_across_suspension_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | | function_calls/promise_callback_sync_reentry_rejects_host_callback_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | @@ -595,7 +597,7 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/sync_retained_closure_rejects_before_dispatch_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node cannot run JS callbacks while a sync native call blocks its event loop | | function_calls/task_factory_failure_completes_dispatch_and_preserves_exception_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python asyncio task factory failures during dispatch | | function_calls/taskgroup_failure_cancels_sibling_baml_call_and_preserves_error_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python callback task and event loop ownership | -| function_calls/timeout_upper_bound_accepted | y | y | y | y | - | - | y | - | y | - | - | all | | +| function_calls/timeout_upper_bound_accepted | y | y | y | y | - | - | y | - | y | - | y | all | | | function_calls/to_thread_sync_entry_inherits_copied_application_context_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | | function_calls/two_application_loops_on_two_threads_share_callback_without_rerouting_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python thread, event loop, and ContextVar semantics | | function_calls/unhandled_spawn_error_uses_host_default | - | - | - | - | y | - | - | y | y | y | - | cpp, go, java, swift | requires subprocess-level SDK harness support | @@ -778,7 +780,7 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | type_shapes/generics_round_trip_wrapper_int | y | y | y | y | - | - | y | - | y | y | - | all | | | type_shapes/handles_baml_fs_open_returns_a_typed_file_handle | - | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | | type_shapes/handles_file_cursor_state_persists_across_calls | y | y | - | - | - | - | y | - | y | y | y | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | -| type_shapes/handles_http_get_response_fields_and_methods | y | y | - | - | - | - | y | - | y | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | +| type_shapes/handles_http_get_response_fields_and_methods | y | y | - | - | - | - | y | - | y | - | y | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | | type_shapes/handles_image_from_base64_roundtrips_payload | y | y | y | y | - | - | y | - | y | y | y | all | | | type_shapes/handles_open_file_returns_file_handle | y | - | - | - | - | - | y | - | y | y | y | all | | | type_shapes/host_created_media_round_trips_through_optional_list | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | validates Python host-created media wrapper encoding | diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_cancellation.rb b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_cancellation.rb new file mode 100644 index 00000000000..ee898e1056c --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_cancellation.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" + +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class CancellationTest < Minitest::Test + def test_cancellation_sync_call_returns_none + assert_nil BamlSdk::ThrowsTest.sleep_ms(1) + end + + def test_cancellation_async_call_returns_none + assert_nil BamlSdk::ThrowsTest.sleep_ms_async(1).wait + end + + def test_cancellation_sync_cancel_via_call_context + token = BamlSdk::Baml::Spawn::CancelToken.baml_new + start = monotonic + timer = Thread.new { sleep 0.05; token.cancel } + error = assert_raises(Baml::CancelledError) do + BamlSdk::ThrowsTest.sleep_ms(60_000, _baml: Baml::Options.new(cancel: token)) + end + assert_cancelled(error, start) + ensure + timer&.kill&.join + end + + def test_cancellation_async_cancel_via_call_context + token = BamlSdk::Baml::Spawn::CancelToken.baml_new + start = monotonic + call = BamlSdk::ThrowsTest.sleep_ms_async(60_000, _baml: Baml::Options.new(cancel: token)) + sleep 0.05 + token.cancel + assert_cancelled(assert_raises(Baml::CancelledError) { call.wait(timeout: 5) }, start) + end + + def test_cancellation_async_cancel_via_task_cancel + start = monotonic + call = BamlSdk::ThrowsTest.sleep_ms_async(60_000) + sleep 0.05 + assert call.cancel + assert_cancelled(assert_raises(Baml::CancelledError) { call.wait(timeout: 5) }, start) + end + + def test_cancellation_async_cancel_via_task_group_sibling + # Ruby has no TaskGroup. Like Java's port, a failing sibling explicitly + # cancels the other call, and the original failure remains observable. + start = monotonic + call = BamlSdk::ThrowsTest.sleep_ms_async(60_000) + sibling = Thread.new do + sleep 0.05 + raise "cancel siblings" + rescue RuntimeError => error + call.cancel + error + end + assert_instance_of RuntimeError, sibling.value + assert_equal "cancel siblings", sibling.value.message + assert_cancelled(assert_raises(Baml::CancelledError) { call.wait(timeout: 5) }, start) + end + + def test_cancellation_async_cancel_via_asyncio_timeout + # A bounded wait leaves the call running; explicit cancel cleans it up. + start = monotonic + call = BamlSdk::ThrowsTest.sleep_ms_async(60_000) + assert_raises(Baml::WaitTimeout) { call.wait(timeout: 0.05) } + refute call.done? + assert call.cancel + assert_cancelled(assert_raises(Baml::CancelledError) { call.wait(timeout: 5) }, start) + + start = monotonic + timed = BamlSdk::ThrowsTest.sleep_ms_async(60_000, _baml: Baml::Options.new(timeout_ms: 50)) + assert_cancelled(assert_raises(Baml::CancelledError) { timed.wait(timeout: 5) }, start) + end + + private + + def monotonic + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + + def assert_cancelled(error, start) + assert_kind_of Baml::Panic, error + refute_kind_of StandardError, error + assert_instance_of BamlSdk::Baml::Panics::Cancelled, error.value + # Java's 5s bound avoids loaded-runner flakes; the call would take 60s. + assert_operator monotonic - start, :<, 5 + end +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_errors.rb b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_errors.rb new file mode 100644 index 00000000000..37ee8e0a18d --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_errors.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "open3" +require "rbconfig" +require "baml_sdk" + +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class ErrorsTest < Minitest::Test + def test_errors_user_panic_surfaces_as_baml_panic + error = assert_raises(Baml::Panic) { BamlSdk::ThrowsTest.do_panic("user-initiated boom") } + assert_instance_of BamlSdk::Baml::Panics::UserPanic, error.value + assert_equal "baml.panics.UserPanic", error.type_name + assert_equal "user-initiated boom", error.message + refute_kind_of StandardError, error + end + + def test_errors_union_throws_preserves_class_name + single = assert_raises(Baml::Error) { BamlSdk::RaisesTest.reparse("x") } + union = assert_raises(Baml::Error) { BamlSdk::RaisesTest.load_doc("x") } + assert_equal "user.raises_test.ParseError", single.type_name + assert_equal single.type_name, union.type_name + assert_instance_of BamlSdk::RaisesTest::ParseError, union.value + assert_equal "x", single.message + assert_equal single.message, union.message + end + + def test_errors_stdlib_error_surfaces_as_baml_error + error = assert_raises(Baml::Error) { BamlSdk::ThrowsTest.parse_json("{not valid json") } + assert_instance_of BamlSdk::Baml::Json::ParseError, error.value + end + + def test_errors_user_throw_surfaces_declared_instance + error = assert_raises(Baml::Error) { BamlSdk::ThrowsTest.throw_my_error } + assert_instance_of BamlSdk::ThrowsTest::MyError, error.value + end + + def test_errors_host_invalid_argument_wraps_baml_errors_invalid_argument + # Ruby binds generated arguments before calling BAML. An undeclared + # keyword raises ArgumentError, rather than a synthesized BAML payload. + assert_raises(ArgumentError) { BamlSdk.hello_world(not_a_param: 2) } + error = assert_raises(Baml::Error) do + Baml::Bridge.call_async("user.hello_world", { "not_a_param" => 2 }).wait + end + assert_instance_of BamlSdk::Baml::Errors::InvalidArgument, error.value + end + + def test_errors_cancellation_surfaces_as_baml_panic + token = BamlSdk::Baml::Spawn::CancelToken.baml_new + call = BamlSdk::ThrowsTest.sleep_ms_async(60_000, _baml: Baml::Options.new(cancel: token)) + timer = Thread.new { sleep 0.1; token.cancel } + error = assert_raises(Baml::CancelledError) { call.wait(timeout: 5) } + assert_kind_of Baml::Panic, error + refute_kind_of StandardError, error + assert_instance_of BamlSdk::Baml::Panics::Cancelled, error.value + timer.join + ensure + timer&.kill&.join + end + + def test_errors_str_is_non_empty + error = assert_raises(Baml::Error) { BamlSdk::ThrowsTest.parse_json("{not valid json") } + refute_empty error.to_s + end + + def test_errors_baml_error_carries_baml_trace + error = assert_raises(Baml::Error) { BamlSdk::ThrowsTest.throw_my_error } + assert_instance_of Array, error.baml_trace + refute_empty error.baml_trace + frame = /\AFile "([^"]*)", line (\d+), in (.*)\z/.match(error.baml_trace.last) + refute_nil frame + assert frame[1].end_with?("types.baml"), frame[1] + assert_operator frame[2].to_i, :>=, 1 + assert_equal "user.throws_test.ThrowMyError", frame[3] + end + + def test_errors_baml_trace_spliced_into_python_traceback + # Like Java's port, assert Ruby's native rendering includes BAML frames. + error = assert_raises(Baml::Error) { BamlSdk::ThrowsTest.parse_json("{not valid json") } + refute_empty error.baml_trace + rendered = error.full_message + assert_includes rendered, "types.baml" + assert_includes rendered, "user.throws_test.ParseJson" + end + + def test_errors_clean_exit_terminates_process_with_code_0 + assert_exit_code(0) + end + + def test_errors_clean_exit_terminates_process_with_code_7 + assert_exit_code(7) + end + + # SDK_PARITY_LINT(skip): Ruby error wrapper for a thrown string + def test_primitive_thrown_error + error = assert_raises(Baml::Error) { BamlSdk.unhandled_spawn_error } + assert_equal "string", error.type_name + assert_equal "boom", error.message + assert_equal "boom", error.value + end + + # SDK_PARITY_LINT(skip): Ruby error messages use a detail field and never inspect a struct + def test_error_message_prefers_detail_and_falls_back_to_the_type_name + value = BamlSdk::Ai::Errors::PreviewUnsupported.new(provider: "test", detail: "image output is unavailable") + error = Baml::Error.new(value, type_name: "ai.errors.PreviewUnsupported") + assert_equal "image output is unavailable", error.message + assert_same value, error.value + assert_equal "user.throws_test.MyError", Baml::Error.new( + BamlSdk::ThrowsTest::MyError.new(code: 7, detail: ""), type_name: "user.throws_test.MyError" + ).message + end + + private + + def assert_exit_code(code) + %w[do_exit do_exit_async].each do |method| + stdout, stderr, status = Open3.capture3( + RbConfig.ruby, "-I", $LOAD_PATH.join(File::PATH_SEPARATOR), "-rbaml_sdk", "-e", + "at_exit { puts 'UNREACHABLE' }; print 'before exit'; BamlSdk::ThrowsTest.#{method}(#{code}); sleep 30; puts 'UNREACHABLE'" + ) + assert_equal code, status.exitstatus, "#{stdout}\n#{stderr}" + assert_equal "before exit", stdout + refute_includes stdout, "UNREACHABLE" + end + end +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_invocation.rb b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_invocation.rb new file mode 100644 index 00000000000..e7537e3ed00 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_invocation.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" + +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class InvocationTest < Minitest::Test + def test_four_call_forms + options = Baml::Options.new(timeout_ms: 1000) + assert_equal [1, 5, 99], BamlSdk.optional_args_probe(1) + assert_equal [1, 7, 99], BamlSdk.optional_args_probe(1, opt1: 7) + assert_equal [1, 5, 99], BamlSdk.optional_args_probe(1, _baml: options) + assert_equal [1, 7, 99], BamlSdk.optional_args_probe(1, opt1: 7, _baml: options) + end + + def test_four_call_forms_async + options = Baml::Options.new(timeout_ms: 1000) + assert_equal [1, 5, 99], BamlSdk.optional_args_probe_async(1).wait + assert_equal [1, 7, 99], BamlSdk.optional_args_probe_async(1, opt1: 7).wait + assert_equal [1, 5, 99], BamlSdk.optional_args_probe_async(1, _baml: options).wait + assert_equal [1, 7, 99], BamlSdk.optional_args_probe_async(1, opt1: 7, _baml: options).wait + end + + def test_empty_controls + assert_equal "hello world", BamlSdk.hello_world + assert_equal "hello world", BamlSdk.hello_world(_baml: Baml::Options.new) + assert_equal "hello world", BamlSdk.hello_world(_baml: nil) + assert_equal "hello world", BamlSdk.hello_world(_baml: Baml::Options.new(trace: nil, cancel: nil, timeout_ms: nil)) + end + + def test_omitted_argument_is_not_null + assert_equal [1, 5, 99], BamlSdk.optional_args_probe(1, _baml: Baml::Options.new) + assert_equal [1, nil, 99], BamlSdk.optional_args_probe(1, opt1: nil, _baml: Baml::Options.new) + end + + def test_invalid_timeout_rejected + [-1, 0.5, Float::INFINITY, Float::NAN, 2_147_483_648, true].each do |timeout| + assert_raises(ArgumentError) { BamlSdk.hello_world(_baml: Baml::Options.new(timeout_ms: timeout)) } + end + assert_raises(TypeError) { BamlSdk.hello_world(_baml: {}) } + assert_raises(ArgumentError) { Baml::Options.new(trace: Object.new) } + assert_raises(TypeError) { BamlSdk.hello_world(_baml: Baml::Options.new(cancel: Object.new)) } + end + + def test_timeout_upper_bound_accepted + assert_equal "hello world", BamlSdk.hello_world(_baml: Baml::Options.new(timeout_ms: 2_147_483_647)) + end + + def test_methods_accept_controls + options = Baml::Options.new + box = BamlSdk::OptBox.make(1, _baml: options) + assert_equal 8, box.base + assert_equal [8, 2, 5], box.probe(2, _baml: options) + assert_equal [8, 2, nil], box.probe(2, opt1: nil, _baml: options) + end + + def test_composite_token_observes_every_source + [0, 1].each do |index| + sources = Array.new(2) { BamlSdk::Baml::Spawn::CancelToken.baml_new } + combined = BamlSdk::Baml::Spawn::CancelToken.any(sources) + sources[index].cancel + assert combined.is_cancelled + error = assert_raises(Baml::CancelledError) do + BamlSdk.hello_world_async(_baml: Baml::Options.new(cancel: combined)).wait + end + assert_instance_of BamlSdk::Baml::Panics::Cancelled, error.value + refute sources[1 - index].is_cancelled + end + end +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_json.rb b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_json.rb new file mode 100644 index 00000000000..833e1bb378c --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_json.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" + +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class JsonTest < Minitest::Test + def test_host_supplied_json_supports_typed_narrowing + json = BamlSdk::GoJsonTests + obj = { "type" => "ok", "nested" => { "list" => [1, { "deep" => "found" }] } } + assert_equal "object", json.json_kind(obj) + assert_equal "array", json.json_kind([1]) + assert_equal "string", json.json_kind("text") + assert_equal "other", json.json_kind(3) + assert_equal "ok", json.json_path_string(obj, ".type") + assert_equal "found", json.json_path_string(obj, ".nested.list[1].deep") + assert_equal "fallback", json.json_path_string_or(obj, ".missing", "fallback") + error = assert_raises(Baml::Error) { json.json_path_string(obj, ".absent") } + assert_includes error.message, "missing field" + end + + def test_json_nested_deeper_than_the_wire_carries_is_refused_as_an_argument + assert_equal nested_json(30), BamlSdk::GoJsonTests.round_trip_json(nested_json(30)) + [60, 1000].each do |depth| + error = assert_raises(TypeError) { BamlSdk::GoJsonTests.round_trip_json(nested_json(depth)) } + assert_includes error.message, "nested too deeply" + end + end + + private + + def nested_json(depth) + depth.times.reduce(1) { |value, _| [value] } + end +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_optional_args.rb b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_optional_args.rb new file mode 100644 index 00000000000..8eb03f852c4 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_optional_args.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true +require "minitest/autorun" +require "baml_sdk" + +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class OptionalArgsTest < Minitest::Test + def test_optional_args_runtime_matrix + assert_equal [1, 5, 99], BamlSdk.optional_args_probe(1) + assert_equal [1, 5, 99], BamlSdk.optional_args_probe(1, opt1: Baml::UNSET, opt2: Baml::UNSET) + # Required arguments are positional in Ruby, as in Java's port. + assert_equal [1, 5, 99], BamlSdk.optional_args_probe(1) + assert_equal [1, nil, 99], BamlSdk.optional_args_probe(1, opt1: nil) + assert_equal [1, 8, 99], BamlSdk.optional_args_probe(1, opt1: 8) + assert_equal [1, 5, nil], BamlSdk.optional_args_probe(1, opt2: nil) + assert_equal [1, 5, 9], BamlSdk.optional_args_probe(1, opt2: 9) + assert_equal [1, nil, nil], BamlSdk.optional_args_probe(1, opt1: nil, opt2: nil) + assert_equal [1, 8, 9], BamlSdk.optional_args_probe(1, opt1: 8, opt2: 9) + end + + def test_optional_args_python_unset_and_none_differ_in_one_call + refute_nil Baml::UNSET + assert_equal [1, 5, nil], BamlSdk.optional_args_probe(1, opt1: Baml::UNSET, opt2: nil) + assert_equal [1, nil, 99], BamlSdk.optional_args_probe(1, opt1: nil, opt2: Baml::UNSET) + end + + def test_optional_args_async_samples + assert_equal [1, 5, 99], BamlSdk.optional_args_probe_async(1).wait + assert_equal [1, nil, 99], BamlSdk.optional_args_probe_async(1, opt1: nil).wait + assert_equal [1, 5, 9], BamlSdk.optional_args_probe_async(1, opt2: 9).wait + end + + def test_optional_args_opt_box_method_matrix + box = BamlSdk::OptBox.make(10) + assert_equal 17, box.base + box2 = BamlSdk::OptBox.make(10, opt1: 0) + assert_equal 10, box2.base + assert_equal [10, 1, 5], box2.probe(1) + assert_equal [10, 1, 8], box2.probe(1, opt1: 8) + end + + def test_optional_args_negative_runtime_cases_reject + assert_raises(ArgumentError) { BamlSdk.optional_args_probe(1, opt3: 1) } + assert_raises(ArgumentError) { BamlSdk.optional_args_probe } + assert_raises(ArgumentError) { BamlSdk.optional_args_probe(1, arg0: 1) } + # Nullable parameters without a BAML default are still required. + assert_raises(ArgumentError) { BamlSdk::StaticMethodEdges::Edge.nullable } + assert_nil BamlSdk::StaticMethodEdges::Edge.nullable(nil) + end +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/typed_consumer.rb b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/typed_consumer.rb index 57600e0f770..4ccca946952 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/typed_consumer.rb +++ b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/typed_consumer.rb @@ -16,4 +16,9 @@ result = T.let(BamlSdk.round_trip_person(person), BamlSdk::Person) T.let(result.name, String) T.let(result.age, Integer) +# Omission, UNSET and nil remain distinct, with typed per-call controls. +T.let(BamlSdk.optional_args_probe(1), T::Array[T.nilable(Integer)]) +T.let(BamlSdk.optional_args_probe(1, opt1: Baml::UNSET, opt2: nil), T::Array[T.nilable(Integer)]) +T.let(BamlSdk.optional_args_probe(1, opt1: nil, opt2: 2, _baml: Baml::Options.new(timeout_ms: 1000)), T::Array[T.nilable(Integer)]) T.let(BamlSdk::StaticMethodEdges::Edge.nullable(nil), T.nilable(Integer)) +T.let(BamlSdk.default_name_collisions("a", options: "b", option: "c"), T::Array[String]) diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_call_test.rb b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_call_test.rb index 0f48b780a08..c3a4027983c 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_call_test.rb +++ b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_call_test.rb @@ -145,6 +145,70 @@ def test_cancel_defers_an_interrupt_until_native_cancellation refute pending.cancel end + def test_unregistered_error_classes_preserve_message_trace_fields_and_handle_ownership + payload = { class_value: { name: "ai.errors.Unregistered", fields: [ + { key: "message", value: { string_value: "render failed" } }, + { key: "nested", value: { class_value: { name: "ai.errors.Nested", fields: [ + { key: "detail", value: { string_value: "nested detail" } }, + { key: "handle", value: { handle_value: { key: 41, handle_type: :ADT_TAGGED_HEAP_HANDLE } } } + ] } } } + ] } } + trace = ['File "test.baml", line 3, in render'] + { error: Baml::Error, panic: Baml::Panic }.each do |kind, klass| + @api.dispatch = ->(_args, id) { @api.deliver(id, **{ kind => { value: payload, trace: trace } }) } + failure = assert_raises(klass) { call } + assert_equal "ai.errors.Unregistered", failure.type_name + assert_equal "render failed", failure.message + assert_equal trace, failure.baml_trace + assert failure.value.frozen? + assert failure.value.fetch("nested").frozen? + assert_equal "nested detail", failure.value.fetch("nested").fetch("detail") + owner = failure.value.fetch("nested").fetch("handle") + assert_instance_of Baml::Handle, owner + assert @api.released_handles.empty? + owner.close + assert_equal 41, released_handle + assert @api.released_handles.empty? + end + @api.dispatch = ->(_args, id) { @api.deliver(id, ok: payload) } + assert_raises(Baml::Bridge::UnsupportedTypeError) { call } + assert_equal 41, released_handle + assert @api.released_handles.empty? + end + + def test_registered_error_with_an_unregistered_child_falls_back_without_double_adopting + protocol = Baml::Bridge::Protocol + type = Struct.new(:handle, :nested, keyword_init: true) + protocol.define_class("user.RegisteredErrorParent", type, fields: { + "handle" => [:handle, [:unknown]], "nested" => [:nested, [:unknown]] + }) + @api.dispatch = ->(_args, id) { @api.deliver(id, error: { value: { class_value: { + name: "user.RegisteredErrorParent", fields: [ + { key: "handle", value: { handle_value: { key: 42, handle_type: :ADT_TAGGED_HEAP_HANDLE } } }, + { key: "nested", value: { class_value: { name: "user.UnregisteredChild", fields: [] } } } + ] + } } }) } + failure = assert_raises(Baml::Error) { call } + assert_instance_of Hash, failure.value + assert failure.value.frozen? + assert_equal({}, failure.value.fetch("nested")) + assert failure.value.fetch("nested").frozen? + assert @api.released_handles.empty? + failure.value.fetch("handle").close + assert_equal 42, released_handle + assert @api.released_handles.empty? + end + + def test_unregistered_error_message_uses_detail_then_type_name + [ [{ key: "detail", value: { string_value: "detail only" } }, "detail only"], + [{ key: "message", value: { int_value: 42 } }, "ai.errors.Unregistered"] ].each do |field, message| + @api.dispatch = ->(_args, id) { @api.deliver(id, error: { value: { class_value: { + name: "ai.errors.Unregistered", fields: [field] + } } }) } + assert_equal message, assert_raises(Baml::Error) { call }.message + end + end + def test_callback_ids_wrap_and_skip_pending_ids first = Object.new last = Object.new diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_encode_test.rb b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_encode_test.rb index 20ded340f56..a9c27540c55 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_encode_test.rb +++ b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_encode_test.rb @@ -626,6 +626,41 @@ def test_encode_call_plans_and_checks_every_argument_before_cloning handle.close end + def test_unset_arguments_are_omitted_before_reading_and_nil_is_explicit + Protocol.define_function("user.encode_defaults", params: [["omitted", [:int]], ["explicit", [:union, [[:int], [:null]]]]]) + args = Wire::CallFunctionArgs.decode(Protocol.encode_call(1, "user.encode_defaults", { + "omitted" => Baml::UNSET, "explicit" => nil, "undeclared" => Baml::UNSET + })) + assert_equal ["explicit"], args.kwargs.map(&:string_key) + assert_nil args.kwargs.first.value.value + end + + def test_invocation_cancel_uses_the_walker_and_keeps_the_class_check + token_type = Class.new(Struct.new(:handle)) + Protocol.define_class("baml.spawn.CancelToken", token_type, fields: { "handle" => [:handle, [:other]] }) + api = Api.new + handle = Baml::Handle.new(api, 41, :ADT_TAGGED_HEAP_HANDLE) + scope = Baml::Bridge::EncodingScope.new + invocation = Protocol.encode_invocation(1, Baml::Options.new(cancel: token_type.new(handle)), api, scope: scope) + assert_equal [:class, "baml.spawn.CancelToken", []], Encode::Slot.from_wire(invocation.cancel.value_type) + assert_equal 41, invocation.cancel.class_value.fields.first.value.handle.key + assert_equal [41], api.cloned + error = assert_raises(TypeError) do + Protocol.encode_invocation(1, Baml::Options.new(cancel: handle), api, scope: scope) + end + assert_includes error.message, "cancel must be a generated baml.spawn.CancelToken" + error = assert_raises(TypeError) do + Protocol.encode_invocation(1, Baml::Options.new(cancel: token_type.new(Object.new)), api, scope: scope) + end + assert_includes error.message, "_baml.cancel.handle" + assert_equal [41], api.cloned + scope.close + assert_equal [41], api.released + ensure + scope&.close + handle&.close + end + def test_complete_host_call_uses_the_abi5_signature native = Baml::Bridge.const_get(:Native, false) received = [] diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_handles.rb b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_handles.rb index ac1db2cc448..583f62bf79b 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_handles.rb +++ b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_handles.rb @@ -2,6 +2,7 @@ require "minitest/autorun" require "baml_sdk" ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") +require "socket" require "tempfile" class HandlesTest < Minitest::Test @@ -44,6 +45,28 @@ def test_handles_image_from_base64_roundtrips_payload assert_equal video.mime_type, BamlSdk::Media.round_trip_video(video).mime_type end + def test_handles_http_get_response_fields_and_methods + server = TCPServer.new("127.0.0.1", 0) + body = "hello from localhost" + thread = Thread.new do + client = server.accept + while (line = client.gets) && line != "\r\n" + # Consume the request headers before writing a response. + end + client.write("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}") + ensure + client&.close + end + response = BamlSdk::Baml::Http.fetch("http://127.0.0.1:#{server.addr[1]}/", timeout: nil) + assert_equal 200, response.status_code + assert_equal true, response.ok + assert_equal body, response.text + ensure + server&.close + thread&.join(2) + thread&.kill if thread&.alive? + end + def test_handles_open_file_returns_file_handle Tempfile.create("digits") do |file| file.write("0123456789") diff --git a/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md b/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md index c08df0dd96d..cd4e17182da 100644 --- a/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md +++ b/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md @@ -4,7 +4,7 @@ repository: baml4 --- # State of BAML↔Ruby completeness -The Ruby/Sorbet SDK generates typed functions, classes and enums for user code and supported standard-library symbols. It runs sync and async calls through the V1 C bridge, with media values. The tables below separate tested support from remaining gaps. +The Ruby/Sorbet SDK generates typed functions, classes and enums for user code and supported standard-library symbols. It runs sync and async calls through the V1 C bridge, with call controls and media. The tables below separate tested support from remaining gaps. ✅ means a named Ruby test proves the stated support, 🚧 means only part of the row is tested or supported, and ❌ means unsupported or not yet parity-verified. The Python column is copied from `ref-python-state-of-completeness.md` at commit `b74b83a3ab`, including its qualifications; it is not a claim about today's Python SDK. Links to `customizable/test_*.rb` identify real-engine tests; streaming tests use the keyless replay server. Fake-bridge tests are identified separately. @@ -15,13 +15,13 @@ Validation: `cargo nextest run -p sdk_test_ruby_sorbet` passes all 13 enabled ha | Call form | Python | Ruby | Ruby surface and evidence | | --- | --- | --- | --- | | Free function (sync) | ✅ | ✅ | Generated namespace methods call the engine: [`test_main_hello_world_returns_literal`][function-main], [`test_main_single_required_arg_round_trips`][function-main]. | -| Free function (async) | ✅ | ✅ | `_async` siblings return `Baml::Call[T]`; `.wait` returns the result: [`test_methods_on_classes_static_create_async_round_trips`][methods]. | +| Free function (async) | ✅ | ✅ | `_async` siblings return `Baml::Call[T]`; `.wait` returns the result: [`test_cancellation_async_call_returns_none`][cancellation], [`test_four_call_forms_async`][invocation]. | | Static method | ✅ | ✅ | Generated class methods and `_async` siblings: [`test_methods_on_classes_static_create_round_trips`][methods], [`test_methods_on_classes_static_create_async_round_trips`][methods]. | | Instance method | ✅ | ✅ | Generated instance methods encode the receiver: [`test_methods_on_classes_instance_greet_with_arg_round_trips`][methods], [`test_methods_on_classes_instance_greet_async_with_arg_round_trips`][methods]. | | Required args (positional) | ✅ | ✅ | Required parameters are positional: [`test_main_single_required_arg_round_trips`][function-main]. | | Required args (keyword) | ✅ | n/a | Generated methods require positional arguments. | -| Optional args (omitted → default) | ✅ | ❌ | Callables with defaulted arguments are not generated. | -| Optional args (supplied) | ✅ | ❌ | Callables with defaulted arguments are not generated. Nullable parameters without defaults remain required. | +| Optional args (omitted → default) | ✅ | ✅ | Keywords default to `Baml::UNSET`; omitted values let BAML evaluate defaults: [`test_optional_args_runtime_matrix`][optional-args]. | +| Optional args (supplied) | ✅ | ✅ | Explicit values and `nil` differ from omission: [`test_optional_args_python_unset_and_none_differ_in_one_call`][optional-args], [`test_optional_args_opt_box_method_matrix`][optional-args]. Nullable parameters without defaults remain required: [`test_optional_args_negative_runtime_cases_reject`][optional-args]. | | Streaming | ✅ | ❌ | No host stream wrapper or generated stream companions. BAML-internal collection remains supported: [`test_streaming_e2e_stream_collect_in_baml`][streaming], [`test_streaming_e2e_stream_doc_collect_in_baml`][streaming]. | | `$build_request` companion | ✅ | ❌ | No function-spec wrapper or generated spec companions. | | Generic function / method (inferred) | ✅ | ❌ | Generic declarations are not generated. | @@ -29,7 +29,7 @@ Validation: `cargo nextest run -p sdk_test_ruby_sorbet` passes all 13 enabled ha | Generic function / method (subscript) | ✅ | ❌ | No callable-subscript API. | | Host callback param | ✅ | ❌ | Declarations requiring function types are not generated. | -Per-call timeout, cancel-token and trace options are unsupported. No `_baml:` keyword is generated. Watcher declarations are not generated. +`_baml: Baml::Options.new(timeout_ms:, cancel:)` supplies per-call controls to free functions and methods: [`test_four_call_forms`][invocation], [`test_methods_accept_controls`][invocation]. Trace options are unsupported: only `trace: nil` is accepted, and [`test_invalid_timeout_rejected`][invocation] checks rejection of a non-nil trace. Watcher declarations are not generated. ## Runtime behaviors @@ -38,11 +38,11 @@ These rows describe what happens when a call returns, throws, or terminates. | Runtime behavior | Python | Ruby | Ruby outcome and evidence | | --- | --- | --- | --- | | Normal return | ✅ | ✅ | Decodes the `ok` value: [`test_main_hello_world_returns_literal`][function-main], [`test_class_refs_round_trip_inner`][class-refs], [`test_streaming_e2e_stream_doc_collect_in_baml`][streaming]. | -| BAML error | ✅ (docs-only) | 🚧 | Raises `Baml::Error < StandardError` with a message and `type_name`. The fake-bridge [`test_call_waiters_cache_outcomes_and_survive_interrupt_and_cancel`][bridge-call] checks a cached error. Decoded payloads and BAML traces are unsupported. | -| BAML panic | ✅ | 🚧 | Raises `Baml::Panic < Exception`, not `StandardError`. Ordinary `rescue => e` does not catch it. Decoded payloads and BAML traces are unsupported. | +| BAML error | ✅ (docs-only) | ✅ | Raises `Baml::Error < StandardError` with decoded `value`, `type_name`, `message` and `baml_trace`: [`test_errors_union_throws_preserves_class_name`][errors], [`test_errors_baml_error_carries_baml_trace`][errors]. BAML frames appear in Ruby's backtrace: [`test_errors_baml_trace_spliced_into_python_traceback`][errors]. | +| BAML panic | ✅ | ✅ | Raises `Baml::Panic < Exception`, not `StandardError`, with a decoded payload: [`test_errors_user_panic_surfaces_as_baml_panic`][errors]. Ordinary `rescue => e` does not catch it. | | Host-callback error | ✅ | ❌ | No host-callback dispatch or exception round trip. | -| Cancellation | ✅ (async only) | 🚧 | `Call#cancel` requests cancellation; a `wait(timeout:)` timeout raises `Baml::WaitTimeout` without cancelling. The fake-bridge [`test_call_waiters_cache_outcomes_and_survive_interrupt_and_cancel`][bridge-call] checks a cancellation race. Cancel-token options, invocation timeouts and a dedicated cancellation exception are unsupported. | -| OS exit | ✅ | ❌ | Exit panics do not terminate the Ruby process. | +| Cancellation | ✅ (async only) | ✅ | Tokens from `BamlSdk::Baml::Spawn::CancelToken.baml_new` cancel sync and async calls; `Call#cancel` cancels an async call. Cancellation raises `Baml::CancelledError < Baml::Panic`: [`test_cancellation_sync_cancel_via_call_context`][cancellation], [`test_cancellation_async_cancel_via_call_context`][cancellation], [`test_cancellation_async_cancel_via_task_cancel`][cancellation]. A `wait(timeout:)` timeout raises `Baml::WaitTimeout` without cancelling; `timeout_ms` cancels the engine call: [`test_cancellation_async_cancel_via_asyncio_timeout`][cancellation]. | +| OS exit | ✅ | ✅ | `baml.sys.exit` terminates the process without Ruby `at_exit` hooks: [`test_errors_clean_exit_terminates_process_with_code_0`][errors], [`test_errors_clean_exit_terminates_process_with_code_7`][errors]. | The fake-bridge test [`test_call_waiters_cache_outcomes_and_survive_interrupt_and_cancel`][bridge-call] checks multiple waiters, cached results and errors, an interrupted waiter, foreign-thread completion and a cancellation race. @@ -54,11 +54,11 @@ Gem packaging, clean-app installation and native-library cache lookup are unsupp The [shared Ruby harness][static-gate] runs `srb tc` on each of the four enabled generated packages and its tests with Sorbet **0.6.13506**, pinned in a separate [test bundle][static-gemfile]. Every generated Ruby file, including bytecode and model declarations, uses `# typed: strict`. No SDK functions or models are recreated in test RBIs. -Strict consumers in the fixture overlays cover sync and async returns, stdlib methods, bigint, bytes, media, unions, aliases, classes and enums. One negative consumer requires exactly one wrong-argument diagnostic (`7002`), so a regression to `T.untyped` fails the gate. +Strict consumers in the fixture overlays cover sync and async returns, stdlib methods, optional arguments, call controls, bigint, bytes, media, unions, aliases, classes and enums. One negative consumer requires exactly one wrong-argument diagnostic (`7002`), so a regression to `T.untyped` fails the gate. The [boundary RBI][static-bridge] describes the runtime-facing API, including call helpers; it does not recreate generated SDK declarations. The bridge's decoded return remains dynamic, so this gate proves generated declarations and consumer checking, not the runtime's adherence to BAML return types. Real-engine and replay tests cover runtime behavior separately, including recursion, forward references and symbol collisions. -Typing still has limits: literal values use their Ruby primitive types, recursive alias back-edges use `T.untyped`. JSON has no special `T.untyped` mapping. Declarations requiring function types are not generated. Method signatures retain Sorbet's lazy validation. +Typing still has limits: literal values use their Ruby primitive types, recursive alias back-edges and JSON use `T.untyped`. Declarations requiring function types are not generated. Method signatures retain Sorbet's lazy validation. The pinned checker runs on x86_64/arm64 macOS and x86_64/aarch64 glibc Linux. Windows, 32-bit Linux and musl hosts explicitly ignore the static test because no compatible binary is supplied; their runtime tests still run. Setup installs the checker under `target/ruby-sorbet-static-bundle` only on supported hosts, and installation failures there fail setup. @@ -92,7 +92,7 @@ Encode means Ruby → BAML; decode means BAML → Ruby. The generated-package te | BAML closure (closure / bound method / generic function / function) | ❌ | ❌ | No callable closure wrapper; declarations requiring function types are not generated. | | BAML type reference values | ❌ | ❌ | No first-class BAML type-value encoding or decoding. | | BAML type definition values | ❌ | ❌ | No type-definition values. | -| BAML `$rust_type` values: files, sockets, etc. | ✅ | 🚧 | Generated resource structs retain `Baml::Handle` fields. Files preserve cursor state: [`test_handles_open_file_returns_file_handle`][handles], [`test_handles_file_cursor_state_persists_across_calls`][handles]. HTTP and socket values are not parity-verified. | +| BAML `$rust_type` values: files, sockets, etc. | ✅ | 🚧 | Generated resource structs retain `Baml::Handle` fields. Files preserve cursor state: [`test_handles_open_file_returns_file_handle`][handles], [`test_handles_file_cursor_state_persists_across_calls`][handles]. HTTP response fields and methods: [`test_handles_http_get_response_fields_and_methods`][handles]. Socket values are not parity-verified. | | Native exception thrown by a host callback | ✅ | ❌ | No opaque host-value registry or exception round trip. | | Unused SDK heap handles (`Handle`) | 🚧 | 🚧 | Handles work inside tested resources: [`test_handles_file_cursor_state_persists_across_calls`][handles]. Standalone unused heap-handle ownership and GC release are not parity-verified. | | Unused SDK collector values (`Collector`) | 🚧 | ❌ | No collector wrapper or verified collector-value path. | @@ -102,6 +102,7 @@ Encode means Ruby → BAML; decode means BAML → Ruby. The generated-package te | Cyclic / self-referential objects | ❌ | ❌ | No cyclic-object encoding or identity preservation. Recursive type declarations and acyclic recursive values do not provide cycle handling. | [function-main]: ../../../sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_main.rb +[errors]: ../../../sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_errors.rb [streaming]: ../../../sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_streaming_e2e.rb [class-refs]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_class_refs.rb [void]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_void.rb @@ -110,6 +111,9 @@ Encode means Ruby → BAML; decode means BAML → Ruby. The generated-package te [static-bridge]: ../../ruby/bridge_ruby/rbi/baml.rbi [static-gate]: ../../../sdk_tests/harness_runner/src/ruby_sorbet.rs [methods]: ../../../sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_methods_on_classes.rb +[optional-args]: ../../../sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_optional_args.rb +[cancellation]: ../../../sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_cancellation.rb +[invocation]: ../../../sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_invocation.rb [primitives]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_primitives.rb [lists]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_lists.rb [maps]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_maps.rb diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge.rb index 11b4c22a375..a948a346d35 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge.rb @@ -2,6 +2,7 @@ require_relative "bridge/version" require_relative "bridge/errors" +require_relative "options" require_relative "bridge/native" require_relative "bridge/call" require_relative "bridge/handle" @@ -34,8 +35,8 @@ def define_function(wire, params:) Protocol.define_function(wire, params: params) end - def call_async(wire, args) - @process_runtime.call_async(wire, args) + def call_async(wire, args, baml: nil) + @process_runtime.call_async(wire, args, baml: baml) end def initialize! diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/errors.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/errors.rb index 4576d91787f..3900402924b 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/errors.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/errors.rb @@ -2,12 +2,26 @@ module Baml module Failure - attr_reader :type_name + attr_reader :value, :baml_trace, :type_name - def initialize(value, type_name: nil) + def initialize(value, baml_trace: [], type_name: nil) + @value = value + @baml_trace = baml_trace.dup.freeze @type_name = type_name - message = value.respond_to?(:message) ? value.message.to_s : value.to_s - super(message.empty? ? (type_name || value.inspect) : message) + message = if value.is_a?(String) + value + else + %i[message detail].filter_map do |field| + text = if value.is_a?(Hash) + item = value[field.to_s] + item if item.is_a?(String) + elsif value.respond_to?(field) + value.public_send(field).to_s + end + text unless text.nil? || text.empty? + end.first + end + super(message.nil? || message.empty? ? (type_name || value.class.name) : message) end end @@ -20,6 +34,8 @@ class Panic < Exception # rubocop:disable Lint/InheritException include Failure end + class CancelledError < Panic; end + module Bridge class Error < StandardError; end class UnsupportedTypeError < Error; end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/native.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/native.rb index eb1714fe051..56abf29eb30 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/native.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/native.rb @@ -86,6 +86,7 @@ class ApiV1 < FFI::Struct new_function_call: [:uint64, []], release_function_call: [:int32, [:uint64]], cancel_function_call: [:int32, [:uint64]], + invocation_clock_ns: [:int32, %i[uint64 pointer]], handle_clone: [:int32, %i[uint64 pointer]], handle_release: [:int32, [:uint64]], call_function: [:void, %i[pointer size_t uint32], { blocking: true }], @@ -156,6 +157,14 @@ def cancel_function_call(call_id) function(:cancel_function_call).call(call_id) end + def invocation_clock_ns(call_id) + out_now = FFI::MemoryPointer.new(:uint64) + status = function(:invocation_clock_ns).call(call_id, out_now) + raise Error, "Unable to read BAML invocation clock: status #{status}" unless status.zero? + + out_now.read_uint64 + end + def handle_clone(key) out_key = FFI::MemoryPointer.new(:uint64) status = function(:handle_clone).call(key, out_key) diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/process_runtime.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/process_runtime.rb index 988ecad8052..f20d6fdcc8b 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/process_runtime.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/process_runtime.rb @@ -108,14 +108,17 @@ def initialize! end end - def call_async(function_name, arguments) + def call_async(function_name, arguments, baml: nil) + raise TypeError, "_baml must be Baml::Options or nil" unless baml.nil? || baml.is_a?(::Baml::Options) + call_id = callback_id = nil submitted = false scope = EncodingScope.new initialize! # Retain native ownership before a deferred interrupt can reach Ruby. Thread.handle_interrupt(Exception => :never) { call_id = @api.new_function_call } - encoded = Protocol.encode_call(call_id, function_name, arguments, scope: scope) + invocation = Protocol.encode_invocation(call_id, baml, @api, scope: scope) + encoded = Protocol.encode_call(call_id, function_name, arguments, scope: scope, invocation: invocation) Thread.handle_interrupt(Exception => :never) do call = ::Baml::Call.new(@api, call_id) callback_id = register_call(call) diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/protocol.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/protocol.rb index a02f3c9c237..ac4a6db6868 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/protocol.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/protocol.rb @@ -136,11 +136,37 @@ def enum_values(name) @enums.fetch(name) end - def encode_call(call_id, function_name, arguments, scope: nil) + def encode_invocation(call_id, options, api, scope: nil) + invocation = Wire::InvocationOptions.new + return invocation unless options + + unless options.timeout_ms.nil? + deadline = api.invocation_clock_ns(call_id) + options.timeout_ms * 1_000_000 + raise ArgumentError, "timeout deadline exceeds the runtime clock range" if deadline > 0xffff_ffff_ffff_ffff + + invocation.deadline_ns = deadline + end + unless options.cancel.nil? + unless @names[options.cancel.class] == "baml.spawn.CancelToken" + raise TypeError, "cancel must be a generated baml.spawn.CancelToken" + end + path = "_baml.cancel" + tree = Encode::Read.new(self).read(options.cancel, path) + plan = Encode::Plan.new(self).plan(tree, [:unknown]) + Encode::Commit.check_depth!(Encode::Commit.depth(plan), path) + invocation.cancel = Encode::Commit.commit(plan, scope: scope) + Encode::Commit.check_depth!(Encode::Slot.wire_depth(invocation.cancel), path) + end + invocation + end + + def encode_call(call_id, function_name, arguments, scope: nil, invocation: Wire::InvocationOptions.new) hints = @functions.fetch(function_name, {}) reader = Encode::Read.new(self) planner = Encode::Plan.new(self) - trees = arguments.map { |name, value| [name, reader.read(value, name)] } + trees = arguments.filter_map do |name, value| + [name, reader.read(value, name)] unless value.equal?(::Baml::UNSET) + end plans = trees.map { |name, tree| [name, planner.plan(tree, hints.fetch(name, [:unknown]))] } plans.each { |name, plan| Encode::Commit.check_depth!(Encode::Commit.depth(plan), name) } kwargs = plans.map do |name, plan| @@ -150,7 +176,7 @@ def encode_call(call_id, function_name, arguments, scope: nil) end Wire::CallFunctionArgs.encode(Wire::CallFunctionArgs.new( call_id: call_id, function_name: function_name, kwargs: kwargs, - invocation: Wire::InvocationOptions.new + invocation: invocation )) rescue Encode::Refusal => error raise error.class, "#{function_name}: #{error.message}" @@ -158,6 +184,9 @@ def encode_call(call_id, function_name, arguments, scope: nil) def capture_result(bytes, api = nil) message = Wire::BamlOutboundResult.decode(bytes) + if message.result == :panic && message.panic.is_exit_panic + exit_process(message.panic.exit_code) + end value = case message.result when :ok then message.ok when :error then message.error.value @@ -211,11 +240,11 @@ def decode_result(bytes, api = nil) when :ok decode_value(result.ok, api, **owners) when :error - raise_failure(::Baml::Error, result.error.value, api, **owners) + raise_failure(::Baml::Error, result.error.value, api, trace: result.error.trace.to_a, **owners) when :panic return exit_process(result.panic.exit_code) if result.panic.is_exit_panic - raise_failure(::Baml::Panic, result.panic.value, api, **owners) + raise_failure(::Baml::Panic, result.panic.value, api, trace: result.panic.trace.to_a, **owners) else raise Error, "BAML returned an empty result envelope" end @@ -237,7 +266,25 @@ def decode_media(media) end end - def decode_value(value, api = nil, handles: nil) + # Error payloads can mention classes deliberately omitted by the generator. + # Inspect before decoding, so a fallback never adopts a handle twice. + def unregistered_class?(value) + return false unless value + + case value.value + when :class_value + !@types.key?(value.class_value.name) || value.class_value.fields.any? { |field| unregistered_class?(field.value) } + when :list_value + value.list_value.items.any? { |item| unregistered_class?(item) } + when :map_value + value.map_value.entries.any? { |entry| unregistered_class?(entry.value) } + when :union_variant_value + unregistered_class?(value.union_variant_value.value) + else false + end + end + + def decode_value(value, api = nil, handles: nil, error_payload: false) raise Error, "BAML returned an empty value" unless value case value.value @@ -261,9 +308,14 @@ def decode_value(value, api = nil, handles: nil) nil when :class_value object = value.class_value + if error_payload && unregistered_class?(value) + return object.fields.to_h do |field| + [field.key, decode_value(field.value, api, handles: handles, error_payload: true)] + end.freeze + end type = registered_type(object.name) fields = @fields.fetch(type) - decoded = object.fields.to_h { |field| [fields.fetch(field.key)[0], decode_value(field.value, api, handles: handles)] } + decoded = object.fields.to_h { |field| [fields.fetch(field.key)[0], decode_value(field.value, api, handles: handles, error_payload: error_payload)] } type.new(**decoded) when :enum_value enum = value.enum_value @@ -272,12 +324,12 @@ def decode_value(value, api = nil, handles: nil) end registered_type(enum.name).deserialize(enum.value) when :list_value - value.list_value.items.map { |item| decode_value(item, api, handles: handles) } + value.list_value.items.map { |item| decode_value(item, api, handles: handles, error_payload: error_payload) } when :map_value map = value.map_value - map.entries.to_h { |entry| [decode_map_key(entry.key, map.key_type), decode_value(entry.value, api, handles: handles)] } + map.entries.to_h { |entry| [decode_map_key(entry.key, map.key_type), decode_value(entry.value, api, handles: handles, error_payload: error_payload)] } when :union_variant_value - decode_value(value.union_variant_value.value, api, handles: handles) + decode_value(value.union_variant_value.value, api, handles: handles, error_payload: error_payload) when :literal_value literal = value.literal_value case literal.literal @@ -332,19 +384,30 @@ def decode_bool_key(key) raise Error, "BAML returned an invalid bool map key #{key.inspect}" end - def raise_failure(error_class, value, api = nil, handles: nil) + def raise_failure(error_class, value, api = nil, trace: [], handles: nil) while value&.value == :union_variant_value value = value.union_variant_value.value end - if value&.value == :class_value - object = value.class_value - message = object.fields.find { |field| field.key == "message" }&.value - raise error_class.new(message ? decode_value(message, api, handles: handles).to_s : object.name, type_name: object.name) + name = if value&.value == :class_value + value.class_value.name + else + kind = value&.value == :literal_value ? value.literal_value.literal : (value&.value || :null_value) + kind.to_s.delete_suffix("_value") + end + if error_class == ::Baml::Error && name == "baml.errors.TypeMismatch" + message = value.class_value.fields.find { |field| field.key == "message" }&.value + error = TypeError.new(message ? decode_value(message, api, handles: handles).to_s : name) + else + error_class = ::Baml::CancelledError if error_class == ::Baml::Panic && name == "baml.panics.Cancelled" + error = error_class.new(decode_value(value, api, handles: handles, error_payload: true), type_name: name, baml_trace: trace) end - - decoded = decode_value(value, api, handles: handles) - kind = value.value == :literal_value ? value.literal_value.literal : (value.value || :null_value) - raise error_class.new(decoded.to_s, type_name: kind.to_s.delete_suffix("_value")) + # Ruby backtraces use path:line:in `function`, while the wire uses + # Python's frame format. Keep the original lines on #baml_trace. + frames = trace.reverse.map do |frame| + frame.sub(/\AFile "([^"]*)", line (\d+), in (.*)\z/, '\1:\2:in `\3\'') + end + error.set_backtrace(frames + caller) + raise error end end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/options.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/options.rb new file mode 100644 index 00000000000..54db44487a8 --- /dev/null +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/options.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Baml + class Unset + private_class_method :new + end + UNSET = Unset.send(:new).freeze + + # Call controls are immutable. The engine clock fixes the deadline before + # argument encoding, so conversion time counts against the timeout too. + class Options + attr_reader :timeout_ms, :cancel, :trace + + def initialize(timeout_ms: nil, cancel: nil, trace: nil) + unless timeout_ms.nil? || (timeout_ms.is_a?(Integer) && (0..2_147_483_647).cover?(timeout_ms)) + raise ArgumentError, "timeout_ms must be an integer in 0…2147483647 or nil" + end + raise ArgumentError, "trace only supports nil for now" unless trace.nil? + + @timeout_ms = timeout_ms + @cancel = cancel + @trace = trace + freeze + end + end +end diff --git a/baml_language/sdks/ruby/bridge_ruby/rbi/baml.rbi b/baml_language/sdks/ruby/bridge_ruby/rbi/baml.rbi index 097f34861ec..f1ec5e74d18 100644 --- a/baml_language/sdks/ruby/bridge_ruby/rbi/baml.rbi +++ b/baml_language/sdks/ruby/bridge_ruby/rbi/baml.rbi @@ -25,20 +25,45 @@ module Baml # Raised by Call#wait(timeout:) when the timeout passes. The call keeps running. class WaitTimeout < StandardError; end + class Unset; end + UNSET = T.let(T.unsafe(nil), Unset) + + class Options + extend T::Sig + # cancel is the generated baml.spawn.CancelToken, checked at the boundary. + sig { params(timeout_ms: T.nilable(Integer), cancel: T.untyped, trace: NilClass).void } + def initialize(timeout_ms: nil, cancel: nil, trace: nil); end + sig { returns(T.nilable(Integer)) } + def timeout_ms; end + sig { returns(T.untyped) } + def cancel; end + sig { returns(NilClass) } + def trace; end + end + class Error < StandardError extend T::Sig - sig { params(value: T.untyped, type_name: T.nilable(String)).void } - def initialize(value, type_name: nil); end + sig { params(value: T.untyped, baml_trace: T::Array[String], type_name: T.nilable(String)).void } + def initialize(value, baml_trace: [], type_name: nil); end + sig { returns(T.untyped) } + def value; end + sig { returns(T::Array[String]) } + def baml_trace; end sig { returns(T.nilable(String)) } def type_name; end end class Panic < Exception extend T::Sig - sig { params(value: T.untyped, type_name: T.nilable(String)).void } - def initialize(value, type_name: nil); end + sig { params(value: T.untyped, baml_trace: T::Array[String], type_name: T.nilable(String)).void } + def initialize(value, baml_trace: [], type_name: nil); end + sig { returns(T.untyped) } + def value; end + sig { returns(T::Array[String]) } + def baml_trace; end sig { returns(T.nilable(String)) } def type_name; end end + class CancelledError < Panic; end # The promise every BAML call returns. Sync generated methods are `call_async(...).wait`. class Call @@ -155,12 +180,14 @@ module Baml def self.define_function(wire, params:); end # Start a call. args: wire parameter name => Ruby value; omitted keys are not sent. + # baml: per-call options. UNSET arguments are omitted. sig do params( wire: String, - args: T::Hash[String, T.untyped] + args: T::Hash[String, T.untyped], + baml: T.nilable(::Baml::Options) ).returns(::Baml::Call[T.untyped]) end - def self.call_async(wire, args); end + def self.call_async(wire, args, baml: nil); end end end diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs index b58d51a21f6..cc365cc6182 100644 --- a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs @@ -192,9 +192,7 @@ impl Emitter<'_> { .arguments .iter() .filter(|a| !omit_injected_argument(a, self.supported, &self.names)) - .any(|a| { - a.default.is_some() || ruby_type(&a.ty, self.supported, &self.names).is_none() - }) + .any(|a| ruby_type(&a.ty, self.supported, &self.names).is_none()) { self.skipped.insert(wire.to_owned()); return; @@ -217,7 +215,9 @@ impl Emitter<'_> { let base = self.names.callable(&name_scope, &base, wire); let method = format!("{base}{suffix}"); let mut params = Vec::new(); + let mut keyword_params = Vec::new(); let mut required = Vec::new(); + let mut keywords = Vec::new(); let mut arguments = Vec::new(); let mut descriptors = Vec::new(); let mut used = BTreeSet::from(["_baml".into(), "_types".into()]); @@ -236,15 +236,34 @@ impl Emitter<'_> { { let arg = parameter(argument.name.as_str(), self.naming, &mut used); arguments.push(format!("{} => {arg}", quote(argument.name.as_str()))); + let optional = argument.default.is_some(); let ty = argument_type.unwrap(); - params.push(format!("{arg}: {ty}")); + let ty = if optional { + format!("::T.any({ty}, ::Baml::Unset)") + } else { + ty + }; + let signature = format!("{arg}: {ty}"); + if optional { + keyword_params.push(signature); + } else { + params.push(signature); + } descriptors.push(format!( "[{}, {}]", quote(argument.name.as_str()), descriptor(&argument.ty) )); - required.push(arg); + if optional { + keywords.push(format!("{arg}: ::Baml::UNSET")); + } else { + required.push(arg); + } } + params.extend(keyword_params); + params.push("_baml: ::T.nilable(::Baml::Options)".into()); + keywords.push("_baml: nil".into()); + required.extend(keywords); writeln!( self.registry, "::Baml::Bridge.define_function({}, params: [{}])", @@ -277,12 +296,7 @@ impl Emitter<'_> { } } doc_comment(body, &doc); - let signature = if params.is_empty() { - String::new() - } else { - format!("params({}).", params.join(", ")) - }; - writeln!(body, "sig {{ {signature}returns({returns}) }}\ndef {target}{method}({})\n ::Baml::Bridge.call_async({}, {{{}}}){}\nend", required.join(", "), quote(wire), arguments.join(", "), if asynchronous { "" } else { ".wait" }).unwrap(); + writeln!(body, "sig {{ params({}).returns({returns}) }}\ndef {target}{method}({})\n ::Baml::Bridge.call_async({}, {{{}}}, baml: _baml){}\nend", params.join(", "), required.join(", "), quote(wire), arguments.join(", "), if asynchronous { "" } else { ".wait" }).unwrap(); } } } diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs index e88bc5fdd46..766c958419b 100644 --- a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs @@ -337,12 +337,19 @@ fn unsupported_injected_defaults_are_omitted_without_dropping_user_arguments() { let source = &files[&PathBuf::from("baml_sdk/__root.rb")]; assert!(skipped.is_empty()); assert!( - source.contains("sig { params(text: ::String).returns(::String) }"), + source.contains( + "sig { params(text: ::String, _baml: ::T.nilable(::Baml::Options)).returns(::String) }" + ), + "{source}" + ); + assert!( + source.contains("def self.extract(text, _baml: nil)"), "{source}" ); - assert!(source.contains("def self.extract(text)"), "{source}"); assert!( - source.contains("::Baml::Bridge.call_async(\"user.extract\", {\"text\" => text}).wait"), + source.contains( + "::Baml::Bridge.call_async(\"user.extract\", {\"text\" => text}, baml: _baml).wait" + ), "{source}" ); assert!(!source.contains("callback"), "{source}"); @@ -358,15 +365,17 @@ fn unsupported_injected_defaults_are_omitted_without_dropping_user_arguments() { required_callback.arguments[1].default = None; assert_eq!(generate(required_callback).1, ["user.extract"]); - // Defaulted callables are omitted, even when their parameter types are supported. + // Defaulted values are keywords; omission leaves evaluation to BAML. for injected in [false, true] { let mut supported_default = function.clone(); supported_default.arguments[1].injected = injected; supported_default.arguments[1].ty = Ty::String; let (files, skipped) = generate(supported_default); - assert_eq!(skipped, ["user.extract"]); - assert!(!files[&PathBuf::from("baml_sdk/__root.rb")].contains("def self.extract")); - assert!(!files[&PathBuf::from("baml_sdk.rb")].contains("define_function")); + let source = &files[&PathBuf::from("baml_sdk/__root.rb")]; + assert!(skipped.is_empty()); + assert!(source.contains("callback: ::Baml::UNSET")); + assert!(source.contains("callback: ::T.any(::String, ::Baml::Unset)")); + assert!(source.contains("\"callback\" => callback")); } // Generic declarations are omitted even when their type parameters are unused. @@ -396,14 +405,6 @@ fn unsupported_injected_defaults_are_omitted_without_dropping_user_arguments() { concrete_generic.return_type = Ty::Class(class_name, Box::new([Ty::Int])); assert_eq!(generate(concrete_generic).1, ["user.extract"]); - let mut no_args = function.clone(); - no_args.arguments.clear(); - let (files, skipped) = generate(no_args); - assert!(skipped.is_empty()); - let source = &files[&PathBuf::from("baml_sdk/__root.rb")]; - assert!(source.contains("sig { returns(::String) }\n def self.extract()")); - assert!(source.contains("sig { returns(::Baml::Call[::String]) }\n def self.extract_async()")); - // Nullable alone does not make a parameter optional. let mut nullable = function; nullable.arguments.truncate(1); @@ -411,18 +412,17 @@ fn unsupported_injected_defaults_are_omitted_without_dropping_user_arguments() { let (files, skipped) = generate(nullable); assert!(skipped.is_empty()); let source = &files[&PathBuf::from("baml_sdk/__root.rb")]; - assert!(source.contains("def self.extract(text)")); + assert!(source.contains("def self.extract(text, _baml: nil)")); assert!(source.contains("text: ::T.nilable(::String)")); assert!( files[&PathBuf::from("baml_sdk.rb")] .contains("[\"text\", [:union, [[:string].freeze, [:null].freeze].freeze].freeze]") ); assert!(!source.contains("_types")); - assert!(!source.contains("_baml")); assert!(!files[&PathBuf::from("baml_sdk.rb")].contains("type_params")); for asynchronous in [false, true] { assert!(source.contains(&format!( - "def self.extract{}(text)", + "def self.extract{}(text, _baml: nil)", if asynchronous { "_async" } else { "" } ))); } diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/types.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/types.rs index fa954d0bd57..5f4e264e055 100644 --- a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/types.rs +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/types.rs @@ -67,6 +67,8 @@ fn render( Ty::Bool => "::T::Boolean".into(), Ty::Null => "::NilClass".into(), Ty::RustType => "::Baml::Handle".into(), + // JSON is a recursive Ruby value, not a generated wrapper. + Ty::TypeAlias(name) if name.to_string() == "baml.json.json" => "::T.untyped".into(), Ty::Never => "::T.noreturn".into(), Ty::List(t) => format!("::T::Array[{}]", child(t)?), Ty::Map { key, value } => format!("::T::Hash[{}, {}]", child(key)?, child(value)?), From 827a1cfb5f848db2cf4bd3e7e8c58b0f28729602 Mon Sep 17 00:00:00 2001 From: Ryan Mazzolini Date: Mon, 2 Nov 2026 14:59:50 +0000 Subject: [PATCH 4/7] feat(ruby): support generic classes, functions and methods Generic declarations were skipped. Generic classes are T::Structs with one type_member per BAML type parameter, and generic callables get type_parameters signatures. The engine infers most type arguments; `_types: { T: Integer }` binds the rest, checked by name and count in Ruby because the engine ignores unknown names. Sorbet erases GenericBox[Integer] at runtime, so a host-built instance that must carry its arguments uses Baml.bind_types(instance, Integer). Decoded instances keep their type arguments when passed back. Under ABI 5 a stated type can't contain type variables, so the runtime substitutes `_types:` and the receiver's bindings, and solves an unbound instance's arguments from its field values. An empty container for an unbound variable raises TypeError asking for `_types:`. Type tokens are now plain descriptors. Tests: ports the shared generic call, inference and round-trip tests, skipping the callable-subscript arity test as Java does; 114 nextest targets pass and Ruby declares 263 shared SDK tests. Developed with AI assistance. --- .../sdk_tests/crates/parity_analysis.md | 202 ++++---- .../customizable/test_generic_calls.rb | 209 ++++++++ .../customizable/test_generic_inference.rb | 329 +++++++++++++ .../customizable/typed_consumer.rb | 11 + .../sdk_tests/crates/ruby_sorbet/src/lib.rs | 2 +- .../ruby_sorbet/test/bridge_call_test.rb | 27 ++ .../ruby_sorbet/test/bridge_encode_test.rb | 457 +++++++++++++++++- .../customizable/test_forward_refs.rb | 7 + .../type_shapes/customizable/test_generics.rb | 60 +++ .../ns_methods_on_classes/ruby_generics.baml | 24 + .../ref-ruby-state-of-completeness.md | 32 +- baml_language/sdks/ruby/bridge_ruby/README.md | 26 +- .../sdks/ruby/bridge_ruby/lib/baml/bridge.rb | 13 +- .../lib/baml/bridge/encode/plan.rb | 271 +++++++++-- .../lib/baml/bridge/encode/read.rb | 6 +- .../lib/baml/bridge/encode/slot.rb | 62 ++- .../lib/baml/bridge/process_runtime.rb | 5 +- .../bridge_ruby/lib/baml/bridge/protocol.rb | 57 ++- .../ruby/bridge_ruby/lib/baml/bridge/types.rb | 84 ++++ .../bridge_ruby/lib/baml/value_equality.rb | 7 +- .../sdks/ruby/bridge_ruby/rbi/baml.rbi | 41 +- .../sdkgen_ruby_sorbet/src/descriptors.rs | 5 +- .../sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs | 110 ++++- .../sdks/ruby/sdkgen_ruby_sorbet/src/lib.rs | 7 +- .../sdks/ruby/sdkgen_ruby_sorbet/src/names.rs | 4 +- .../sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs | 51 +- .../sdks/ruby/sdkgen_ruby_sorbet/src/types.rs | 54 ++- 27 files changed, 1907 insertions(+), 256 deletions(-) create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_generic_calls.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_generic_inference.rb create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_generics.rb create mode 100644 baml_language/sdk_tests/fixtures/function_calls/baml_src/ns_methods_on_classes/ruby_generics.baml create mode 100644 baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/types.rb diff --git a/baml_language/sdk_tests/crates/parity_analysis.md b/baml_language/sdk_tests/crates/parity_analysis.md index d9790d47b2b..90c1e330a06 100644 --- a/baml_language/sdk_tests/crates/parity_analysis.md +++ b/baml_language/sdk_tests/crates/parity_analysis.md @@ -2,9 +2,9 @@ This report inventories checked-in test declarations. It does not report whether tests passed. -Distinct exact test IDs: 985. IDs with complete required parity: 236. Required gaps: 5727. +Distinct exact test IDs: 987. IDs with complete required parity: 239. Required gaps: 5624. -Baseline ratchet: UNCHANGED. Required gaps: 5727 (baseline: 5727). Present declarations: 2780 (baseline: 2780). Newly missing required pairs: 0. Resolved baseline gaps: 0. Weakened requirements: 0. +Baseline ratchet: UNCHANGED. Required gaps: 5624 (baseline: 5624). Present declarations: 2879 (baseline: 2879). Newly missing required pairs: 0. Resolved baseline gaps: 0. Weakened requirements: 0. ## Python-baselined parity @@ -22,7 +22,7 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | go | 21 / 457 | 4.6% | | java | 307 / 457 | 67.2% | | swift | 191 / 457 | 41.8% | -| ruby_sorbet | 166 / 457 | 36.3% | +| ruby_sorbet | 263 / 457 | 57.5% | | Test case | python_pydantic2 | typescript_node | typescript_web_chromium | typescript_web_cloudflare_workers | cpp | csharp | rust | go | java | swift | ruby_sorbet | Required in | Reason | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | @@ -67,6 +67,7 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/baml_time_raw_class_transport_does_not_enforce_semantic_invariants | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/baml_trace_is_embedded_in_go_error_string | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/boolean_timeout_rejected_python_only | y | - | - | - | - | - | - | - | - | - | - | all | | +| function_calls/bounded_class_parameter_unused_by_static_method_is_sent | - | - | - | - | - | - | - | - | - | - | y | ruby_sorbet | Ruby named _types must include bounded-only static class parameters | | function_calls/callable_entry_invokes_callback | y | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/callable_entry_waits_for_callback_completion | y | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/callback_bound_method_adoption_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | covers Python bound method identity via its exact SDK function | @@ -188,34 +189,34 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/failed_admission_does_not_consume_reservation | - | - | - | - | - | - | y | - | y | - | - | all | | | function_calls/four_call_forms | y | y | y | y | - | - | y | - | y | y | y | all | | | function_calls/four_call_forms_async | y | y | y | y | - | - | y | - | y | y | y | all | | -| function_calls/generic_calls_choose_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | -| function_calls/generic_calls_consume_int_wrapper_baseline | y | - | - | - | - | - | y | - | y | y | - | all | | -| function_calls/generic_calls_extract_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | -| function_calls/generic_calls_generic_free_fn_requires_binding | y | - | - | - | - | - | y | - | y | y | - | all | | -| function_calls/generic_calls_generic_static_infers_binding | y | - | - | - | - | - | y | - | y | - | - | all | | -| function_calls/generic_calls_genericbox_get_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | -| function_calls/generic_calls_genericbox_new_static_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | -| function_calls/generic_calls_genericbox_pair_with_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | -| function_calls/generic_calls_identity_async_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | -| function_calls/generic_calls_identity_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/generic_calls_choose_explicit | y | - | - | - | - | - | y | - | y | - | y | all | | +| function_calls/generic_calls_consume_int_wrapper_baseline | y | - | - | - | - | - | y | - | y | y | y | all | | +| function_calls/generic_calls_extract_explicit | y | - | - | - | - | - | y | - | y | - | y | all | | +| function_calls/generic_calls_generic_free_fn_requires_binding | y | - | - | - | - | - | y | - | y | y | y | all | | +| function_calls/generic_calls_generic_static_infers_binding | y | - | - | - | - | - | y | - | y | - | y | all | | +| function_calls/generic_calls_genericbox_get_explicit | y | - | - | - | - | - | y | - | y | - | y | all | | +| function_calls/generic_calls_genericbox_new_static_explicit | y | - | - | - | - | - | y | - | y | - | y | all | | +| function_calls/generic_calls_genericbox_pair_with_explicit | y | - | - | - | - | - | y | - | y | - | y | all | | +| function_calls/generic_calls_identity_async_explicit | y | - | - | - | - | - | y | - | y | - | y | all | | +| function_calls/generic_calls_identity_explicit | y | - | - | - | - | - | y | - | y | - | y | all | | | function_calls/generic_calls_identity_explicit_class_cannot_restate_an_instance | - | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_calls_instance_method_unparameterized_receiver_raises | y | - | - | - | - | - | y | - | y | - | - | all | | -| function_calls/generic_calls_list_head_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | -| function_calls/generic_calls_make_int_box_reified | y | - | - | - | - | - | y | - | y | y | - | all | | -| function_calls/generic_calls_make_int_container_reified | y | - | - | - | - | - | y | - | y | y | - | all | | -| function_calls/generic_calls_make_int_str_bool_triple_reified | y | - | - | - | - | - | y | - | y | y | - | all | | -| function_calls/generic_calls_make_nested_box_reified | y | - | - | - | - | - | y | - | y | y | - | all | | -| function_calls/generic_calls_make_triple_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | -| function_calls/generic_calls_named_static_distinct_typevar_names | y | - | - | - | - | - | y | - | y | - | - | all | | -| function_calls/generic_calls_one_type_arg_explicit | y | - | - | - | - | - | y | - | y | y | - | all | | +| function_calls/generic_calls_instance_method_unparameterized_receiver_raises | y | - | - | - | - | - | y | - | y | - | y | all | | +| function_calls/generic_calls_list_head_explicit | y | - | - | - | - | - | y | - | y | - | y | all | | +| function_calls/generic_calls_make_int_box_reified | y | - | - | - | - | - | y | - | y | y | y | all | | +| function_calls/generic_calls_make_int_container_reified | y | - | - | - | - | - | y | - | y | y | y | all | | +| function_calls/generic_calls_make_int_str_bool_triple_reified | y | - | - | - | - | - | y | - | y | y | y | all | | +| function_calls/generic_calls_make_nested_box_reified | y | - | - | - | - | - | y | - | y | y | y | all | | +| function_calls/generic_calls_make_triple_explicit | y | - | - | - | - | - | y | - | y | - | y | all | | +| function_calls/generic_calls_named_static_distinct_typevar_names | y | - | - | - | - | - | y | - | y | - | y | all | | +| function_calls/generic_calls_one_type_arg_explicit | y | - | - | - | - | - | y | - | y | y | y | all | | | function_calls/generic_calls_optional_only_unset_names_its_type_arg | - | - | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_calls_parse_as_explicit | y | - | - | - | - | - | y | - | y | y | - | all | | -| function_calls/generic_calls_read_items_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | -| function_calls/generic_calls_second_of_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | -| function_calls/generic_calls_subscript_wrong_arity_raises | y | - | - | - | - | - | y | - | y | - | - | all | | -| function_calls/generic_calls_tag_or_value_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | -| function_calls/generic_calls_two_type_args_explicit | y | - | - | - | - | - | y | - | y | y | - | all | | -| function_calls/generic_calls_wrap_explicit | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/generic_calls_parse_as_explicit | y | - | - | - | - | - | y | - | y | y | y | all | | +| function_calls/generic_calls_read_items_explicit | y | - | - | - | - | - | y | - | y | - | y | all | | +| function_calls/generic_calls_second_of_explicit | y | - | - | - | - | - | y | - | y | - | y | all | | +| function_calls/generic_calls_subscript_wrong_arity_raises | y | - | - | - | - | - | y | - | y | - | y | all | | +| function_calls/generic_calls_tag_or_value_explicit | y | - | - | - | - | - | y | - | y | - | y | all | | +| function_calls/generic_calls_two_type_args_explicit | y | - | - | - | - | - | y | - | y | y | y | all | | +| function_calls/generic_calls_wrap_explicit | y | - | - | - | - | - | y | - | y | - | y | all | | | function_calls/generic_class_instantiated_at_int | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/generic_class_with_a_json_field_round_trips | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/generic_classes_containers_and_concrete_outputs | - | - | - | - | - | - | - | y | - | - | - | all | | @@ -225,75 +226,75 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/generic_identity_inference_and_explicit_types | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/generic_inference_apply_closure_poisons_typevars_must_specify | y | - | - | - | - | - | - | - | y | - | - | all | | | function_calls/generic_inference_apply_closure_typevars_specified_succeeds | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_body_only_var_still_requires_binding | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_choose_divergent_generic_instances_union | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_choose_infers_divergent_union | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_choose_infers_unified_typevar | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_choose_union_outside_container_is_sound | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_combine_invariant_class_arg_conflict_rejects | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_body_only_var_still_requires_binding | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_choose_divergent_generic_instances_union | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_choose_infers_divergent_union | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_choose_infers_unified_typevar | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_choose_union_outside_container_is_sound | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_combine_invariant_class_arg_conflict_rejects | y | - | - | - | - | - | - | - | y | - | y | all | | | function_calls/generic_inference_default_only_value_position | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | validates Python default-argument stubs and runtime TypeVar inference | -| function_calls/generic_inference_elem_type_heterogeneous_array_unifies | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_elem_type_homogeneous_array_is_single_type | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_elem_type_three_way_heterogeneous_array_unifies | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_extract_fully_unbound_nested_pair_recovers_all_vars | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_extract_infers_four_typevars_from_nesting | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_first_or_empty_list_requires_binding | y | - | - | - | - | - | - | - | y | - | - | python_pydantic2, java | a dynamic host has no static element type for an empty container; the typed SDKs state theirs and round-trip | +| function_calls/generic_inference_elem_type_heterogeneous_array_unifies | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_elem_type_homogeneous_array_is_single_type | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_elem_type_three_way_heterogeneous_array_unifies | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_extract_fully_unbound_nested_pair_recovers_all_vars | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_extract_infers_four_typevars_from_nesting | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_first_or_empty_list_requires_binding | y | - | - | - | - | - | - | - | y | - | y | python_pydantic2, java, ruby_sorbet | a dynamic host has no static element type for an empty container; the typed SDKs state theirs and round-trip | | function_calls/generic_inference_first_or_empty_list_round_trips_none | - | - | - | - | - | - | - | - | - | y | - | all | | | function_calls/generic_inference_first_or_empty_list_with_explicit_type_round_trips | - | - | - | - | - | - | - | - | - | y | - | swift | Swift names `T` statically, so an empty list cannot leave it unbound; this pins the explicit binding round trip | -| function_calls/generic_inference_first_or_nonempty_infers_element | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_generic_static_infers_own_typevar | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_genericbox_get_infers_class_var_from_receiver | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_genericbox_pair_with_infers_method_typevar | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_genericbox_pair_with_unbound_receiver_recovers_class_var | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_glue_invariant_and_covariant_agree_binds | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_glue_invariant_vs_covariant_conflict_rejects | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_host_only_object_not_encodable_from_python | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_identity_async_infers | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_identity_enum_round_trips | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_identity_infers_generic_instance | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_identity_infers_primitives | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_identity_infers_user_class | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_identity_nested_unbound_round_trips | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_identity_null_binds_null | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_first_or_nonempty_infers_element | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_generic_static_infers_own_typevar | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_genericbox_get_infers_class_var_from_receiver | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_genericbox_pair_with_infers_method_typevar | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_genericbox_pair_with_unbound_receiver_recovers_class_var | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_glue_invariant_and_covariant_agree_binds | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_glue_invariant_vs_covariant_conflict_rejects | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_host_only_object_not_encodable_from_python | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_identity_async_infers | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_identity_enum_round_trips | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_identity_infers_generic_instance | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_identity_infers_primitives | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_identity_infers_user_class | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_identity_nested_unbound_round_trips | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_identity_null_binds_null | y | - | - | - | - | - | - | - | y | y | y | all | | | function_calls/generic_inference_identity_null_with_explicit_optional_type_round_trips | - | - | - | - | - | - | - | - | - | y | - | swift | Swift names `T` statically, so a null argument infers nothing here; this pins the explicit binding round trip | -| function_calls/generic_inference_identity_unbound_generic_instance_round_trips | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_list_head_infers_from_recursive_generic | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_list_head_unbound_recursive_recovers_t_from_fields | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_make_triple_full_subscript_contradicted_by_actual_rejects | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_make_triple_heterogeneous_list_element_unions | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_make_triple_infers_multiple_typevars | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_make_triple_partial_explicit_then_infer | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_make_triple_partial_subscript_requires_full_arity | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_make_triple_subscript_fully_bound | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_make_triple_types_kwarg_contradicted_by_actual_rejects | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_maybe_id_null_round_trips | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_maybe_id_present_value_infers | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_merge_invariant_map_value_conflict_rejects | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_named_static_infers_distinct_typevars | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_one_type_arg_explicit_types_succeeds | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_pair_invariant_list_agree_binds | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_pair_invariant_list_conflict_rejects | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_parse_as_explicit_types_succeeds | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_read_items_infers_from_instance_wire_args | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_read_items_unbound_container_recovers_t_from_fields | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_return_only_var_still_requires_binding | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_second_of_infers_from_nested_generic | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_second_of_unbound_instance_recovers_field_type | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_tag_or_value_binds_generic_instance | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_triple_choose_join_includes_concrete_class | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_triple_choose_join_includes_enum_variant | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_triple_choose_three_covariant_join | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_two_typevar_union_is_uninferrable_rejects | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_union_concrete_sibling_absorbs_value_binds_never | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_identity_unbound_generic_instance_round_trips | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_list_head_infers_from_recursive_generic | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_list_head_unbound_recursive_recovers_t_from_fields | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_make_triple_full_subscript_contradicted_by_actual_rejects | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_make_triple_heterogeneous_list_element_unions | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_make_triple_infers_multiple_typevars | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_make_triple_partial_explicit_then_infer | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_make_triple_partial_subscript_requires_full_arity | y | - | - | - | - | - | - | - | y | - | y | python_pydantic2, java, ruby_sorbet | Ruby uses named _types hashes and has no callable-subscript arity surface | +| function_calls/generic_inference_make_triple_subscript_fully_bound | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_make_triple_types_kwarg_contradicted_by_actual_rejects | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_maybe_id_null_round_trips | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_maybe_id_present_value_infers | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_merge_invariant_map_value_conflict_rejects | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_named_static_infers_distinct_typevars | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_one_type_arg_explicit_types_succeeds | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_pair_invariant_list_agree_binds | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_pair_invariant_list_conflict_rejects | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_parse_as_explicit_types_succeeds | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_read_items_infers_from_instance_wire_args | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_read_items_unbound_container_recovers_t_from_fields | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_return_only_var_still_requires_binding | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_second_of_infers_from_nested_generic | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_second_of_unbound_instance_recovers_field_type | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_tag_or_value_binds_generic_instance | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_triple_choose_join_includes_concrete_class | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_triple_choose_join_includes_enum_variant | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_triple_choose_three_covariant_join | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_two_typevar_union_is_uninferrable_rejects | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_union_concrete_sibling_absorbs_value_binds_never | y | - | - | - | - | - | - | - | y | - | y | all | | | function_calls/generic_inference_union_concrete_sibling_keeps_the_static_type_arg | - | - | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_union_null_actual_binds_never | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_union_null_actual_binds_never | y | - | - | - | - | - | - | - | y | - | y | all | | | function_calls/generic_inference_union_null_actual_keeps_the_static_type_arg | - | - | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_union_with_concrete_sibling_infers_typevar | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_values_of_empty_map_requires_binding | y | - | - | - | - | - | - | - | y | - | - | python_pydantic2, java | a dynamic host has no static value type for an empty map; the typed SDKs state theirs and round-trip | +| function_calls/generic_inference_union_with_concrete_sibling_infers_typevar | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_values_of_empty_map_requires_binding | y | - | - | - | - | - | - | - | y | - | y | python_pydantic2, java, ruby_sorbet | a dynamic host has no static value type for an empty map; the typed SDKs state theirs and round-trip | | function_calls/generic_inference_values_of_empty_map_round_trips_empty_list | - | - | - | - | - | - | - | - | - | y | - | all | | -| function_calls/generic_inference_values_of_nonempty_returns_values | y | - | - | - | - | - | - | - | y | y | - | all | | -| function_calls/generic_inference_wrap_infers_and_returns_bound_generic | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_wrap_infers_and_returns_generic | y | - | - | - | - | - | - | - | y | y | - | all | | +| function_calls/generic_inference_values_of_nonempty_returns_values | y | - | - | - | - | - | - | - | y | y | y | all | | +| function_calls/generic_inference_wrap_infers_and_returns_bound_generic | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_wrap_infers_and_returns_generic | y | - | - | - | - | - | - | - | y | y | y | all | | | function_calls/generic_nullable_type_variables_preserve_every_pointer_boundary | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/generic_receiver_and_static_helpers | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/generic_return_only_type_arguments | - | - | - | - | - | - | - | y | - | - | - | all | | @@ -531,6 +532,7 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/repeated_dispatches_invoke_callback_in_order | y | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/reservation_does_not_inherit | y | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/reservation_is_single_use | - | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/reserved_binding_field_does_not_overwrite_type_arguments | - | - | - | - | - | - | - | - | - | - | y | ruby_sorbet | Ruby stores erased generic bindings in a reserved instance variable | | function_calls/retained_callback_uses_invocation_async_local_storage_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | | function_calls/retained_context_survives_parent_completion | y | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/retained_effective_token_observes_late_parent_cancellation | - | - | - | - | - | - | y | - | y | - | - | all | | @@ -764,20 +766,20 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | type_shapes/enums_round_trip_sentiment | y | y | y | y | y | - | y | - | y | y | y | all | | | type_shapes/enums_round_trip_sentiment_positive | y | y | y | y | y | - | y | - | y | - | y | all | | | type_shapes/every_supported_leaf_through_lists_and_maps | - | - | - | - | - | - | - | y | - | - | - | all | | -| type_shapes/forward_refs_round_trip_g_node_int | y | y | y | y | - | - | y | - | y | - | - | all | | +| type_shapes/forward_refs_round_trip_g_node_int | y | y | y | y | - | - | y | - | y | - | y | all | | | type_shapes/forward_refs_round_trip_node_symbol_exists | - | - | - | - | y | - | - | - | - | y | - | all | | | type_shapes/forward_refs_round_trip_other | y | y | y | y | y | - | y | - | y | y | y | all | | | type_shapes/forward_refs_round_trip_rec_list | y | y | y | y | y | - | y | - | y | y | y | all | | | type_shapes/forward_refs_round_trip_rec_list_with_other | y | y | y | y | y | - | y | - | y | y | y | all | | -| type_shapes/generic_generic | y | y | y | y | - | - | y | - | y | y | - | all | | -| type_shapes/generic_generic_wrapper_get_value | y | - | - | - | - | - | y | - | y | y | - | all | | +| type_shapes/generic_generic | y | y | y | y | - | - | y | - | y | y | y | all | | +| type_shapes/generic_generic_wrapper_get_value | y | - | - | - | - | - | y | - | y | y | y | all | | | type_shapes/generic_wrapper_get_value | - | - | - | - | - | - | - | y | - | - | - | all | | -| type_shapes/generics_round_trip_box_int | y | y | y | y | - | - | y | - | y | y | - | all | | -| type_shapes/generics_round_trip_differing_instantiation | y | y | y | y | - | - | y | - | y | y | - | all | | -| type_shapes/generics_round_trip_generic_binary_tree_int | y | y | y | y | - | - | y | - | y | y | - | all | | -| type_shapes/generics_round_trip_generic_linked_list_int | y | y | y | y | - | - | y | - | y | y | - | all | | -| type_shapes/generics_round_trip_nested_generics | y | y | y | y | - | - | y | - | y | y | - | all | | -| type_shapes/generics_round_trip_wrapper_int | y | y | y | y | - | - | y | - | y | y | - | all | | +| type_shapes/generics_round_trip_box_int | y | y | y | y | - | - | y | - | y | y | y | all | | +| type_shapes/generics_round_trip_differing_instantiation | y | y | y | y | - | - | y | - | y | y | y | all | | +| type_shapes/generics_round_trip_generic_binary_tree_int | y | y | y | y | - | - | y | - | y | y | y | all | | +| type_shapes/generics_round_trip_generic_linked_list_int | y | y | y | y | - | - | y | - | y | y | y | all | | +| type_shapes/generics_round_trip_nested_generics | y | y | y | y | - | - | y | - | y | y | y | all | | +| type_shapes/generics_round_trip_wrapper_int | y | y | y | y | - | - | y | - | y | y | y | all | | | type_shapes/handles_baml_fs_open_returns_a_typed_file_handle | - | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | | type_shapes/handles_file_cursor_state_persists_across_calls | y | y | - | - | - | - | y | - | y | y | y | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | | type_shapes/handles_http_get_response_fields_and_methods | y | y | - | - | - | - | y | - | y | - | y | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_generic_calls.rb b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_generic_calls.rb new file mode 100644 index 00000000000..00a04e31b0e --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_generic_calls.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "baml_sdk" +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class GenericCallsTest < Minitest::Test + G = BamlSdk::GenericTests + Types = Baml::Types + + def bound(klass, *types, **fields) + Baml.bind_types(klass.new(**fields), *types) + end + + # SDK_PARITY_LINT(skip): Ruby stores erased generic bindings in a reserved instance variable + def test_reserved_binding_field_does_not_overwrite_type_arguments + m = BamlSdk::MethodsOnClasses + plain = m::BindingField.new(__baml_type_args_: "plain") + assert_equal "plain", m.binding_field(plain).__baml_type_args_ + generic = m::GenericBindingField.new(__baml_type_args_: "generic", value: 3) + assert_same generic, Baml.bind_types(generic, Integer) + assert_equal "generic", generic.__baml_type_args_ + result = m.generic_binding_field(generic) + assert_equal "generic", result.__baml_type_args_ + assert_equal 3, result.value + assert_equal [[:int]], Types.instance_args(generic) + assert_equal [[:int]], Types.instance_args(result) + end + + # SDK_PARITY_LINT(skip): Ruby named _types must include bounded-only static class parameters + def test_bounded_class_parameter_unused_by_static_method_is_sent + klass = BamlSdk::MethodsOnClasses::RubyTagged + assert_raises(TypeError) { klass.zero } + assert_equal 0, klass.zero(_types: { T: Integer }) + assert_equal 0, klass.zero_async(_types: { T: Integer }).wait + assert_raises(TypeError) { klass.zero(_types: { Unknown: Integer }) } + end + + def test_generic_calls_identity_explicit + assert_equal 5, G.identity(5, _types: { T: Integer }) + assert_equal "hi", G.identity("hi", _types: { T: String }) + pair = G::StringIntPair.new(my_string: "a", my_int: 1) + assert_equal pair, G.identity(pair, _types: { T: G::StringIntPair }) + box = bound(G::GenericBox, Types.generic(G::GenericBox, String), value: bound(G::GenericBox, String, value: "hello")) + assert_equal box, G.identity(box, _types: { T: Types.generic(G::GenericBox, Types.generic(G::GenericBox, String)) }) + triple = bound(G::GenericTriple, Types.generic(G::GenericBox, String), Float, T::Boolean, + first: bound(G::GenericBox, String, value: "hello"), second: [1.1, 2.2], third: { "lorem" => true, "ipsum" => false }) + assert_equal triple, G.identity(triple, _types: { T: Types.generic(G::GenericTriple, Types.generic(G::GenericBox, String), Float, T::Boolean) }) + end + + def test_generic_calls_identity_async_explicit + assert_equal 7, G.identity_async(7, _types: { T: Integer }).wait + end + + def test_generic_calls_tag_or_value_explicit + assert_equal "int", G.tag_or_value(5, _types: { T: Integer }) + assert_equal "string", G.tag_or_value("plain", _types: { T: String }) + pair = G::StringIntPair.new(my_string: "b", my_int: 2) + assert_includes G.tag_or_value(pair, _types: { T: G::StringIntPair }), "StringIntPair" + end + + def test_generic_calls_make_triple_explicit + triple = G.make_triple(1, ["a", "b"], { "k" => true }, _types: { A: Integer, B: String, C: T::Boolean }) + assert_instance_of G::GenericTriple, triple + assert_equal 1, triple.first + assert_equal ["a", "b"], triple.second + assert_equal({ "k" => true }, triple.third) + end + + def test_generic_calls_one_type_arg_explicit + assert_equal "int", G.one_type_arg(_types: { T: Integer }) + assert_equal "string", G.one_type_arg(_types: { T: String }) + nested = G.one_type_arg(_types: { T: Types.generic(G::GenericBox, Integer) }) + assert_includes nested, "GenericBox" + assert_includes nested, "int" + end + + def test_generic_calls_two_type_args_explicit + assert_equal "int | string", G.two_type_args(_types: { A: Integer, B: String }) + end + + def test_generic_calls_generic_free_fn_requires_binding + error = assert_raises(TypeError) { G.one_type_arg } + assert_includes error.message, "could not infer a type" + assert_includes error.message, "one_type_arg" + error = assert_raises(TypeError) { G.two_type_args } + assert_includes error.message, "could not infer a type" + end + + def test_generic_calls_subscript_wrong_arity_raises + # Like Java, named bindings permit partial arity; body-only B cannot infer. + assert_raises(TypeError) { G.two_type_args(_types: { A: Integer }) } + end + + def test_generic_calls_consume_int_wrapper_baseline + assert_equal 9, G.consume_int_wrapper(bound(G::GenericBox, Integer, value: 9)) + end + + def test_generic_calls_genericbox_get_explicit + assert_equal "int", bound(G::GenericBox, Integer, value: 5).get + end + + def test_generic_calls_genericbox_pair_with_explicit + box = bound(G::GenericBox, Integer, value: 5) + assert_equal "int | string", box.pair_with("hello world", _types: { U: String }) + end + + def test_generic_calls_genericbox_new_static_explicit + box = G::GenericBox.baml_new(5, _types: { V: Integer }) + assert_instance_of G::GenericBox, box + assert_equal 5, box.value + end + + def test_generic_calls_generic_static_infers_binding + box = G::GenericBox.baml_new(5) + assert_instance_of G::GenericBox, box + assert_equal 5, box.value + assert_equal 9, G::GenericBox.static_echo(9, _types: { T: Integer }) + assert_equal "echo", G::GenericBox.static_echo_async("echo", _types: { T: String }).wait + assert_equal "int | string", G::GenericBox.static_pair(1, "x", _types: { T: Integer, U: String }) + assert_equal [7, 7], BamlSdk::Baml::Array.filled(2, 7, _types: { T: Integer }) + assert_equal 1, G::GenericBox.baml_new(1, _types: { T: Integer }).value + assert_raises(TypeError) { G::GenericBox.baml_new(1, _types: { Unknown: Integer }) } + end + + def test_generic_calls_named_static_distinct_typevar_names + assert_equal "int | string", G::NamedStatic.make(1, "x", _types: { D: Integer, E: String }) + end + + def test_generic_calls_instance_method_unparameterized_receiver_raises + error = assert_raises(TypeError) { G::GenericBox.new(value: 5).pair_with("x", _types: { U: String }) } + assert_includes error.message, "receiver" + end + + def test_generic_calls_extract_explicit + pair = bound(G::GenericPair, Types.generic(G::GenericPair, Integer, String), Types.generic(G::GenericPair, T::Boolean, Float), + first: bound(G::GenericPair, Integer, String, first: 1, second: "a"), + second: bound(G::GenericPair, T::Boolean, Float, first: true, second: 1.5)) + assert_equal "int | string | bool | float", G.extract(pair, _types: { A: Integer, B: String, C: T::Boolean, D: Float }) + end + + def test_generic_calls_parse_as_explicit + assert_equal G::StringIntPair.new(my_string: "x", my_int: 3), G.parse_as('{"my_string": "x", "my_int": 3}', _types: { T: G::StringIntPair }) + assert_equal 42, G.parse_as("42", _types: { T: Integer }) + end + + def test_generic_calls_second_of_explicit + assert_equal "hi", G.second_of(bound(G::GenericPair, Integer, String, first: 1, second: "hi"), _types: { T: String }) + pair = G::StringIntPair.new(my_string: "z", my_int: 9) + assert_equal pair, G.second_of(bound(G::GenericPair, Integer, G::StringIntPair, first: 0, second: pair), _types: { T: G::StringIntPair }) + end + + def test_generic_calls_list_head_explicit + linked = bound(G::GenericRecursive, Integer, value: 7, next_: bound(G::GenericRecursive, Integer, value: 8, next_: nil)) + assert_equal 7, G.list_head(linked, _types: { T: Integer }) + end + + def test_generic_calls_choose_explicit + assert_equal 1, G.choose(1, 2, _types: { T: Integer }) + assert_equal "a", G.choose("a", "b", _types: { T: String }) + end + + def test_generic_calls_read_items_explicit + container = bound(G::ContainerShapes, Integer, item: 1, items: [1, 2, 3], by_key: { "k" => 4 }, maybe: nil, mixed: nil) + assert_equal [1, 2, 3], G.read_items(container, _types: { T: Integer }) + end + + def test_generic_calls_wrap_explicit + box = G.wrap(5, _types: { T: Integer }) + assert_instance_of G::GenericBox, box + assert_equal 5, box.value + end + + def test_generic_calls_make_int_box_reified + box = G.make_int_box + assert_instance_of G::GenericBox, box + assert_equal [Types.token(Integer)], Types.instance_args(box) + assert_equal 7, box.value + end + + def test_generic_calls_make_int_container_reified + container = G.make_int_container + assert_instance_of G::ContainerShapes, container + assert_equal [Types.token(Integer)], Types.instance_args(container) + assert_equal 1, container.item + assert_equal [1, 2, 3], container.items + assert_equal({ "k" => 4 }, container.by_key) + assert_nil container.maybe + assert_equal 5, container.mixed + end + + def test_generic_calls_make_nested_box_reified + outer = G.make_nested_box + assert_instance_of G::GenericBox, outer + assert_equal [Types.generic(G::GenericBox, Integer)], Types.instance_args(outer) + assert_instance_of G::GenericBox, outer.value + assert_equal [Types.token(Integer)], Types.instance_args(outer.value) + assert_equal 9, outer.value.value + end + + def test_generic_calls_make_int_str_bool_triple_reified + triple = G.make_int_str_bool_triple + assert_instance_of G::GenericTriple, triple + assert_equal [Types.token(Integer), Types.token(String), Types.token(T::Boolean)], Types.instance_args(triple) + assert_equal 1, triple.first + assert_equal ["a", "b"], triple.second + assert_equal({ "k" => true }, triple.third) + end +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_generic_inference.rb b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_generic_inference.rb new file mode 100644 index 00000000000..03542140f25 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_generic_inference.rb @@ -0,0 +1,329 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "baml_sdk" +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class GenericInferenceTest < Minitest::Test + G = BamlSdk::GenericTests + Types = Baml::Types + + def bound(klass, *types, **fields) + Baml.bind_types(klass.new(**fields), *types) + end + + def assert_type_error(*needles) + error = assert_raises(TypeError) { yield } + needles.each { |needle| assert_includes error.message, needle } + end + + def test_generic_inference_identity_infers_primitives + assert_equal 5, G.identity(5) + assert_equal "hi", G.identity("hi") + assert_same true, G.identity(true) + end + + def test_generic_inference_identity_infers_user_class + pair = G::StringIntPair.new(my_string: "a", my_int: 1) + assert_equal pair, G.identity(pair) + end + + def test_generic_inference_identity_infers_generic_instance + box = bound(G::GenericBox, Integer, value: 5) + assert_equal box, G.identity(box) + nested = bound(G::GenericBox, Types.generic(G::GenericBox, String), value: bound(G::GenericBox, String, value: "hello")) + assert_equal nested, G.identity(nested) + end + + def test_generic_inference_identity_async_infers + assert_equal 7, G.identity_async(7).wait + end + + def test_generic_inference_identity_null_binds_null + assert_nil G.identity(nil) + end + + def test_generic_inference_identity_unbound_generic_instance_round_trips + unbound = G::GenericBox.new(value: 5) + assert_equal bound(G::GenericBox, Integer, value: 5), G.identity(unbound) + assert_empty Types.instance_args(unbound) + assert_equal 5, unbound.value + end + + def test_generic_inference_make_triple_infers_multiple_typevars + triple = G.make_triple(1, ["a", "b"], { "k" => true }) + assert_instance_of G::GenericTriple, triple + assert_equal 1, triple.first + assert_equal ["a", "b"], triple.second + assert_equal({ "k" => true }, triple.third) + end + + def test_generic_inference_second_of_infers_from_nested_generic + assert_equal "hi", G.second_of(bound(G::GenericPair, Integer, String, first: 1, second: "hi")) + pair = G::StringIntPair.new(my_string: "z", my_int: 9) + assert_equal pair, G.second_of(bound(G::GenericPair, Integer, G::StringIntPair, first: 0, second: pair)) + end + + def test_generic_inference_read_items_infers_from_instance_wire_args + container = bound(G::ContainerShapes, Integer, item: 1, items: [1, 2, 3], by_key: { "k" => 4 }, maybe: nil, mixed: nil) + assert_equal [1, 2, 3], G.read_items(container) + empty_fields = bound(G::ContainerShapes, Integer, item: 1, items: [], by_key: {}, maybe: nil, mixed: nil) + assert_equal [], G.read_items(empty_fields) + end + + def test_generic_inference_list_head_infers_from_recursive_generic + linked = bound(G::GenericRecursive, Integer, value: 7, next_: bound(G::GenericRecursive, Integer, value: 8, next_: nil)) + assert_equal 7, G.list_head(linked) + end + + def test_generic_inference_extract_infers_four_typevars_from_nesting + pair = bound(G::GenericPair, Types.generic(G::GenericPair, Integer, String), Types.generic(G::GenericPair, T::Boolean, Float), + first: bound(G::GenericPair, Integer, String, first: 1, second: "a"), + second: bound(G::GenericPair, T::Boolean, Float, first: true, second: 1.5)) + assert_equal "int | string | bool | float", G.extract(pair) + end + + def test_generic_inference_choose_infers_unified_typevar + assert_equal 5, G.choose(5, 6) + assert_equal "a", G.choose("a", "b") + end + + def test_generic_inference_choose_infers_divergent_union + assert_equal 5, G.choose(5, "asdf") + end + + def test_generic_inference_make_triple_partial_explicit_then_infer + triple = G.make_triple(1, ["x", "y"], { "k" => true }, _types: { A: Integer }) + assert_instance_of G::GenericTriple, triple + assert_equal 1, triple.first + assert_equal ["x", "y"], triple.second + assert_equal({ "k" => true }, triple.third) + end + + def test_generic_inference_wrap_infers_and_returns_generic + box = G.wrap(5) + assert_instance_of G::GenericBox, box + assert_equal 5, box.value + end + + def test_generic_inference_genericbox_pair_with_infers_method_typevar + assert_equal "int | string", bound(G::GenericBox, Integer, value: 5).pair_with("hello world") + end + + def test_generic_inference_generic_static_infers_own_typevar + box = G::GenericBox.baml_new(5) + assert_instance_of G::GenericBox, box + assert_equal 5, box.value + end + + def test_generic_inference_named_static_infers_distinct_typevars + assert_equal "int | string", G::NamedStatic.make(1, "x") + end + + def test_generic_inference_union_with_concrete_sibling_infers_typevar + assert_equal "int", G.tag_or_value(5) + end + + def test_generic_inference_union_concrete_sibling_absorbs_value_binds_never + assert_equal "never", G.tag_or_value("hi") + assert_equal "int", G.tag_or_value("hi", _types: { T: Integer }) + end + + def test_generic_inference_union_null_actual_binds_never + assert_equal "never", G.tag_or_value(nil) + end + + def test_generic_inference_return_only_var_still_requires_binding + assert_type_error("could not infer a type", "parse_as") { G.parse_as("42") } + end + + def test_generic_inference_body_only_var_still_requires_binding + assert_type_error("could not infer a type", "one_type_arg") { G.one_type_arg } + end + + def test_generic_inference_pair_invariant_list_conflict_rejects + assert_type_error("can't be reconciled", "pair", "int", "string") { G.pair([1, 2], ["a", "b"]) } + end + + def test_generic_inference_pair_invariant_list_agree_binds + assert_equal "int", G.pair([1, 2], [3, 4]) + end + + def test_generic_inference_choose_union_outside_container_is_sound + assert_equal [1, 2], G.choose([1, 2], ["a"]) + end + + def test_generic_inference_merge_invariant_map_value_conflict_rejects + assert_type_error("can't be reconciled", "merge") { G.merge({ "k" => 1 }, { "k" => "a" }) } + end + + def test_generic_inference_combine_invariant_class_arg_conflict_rejects + assert_type_error("can't be reconciled", "combine") do + G.combine(bound(G::GenericBox, Integer, value: 1), bound(G::GenericBox, String, value: "x")) + end + end + + def test_generic_inference_glue_invariant_vs_covariant_conflict_rejects + assert_type_error("can't be reconciled", "glue") { G.glue(1, ["a"]) } + end + + def test_generic_inference_glue_invariant_and_covariant_agree_binds + assert_equal "int", G.glue(1, [2, 3]) + end + + def test_generic_inference_two_typevar_union_is_uninferrable_rejects + assert_type_error("two_in_union") { G.two_in_union("hello") } + end + + def test_generic_inference_triple_choose_three_covariant_join + assert_equal "int | string | bool", G.triple_choose(5, "asdf", true) + end + + def test_generic_inference_make_triple_heterogeneous_list_element_unions + triple = G.make_triple(1, [1, "x"], { "k" => true }) + assert_instance_of G::GenericTriple, triple + assert_equal [1, "x"], triple.second + end + + def test_generic_inference_choose_divergent_generic_instances_union + left = bound(G::GenericBox, Integer, value: 1) + assert_equal left, G.choose(left, bound(G::GenericBox, String, value: "x")) + end + + def test_generic_inference_tag_or_value_binds_generic_instance + rendered = G.tag_or_value(bound(G::GenericBox, String, value: "asdf")) + assert_includes rendered, "GenericBox" + assert_includes rendered, "string" + end + + def test_generic_inference_first_or_empty_list_requires_binding + assert_type_error("could not infer a type", "first_or") { G.first_or([]) } + assert_nil G.first_or([], _types: { T: Integer }) + end + + def test_generic_inference_first_or_nonempty_infers_element + assert_equal 7, G.first_or([7, 8, 9]) + end + + def test_generic_inference_values_of_empty_map_requires_binding + assert_type_error("could not infer a type", "values_of") { G.values_of({}) } + assert_equal [], G.values_of({}, _types: { T: Integer }) + end + + def test_generic_inference_values_of_nonempty_returns_values + assert_equal [1, 2], G.values_of({ "a" => 1, "b" => 2 }) + end + + # SDK_PARITY_LINT(skip): Ruby uses named _types hashes and has no callable-subscript arity surface + def test_generic_inference_make_triple_partial_subscript_requires_full_arity + skip "Ruby has no callable-subscript arity surface" + end + + def test_generic_inference_make_triple_subscript_fully_bound + triple = G.make_triple(5, ["x"], { "k" => true }, _types: { A: Integer, B: String, C: T::Boolean }) + assert_instance_of G::GenericTriple, triple + assert_equal 5, triple.first + assert_equal ["x"], triple.second + assert_equal({ "k" => true }, triple.third) + end + + def test_generic_inference_one_type_arg_explicit_types_succeeds + assert_equal "int", G.one_type_arg(_types: { T: Integer }) + end + + def test_generic_inference_parse_as_explicit_types_succeeds + assert_equal 42, G.parse_as("42", _types: { T: Integer }) + end + + def test_generic_inference_second_of_unbound_instance_recovers_field_type + assert_equal "hi", G.second_of(G::GenericPair.new(first: 1, second: "hi")) + end + + def test_generic_inference_identity_nested_unbound_round_trips + nested = G::GenericBox.new(value: G::GenericBox.new(value: "hello")) + expected = bound(G::GenericBox, Types.generic(G::GenericBox, String), value: bound(G::GenericBox, String, value: "hello")) + assert_equal expected, G.identity(nested) + assert_empty Types.instance_args(nested) + assert_empty Types.instance_args(nested.value) + assert_equal "hello", nested.value.value + end + + def test_generic_inference_wrap_infers_and_returns_bound_generic + assert_equal bound(G::GenericBox, Integer, value: 5), G.wrap(5) + end + + def test_generic_inference_maybe_id_present_value_infers + assert_equal 5, G.maybe_id(5) + end + + def test_generic_inference_maybe_id_null_round_trips + assert_nil G.maybe_id(nil) + assert_nil G.maybe_id(nil, _types: { T: Integer }) + end + + def test_generic_inference_identity_enum_round_trips + result = G.identity(G::SomeEnum::VARIANT) + assert_same G::SomeEnum::VARIANT, result + assert_instance_of G::SomeEnum, result + end + + def test_generic_inference_host_only_object_not_encodable_from_python + error = assert_raises(TypeError) { G.identity(Object.new) } + assert_includes error.message, "cannot encode" + assert_includes error.message, "no BAML type" + end + + def test_generic_inference_genericbox_get_infers_class_var_from_receiver + assert_equal "int", bound(G::GenericBox, Integer, value: 5).get + end + + def test_generic_inference_genericbox_pair_with_unbound_receiver_recovers_class_var + assert_equal "int | string", G::GenericBox.new(value: 5).pair_with("x") + end + + def test_generic_inference_make_triple_types_kwarg_contradicted_by_actual_rejects + assert_type_error("make_triple") { G.make_triple("nope", ["x"], { "k" => true }, _types: { A: Integer }) } + end + + def test_generic_inference_make_triple_full_subscript_contradicted_by_actual_rejects + assert_type_error("make_triple") { G.make_triple("nope", ["x"], { "k" => true }, _types: { A: Integer, B: String, C: T::Boolean }) } + end + + def test_generic_inference_elem_type_heterogeneous_array_unifies + assert_equal "int | string", G.elem_type([1, "x"]) + end + + def test_generic_inference_elem_type_homogeneous_array_is_single_type + assert_equal "int", G.elem_type([1, 2, 3]) + end + + def test_generic_inference_elem_type_three_way_heterogeneous_array_unifies + rendered = G.elem_type([1, "x", true]) + %w[int string bool].each { |type| assert_includes rendered, type } + end + + def test_generic_inference_read_items_unbound_container_recovers_t_from_fields + unbound = G::ContainerShapes.new(item: 1, items: [1, 2, 3], by_key: { "k" => 4 }, maybe: nil, mixed: nil) + assert_equal [1, 2, 3], G.read_items(unbound) + end + + def test_generic_inference_list_head_unbound_recursive_recovers_t_from_fields + unbound = G::GenericRecursive.new(value: 7, next_: G::GenericRecursive.new(value: 8, next_: nil)) + assert_equal 7, G.list_head(unbound) + end + + def test_generic_inference_extract_fully_unbound_nested_pair_recovers_all_vars + pair = G::GenericPair.new(first: G::GenericPair.new(first: 1, second: "a"), second: G::GenericPair.new(first: true, second: 1.5)) + assert_equal "int | string | bool | float", G.extract(pair) + end + + def test_generic_inference_triple_choose_join_includes_concrete_class + rendered = G.triple_choose(5, G::StringIntPair.new(my_string: "a", my_int: 1), "x") + %w[int StringIntPair string].each { |type| assert_includes rendered, type } + end + + def test_generic_inference_triple_choose_join_includes_enum_variant + rendered = G.triple_choose(5, G::SomeEnum::VARIANT, G::StringIntPair.new(my_string: "a", my_int: 1)) + %w[int SomeEnum StringIntPair].each { |type| assert_includes rendered, type } + end +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/typed_consumer.rb b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/typed_consumer.rb index 4ccca946952..b967e531a97 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/typed_consumer.rb +++ b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/typed_consumer.rb @@ -22,3 +22,14 @@ T.let(BamlSdk.optional_args_probe(1, opt1: nil, opt2: 2, _baml: Baml::Options.new(timeout_ms: 1000)), T::Array[T.nilable(Integer)]) T.let(BamlSdk::StaticMethodEdges::Edge.nullable(nil), T.nilable(Integer)) T.let(BamlSdk.default_name_collisions("a", options: "b", option: "c"), T::Array[String]) + +T.let(BamlSdk::GenericTests.identity(5), Integer) +T.let(BamlSdk::GenericTests.identity_async("hi").wait, String) +box = Baml.bind_types(BamlSdk::GenericTests::GenericBox[Integer].new(value: 5), Integer) +T.let(box, BamlSdk::GenericTests::GenericBox[Integer]) +T.let(box.value, Integer) +T.let(box.get, String) +T.let(box.pair_with("hi", _types: { U: String }), String) +T.let(BamlSdk::GenericTests.wrap(5), BamlSdk::GenericTests::GenericBox[Integer]) +T.let(BamlSdk::GenericTests::GenericBox.baml_new("hi"), BamlSdk::GenericTests::GenericBox[String]) +T.let(BamlSdk::GenericTests::GenericNameCollision[String].new(value: "hi").pair(1), String) diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/src/lib.rs b/baml_language/sdk_tests/crates/ruby_sorbet/src/lib.rs index f7dfd9c76cf..f94f0e42a52 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/src/lib.rs +++ b/baml_language/sdk_tests/crates/ruby_sorbet/src/lib.rs @@ -4,7 +4,7 @@ sdk_test_harness_runner::ruby_sorbet::test_suite! { fixture function_calls { minitest: on, sorbet: on } fixture llm_functions { minitest: on, sorbet: on } fixture type_shapes { minitest: on, sorbet: on } - fixture unsupported_only { minitest: later, sorbet: later } + fixture unsupported_only { minitest: on, sorbet: on } } #[cfg(test)] diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_call_test.rb b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_call_test.rb index c3a4027983c..69d726c8ada 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_call_test.rb +++ b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_call_test.rb @@ -305,6 +305,33 @@ def test_interrupted_callback_registration_is_retained_and_completed_once assert_same runtime.instance_variable_get(:@result_callback).function, api.callback end + class GenericValue < T::Struct + const :value, String + end + + def test_decoded_generic_preserves_string_literal_and_alias_hints + protocol = Baml::Bridge::Protocol + Baml::Bridge.define_class("user.GenericValue", GenericValue, fields: { "value" => [:value, [:param, 0, "T"]] }, type_params: ["T"]) + protocol.define_alias("user.Text", [:string]) + [{ primitive: { kind: :BAML_TY_PRIMITIVE_STRING } }, { literal: { string_value: "text" } }, + { type_alias: { name: "user.Text" } }].each do |text_type| + type = { union: { options: [text_type, { primitive: { kind: :BAML_TY_PRIMITIVE_BYTES } }] } } + [:string_value, :uint8array_value].each do |kind| + @api.dispatch = lambda do |args, id| + if args.kwargs.empty? + @api.deliver(id, ok: { class_value: { name: "user.GenericValue", type_args: [type], fields: [ + { key: "value", value: { kind => "text" } } + ] } }) + else + assert_equal kind, args.kwargs.first.value.class_value.fields.first.value.value + @api.deliver(id, ok: { bool_value: true }) + end + end + assert call("value" => call) + end + end + end + def test_concurrent_calls_receive_only_their_own_result dispatched = Queue.new @api.dispatch = ->(args, id) { dispatched << [args, id] } diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_encode_test.rb b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_encode_test.rb index a9c27540c55..23636ee516b 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_encode_test.rb +++ b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_encode_test.rb @@ -26,14 +26,21 @@ def initialize @aliases = {} @normalized_aliases = {} @crossings = {} + @params = {} end - def define(type, name, fields = nil, crossing: :fields) + def define(type, name, fields = nil, crossing: :fields, type_params: []) + @params[name] = type_params + Baml::Types.register(type, name, fields ? type_params.length : nil) @types[type] = [name, fields] @fields[name] = fields @crossings[name] = crossing end + def class_type_params(name) + @params.fetch(name) + end + def class_crossing(name) @crossings.fetch(name, :fields) end @@ -88,7 +95,7 @@ def setup @registry.define(Box, "user.Box", { "value" => [:value, [:param, 0, "T"]], "note" => [:note, [:union, [[:null], [:string]]]] - }) + }, type_params: ["T"]) end def test_scope_attempts_every_release_before_raising_the_first_error @@ -209,6 +216,24 @@ def test_class_field_alias_normalization_spends_the_budget end end + def test_generic_class_field_alias_normalization_spends_the_budget + expanding_aliases(18) + klass = Class.new(Struct.new(:value, :items)) + @registry.define(klass, "user.DeepGeneric", { + "value" => [:value, [:param, 0, "T"]], + "items" => [:items, [:alias, "user.Deep18"]] + }, type_params: ["T"]) + tree = Encode::Read.new(@registry).read(klass.new(1, []), "generic") + [[:hole], [:int]].each do |arg| + 2.times do + Timeout.timeout(0.1) do + error = assert_raises(Encode::Fault) { Encode::Plan.new(@registry, budget: 10).plan(tree, [:class, "user.DeepGeneric", [arg]]) } + assert_includes error.message, "planning-step budget" + end + end + end + end + def test_scalars_follow_the_slot_and_the_hosts_rules [[nil, :null, nil], [true, :bool, :bool_value], [false, :bool, :bool_value], [7, :int, :int_value], [2.5, :float, :float_value], ["text", :string, :string_value]].each do |value, kind, arm| @@ -517,6 +542,426 @@ def test_fixed_class_arguments_drive_fields_and_optional_fields_are_present assert_raises(TypeError) { encode({ "value" => 3 }, [:class, "user.Box", [[:int]]]) } end + def test_generic_class_arguments_come_from_the_slot_the_value_or_the_fields + box = Box.new(value: 3) + assert_equal [:class, "user.Box", [[:int]]], type_of(box) + assert_empty Baml::Types.instance_args(box) + assert_equal [:class, "user.Box", [[:float]]], type_of(box, [:class, "user.Box", [[:float]]]) + Baml.bind_types(box, Integer) + assert_equal [:class, "user.Box", [[:int]]], type_of(box) + assert_raises(TypeError) { encode(box, [:class, "user.Box", [[:float]]]) } + assert_equal [:class, "user.Box", [[:int]]], type_of(Box.new(value: 3), union( + [:class, "user.Box", [[:int]]], [:class, "user.Box", [[:string]]] + )) + assert_raises(TypeError) do + encode(Box.new(value: []), union([:class, "user.Box", [[:list, [:int]]]], [:class, "user.Box", [[:list, [:string]]]])) + end + + param = [:param, 0, "T"] + pair = Class.new(Struct.new(:a, :b)) + @registry.define(pair, "user.Pair", { "a" => [:a, [:list, param]], "b" => [:b, param] }, type_params: ["T"]) + result = encode(pair.new([], 1)) + assert_equal [:class, "user.Pair", [[:int]]], Encode::Slot.from_wire(result.value_type) + assert_equal [:list, [:int]], Encode::Slot.from_wire(result.class_value.fields.first.value.value_type) + error = assert_raises(Encode::TypeArgConflict) { encode(pair.new([1], "x")) } + assert_includes error.message, "user.Pair" + assert_includes error.message, "T" + assert_raises(Encode::TypeArgConflict) { encode(pair.new(["x"], 1)) } + assert_raises(Encode::MissingTypeArgs) { encode(pair.new([], [])) } + @registry.define(pair, "user.Pair", { "a" => [:a, param], "b" => [:b, [:param, 1, "U"]] }, type_params: %w[T U]) + result = encode(pair.new("x", 1), [:class, "user.Pair", [param, [:float]]]) + assert_equal [:class, "user.Pair", [[:string], [:float]]], Encode::Slot.from_wire(result.value_type) + assert_equal 1.0, result.class_value.fields.last.value.float_value + phantom = Class.new(Struct.new(:value)) + @registry.define(phantom, "user.Phantom", { "value" => [:value, [:int]] }, type_params: ["T"]) + assert_raises(Encode::MissingTypeArgs) { encode(phantom.new(1)) } + end + + def test_fixed_literal_union_arm_does_not_contribute_parameter_evidence + param = [:param, 0, "T"] + klass = Class.new(Struct.new(:value, :status)) + @registry.define(klass, "user.LiteralStatus", { + "value" => [:value, param], "status" => [:status, union(param, [:literal, "none"])] + }, type_params: ["T"]) + [[:unknown], [:class, "user.LiteralStatus", [param]]].each do |slot| + assert_equal [:class, "user.LiteralStatus", [[:int]]], type_of(klass.new(1, "none"), slot) + assert_equal [:class, "user.LiteralStatus", [[:string]]], type_of(klass.new("a", "none"), slot) + assert_raises(Encode::TypeArgConflict) { encode(klass.new(1, "other"), slot) } + end + end + + def test_fixed_literal_union_arms_survive_list_and_map_inference + param = [:param, 0, "T"] + klass = Class.new(Struct.new(:value, :status)) + [[:list, union(param, [:literal, "none"])], + [:map, [:string], union(param, [:literal, "none"])]].each do |container| + @registry.define(klass, "user.ContainerStatus", { + "value" => [:value, param], "status" => [:status, container] + }, type_params: ["T"]) + wrap = ->(items) { container[0] == :list ? items : items.each_with_index.to_h { |item, i| [i.to_s, item] } } + [[1, ["none"], [:int]], [1, [2, "none"], [:int]], + ["a", ["none"], [:string]], [1, ["none", 2], [:int]]].each do |value, items, expected| + [[:unknown], [:class, "user.ContainerStatus", [param]]].each do |slot| + assert_equal [:class, "user.ContainerStatus", [expected]], type_of(klass.new(value, wrap.call(items)), slot) + end + end + assert_raises(Encode::TypeArgConflict) { encode(klass.new(1, wrap.call(["other"]))) } + @registry.define(klass, "user.ContainerStatus", { "status" => [:status, container] }, type_params: ["T"]) + assert_equal [:class, "user.ContainerStatus", [[:never]]], type_of(klass.new(nil, wrap.call(["none"]))) + end + end + + def test_fixed_integer_arm_alone_leaves_the_parameter_never_but_a_value_binds_it + param = [:param, 0, "T"] + klass = Class.new(Struct.new(:value, :status)) + status = [:status, union(param, [:literal, 1])] + @registry.define(klass, "user.IntegerStatus", { "status" => status }, type_params: ["T"]) + assert_equal [:class, "user.IntegerStatus", [[:never]]], type_of(klass.new(nil, 1)) + @registry.define(klass, "user.IntegerStatus", { "status" => status, "value" => [:value, param] }, type_params: ["T"]) + assert_equal [:class, "user.IntegerStatus", [[:int]]], type_of(klass.new(1, 1)) + @registry.define(klass, "user.IntegerStatus", { + "status" => [:status, union(param, [:param, 1, "U"]) ] + }, type_params: %w[T U]) + assert_raises(Encode::MissingTypeArgs) { encode(klass.new(nil, 1)) } + end + + def test_fixed_enum_variant_arms_do_not_learn_the_widened_enum_type + param = [:param, 0, "T"] + status = union(param, [:enum_variant, "user.Color", "red"]) + klass = Class.new(Struct.new(:value, :status)) + [[status, ->(value) { value }], + [[:list, status], ->(value) { [value] }], + [[:map, [:string], status], ->(value) { { "status" => value } }]].each do |slot, wrap| + @registry.define(klass, "user.EnumStatus", { + "value" => [:value, param], "status" => [:status, slot] + }, type_params: ["T"]) + [[1, [:int]], [Color::Blue, [:enum, "user.Color"]]].each do |value, expected| + result = encode(klass.new(value, wrap.call(Color::Red))) + assert_equal [:class, "user.EnumStatus", [expected]], Encode::Slot.from_wire(result.value_type) + bound = Baml.bind_types(klass.new(value, wrap.call(Color::Red)), expected) + assert_equal encode(bound), result + end + assert_raises(Encode::TypeArgConflict) { encode(klass.new(1, wrap.call(Color::Blue))) } + end + end + + def test_equivalent_fixed_and_open_arms_keep_the_existing_wire_output + param = [:param, 0, "T"] + slot = [:list, union([:list, param], [:list, [:int]])] + expected = [:list, union([:list, [:never]], [:list, [:int]])] + result = encode([[1]], slot) + assert_equal expected, Encode::Slot.from_wire(result.value_type) + assert_equal [:list, [:int]], Encode::Slot.from_wire(result.list_value.values.first.value_type) + assert_equal encode([[1]], expected), result + end + + def test_empty_and_nonempty_fixed_containers_are_absorbed_in_lists_and_maps + param = [:param, 0, "T"] + [[[:list, [:int]], [], [1]], + [[:map, [:string], [:int]], {}, { "n" => 1 }]].each do |fixed, empty, full| + member = union(param, fixed) + [[[:list, member], ->(items) { items }], + [[:map, [:string], member], ->(items) { items.each_with_index.to_h { |item, i| [i.to_s, item] } }]].each do |slot, wrap| + [[empty], [full], [empty, full]].each do |items| + expected = Encode::Slot.substitute(slot, [[:never]]) + assert_equal expected, type_of(wrap.call(items), slot) + assert_equal encode(wrap.call(items), expected), encode(wrap.call(items), slot) + end + [[empty, "a"], ["a", full]].each do |items| + assert_equal Encode::Slot.substitute(slot, [[:string]]), type_of(wrap.call(items), slot) + end + assert_raises(Encode::UnboundTypeArgs) { encode(wrap.call([]), slot) } + end + end + end + + def test_fixed_container_fields_do_not_conflict_with_other_parameter_evidence + param = [:param, 0, "T"] + klass = Class.new(Struct.new(:value, :status)) + [[[:list, [:int]], []], [[:map, [:string], [:int]], {}], + [[:list, [:literal, "none"]], ["none"]], + [[:map, [:string], [:literal, "none"]], { "status" => "none" }]].each do |fixed, status| + fields = { "status" => [:status, union(param, fixed)] } + @registry.define(klass, "user.FixedContainer", fields, type_params: ["T"]) + assert_equal [:class, "user.FixedContainer", [[:never]]], type_of(klass.new(nil, status)) + @registry.define(klass, "user.FixedContainer", fields.merge("value" => [:value, param]), type_params: ["T"]) + result = encode(klass.new(1, status)) + assert_equal [:class, "user.FixedContainer", [[:int]]], Encode::Slot.from_wire(result.value_type) + assert_equal encode(Baml.bind_types(klass.new(1, status), Integer)), result + end + end + + def test_fixed_arm_choices_survive_recursive_container_learning + param = [:param, 0, "T"] + klass = Class.new(Struct.new(:value, :status)) + status = [:list, [:map, [:string], [:list, union(param, [:enum_variant, "user.Color", "red"])]]] + @registry.define(klass, "user.NestedStatus", { + "value" => [:value, param], "status" => [:status, status] + }, type_params: ["T"]) + [[{ "red" => [Color::Red] }], + [{ "red" => [Color::Red] }, { "int" => [2] }], + [{ "int" => [2] }, { "red" => [Color::Red] }]].each do |items| + result = encode(klass.new(1, items)) + assert_equal [:class, "user.NestedStatus", [[:int]]], Encode::Slot.from_wire(result.value_type) + assert_equal encode(Baml.bind_types(klass.new(1, items), Integer)), result + end + @registry.define(klass, "user.NestedStatus", { "status" => [:status, status] }, type_params: ["T"]) + assert_equal [:class, "user.NestedStatus", [[:never]]], type_of(klass.new(nil, [{ "red" => [Color::Red] }])) + end + + def test_fixed_arm_choices_survive_nested_class_argument_learning + param = [:param, 0, "T"] + inner = Class.new(Struct.new(:status)) + outer = Class.new(Struct.new(:value, :inner)) + @registry.define(inner, "user.InnerStatus", { + "status" => [:status, union(param, [:enum_variant, "user.Color", "red"])] + }, type_params: ["T"]) + @registry.define(outer, "user.OuterStatus", { + "value" => [:value, param], "inner" => [:inner, [:class, "user.InnerStatus", [param]]] + }, type_params: ["T"]) + result = encode(outer.new(1, inner.new(Color::Red))) + assert_equal [:class, "user.OuterStatus", [[:int]]], Encode::Slot.from_wire(result.value_type) + assert_equal encode(Baml.bind_types(outer.new(1, inner.new(Color::Red)), Integer)), result + assert_raises(Encode::TypeArgConflict) { encode(outer.new(1, inner.new(Color::Blue))) } + nested = [:class, "user.InnerStatus", [[:never]]] + assert_equal [:class, "user.OuterStatus", [nested]], type_of(outer.new(inner.new(Color::Red), inner.new(Color::Red))) + + member = [:class, "user.InnerStatus", [param]] + [[[:list, member], ->(items) { items }], + [[:map, [:string], member], ->(items) { items.each_with_index.to_h { |item, i| [i.to_s, item] } }]].each do |slot, wrap| + [ [Color::Red, Color::Blue], [Color::Blue, Color::Red] ].each do |colors| + values = wrap.call(colors.map { |color| inner.new(color) }) + expected = Encode::Slot.substitute(slot, [[:enum, "user.Color"]]) + assert_equal expected, type_of(values, slot) + assert_equal encode(values, expected), encode(values, slot) + end + end + end + + def test_fixed_unions_in_nested_class_arguments_do_not_bind_the_parent_parameter + param = [:param, 0, "T"] + klass = Class.new(Struct.new(:value, :status)) + [[[:int], 1, "a", [:string]], [[:string], "s", 1, [:int]], + [[:list, [:int]], [1], "a", [:string]], + [[:map, [:string], [:int]], { "n" => 1 }, "a", [:string]]].each do |fixed, status, value, expected| + field = [:status, [:class, "user.Box", [union(param, fixed)]]] + fields = { "value" => [:value, param], "status" => field } + [fields, fields.to_a.reverse.to_h].each do |ordered| + @registry.define(klass, "user.NestedArgument", ordered, type_params: ["T"]) + result = encode(klass.new(value, Box.new(value: status))) + assert_equal [:class, "user.NestedArgument", [expected]], Encode::Slot.from_wire(result.value_type) + assert_equal encode(Baml.bind_types(klass.new(value, Box.new(value: status)), expected)), result + end + @registry.define(klass, "user.NestedArgument", { "status" => field }, type_params: ["T"]) + result = encode(klass.new(nil, Box.new(value: status))) + assert_equal [:class, "user.NestedArgument", [[:never]]], Encode::Slot.from_wire(result.value_type) + assert_equal encode(Baml.bind_types(klass.new(nil, Box.new(value: status)), [:never])), result + end + end + + def test_fixed_unions_nested_inside_class_argument_containers_are_absorbed + param = [:param, 0, "T"] + klass = Class.new(Struct.new(:value, :status)) + [[[:list, union(param, [:int])], [1]], + [[:map, [:string], union(param, [:int])], { "n" => 1 }], + [union([:list, param], [:list, [:int]]), [1]]].each do |argument, status| + @registry.define(klass, "user.NestedArgumentContainer", { + "value" => [:value, param], "status" => [:status, [:class, "user.Box", [argument]]] + }, type_params: ["T"]) + result = encode(klass.new("a", Box.new(value: status))) + assert_equal [:class, "user.NestedArgumentContainer", [[:string]]], Encode::Slot.from_wire(result.value_type) + assert_equal encode(Baml.bind_types(klass.new("a", Box.new(value: status)), String)), result + assert_raises(Encode::Refusal) { encode(klass.new("a", Box.new(value: true))) } + end + end + + def test_absorbed_evidence_beats_empty_list_and_map_siblings_in_either_order + param = [:param, 0, "T"] + red = [:enum_variant, "user.Color", "red"] + [[[:list, [:list, union(param, red)]], [[], [Color::Red]], [[], [Color::Red], [1]]], + [[:map, [:string], [:map, [:string], union(param, red)]], + [{}, { "r" => Color::Red }], [{}, { "r" => Color::Red }, { "n" => 1 }]]].each do |slot, absorbed, concrete| + wrap = ->(items) { slot[0] == :list ? items : items.each_with_index.to_h { |item, i| [i.to_s, item] } } + [[absorbed, [:never]], [concrete, [:int]]].each do |items, binding| + [items, items.reverse].each do |ordered| + expected = Encode::Slot.substitute(slot, [binding]) + result = encode(wrap.call(ordered), slot) + assert_equal expected, Encode::Slot.from_wire(result.value_type) + assert_equal encode(wrap.call(ordered), expected), result + end + end + end + end + + def test_absorbed_evidence_beats_empty_class_fields_in_either_order + param = [:param, 0, "T"] + klass = Class.new(Struct.new(:a, :b)) + red = union(param, [:enum_variant, "user.Color", "red"]) + [[[:list, red], [], [Color::Red]], + [[:map, [:string], red], {}, { "r" => Color::Red }]].each do |slot, empty, absorbed| + fields = { "a" => [:a, slot], "b" => [:b, slot] } + [fields, fields.to_a.reverse.to_h].each do |ordered| + @registry.define(klass, "user.EmptyAndAbsorbed", ordered, type_params: ["T"]) + [[empty, absorbed], [absorbed, empty]].each do |values| + result = encode(klass.new(*values)) + assert_equal [:class, "user.EmptyAndAbsorbed", [[:never]]], Encode::Slot.from_wire(result.value_type) + assert_equal encode(Baml.bind_types(klass.new(*values), [:never])), result + end + assert_raises(Encode::MissingTypeArgs) { encode(klass.new(empty, empty)) } + end + end + end + + def test_a_fixed_arm_does_not_determine_two_open_union_arms + t, u = [:param, 0, "T"], [:param, 1, "U"] + klass = Class.new(Struct.new(:status)) + @registry.define(klass, "user.TwoOpen", { + "status" => [:status, union(t, u, [:enum_variant, "user.Color", "red"])] + }, type_params: %w[T U]) + assert_raises(Encode::MissingTypeArgs) { encode(klass.new(Color::Red)) } + assert_raises(Encode::UnboundTypeArgs) { encode([Color::Red], [:list, union(t, u, [:enum_variant, "user.Color", "red"])]) } + end + + def test_fixed_arm_inference_shares_the_budget_and_scales_with_the_elements + param = [:param, 0, "T"] + slot = [:list, [:map, [:string], union(param, [:enum_variant, "user.Color", "red"])]] + tree = Encode::Read.new(@registry).read(Array.new(2_000) { { "status" => Color::Red } }, "fixed") + assert_raises(Encode::Fault) { Encode::Plan.new(@registry, budget: 100).plan_root(tree, slot) } + planned = Timeout.timeout(5) { Encode::Plan.new(@registry, budget: 100_000).plan_root(tree, slot) } + assert_equal 2_000, planned.children.length + assert_equal Encode::Slot.substitute(slot, [[:never]]), planned.ty + end + + def test_stored_bindings_match_normalized_decoded_bindings + klass = Class.new(T::Struct) do + include Baml::ValueEquality + const :value, T.nilable(Integer) + end + fields = { "value" => [:value, [:param, 0, "T"]] } + @registry.define(klass, "user.NormalizedBox", fields, type_params: ["T"]) + Protocol.define_class("user.NormalizedBox", klass, fields: fields, type_params: ["T"]) + [Baml::Types.optional(Baml::Types.optional(Integer)), union([:int], [:int], [:null], [:null])].each do |binding| + original = Baml.bind_types(klass.new(value: 1), binding) + encoded = encode(original) + outbound = Wire::BamlOutboundValue.new(class_value: { + name: "user.NormalizedBox", type_args: encoded.value_type.class_ty.type_args.to_a, + fields: [{ key: "value", value: { int_value: 1 } }] + }) + decoded = Protocol.decode_value(Wire::BamlOutboundValue.decode(Wire::BamlOutboundValue.encode(outbound))) + assert_equal original, decoded + assert original.eql?(decoded) + assert_equal original.hash, decoded.hash + assert_equal [union([:int], [:null])], Baml::Types.instance_args(original) + end + end + + def test_alias_and_inline_field_templates_infer_the_same_never_argument + param = [:param, 0, "T"] + @registry.define_alias("user.Name", [:string]) + [[:alias, "user.Name"], [:string]].each do |known| + box = Class.new(Struct.new(:value)) + @registry.define(box, "user.AliasBox", { "value" => [:value, union(param, known)] }, type_params: ["T"]) + assert_equal [:class, "user.AliasBox", [[:never]]], type_of(box.new("hi")) + end + end + + def test_a_solved_element_slot_is_normalized_once_not_for_every_child + param = [:param, 0, "T"] + value = (1..30).map { |size| [Array.new(size, 1), ["a"]] } + tree = Encode::Read.new(@registry).read(value, "solved") + normalizations = Hash.new(0) + original = Encode::Slot.method(:stated_form) + Encode::Slot.stub(:stated_form, ->(slot, *args, **kwargs, &work) { normalizations[slot.object_id] += 1; original.call(slot, *args, **kwargs, &work) }) do + planned = Encode::Plan.new(@registry).plan_root(tree, [:list, param]) + assert_equal 30, planned.children.length + end + assert_equal 1, normalizations.values.max + end + + def test_a_known_sibling_taking_the_value_leaves_the_variable_never + param = [:param, 0, "T"] + slot = [:list, union(param, [:null])] + opt = Class.new(Struct.new(:items)) + @registry.define(opt, "user.Opt", { "items" => [:items, slot] }, type_params: ["T"]) + assert_equal [:class, "user.Opt", [[:never]]], type_of(opt.new([nil])) + [ [nil, 1], [1, nil] ].each do |items| + result = encode(opt.new(items)) + assert_equal [:class, "user.Opt", [[:int]]], Encode::Slot.from_wire(result.value_type) + assert_equal [:list, union([:int], [:null])], Encode::Slot.from_wire(result.class_value.fields.first.value.value_type) + end + assert_equal [:list, union([:never], [:null])], type_of([nil], slot) + assert_raises(Encode::MissingTypeArgs) { encode(opt.new([])) } + assert_equal [:list, union([:class, "user.Box", [[:int]]], [:null])], type_of( + [Box.new(value: 1), nil], [:list, union([:class, "user.Box", [param]], [:null])] + ) + end + + def test_a_handle_nested_under_an_open_variable_is_not_typed_by_a_sibling + param = [:param, 0, "T"] + slot = [:list, [:list, param]] + api = Api.new + handle = Baml::Handle.new(api, 41, :ADT_TAGGED_HEAP_HANDLE) + scope = Baml::Bridge::EncodingScope.new + [[[handle], [1]], [[1], [handle]]].each do |value| + error = assert_raises(Encode::UnboundTypeArgs) { encode(value, slot, scope: scope) } + assert_includes error.message, "could not infer a type" + end + assert_empty api.cloned + assert_equal [:list, [:list, [:int]]], type_of([[1], [2]], slot) + assert_equal [:list, [:list, [:int]]], type_of([[], [1]], slot) + [ [[], param], [[], [:list, param]], [{}, [:map, [:string], param]] ].each do |value, open| + assert_raises(Encode::UnboundTypeArgs) { encode(value, open) } + end + assert_equal [:map, [:string], [:int]], type_of({ "x" => 1 }, [:map, param, [:int]]) + boxes = [Baml.bind_types(Box.new(value: 1), Integer), Baml.bind_types(Box.new(value: "x"), String)] + assert_raises(Encode::UnboundTypeArgs) { encode(boxes, [:list, [:class, "user.Box", [param]]]) } + assert_equal 41, encode(handle, param, scope: scope).handle.key + ensure + scope&.close + handle&.close + end + + def test_arguments_are_keyed_by_declared_name_and_type_args_substitute + param = [:param, 0, "T"] + Protocol.define_function("user.bound_args", params: [["a", [:int]], ["p", [:list, param]]], type_params: ["T"]) + types = Protocol.prepare_type_args("user.bound_args", {}, { T: String }) + result = Wire::CallFunctionArgs.decode(Protocol.encode_call(1, "user.bound_args", { "p" => ["s"], "a" => 1 }, type_args: types)) + assert_equal %w[p a], result.kwargs.map(&:string_key) + assert_equal [:list, [:string]], Encode::Slot.from_wire(result.kwargs.first.value.value_type) + error = assert_raises(TypeError) { Protocol.encode_call(1, "user.bound_args", { "p" => [1] }, type_args: types) } + assert_includes error.message, "user.bound_args: p[0]" + [{ Wrong: Integer }, { "T" => Integer }, []].each do |types| + assert_raises(TypeError) { Protocol.prepare_type_args("user.bound_args", {}, types) } + end + + Protocol.define_class("user.Receiver", Box, fields: { "value" => [:value, param], "note" => [:note, [:null]] }, type_params: ["T"]) + Protocol.define_function("user.Receiver.method", params: [["self", [:class, "user.Receiver", [param]]], ["p", [:list, param]], + ["other", [:param, 1, "U"]]], class_type_params: ["T"], type_params: ["U"]) + receiver = Baml.bind_types(Box.new(value: 1), Float) + arguments = { "self" => receiver, "p" => [2], "other" => "s" } + types = Protocol.prepare_type_args("user.Receiver.method", arguments, { U: String }) + assert_equal %w[T U], types.map(&:type_var) + result = Wire::CallFunctionArgs.decode(Protocol.encode_call(1, "user.Receiver.method", arguments, type_args: types)) + assert_equal 1.0, result.kwargs.first.value.class_value.fields.first.value.float_value + assert_equal [:list, [:float]], Encode::Slot.from_wire(result.kwargs[1].value.value_type) + signature = [:function, [["x", param, false]], [:list, param], [:param, 1, "U"]] + assert_equal [:function, [["x", [:float], false]], [:list, [:float]], [:string]], + Encode::Slot.substitute(signature, { "T" => [:float], "U" => [:string] }, indexed: false) + end + + def test_type_tokens_and_instance_arguments_are_immutable_descriptors + type = Baml::Types.generic(Box, Baml::Types.optional(Baml::Types.list(Integer))) + assert_equal [:class, "user.Box", [[:union, [[:list, [:int]], [:null]]]]], type + assert type.frozen? + assert type[2].first[1].first[1].frozen? + assert_raises(TypeError) { Baml::Types.generic(Box) } + assert_raises(TypeError) { Baml::Types.token(Box) } + value = Box.new(value: []) + assert_same value, Baml.bind_types(value, type) + assert_equal [type], Baml::Types.instance_args(value) + assert Baml::Types.instance_args(value).frozen? + end + def test_map_keys_are_strings_and_errors_carry_the_entry [1, true, :symbol, Color::Red].each do |key| error = assert_raises(TypeError) { encode({ key => 1 }) } @@ -579,8 +1024,8 @@ def test_planning_trials_share_one_budget_even_for_recursive_class_unions b = [:class, "user.Left", [[:unknown]]] c = [:class, "user.Right", [[:int]]] d = [:class, "user.Right", [[:unknown]]] - @registry.define(left, "user.Left", { "child" => [:child, union([:null], c, d)] }) - @registry.define(right, "user.Right", { "child" => [:child, union([:null], a, b)] }) + @registry.define(left, "user.Left", { "child" => [:child, union([:null], c, d)] }, type_params: ["T"]) + @registry.define(right, "user.Right", { "child" => [:child, union([:null], a, b)] }, type_params: ["T"]) value = 8.times.reduce(nil) { |child, index| (index.even? ? right : left).new(child) } tree = Encode::Read.new(@registry).read(value, "recursive") error = assert_raises(TypeError) { Encode::Plan.new(@registry, budget: 100).plan(tree, union(a, b)) } @@ -594,7 +1039,7 @@ def test_reading_happens_once_and_host_exceptions_are_not_reclassified type = Class.new do define_method(:value) { reads += 1; 1 } end - @registry.define(type, "user.ReadOnce", { "value" => [:value, [:param, 0, "T"]] }) + @registry.define(type, "user.ReadOnce", { "value" => [:value, [:param, 0, "T"]] }, type_params: ["T"]) slot = union([:class, "user.ReadOnce", [[:int]]], [:class, "user.ReadOnce", [[:string]]]) assert_equal :class_value, encode(type.new, slot).value assert_equal 1, reads @@ -715,7 +1160,7 @@ def union(*members) def encode(value, slot = [:unknown], scope: nil) tree = Encode::Read.new(@registry).read(value, "argument") - plan = Encode::Plan.new(@registry).plan(tree, slot) + plan = Encode::Plan.new(@registry).plan_root(tree, slot) Encode::Commit.check_depth!(Encode::Commit.depth(plan), "argument") result = Encode::Commit.commit(plan, scope: scope) assert_equal Encode::Commit.depth(plan), Encode::Slot.wire_depth(result) diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_forward_refs.rb b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_forward_refs.rb index ba89626914c..6866fabf468 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_forward_refs.rb +++ b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_forward_refs.rb @@ -16,6 +16,13 @@ def test_forward_refs_round_trip_rec_list assert_equal [1, [2, 3]], BamlSdk::ForwardRefs.round_trip_rec_list([1, [2, 3]]) end + def test_forward_refs_round_trip_g_node_int + node = BamlSdk::ForwardRefs::GNode + leaf = Baml.bind_types(node[Integer].new(children: []), Integer) + tree = Baml.bind_types(node[Integer].new(children: [leaf]), Integer) + assert_equal tree, BamlSdk::ForwardRefs.round_trip_g_node_int(tree) + end + def test_forward_refs_round_trip_rec_list_with_other assert_equal 1, BamlSdk::ForwardRefs.round_trip_rec_list_with_other(1) assert_equal [1, 2], BamlSdk::ForwardRefs.round_trip_rec_list_with_other([1, 2]) diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_generics.rb b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_generics.rb new file mode 100644 index 00000000000..dcd116b530f --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_generics.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "baml_sdk" +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class GenericsTest < Minitest::Test + G = BamlSdk::Generics + + def test_generic_generic + wrapper = G.make_wrapper_methods("hello") + assert_equal "hello", wrapper.get_value_or_marker + end + + def test_generic_generic_wrapper_get_value + wrapper = G.make_wrapper_methods("hello") + assert_equal "hello", wrapper.get_value + end + + # Bind expected values so equality checks their concrete BAML arguments too. + def test_generics_round_trip_wrapper_int + wrapper = Baml.bind_types(G::Wrapper[Integer].new(value: 5), Integer) + assert_equal wrapper, G.round_trip_wrapper_int(wrapper) + end + + def test_generics_round_trip_generic_linked_list_int + tail = Baml.bind_types(G::GenericLinkedList[Integer].new(value: 2, next_: nil), Integer) + linked = Baml.bind_types(G::GenericLinkedList[Integer].new(value: 1, next_: tail), Integer) + assert_equal linked, G.round_trip_generic_linked_list_int(linked) + end + + def test_generics_round_trip_generic_binary_tree_int + tree = Baml.bind_types(G::GenericBinaryTree[Integer].new(value: 1, left: nil, right: nil), Integer) + assert_equal tree, G.round_trip_generic_binary_tree_int(tree) + end + + def test_generics_round_trip_box_int + wrapped = Baml.bind_types(G::Wrapper[Integer].new(value: 4), Integer) + box = Baml.bind_types(G::Box[Integer].new(value: 3, wrapped: wrapped), Integer) + assert_equal box, G.round_trip_box_int(box) + end + + def test_generics_round_trip_nested_generics + wrapped = Baml.bind_types(G::Wrapper[Integer].new(value: 1), Integer) + linked = Baml.bind_types(G::GenericLinkedList[Integer].new(value: 9, next_: nil), Integer) + nested = G::NestedGenerics.new( + ww: Baml.bind_types(G::Wrapper[G::Wrapper[Integer]].new(value: wrapped), Baml::Types.generic(G::Wrapper, Integer)), + wl: Baml.bind_types(G::Wrapper[T::Array[Integer]].new(value: [1, 2]), Baml::Types.list(Integer)), + wr: Baml.bind_types(G::Wrapper[G::GenericLinkedList[Integer]].new(value: linked), Baml::Types.generic(G::GenericLinkedList, Integer)) + ) + assert_equal nested, G.round_trip_nested_generics(nested) + end + + def test_generics_round_trip_differing_instantiation + wrapped = Baml.bind_types(G::Wrapper[Integer].new(value: 1), Integer) + linked = Baml.bind_types(G::GenericLinkedList[G::Wrapper[Integer]].new(value: wrapped, next_: nil), Baml::Types.generic(G::Wrapper, Integer)) + different = G::DifferingInstantiation.new(list: linked) + assert_equal different, G.round_trip_differing_instantiation(different) + end +end diff --git a/baml_language/sdk_tests/fixtures/function_calls/baml_src/ns_methods_on_classes/ruby_generics.baml b/baml_language/sdk_tests/fixtures/function_calls/baml_src/ns_methods_on_classes/ruby_generics.baml new file mode 100644 index 00000000000..515730b825e --- /dev/null +++ b/baml_language/sdk_tests/fixtures/function_calls/baml_src/ns_methods_on_classes/ruby_generics.baml @@ -0,0 +1,24 @@ +class BindingField { + __baml_type_args string +} + +class GenericBindingField { + __baml_type_args string + value T +} + +function binding_field(value: BindingField) -> BindingField { value } +function generic_binding_field(value: GenericBindingField) -> GenericBindingField { value } + +interface RubyNamed { + function label(self, other: X) -> string throws never; +} + +implement RubyNamed for int { + function label(self, other: int) -> string throws never { "int" } +} + +class RubyTagged> { + value T + function zero() -> int { 0 } +} diff --git a/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md b/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md index cd4e17182da..6f888803ef3 100644 --- a/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md +++ b/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md @@ -4,11 +4,11 @@ repository: baml4 --- # State of BAML↔Ruby completeness -The Ruby/Sorbet SDK generates typed functions, classes and enums for user code and supported standard-library symbols. It runs sync and async calls through the V1 C bridge, with call controls and media. The tables below separate tested support from remaining gaps. +The Ruby/Sorbet SDK generates typed functions, classes and enums for user code and supported standard-library symbols. It runs sync and async calls through the V1 C bridge, with call controls, generics and media. The tables below separate tested support from remaining gaps. ✅ means a named Ruby test proves the stated support, 🚧 means only part of the row is tested or supported, and ❌ means unsupported or not yet parity-verified. The Python column is copied from `ref-python-state-of-completeness.md` at commit `b74b83a3ab`, including its qualifications; it is not a claim about today's Python SDK. Links to `customizable/test_*.rb` identify real-engine tests; streaming tests use the keyless replay server. Fake-bridge tests are identified separately. -Validation: `cargo nextest run -p sdk_test_ruby_sorbet` passes all 13 enabled harness tests, including minitest and Sorbet checks for four shared fixtures, the fixture manifest and runtime checks. The `unsupported_only` fixture's minitest and Sorbet gates are ignored because it has no test files. +Validation: `cargo nextest run -p sdk_test_ruby_sorbet` passes all 15 harness tests, including minitest and Sorbet checks for all five shared fixtures, the fixture manifest and runtime checks. Individual Ruby tests still skip callable-subscript arity; a passing harness does not make those capabilities supported. ## Function-call forms @@ -24,13 +24,15 @@ Validation: `cargo nextest run -p sdk_test_ruby_sorbet` passes all 13 enabled ha | Optional args (supplied) | ✅ | ✅ | Explicit values and `nil` differ from omission: [`test_optional_args_python_unset_and_none_differ_in_one_call`][optional-args], [`test_optional_args_opt_box_method_matrix`][optional-args]. Nullable parameters without defaults remain required: [`test_optional_args_negative_runtime_cases_reject`][optional-args]. | | Streaming | ✅ | ❌ | No host stream wrapper or generated stream companions. BAML-internal collection remains supported: [`test_streaming_e2e_stream_collect_in_baml`][streaming], [`test_streaming_e2e_stream_doc_collect_in_baml`][streaming]. | | `$build_request` companion | ✅ | ❌ | No function-spec wrapper or generated spec companions. | -| Generic function / method (inferred) | ✅ | ❌ | Generic declarations are not generated. | -| Generic function / method (`_types=` kwarg) | ✅ | ❌ | No explicit type-binding API; generic declarations are not generated. | -| Generic function / method (subscript) | ✅ | ❌ | No callable-subscript API. | +| Generic function / method (inferred) | ✅ | ✅ | Engine inference from values and receivers: [`test_generic_inference_identity_infers_primitives`][generic-inference], [`test_generic_inference_genericbox_pair_with_unbound_receiver_recovers_class_var`][generic-inference]. | +| Generic function / method (`_types=` kwarg) | ✅ | ✅ | Named tokens, e.g. `_types: { T: Integer }`: [`test_generic_calls_identity_explicit`][generic-calls], [`test_generic_calls_identity_async_explicit`][generic-calls], [`test_generic_calls_genericbox_pair_with_explicit`][generic-calls]. | +| Generic function / method (subscript) | ✅ | ❌ | No callable-subscript API; use `_types:`. [`test_generic_inference_make_triple_partial_subscript_requires_full_arity`][generic-inference] explicitly skips this surface. | | Host callback param | ✅ | ❌ | Declarations requiring function types are not generated. | `_baml: Baml::Options.new(timeout_ms:, cancel:)` supplies per-call controls to free functions and methods: [`test_four_call_forms`][invocation], [`test_methods_accept_controls`][invocation]. Trace options are unsupported: only `trace: nil` is accepted, and [`test_invalid_timeout_rejected`][invocation] checks rejection of a non-nil trace. Watcher declarations are not generated. +Sorbet erases `GenericBox[Integer]` at runtime. Use `Baml.bind_types(instance, Integer)` when a host-built instance must carry concrete type arguments; decoded instances retain them automatically. Explicit method bindings require a bound receiver: [`test_generic_calls_genericbox_pair_with_explicit`][generic-calls], [`test_generic_calls_instance_method_unparameterized_receiver_raises`][generic-calls]. `T::Struct#with` makes a new instance and drops the binding; bind the copy again when needed. + ## Runtime behaviors These rows describe what happens when a call returns, throws, or terminates. @@ -52,13 +54,13 @@ Gem packaging, clean-app installation and native-library cache lookup are unsupp ## Static typing -The [shared Ruby harness][static-gate] runs `srb tc` on each of the four enabled generated packages and its tests with Sorbet **0.6.13506**, pinned in a separate [test bundle][static-gemfile]. Every generated Ruby file, including bytecode and model declarations, uses `# typed: strict`. No SDK functions or models are recreated in test RBIs. +The [shared Ruby harness][static-gate] runs `srb tc` on each of the five generated packages and its tests with Sorbet **0.6.13506**, pinned in a separate [test bundle][static-gemfile]. Every generated Ruby file, including bytecode and model declarations, uses `# typed: strict`. No SDK functions or models are recreated in test RBIs. -Strict consumers in the fixture overlays cover sync and async returns, stdlib methods, optional arguments, call controls, bigint, bytes, media, unions, aliases, classes and enums. One negative consumer requires exactly one wrong-argument diagnostic (`7002`), so a regression to `T.untyped` fails the gate. +Strict consumers in the fixture overlays cover sync and async returns, stdlib methods, optional arguments, call controls, generics, bigint, bytes, media, unions, aliases, classes and enums. One negative consumer requires exactly one wrong-argument diagnostic (`7002`), so a regression to `T.untyped` fails the gate. -The [boundary RBI][static-bridge] describes the runtime-facing API, including call helpers; it does not recreate generated SDK declarations. The bridge's decoded return remains dynamic, so this gate proves generated declarations and consumer checking, not the runtime's adherence to BAML return types. Real-engine and replay tests cover runtime behavior separately, including recursion, forward references and symbol collisions. +The [boundary RBI][static-bridge] describes the runtime-facing API, including call and generic helpers; it does not recreate generated SDK declarations. The bridge's decoded return remains dynamic, so this gate proves generated declarations and consumer checking, not the runtime's adherence to BAML return types. Real-engine and replay tests cover runtime behavior separately, including recursion, forward references and symbol collisions. -Typing still has limits: literal values use their Ruby primitive types, recursive alias back-edges and JSON use `T.untyped`. Declarations requiring function types are not generated. Method signatures retain Sorbet's lazy validation. +Typing still has limits: literal values use their Ruby primitive types, recursive alias back-edges and JSON use `T.untyped`, and `_types:` token values are dynamic. Declarations requiring function types are not generated. Method signatures retain Sorbet's lazy validation. The pinned checker runs on x86_64/arm64 macOS and x86_64/aarch64 glibc Linux. Windows, 32-bit Linux and musl hosts explicitly ignore the static test because no compatible binary is supplied; their runtime tests still run. Setup installs the checker under `target/ruby-sorbet-static-bundle` only on supported hosts, and installation failures there fail setup. @@ -79,9 +81,9 @@ Encode means Ruby → BAML; decode means BAML → Ruby. The generated-package te | Map | ✅ | ✅ | `Hash`; encoding refuses non-string keys with "map keys must be string": [`test_maps_round_trip_simple_map`][maps], [`test_maps_round_trip_list_valued_map`][maps]. | | Enum | ✅ | ✅ | Generated `T::Enum`: [`test_enums_round_trip_sentiment`][enums], [`test_enums_round_trip_sentiment_positive`][enums]. Dynamic enums are unsupported. | | Class | ✅ | ✅ | Generated `T::Struct`, registered by BAML type name: [`test_class_refs_round_trip_inner`][class-refs], [`test_streaming_e2e_stream_doc_collect_in_baml`][streaming]. | -| Generic explicitly reified by BAML-known type | ✅ | ❌ | Generic classes and declarations requiring them are not generated. | -| Generic implicitly reified by BAML-known type | ✅ | ❌ | Generic classes and declarations requiring them are not generated. | -| Generic reified by host-only type | ❌ (need more rules for serializing Python-only types) | ❌ | No generic value support. | +| Generic explicitly reified by BAML-known type | ✅ | ✅ | `Baml.bind_types` attaches runtime type arguments to generated generic structs: [`test_generic_calls_identity_explicit`][generic-calls]. Decoded type arguments are retained, including nested ones: [`test_generic_calls_make_int_box_reified`][generic-calls], [`test_generic_calls_make_nested_box_reified`][generic-calls]. | +| Generic implicitly reified by BAML-known type | ✅ | ✅ | The Ruby walker solves unbound instance arguments from their fields before encoding: [`test_generic_inference_identity_unbound_generic_instance_round_trips`][generic-inference], [`test_generic_inference_extract_fully_unbound_nested_pair_recovers_all_vars`][generic-inference]. | +| Generic reified by host-only type | ❌ (need more rules for serializing Python-only types) | ❌ | Unregistered Ruby objects cannot be encoded: [`test_generic_inference_host_only_object_not_encodable_from_python`][generic-inference]. | | Union | ✅ (union metadata dropped) | ✅ | Decodes to the Ruby variant value; metadata is dropped. Ambiguous inputs, such as `[]` for `int[] \| string[]`, raise `TypeError` instead of choosing an arm. Generated signatures use `T.any` / `T.nilable`: [`test_unions_round_trip_null_to_end`][unions], [`test_unions_round_trip_optional_plus_null`][unions], [`test_unions_round_trip_union_container`][unions]. | | BAML interface | ❌ | ❌ | No interface value support; affected declarations are not generated. | | Media | ✅ | ✅ | Frozen value objects `Baml::Image`, `Audio`, `Video` and `Pdf`, not engine handles. URL round trips: [`test_media_round_trip_media`][media]. Base64, file contents, names, MIME types and local-file loading: [`test_handles_image_from_base64_roundtrips_payload`][handles]. | @@ -90,7 +92,7 @@ Encode means Ruby → BAML; decode means BAML → Ruby. The generated-package te | Host callable (async) | ✅ (works, but returned coroutine is driven to completion on new asyncio event loop) | ❌ | No host-callback dispatch. | | BAML closure (function reference) | ❌ | ❌ | No callable closure wrapper; declarations requiring function types are not generated. | | BAML closure (closure / bound method / generic function / function) | ❌ | ❌ | No callable closure wrapper; declarations requiring function types are not generated. | -| BAML type reference values | ❌ | ❌ | No first-class BAML type-value encoding or decoding. | +| BAML type reference values | ❌ | ❌ | No first-class BAML type-value encoding or decoding. `Baml::Types` tokens are call metadata, not ordinary argument values. | | BAML type definition values | ❌ | ❌ | No type-definition values. | | BAML `$rust_type` values: files, sockets, etc. | ✅ | 🚧 | Generated resource structs retain `Baml::Handle` fields. Files preserve cursor state: [`test_handles_open_file_returns_file_handle`][handles], [`test_handles_file_cursor_state_persists_across_calls`][handles]. HTTP response fields and methods: [`test_handles_http_get_response_fields_and_methods`][handles]. Socket values are not parity-verified. | | Native exception thrown by a host callback | ✅ | ❌ | No opaque host-value registry or exception round trip. | @@ -98,7 +100,7 @@ Encode means Ruby → BAML; decode means BAML → Ruby. The generated-package te | Unused SDK collector values (`Collector`) | 🚧 | ❌ | No collector wrapper or verified collector-value path. | | Unused SDK prompt values (`PromptAst`) | 🚧 | ❌ | No prompt-value support. | | BAML builtin type (`Future` / `UnscheduledFuture`) | ❌ | ❌ | No first-class engine future values. `Baml::Call` is the host call promise, not a BAML future value. | -| Arbitrary unsupported host object | ❌ | ❌ | Encoding raises `TypeError`. | +| Arbitrary unsupported host object | ❌ | ❌ | Encoding raises `TypeError`: [`test_generic_inference_host_only_object_not_encodable_from_python`][generic-inference]. | | Cyclic / self-referential objects | ❌ | ❌ | No cyclic-object encoding or identity preservation. Recursive type declarations and acyclic recursive values do not provide cycle handling. | [function-main]: ../../../sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_main.rb @@ -114,6 +116,8 @@ Encode means Ruby → BAML; decode means BAML → Ruby. The generated-package te [optional-args]: ../../../sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_optional_args.rb [cancellation]: ../../../sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_cancellation.rb [invocation]: ../../../sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_invocation.rb +[generic-calls]: ../../../sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_generic_calls.rb +[generic-inference]: ../../../sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_generic_inference.rb [primitives]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_primitives.rb [lists]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_lists.rb [maps]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_maps.rb diff --git a/baml_language/sdks/ruby/bridge_ruby/README.md b/baml_language/sdks/ruby/bridge_ruby/README.md index 76bc180d7ca..8fc2415bf29 100644 --- a/baml_language/sdks/ruby/bridge_ruby/README.md +++ b/baml_language/sdks/ruby/bridge_ruby/README.md @@ -26,7 +26,31 @@ Generated functions initialize the runtime on their first call. `BamlSdk.initial `rbi/baml.rbi` is the runtime contract used to check generated code. Check it alongside your generated `baml_sdk.rb` and `baml_sdk/` sources. -Generated structs compare by their fields. +Generated structs compare by their fields and bound generic type arguments. + +## Generic values and calls + +Generated generic classes use Sorbet's normal `type_member` and `[]`. The engine +infers generic function arguments from values where possible. To bind them +explicitly, pass named tokens, for example `identity(5, _types: { T: Integer })`. +Tokens include `Integer`, `String`, `Float`, `T::Boolean`, `NilClass`, generated +classes and enums, and `Baml::Types.list(t)`, `.map(k, v)`, `.optional(t)` and +`.generic(Klass, *args)` for compound types. Sorbet's erased `Klass[T]` is not a +runtime token for a parameterized class; use `Baml::Types.generic(Klass, T)`. + +For a host-built instance that must retain concrete runtime arguments, use: + +```ruby +box = Baml.bind_types(BamlSdk::GenericTests::GenericBox[Integer].new(value: 5), Integer) +box.pair_with("hello", _types: { U: String }) +``` + +`bind_types` returns the same instance and preserves its Sorbet type. Decoded +instances retain their runtime arguments automatically. `Baml::Types.instance_args` +returns these bindings in canonical form, with optionals and unions flattened. +`T::Struct#with` creates a new instance and drops this binding, so bind the copy again when needed. +Explicit method bindings require a bound receiver; bare calls can infer from +an unbound receiver's fields. ## Native library lookup diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge.rb index a948a346d35..b2f7381205d 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge.rb @@ -6,6 +6,7 @@ require_relative "bridge/native" require_relative "bridge/call" require_relative "bridge/handle" +require_relative "bridge/types" require_relative "value_equality" require_relative "bridge/protocol" require_relative "bridge/process_runtime" @@ -19,8 +20,8 @@ def register_program(bytecode, metadata = nil) @process_runtime.register_program(bytecode, metadata) end - def define_class(wire, klass, fields:, crossing: :fields) - Protocol.define_class(wire, klass, fields: fields, crossing: crossing) + def define_class(wire, klass, fields:, crossing: :fields, type_params: []) + Protocol.define_class(wire, klass, fields: fields, crossing: crossing, type_params: type_params) end def define_enum(wire, klass) @@ -31,12 +32,12 @@ def define_alias(wire, descriptor, recursive: false) Protocol.define_alias(wire, descriptor, recursive: recursive) end - def define_function(wire, params:) - Protocol.define_function(wire, params: params) + def define_function(wire, params:, class_type_params: [], type_params: []) + Protocol.define_function(wire, params: params, class_type_params: class_type_params, type_params: type_params) end - def call_async(wire, args, baml: nil) - @process_runtime.call_async(wire, args, baml: baml) + def call_async(wire, args, types: nil, baml: nil) + @process_runtime.call_async(wire, args, types: types, baml: baml) end def initialize! diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/plan.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/plan.rb index 7308671ffb2..b3492711e29 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/plan.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/plan.rb @@ -3,7 +3,96 @@ module Baml module Bridge module Encode - Node = Struct.new(:kind, :ty, :value, :children, :path, keyword_init: true) + Node = Struct.new(:kind, :ty, :value, :children, :path, :evidence, keyword_init: true) + + # Fields learn one binding per parameter. A known union member absorbs + # a variable as never; empty containers defer to later concrete evidence. + class Solution + def initialize(work: nil, substitutions: nil) + @work = work + @substitutions = substitutions + @bindings = {} + @conflicts = {} + end + + def learn(template, found, type_argument: false) + if found[0] == :absorbed && template[0] != :union + absorb(template) + return + end + + case template[0] + when :param + index = template[1] + bound = @bindings[index] + return if bound == :absorbed && found == [:hole] + + if !bound || bound == :absorbed || (Slot.open?(bound, &@work) && !Slot.open?(found, &@work)) + @bindings[index] = found + elsif !Slot.equivalent?(bound, found, &@work) && !Slot.open?(found, &@work) + @conflicts[index] = true + end + when :list + learn(template[1], found[1], type_argument: type_argument) if found[0] == :list + when :map + if found[0] == :map + learn(template[1], found[1], type_argument: type_argument) + learn(template[2], found[2], type_argument: type_argument) + end + when :class + if found[0] == :class && template[1] == found[1] && template[2].length == found[2].length + template[2].zip(found[2]).each { |arg, actual| learn(arg, actual, type_argument: true) } + end + when :union + open, fixed = template[1].partition { |member| Slot.mentions_param?(member, &@work) } + return unless open.length == 1 + + if type_argument && found[0] != :absorbed + # A nested class solves its own parameters, without choosing the + # parent's argument union. Only type evidence crosses that boundary. + known = fixed.to_h { |member| [Slot.canonical(member, &@work), true] } + rest = (found[0] == :union ? found[1] : [found]).reject do |member| + known.key?(Slot.canonical(member, &@work)) + end + found = rest.empty? ? [:absorbed] : Slot.union(rest, &@work) + end + if found[0] == :absorbed + absorb(open.first) + else + learn(open.first, found, type_argument: type_argument) + end + when :function + if found[0] == :function && template[1].length == found[1].length + template[1].zip(found[1]).each { |param, actual| learn(param[1], actual[1], type_argument: type_argument) } + learn(template[2], found[2], type_argument: type_argument) + learn(template[3], found[3], type_argument: type_argument) + end + end + end + + def binding(index, inference: false) + bound = @bindings[index] + bound == :absorbed ? [inference ? :absorbed : :never] : bound + end + + def conflict?(index) + @conflicts[index] + end + + def apply(template) + bindings = @bindings.to_h { |index, _| [index, binding(index)] } + Slot.substitute(template, bindings, memo: @substitutions, &@work) + end + + private + + def absorb(template) + Slot.params(template, &@work).each do |param| + bound = @bindings[param[1]] + @bindings[param[1]] = :absorbed if !bound || bound == [:hole] + end + end + end class Plan # Ruby has no memo, and a step costs about 5 µs: use less fuel than Rust. @@ -14,13 +103,30 @@ def initialize(registry, budget: STEP_BUDGET) @remaining = budget @work = ->(steps = 1) { spend(steps, @path || "value") } @members = {}.compare_by_identity + @substitutions = {}.compare_by_identity @normalized = {}.compare_by_identity end + def plan_root(tree, slot) + planned = plan(tree, slot) + if planned.kind != :handle && Slot.open?(planned.ty, &@work) && Slot.mentions_param?(slot, &@work) + name = Slot.params(slot, &@work).first[2] + raise UnboundTypeArgs, "#{tree.path}: could not infer a type for `#{name}`; pass the call's type arguments" + end + restate(planned, fill_type(planned.ty)) + end + def plan(tree, slot) + @path = tree.path + plan_normalized(tree, Slot.stated_form(slot, @registry, memo: @normalized, &@work)) + end + + private + + def plan_normalized(tree, slot) @path = tree.path spend(1, tree.path) - slot = Slot.unfold(Slot.stated_form(slot, @registry, memo: @normalized, &@work), @registry, &@work) + slot = Slot.unfold(slot, @registry, &@work) return choose(tree, slot[1]) if slot[0] == :union return node(tree, [:opaque]) if tree.kind == :handle if %i[unknown interface].include?(slot[0]) @@ -29,6 +135,8 @@ def plan(tree, slot) end case slot[0] + when :param, :hole, :opaque + return natural(tree) when :null return node(tree, [:null]) if tree.kind == :null when :int, :bigint, :float, :bool, :string, :bytes @@ -52,14 +160,18 @@ def plan(tree, slot) end when :list if tree.kind == :list - return node(tree, slot, children: tree.children.map { |child| plan(child, slot[1]) }) + inner, found = solve_elements(tree.children, slot[1], tree.path) + return node(tree, [:list, inner], children: tree.children.map { |child| plan_normalized(child, inner) }, + evidence: found && [:list, found]) end when :map if tree.kind == :map + key_slot = Slot.mentions_param?(slot[1], &@work) ? [:string] : slot[1] + inner, found = solve_elements(tree.children.map(&:last), slot[2], tree.path) entries = tree.children.map do |key, child| - [map_key(key, slot[1]), plan(child, slot[2])] + [map_key(key, key_slot), plan_normalized(child, inner)] end - return node(tree, slot, children: entries) + return node(tree, [:map, key_slot, inner], children: entries, evidence: found && [:map, key_slot, found]) end when :class return instance(tree, slot) if tree.kind == :class && tree.name == slot[1] @@ -68,8 +180,6 @@ def plan(tree, slot) refuse(tree, "cannot encode #{tree.kind} as #{slot.inspect}") end - private - def spend(steps, path) @remaining -= steps raise Fault, "#{path}: encoding exceeds the planning-step budget" if @remaining.negative? @@ -83,17 +193,71 @@ def refuse(tree, message) raise Refusal, "#{tree.path}: #{message}" end - def node(tree, ty, kind: tree.kind, value: tree.value, children: nil) - Node.new(kind: kind, ty: ty, value: value, children: children, path: tree.path) + def node(tree, ty, kind: tree.kind, value: tree.value, children: nil, evidence: nil) + Node.new(kind: kind, ty: ty, value: value, children: children, path: tree.path, evidence: evidence) + end + + # Evidence follows the original arm choices, not the wire types rebuilt + # after solving a container. :absorbed is inference-only, never a wire type. + def evidence(node) + node.evidence || node.ty + end + + def solve_elements(children, template, path) + return [template, nil] unless Slot.mentions_param?(template, &@work) + + planned = children.map { |child| plan_normalized(child, template) } + spend(children.length, path) + found = join(planned, inference: true) + solved = Solution.new(work: @work, substitutions: @substitutions) + solved.learn(template, found) + result = solved.apply(template) + holes = Slot.params(result, &@work).to_h { |param| [param[1], [:hole]] } + [Slot.stated_form(Slot.substitute(result, holes, memo: @substitutions, &@work), @registry, memo: @normalized, &@work), found] end - def instance(tree, slot) - args = slot[2] || [] - fields = @registry.encoding_fields(slot[1]) + def instance(tree, slot = nil) + params = @registry.class_type_params(tree.name) + fields = @registry.encoding_fields(tree.name) + args = slot ? slot[2] : Array.new(params.length) + unless args.length == params.length + raise Fault, "#{tree.path}: wrong number of type arguments for #{tree.name}" + end + fixed = args.map { |arg| arg unless arg && (Slot.mentions_param?(arg, &@work) || Slot.open?(arg, &@work)) } + unless tree.type_args.empty? + own = tree.type_args.map { |arg| Slot.stated_form(arg, @registry, memo: @normalized, &@work) } + unless own.length == params.length && own.zip(fixed).all? { |arg, known| !known || Slot.equivalent?(arg, known, &@work) } + refuse(tree, "instance type arguments do not match #{slot.inspect}") + end + fixed = own + end + unless fixed.all? + solved = Solution.new(work: @work, substitutions: @substitutions) + tree.children.each do |name, child| + template = Slot.stated_form(Slot.substitute(fields.fetch(name)[1], fixed, memo: @substitutions, &@work), @registry, memo: @normalized, &@work) + planned = plan_normalized(child, template) + solved.learn(template, evidence(planned)) + end + fixed = fixed.each_with_index.map do |known, index| + next known if known + if solved.conflict?(index) + raise TypeArgConflict, "#{tree.path}: the fields of this #{tree.name} disagree about its type argument `#{params[index]}`" + end + found = solved.binding(index) + unless found && !Slot.open?(found, &@work) + raise MissingTypeArgs, "#{tree.path}: the fields of this #{tree.name} do not determine its type arguments; use Baml.bind_types(instance, ...)" + end + found + end + if slot && Slot.mentions_param?(slot, &@work) + inferred = fixed.each_index.map { |index| solved.binding(index, inference: true) || fixed[index] } + end + end children = tree.children.map do |name, child| - [name, plan(child, Slot.substitute(fields.fetch(name)[1], args, &@work))] + template = Slot.stated_form(Slot.substitute(fields.fetch(name)[1], fixed, memo: @substitutions, &@work), @registry, memo: @normalized, &@work) + [name, plan_normalized(child, template)] end - node(tree, [:class, slot[1], args], children: children) + node(tree, [:class, tree.name, fixed], children: children, evidence: inferred && [:class, tree.name, inferred]) end def natural(tree) @@ -107,7 +271,7 @@ def natural(tree) refuse(tree, "unknown variant #{tree.value.inspect} of #{tree.name}") end node(tree, [:enum, tree.name]) - when :class then instance(tree, [:class, tree.name, []]) + when :class then instance(tree) when :handle then node(tree, [:opaque]) when :list children = tree.children.map { |child| natural(child) } @@ -130,7 +294,7 @@ def map_key(key, slot) unless %i[string bytes].include?(key.kind) refuse(key, "map keys must be string") end - planned = plan(key, slot) + planned = plan_normalized(key, slot) refuse(key, "map keys must be string") unless planned.kind == :string planned.value end @@ -148,7 +312,7 @@ def rank(tree, slot) slot = Slot.unfold(slot, @registry, &@work) return 0 if tree.kind == :media && media_fits?(tree, slot) return 0 if tree.kind == :enum && %i[enum enum_variant].include?(slot[0]) && tree.name == slot[1] - return 2 if %i[unknown interface].include?(slot[0]) || tree.kind == :handle + return 2 if %i[unknown interface param hole opaque].include?(slot[0]) || tree.kind == :handle return 0 if tree.kind == :null && slot[0] == :null return 0 if %i[class enum].include?(tree.kind) && tree.kind == slot[0] && tree.name == slot[1] return 1 if %i[list map].include?(tree.kind) && tree.kind == slot[0] @@ -163,8 +327,9 @@ def rank(tree, slot) def choose(tree, members) spend(1, tree.path) members = @members[members] ||= Slot.union(Slot.members(members, @registry, &@work), &@work) - return plan(tree, members) unless members[0] == :union + return plan_normalized(tree, members) unless members[0] == :union + inferring = Slot.mentions_param?(members, &@work) ranked = members[1].filter_map do |member| priority = rank(tree, member) [priority, member] if priority @@ -174,8 +339,15 @@ def choose(tree, members) fits = [] group.each do |_, member| begin - candidate = plan(tree, member) - fits << candidate unless fits.any? { |seen| Slot.equivalent?(seen.ty, candidate.ty, &@work) } + candidate = plan_normalized(tree, member) + candidate.evidence = [:absorbed] if inferring && !Slot.mentions_param?(member, &@work) + seen = fits.find { |fit| Slot.equivalent?(fit.ty, candidate.ty, &@work) } + if !seen + fits << candidate + elsif candidate.evidence == [:absorbed] + # Equivalent fixed arms win inference ties; keep the first plan's wire spelling and order. + seen.evidence = candidate.evidence + end rescue Fault raise rescue Refusal => error @@ -202,17 +374,31 @@ def choose(tree, members) refuse(tree, "cannot encode #{tree.kind}: no union member fits") end - # Only holes borrow a sibling's type. Closed containers keep their - # types: [[1], ["a"]] is (int[] | string[])[], not (int | string)[][]. - def join(nodes) + # Only holes borrow a sibling's wire type. Inference also skips absorbed + # evidence, including inside containers and class arguments. Closed + # containers keep their types: [[1], ["a"]] is (int[] | string[])[], + # not (int | string)[][]. + def join(nodes, inference: false) + if inference + nodes = nodes.map { |child| child.dup.tap { |copy| copy.ty = evidence(child) } } + contributing = nodes.reject { |child| child.ty[0] == :absorbed } + return [:absorbed] if !nodes.empty? && contributing.empty? + + # Absorbed evidence can borrow a concrete type, but holes must borrow + # absorbed evidence, never erase it. Visit the latter before holes. + priorities = {}.compare_by_identity + groups = contributing.group_by { |child| priorities[child.ty] ||= evidence_priority(child.ty) } + nodes = groups.values_at(0, 1, 2).compact.flatten(1) + end + open = ->(ty) { inference ? priorities.fetch(ty).positive? : Slot.open?(ty, &@work) } distinct = {} add = ->(ty) { distinct[Slot.canonical(ty, &@work)] ||= ty } - nodes.each { |child| add.call(child.ty) unless open_type?(child.ty) } + nodes.each { |child| add.call(child.ty) unless open.call(child.ty) } nodes.map! do |child| - if open_type?(child.ty) - target = distinct.each_value.find { |ty| fills?(child.ty, ty) } + if open.call(child.ty) + target = distinct.each_value.find { |ty| fills?(child.ty, ty, inference: inference) } if target - child = restate(child, target) + inference ? child.ty = target : child = restate(child, target) else add.call(child.ty) end @@ -222,23 +408,28 @@ def join(nodes) distinct.empty? ? [:hole] : Slot.union(distinct.values, &@work) end - def open_type?(ty) - case ty[0] - when :hole, :opaque then true - when :list then open_type?(ty[1]) - when :map then open_type?(ty[1]) || open_type?(ty[2]) - when :union then ty[1].any? { |item| open_type?(item) } - else false - end + def evidence_priority(ty) + return 2 if %i[hole opaque].include?(ty[0]) + return 1 if ty[0] == :absorbed + + @work.call(1) + Slot.children(ty).reduce(0) { |priority, child| [priority, evidence_priority(child)].max } end - def fills?(own, target) + def fills?(own, target, inference: false) return true if own[0] == :hole + return target[0] != :hole if inference && own[0] == :absorbed return false if own[0] == :opaque || own[0] != target[0] case own[0] - when :list then fills?(own[1], target[1]) - when :map then fills?(own[1], target[1]) && fills?(own[2], target[2]) + when :list then fills?(own[1], target[1], inference: inference) + when :map then fills?(own[1], target[1], inference: inference) && fills?(own[2], target[2], inference: inference) + when :class + if inference && own[1] == target[1] && own[2].length == target[2].length + own[2].zip(target[2]).all? { |arg, actual| fills?(arg, actual, inference: true) } + else + Slot.equivalent?(own, target, &@work) + end else Slot.equivalent?(own, target, &@work) end end @@ -256,7 +447,7 @@ def fill_type(ty) end def restate(node, target) - return node unless open_type?(node.ty) + return node unless Slot.open?(node.ty, &@work) ty = if fills?(node.ty, target) target @@ -270,7 +461,7 @@ def restate(node, target) when :map then node.children.map { |key, child| [key, restate(child, ty[2])] } else return node end - Node.new(kind: node.kind, ty: ty, value: node.value, children: children, path: node.path) + Node.new(kind: node.kind, ty: ty, value: node.value, children: children, path: node.path, evidence: node.evidence) end end end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/read.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/read.rb index 23e34006bf4..a8a1aa7bed7 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/read.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/read.rb @@ -3,7 +3,7 @@ module Baml module Bridge module Encode - Tree = Struct.new(:kind, :value, :name, :children, :path, keyword_init: true) + Tree = Struct.new(:kind, :value, :name, :children, :path, :type_args, keyword_init: true) # Only reading runs host code. All arguments share one budget, charged # while pulling children, never trusting a host-reported collection size. @@ -23,6 +23,7 @@ def read(value, path, depth = 1) @remaining -= 1 name, fields = @registry.encoding_type(value.class) children = nil + type_args = [] kind = if value.nil? :null elsif value.is_a?(::Baml::Handle) @@ -38,6 +39,7 @@ def read(value, path, depth = 1) unless @registry.class_crossing(name) == :fields raise Refusal, "#{path}: #{name} is built only by BAML; pass the instance you received" end + type_args = ::Baml::Types.instance_args(value) children = fields.map do |wire, (ruby, _descriptor)| [wire, read(value.public_send(ruby), "#{path}.#{wire}", depth + 1)] end @@ -63,7 +65,7 @@ def read(value, path, depth = 1) end value = value.dup.freeze end - Tree.new(kind: kind, value: value, name: name, children: children, path: path) + Tree.new(kind: kind, value: value, name: name, children: children, path: path, type_args: type_args) end end end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/slot.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/slot.rb index 1ec8e42a8a6..207bc5e1d76 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/slot.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/slot.rb @@ -7,6 +7,9 @@ module Encode class Refusal < TypeError; end class Fault < Refusal; end + class UnboundTypeArgs < Refusal; end + class MissingTypeArgs < Refusal; end + class TypeArgConflict < Refusal; end module Slot module_function @@ -50,7 +53,7 @@ def members(slots, registry, seen = [], &work) if seen.include?(slot[1]) [] elsif definition - members([definition[0]], registry, seen + [slot[1]], &work) + members([stated_form(definition[0], registry, &work)], registry, seen + [slot[1]], &work) else [slot] end @@ -154,6 +157,38 @@ def equivalent?(left, right, &work) canonical(left, &work) == canonical(right, &work) end + def map_children(slot, work: nil, &map) + work&.call(1) + case slot[0] + when :list then [:list, map.call(slot[1])] + when :map then [:map, map.call(slot[1]), map.call(slot[2])] + when :union then union(slot[1].map(&map), &work) + when :class then [:class, slot[1], (slot[2] || []).map(&map)] + when :function + [:function, slot[1].map { |name, ty, optional| [name, map.call(ty), optional] }, map.call(slot[2]), map.call(slot[3])] + else slot + end + end + + def substitute(slot, args, indexed: true, memo: nil, &work) + if memo + values = args.is_a?(Hash) ? args.values : args + values.each { |arg| charge(arg, &work) if arg } + cached = (memo[slot] ||= {}) + key = [indexed, args] + work&.call(1) + return cached[key] if cached.key?(key) + + return cached[key] = substitute(slot, args, indexed: indexed, &work) + end + if slot[0] == :param + work&.call(1) + return args.fetch(slot[indexed ? 1 : 2], nil) || slot + end + + map_children(slot, work: work) { |child| substitute(child, args, indexed: indexed, &work) } + end + def children(slot) case slot[0] when :list then [slot[1]] @@ -165,16 +200,23 @@ def children(slot) end end - def substitute(slot, args, &work) + def params(slot, &work) work&.call(1) - case slot[0] - when :param then args.fetch(slot[1]) { [:other] } - when :list then [:list, substitute(slot[1], args, &work)] - when :map then [:map, substitute(slot[1], args, &work), substitute(slot[2], args, &work)] - when :union then union(slot[1].map { |item| substitute(item, args, &work) }, &work) - when :class then [:class, slot[1], (slot[2] || []).map { |item| substitute(item, args, &work) }] - else slot - end + return [slot] if slot[0] == :param + + children(slot).flat_map { |child| params(child, &work) } + end + + def mentions_param?(slot, &work) + work&.call(1) + slot[0] == :param || children(slot).any? { |child| mentions_param?(child, &work) } + end + + def open?(slot, &work) + work&.call(1) + return true if %i[hole opaque].include?(slot[0]) + + children(slot).any? { |child| open?(child, &work) } end def to_wire(slot) diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/process_runtime.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/process_runtime.rb index f20d6fdcc8b..c6f54fbb61f 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/process_runtime.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/process_runtime.rb @@ -108,8 +108,9 @@ def initialize! end end - def call_async(function_name, arguments, baml: nil) + def call_async(function_name, arguments, types: nil, baml: nil) raise TypeError, "_baml must be Baml::Options or nil" unless baml.nil? || baml.is_a?(::Baml::Options) + type_args = Protocol.prepare_type_args(function_name, arguments, types) call_id = callback_id = nil submitted = false @@ -118,7 +119,7 @@ def call_async(function_name, arguments, baml: nil) # Retain native ownership before a deferred interrupt can reach Ruby. Thread.handle_interrupt(Exception => :never) { call_id = @api.new_function_call } invocation = Protocol.encode_invocation(call_id, baml, @api, scope: scope) - encoded = Protocol.encode_call(call_id, function_name, arguments, scope: scope, invocation: invocation) + encoded = Protocol.encode_call(call_id, function_name, arguments, scope: scope, invocation: invocation, type_args: type_args) Thread.handle_interrupt(Exception => :never) do call = ::Baml::Call.new(@api, call_id) callback_id = register_call(call) diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/protocol.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/protocol.rb index ac4a6db6868..8051e533ca2 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/protocol.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/protocol.rb @@ -61,6 +61,8 @@ def close @names = {} @fields = {} @functions = {} + @function_type_params = {} + @class_type_params = {} @enums = {} @aliases = {} @normalized_aliases = {} @@ -71,7 +73,9 @@ def close # The generator owns Ruby naming, including renamed fields. No constant # lookup or naming conversion happens while decoding untrusted wire data. - def define_class(name, type, fields:, crossing: :fields) + def define_class(name, type, fields:, crossing: :fields, type_params: []) + ::Baml::Types.register(type, name, type_params.length) + @class_type_params[name] = type_params.freeze @types[name] = type @names[type] = name @crossings[name] = crossing @@ -79,6 +83,7 @@ def define_class(name, type, fields:, crossing: :fields) end def define_enum(name, type) + ::Baml::Types.register(type, name) @types[name] = type @names[type] = name @fields[type] = nil @@ -114,8 +119,13 @@ def class_crossing(name) @crossings.fetch(name, :fields) end - def define_function(name, params:) + def class_type_params(name) + @class_type_params.fetch(name) + end + + def define_function(name, params:, class_type_params: [], type_params: []) @functions[name] = params.to_h.freeze + @function_type_params[name] = [class_type_params.freeze, type_params.freeze].freeze end def registered_type(name) @@ -160,14 +170,45 @@ def encode_invocation(call_id, options, api, scope: nil) invocation end - def encode_call(call_id, function_name, arguments, scope: nil, invocation: Wire::InvocationOptions.new) + def prepare_type_args(function_name, arguments, types) + unless types.nil? || types.is_a?(Hash) + raise TypeError, "_types must be a Hash of Symbol names to BAML type tokens" + end + class_params, method_params = @function_type_params.fetch(function_name, [[], []]) + types ||= {} + types.each_key do |name| + unless name.is_a?(Symbol) && method_params.include?(name.to_s) + raise TypeError, "unknown type binding #{name.inspect} for #{function_name}; expected #{method_params.inspect}" + end + end + explicit = method_params.filter_map do |name| + [name, ::Baml::Types.token(types[name.to_sym])] if types.key?(name.to_sym) + end + receiver_args = arguments.key?("self") ? ::Baml::Types.instance_args(arguments["self"]) : [] + if !explicit.empty? && !class_params.empty? && receiver_args.empty? + raise TypeError, "_types on a generic method requires a parameterized receiver; use Baml.bind_types(receiver, ...)" + end + unless receiver_args.empty? + ::Baml::Types.check_arity("#{function_name} receiver", class_params.length, receiver_args.length) + end + inherited = receiver_args.each_with_index.map { |type, index| [class_params.fetch(index), type] } + (inherited + explicit).map do |name, type| + Wire::BamlTyArg.new(type_var: name, type_value: Encode::Slot.to_wire(type)) + end + end + + def encode_call(call_id, function_name, arguments, scope: nil, invocation: Wire::InvocationOptions.new, type_args: []) hints = @functions.fetch(function_name, {}) + bindings = type_args.to_h { |arg| [arg.type_var, Encode::Slot.from_wire(arg.type_value)] } reader = Encode::Read.new(self) planner = Encode::Plan.new(self) trees = arguments.filter_map do |name, value| [name, reader.read(value, name)] unless value.equal?(::Baml::UNSET) end - plans = trees.map { |name, tree| [name, planner.plan(tree, hints.fetch(name, [:unknown]))] } + plans = trees.map do |name, tree| + slot = Encode::Slot.substitute(hints.fetch(name, [:unknown]), bindings, indexed: false) + [name, planner.plan_root(tree, slot)] + end plans.each { |name, plan| Encode::Commit.check_depth!(Encode::Commit.depth(plan), name) } kwargs = plans.map do |name, plan| value = Encode::Commit.commit(plan, scope: scope) @@ -175,7 +216,7 @@ def encode_call(call_id, function_name, arguments, scope: nil, invocation: Wire: Wire::InboundMapEntry.new(string_key: name, value: value) end Wire::CallFunctionArgs.encode(Wire::CallFunctionArgs.new( - call_id: call_id, function_name: function_name, kwargs: kwargs, + call_id: call_id, function_name: function_name, kwargs: kwargs, type_args: type_args, invocation: invocation )) rescue Encode::Refusal => error @@ -316,7 +357,11 @@ def decode_value(value, api = nil, handles: nil, error_payload: false) type = registered_type(object.name) fields = @fields.fetch(type) decoded = object.fields.to_h { |field| [fields.fetch(field.key)[0], decode_value(field.value, api, handles: handles, error_payload: error_payload)] } - type.new(**decoded) + instance = type.new(**decoded) + unless object.type_args.empty? + ::Baml::Types.bind(instance, *object.type_args.map { |arg| Encode::Slot.from_wire(arg) }) + end + instance when :enum_value enum = value.enum_value if enum.is_dynamic diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/types.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/types.rb new file mode 100644 index 00000000000..06802c01557 --- /dev/null +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/types.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "sorbet-runtime" + +module Baml + # Sorbet erases Klass[T] at runtime. Immutable descriptors retain the complete + # BAML type, including nested arguments, until the walker commits to wire. + module Types + INSTANCE_ARGS = :@__baml_type_args + @registered = {} + module_function + + def register(klass, name, arity = nil) + @registered[klass] = [name, arity].freeze + end + + def list(item) + [:list, token(item)].freeze + end + + def map(key, value) + [:map, token(key), token(value)].freeze + end + + def optional(inner) + [:union, [token(inner), [:null].freeze].freeze].freeze + end + + def generic(klass, *args) + name, arity = @registered.fetch(klass) { raise TypeError, "#{klass} is not a registered BAML class" } + raise TypeError, "#{klass} is not a generic BAML class" unless arity && arity.positive? + check_arity(klass, arity, args.length) + [:class, name, args.map { |arg| token(arg) }.freeze].freeze + end + + def token(type) + return immutable(type) if type.is_a?(Array) + + primitive = { Integer => :int, String => :string, Float => :float, T::Boolean => :bool, NilClass => :null }[type] + return [primitive].freeze if primitive + + name, arity = @registered.fetch(type) { raise TypeError, "#{type.inspect} is not a BAML type token" } + if arity + raise TypeError, "Use Baml::Types.generic(#{type}, ...) to supply its #{arity} type argument(s)" if arity.positive? + [:class, name, [].freeze].freeze + else + [:enum, name].freeze + end + end + + def immutable(value) + case value + when Array then value.map { |item| immutable(item) }.freeze + when String then value.dup.freeze + else value + end + end + private_class_method :immutable + + def bind(instance, *types) + _name, arity = @registered.fetch(instance.class) { raise TypeError, "#{instance.class} is not a registered BAML class" } + raise TypeError, "#{instance.class} is not a generic BAML class" unless arity && arity.positive? + check_arity(instance.class, arity, types.length) + args = types.map { |type| immutable(Bridge::Encode::Slot.stated_form(token(type))) }.freeze + instance.instance_variable_set(INSTANCE_ARGS, args) + instance + end + + def instance_args(instance) + instance.instance_variable_get(INSTANCE_ARGS) || [] + end + + def check_arity(klass, expected, actual) + return if expected == actual + raise TypeError, "expected #{expected} type argument(s) for #{klass}, got #{actual}" + end + end + + # Preserve the instance's Sorbet type and identity. T::Struct#with constructs + # a new object and drops this binding; bind the copy again when needed. + def self.bind_types(instance, *types) + Types.bind(instance, *types) + end +end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/value_equality.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/value_equality.rb index 1586688bce3..e430ce4e46c 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/value_equality.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/value_equality.rb @@ -1,20 +1,23 @@ # frozen_string_literal: true module Baml - # Generated structs are values. + # Generated structs are values. Exact runtime type arguments are part of a + # generic value's identity, even though Sorbet erases its [] specialization. module ValueEquality def ==(other) other.instance_of?(self.class) && + Types.instance_args(self) == Types.instance_args(other) && self.class.props.each_key.all? { |field| public_send(field) == other.public_send(field) } end def eql?(other) other.instance_of?(self.class) && + Types.instance_args(self).eql?(Types.instance_args(other)) && self.class.props.each_key.all? { |field| public_send(field).eql?(other.public_send(field)) } end def hash - [self.class, self.class.props.keys.map { |field| public_send(field) }].hash + [self.class, Types.instance_args(self), self.class.props.keys.map { |field| public_send(field) }].hash end end end diff --git a/baml_language/sdks/ruby/bridge_ruby/rbi/baml.rbi b/baml_language/sdks/ruby/bridge_ruby/rbi/baml.rbi index f1ec5e74d18..d9c448793c6 100644 --- a/baml_language/sdks/ruby/bridge_ruby/rbi/baml.rbi +++ b/baml_language/sdks/ruby/bridge_ruby/rbi/baml.rbi @@ -12,6 +12,11 @@ module Baml extend T::Sig + # Attach runtime arguments without changing the instance's Sorbet type. + # T::Struct#with builds a new instance and drops the binding. + sig { type_parameters(:Instance).params(instance: T.type_parameter(:Instance), types: T.untyped).returns(T.type_parameter(:Instance)) } + def self.bind_types(instance, *types); end + module ValueEquality extend T::Sig sig { params(other: T.untyped).returns(T::Boolean) } @@ -22,6 +27,22 @@ module Baml def hash; end end + module Types + extend T::Sig + + sig { params(item: T.untyped).returns(T.untyped) } + def self.list(item); end + + sig { params(key: T.untyped, value: T.untyped).returns(T.untyped) } + def self.map(key, value); end + + sig { params(inner: T.untyped).returns(T.untyped) } + def self.optional(inner); end + + sig { params(klass: T.untyped, args: T.untyped).returns(T.untyped) } + def self.generic(klass, *args); end + end + # Raised by Call#wait(timeout:) when the timeout passes. The call keeps running. class WaitTimeout < StandardError; end @@ -130,6 +151,7 @@ module Baml # [:literal, value] value: String, Integer, Float, true or false # [:literal, :bigint, integer] preserves the bigint literal kind # [:media, kind] kind: :image, :audio, :video, :pdf, :generic + # [:param, index, "T"] compiler type-parameter index and name # [:alias, "pkg.ns.Name"] # [:class, "pkg.ns.Name", [type_args...]] # [:enum, "pkg.ns.Name"] [:enum_variant, "pkg.ns.Name", "Variant"] @@ -157,10 +179,11 @@ module Baml wire: String, klass: T.class_of(T::Struct), fields: T::Hash[String, [Symbol, Descriptor]], - crossing: Symbol + crossing: Symbol, + type_params: T::Array[String] ).void end - def self.define_class(wire, klass, fields:, crossing: :fields); end + def self.define_class(wire, klass, fields:, crossing: :fields, type_params: []); end sig { params(wire: String, klass: T.class_of(T::Enum)).void } def self.define_enum(wire, klass); end @@ -174,20 +197,26 @@ module Baml sig do params( wire: String, - params: T::Array[[String, Descriptor]] + params: T::Array[[String, Descriptor]], + class_type_params: T::Array[String], + type_params: T::Array[String] ).void end - def self.define_function(wire, params:); end + def self.define_function(wire, params:, class_type_params: [], type_params: []); end # Start a call. args: wire parameter name => Ruby value; omitted keys are not sent. - # baml: per-call options. UNSET arguments are omitted. + # types: explicit type bindings (Change D). baml: per-call options (Change C). + # UNSET arguments are omitted. Binding names are method/function parameters; + # class bindings come from the receiver's hidden runtime arguments and + # precede method bindings. sig do params( wire: String, args: T::Hash[String, T.untyped], + types: T.nilable(T::Hash[Symbol, T.untyped]), baml: T.nilable(::Baml::Options) ).returns(::Baml::Call[T.untyped]) end - def self.call_async(wire, args, baml: nil); end + def self.call_async(wire, args, types: nil, baml: nil); end end end diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/descriptors.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/descriptors.rs index cb1c20b15d2..72fbb019e3d 100644 --- a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/descriptors.rs +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/descriptors.rs @@ -78,9 +78,8 @@ pub(crate) fn descriptor(ty: &Ty) -> String { Ty::Union(items) => { format!(":union, {}", descriptors(items)) } - Ty::Type | Ty::Resource | Ty::PromptAst | Ty::Future(..) | Ty::TypeVar(..) => { - ":other".into() - } + Ty::TypeVar(param) => format!(":param, {}, {}", param.index(), quote(param.as_str())), + Ty::Type | Ty::Resource | Ty::PromptAst | Ty::Future(..) => ":other".into(), }; format!("[{body}].freeze") } diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs index cc365cc6182..7a00516656c 100644 --- a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs @@ -7,7 +7,7 @@ use baml_sdkgen_types::{ use crate::{ descriptors::descriptor, names::{NameTable, callable_parts, identifier, parameter, quote, variant_constant}, - types::{ruby_alias_type, ruby_input_type, ruby_type}, + types::{ruby_alias_type, ruby_input_type, ruby_type, ruby_type_with_params}, }; pub(crate) struct Emitter<'a> { @@ -42,6 +42,20 @@ impl Emitter<'_> { scope.rsplit("::").next().unwrap() ) .unwrap(); + let class_params: Vec<_> = class + .generic_params + .iter() + .enumerate() + .map(|(index, _)| format!("BamlTypeParam{index}")) + .collect(); + if !class_params.is_empty() { + // Forward references may apply [] before the class body is loaded. + writeln!(self.shells, "{scope}.extend(::T::Generic)").unwrap(); + body.push_str(" extend ::T::Generic\n"); + for param in &class_params { + writeln!(body, " {param} = type_member").unwrap(); + } + } let mut fields = Vec::new(); for property in &class.properties { let field = self.names.claim( @@ -52,7 +66,14 @@ impl Emitter<'_> { writeln!( body, " const :{field}, {}", - ruby_input_type(&property.ty, self.supported, &self.names, self.pool).unwrap() + ruby_input_type( + &property.ty, + self.supported, + &self.names, + self.pool, + &class_params + ) + .unwrap() ) .unwrap(); fields.push(format!( @@ -63,9 +84,10 @@ impl Emitter<'_> { } writeln!( self.registry, - "::Baml::Bridge.define_class({}, ::{scope}, fields: {{{}}}, crossing: :{})", + "::Baml::Bridge.define_class({}, ::{scope}, fields: {{{}}}, type_params: [{}], crossing: :{})", quote(&name.to_string()), fields.join(", "), + class.generic_params.iter().map(|name| quote(name)).collect::>().join(", "), match class.crossing { baml_type::ClassCrossing::Fields => "fields".into(), baml_type::ClassCrossing::Opaque => "opaque".into(), @@ -94,6 +116,7 @@ impl Emitter<'_> { &format!("{name}.{}", method.name), method, instance.then_some(name), + &class.generic_params, &mut source, ); for line in source.lines() { @@ -182,10 +205,10 @@ impl Emitter<'_> { wire: &str, function: &Function, receiver: Option<&Name>, + class_params: &[baml_base::Name], body: &mut String, ) { if wire.contains('$') - || !function.generic_params.is_empty() || !function.watchers.is_empty() || ruby_type(&function.return_type, self.supported, &self.names).is_none() || function @@ -197,11 +220,31 @@ impl Emitter<'_> { self.skipped.insert(wire.to_owned()); return; } - let result = ruby_type(&function.return_type, self.supported, &self.names).unwrap(); + let mut signature_params = Vec::new(); + let mut type_params = Vec::new(); + for index in 0..class_params.len() + function.generic_params.len() { + let name = format!("BamlTypeParam{index}"); + if receiver.is_some() && index < class_params.len() { + signature_params.push(name); + } else { + signature_params.push(format!("::T.type_parameter(:{name})")); + type_params.push(format!(":{name}")); + } + } + let render = |ty| ruby_type_with_params(ty, self.supported, &self.names, &signature_params); + let result = render(&function.return_type).unwrap(); let argument_types: Vec<_> = function .arguments .iter() - .map(|a| ruby_input_type(&a.ty, self.supported, &self.names, self.pool)) + .map(|a| { + ruby_input_type( + &a.ty, + self.supported, + &self.names, + self.pool, + &signature_params, + ) + }) .collect(); let (base, suffix) = callable_parts(function.name.as_str()); let base = identifier(base, self.naming); @@ -225,7 +268,19 @@ impl Emitter<'_> { arguments.push("\"self\" => self".into()); descriptors.push(format!( "[\"self\", {}]", - descriptor(&baml_sdkgen_types::Ty::Class(class.clone(), Box::new([]))) + descriptor(&baml_sdkgen_types::Ty::Class( + class.clone(), + class_params + .iter() + .enumerate() + .map(|(index, name)| { + baml_sdkgen_types::Ty::TypeVar(baml_sdkgen_types::ParamTy::new( + u32::try_from(index).expect("type parameter index fits u32"), + name.clone(), + )) + }) + .collect(), + )) )); } for (argument, argument_type) in function @@ -263,14 +318,39 @@ impl Emitter<'_> { params.extend(keyword_params); params.push("_baml: ::T.nilable(::Baml::Options)".into()); keywords.push("_baml: nil".into()); + let generic = !function.generic_params.is_empty() || !class_params.is_empty(); + if generic { + params.push("_types: ::T.nilable(::T::Hash[::Symbol, ::T.untyped])".into()); + keywords.push("_types: nil".into()); + } required.extend(keywords); - writeln!( - self.registry, - "::Baml::Bridge.define_function({}, params: [{}])", - quote(wire), - descriptors.join(", ") - ) - .unwrap(); + let receiver_params = if receiver.is_some() { + class_params + } else { + &[] + }; + let caller_params: Vec<_> = class_params + .iter() + .filter(|_| receiver.is_none()) + .chain(function.generic_params.iter()) + .collect(); + let receiver_params = receiver_params + .iter() + .map(|name| quote(name)) + .collect::>() + .join(", "); + let caller_params = caller_params + .iter() + .map(|name| quote(name)) + .collect::>() + .join(", "); + writeln!(self.registry, "::Baml::Bridge.define_function({}, params: [{}], class_type_params: [{}], type_params: [{}])", quote(wire), descriptors.join(", "), receiver_params, caller_params).unwrap(); + let generic_signature = if type_params.is_empty() { + String::new() + } else { + format!("type_parameters({}).", type_params.join(", ")) + }; + let type_bindings = if generic { ", types: _types" } else { "" }; for asynchronous in [false, true] { let method = format!("{method}{}", if asynchronous { "_async" } else { "" }); let returns = if asynchronous { @@ -296,7 +376,7 @@ impl Emitter<'_> { } } doc_comment(body, &doc); - writeln!(body, "sig {{ params({}).returns({returns}) }}\ndef {target}{method}({})\n ::Baml::Bridge.call_async({}, {{{}}}, baml: _baml){}\nend", params.join(", "), required.join(", "), quote(wire), arguments.join(", "), if asynchronous { "" } else { ".wait" }).unwrap(); + writeln!(body, "sig {{ {generic_signature}params({}).returns({returns}) }}\ndef {target}{method}({})\n ::Baml::Bridge.call_async({}, {{{}}}, baml: _baml{type_bindings}){}\nend", params.join(", "), required.join(", "), quote(wire), arguments.join(", "), if asynchronous { "" } else { ".wait" }).unwrap(); } } } diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/lib.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/lib.rs index 9a1f2eafd62..0e2b455034a 100644 --- a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/lib.rs +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/lib.rs @@ -65,9 +65,8 @@ pub fn to_source_code_with_bytecode_and_skipped( Symbol::Class(class) if class.crossing == baml_sdkgen_types::ClassCrossing::Prompt => { None } - Symbol::Class(class) if class.generic_params.is_empty() => Some((*n).clone()), - Symbol::Enum(_) | Symbol::TypeAlias(_) => Some((*n).clone()), - _ => None, + Symbol::Class(_) | Symbol::Enum(_) | Symbol::TypeAlias(_) => Some((*n).clone()), + Symbol::Function(_) => None, }) .collect(); // Remove declarations referring to omitted types, including transitive edges. @@ -121,7 +120,7 @@ pub fn to_source_code_with_bytecode_and_skipped( emitter.type_alias(name, alias, pool); } Symbol::Function(function) => { - emitter.function(&module, &name.to_string(), function, None, body); + emitter.function(&module, &name.to_string(), function, None, &[], body); } _ => { emitter.skipped.insert(name.to_string()); diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/names.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/names.rs index 6c071faed7d..a0d22bbca79 100644 --- a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/names.rs +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/names.rs @@ -522,7 +522,9 @@ pub(crate) fn identifier(name: &str, naming: NamingConvention) -> String { fn callable_identifier(name: &str, naming: NamingConvention, singleton: bool) -> String { let mut result = project(name, naming); // Keywords retain their suffix (notably then_), even if Object defines them. - if KEYWORDS.contains(&result.as_str()) || ["_baml", "_types"].contains(&result.as_str()) { + if KEYWORDS.contains(&result.as_str()) + || ["_baml", "_types", "__baml_type_args"].contains(&result.as_str()) + { result.push('_'); return result; } diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs index 766c958419b..a62adfc00be 100644 --- a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs @@ -134,7 +134,23 @@ fn ruby_names_and_container_types() { ); assert_eq!(ruby_type(&Ty::Unknown, &BTreeSet::new(), &names), None); let parameter = Ty::TypeVar(baml_sdkgen_types::ParamTy::new(1, "foo_bar".into())); - assert_eq!(ruby_type(¶meter, &BTreeSet::new(), &names), None); + let generic_union = Ty::Union(Box::new([parameter.clone(), Ty::String, Ty::Null])); + assert_eq!( + crate::types::ruby_type_with_params( + &generic_union, + &BTreeSet::new(), + &names, + &[ + "BamlTypeParam0".into(), + "::T.type_parameter(:BamlTypeParam1)".into() + ], + ), + Some("::T.any(::T.type_parameter(:BamlTypeParam1), ::String, ::NilClass)".into()) + ); + assert_eq!( + crate::descriptors::descriptor(¶meter), + "[:param, 1, \"foo_bar\"].freeze" + ); } #[test] @@ -378,33 +394,6 @@ fn unsupported_injected_defaults_are_omitted_without_dropping_user_arguments() { assert!(source.contains("\"callback\" => callback")); } - // Generic declarations are omitted even when their type parameters are unused. - let mut generic = function.clone(); - generic.generic_params.push("T".into()); - assert_eq!(generate(generic).1, ["user.extract"]); - let class_name = Name::new("user".into(), vec![], "Box".into()); - let pool = SymbolPool::from([( - class_name.clone(), - Symbol::Class(baml_sdkgen_types::Class { - crossing: baml_sdkgen_types::ClassCrossing::Fields, - name: class_name.clone(), - generic_params: vec!["T".into()], - docstring: None, - properties: vec![], - static_methods: vec![function.clone()], - instance_methods: vec![], - origin: function.origin.clone(), - }), - )]); - let (files, skipped) = - to_source_code_with_bytecode_and_skipped(&pool, &[], NamingConvention::Language, None); - assert_eq!(skipped, ["user.Box", "user.Box.extract"]); - assert!(!files[&PathBuf::from("baml_sdk.rb")].contains("define_class")); - assert!(!files[&PathBuf::from("baml_sdk/__root.rb")].contains("class Box")); - let mut concrete_generic = function.clone(); - concrete_generic.return_type = Ty::Class(class_name, Box::new([Ty::Int])); - assert_eq!(generate(concrete_generic).1, ["user.extract"]); - // Nullable alone does not make a parameter optional. let mut nullable = function; nullable.arguments.truncate(1); @@ -418,8 +407,6 @@ fn unsupported_injected_defaults_are_omitted_without_dropping_user_arguments() { files[&PathBuf::from("baml_sdk.rb")] .contains("[\"text\", [:union, [[:string].freeze, [:null].freeze].freeze].freeze]") ); - assert!(!source.contains("_types")); - assert!(!files[&PathBuf::from("baml_sdk.rb")].contains("type_params")); for asynchronous in [false, true] { assert!(source.contains(&format!( "def self.extract{}(text, _baml: nil)", @@ -473,7 +460,7 @@ fn classes_register_how_they_cross_the_bridge() { } else { assert!(warnings.is_empty()); assert!(files[&PathBuf::from("baml_sdk.rb")].contains(&format!( - "::Baml::Bridge.define_class(\"user.Boundary\", ::BamlSdk::Boundary, fields: {{}}, crossing: :{expected})" + "::Baml::Bridge.define_class(\"user.Boundary\", ::BamlSdk::Boundary, fields: {{}}, type_params: [], crossing: :{expected})" ))); } } @@ -523,7 +510,7 @@ fn aliases_preserve_descriptors_and_widen_only_recursive_back_edges() { Some("::BamlSdk::Floats") ); assert_eq!( - types::ruby_input_type(&ty, &supported, &names, &pool).as_deref(), + types::ruby_input_type(&ty, &supported, &names, &pool, &[]).as_deref(), Some("::T::Array[::T.any(::Float, ::Integer)]") ); let (files, skipped) = diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/types.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/types.rs index 5f4e264e055..fb93bdacfc4 100644 --- a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/types.rs +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/types.rs @@ -5,7 +5,24 @@ use baml_sdkgen_types::{Name, Symbol, SymbolPool, Ty}; use crate::names::NameTable; pub(crate) fn ruby_type(ty: &Ty, supported: &BTreeSet, names: &NameTable) -> Option { - render(ty, supported, names, false, None, &mut BTreeSet::new()) + ruby_type_with_params(ty, supported, names, &[]) +} + +pub(crate) fn ruby_type_with_params( + ty: &Ty, + supported: &BTreeSet, + names: &NameTable, + params: &[String], +) -> Option { + render( + ty, + supported, + names, + false, + None, + params, + &mut BTreeSet::new(), + ) } pub(crate) fn ruby_input_type( @@ -13,8 +30,17 @@ pub(crate) fn ruby_input_type( supported: &BTreeSet, names: &NameTable, pool: &SymbolPool, + params: &[String], ) -> Option { - render(ty, supported, names, true, Some(pool), &mut BTreeSet::new()) + render( + ty, + supported, + names, + true, + Some(pool), + params, + &mut BTreeSet::new(), + ) } pub(crate) fn ruby_alias_type( @@ -29,6 +55,7 @@ pub(crate) fn ruby_alias_type( names, false, Some(pool), + &[], &mut BTreeSet::new(), ) } @@ -39,9 +66,10 @@ fn render( names: &NameTable, input: bool, aliases: Option<&SymbolPool>, + params: &[String], visiting: &mut BTreeSet, ) -> Option { - let mut child = |ty: &Ty| render(ty, supported, names, input, aliases, visiting); + let mut child = |ty: &Ty| render(ty, supported, names, input, aliases, params, visiting); Some(match ty { // The pool also defines baml.String and baml.Float; do not resolve to those wrappers. Ty::Literal(baml_base::Literal::String(_), ..) => "::String".into(), @@ -70,10 +98,25 @@ fn render( // JSON is a recursive Ruby value, not a generated wrapper. Ty::TypeAlias(name) if name.to_string() == "baml.json.json" => "::T.untyped".into(), Ty::Never => "::T.noreturn".into(), + Ty::TypeVar(param) => params + .get(param.index() as usize) + .cloned() + .unwrap_or_else(|| "::T.untyped".into()), Ty::List(t) => format!("::T::Array[{}]", child(t)?), Ty::Map { key, value } => format!("::T::Hash[{}, {}]", child(key)?, child(value)?), - Ty::Class(n, args) if args.is_empty() && supported.contains(n) => { - format!("::{}", names.qualified(n)) + Ty::Class(n, args) if supported.contains(n) => { + let base = format!("::{}", names.qualified(n)); + if args.is_empty() { + base + } else { + format!( + "{base}[{}]", + args.iter() + .map(&mut child) + .collect::>>()? + .join(", ") + ) + } } Ty::Enum(n) | Ty::EnumVariant(n, _) if supported.contains(n) => { format!("::{}", names.qualified(n)) @@ -94,6 +137,7 @@ fn render( names, input, aliases, + params, visiting, ); visiting.remove(n); From bbdc254eecdc20aa1529da17d21297be43d6a808 Mon Sep 17 00:00:00 2001 From: Ryan Mazzolini Date: Mon, 2 Nov 2026 14:59:50 +0000 Subject: [PATCH 5/7] feat(ruby): run Ruby callables from BAML and call returned closures BAML functions could not call Ruby code, and BAML closures returned to Ruby could not be called. Any object responding to #call is registered as a host value. Each host invocation runs on its own Ruby thread and completes exactly once: a return value is encoded with its declared type, a raised Baml::Error sends its BAML value, and any other exception is kept so it comes back to Ruby as the same object. Cancellation is cooperative; resources stay alive until the proc returns. Result capture pins borrowed host values so the engine cannot release them before decoding. Returned closures decode to Baml::Closure with call and call_async. BAML function types are T.any(T.proc..., Baml::Closure). Sorbet's runtime T.proc check accepts only Proc, so generated methods taking or returning functions skip runtime checking and keep their static signatures. Under ABI 5 a callable is sent with its declared function type, and a callback completes with one of five outcomes: returned, thrown, raised, contract violation or bridge failure. An exception raised while reading the callback's result counts as the callback's own. Tests: ports the shared host-callable tests and the call-control and generic tests that need callbacks, skipping release on engine GC as Java does; 115 nextest targets pass and Ruby declares 297 shared SDK tests. Developed with AI assistance. --- .../sdk_tests/crates/parity_analysis.md | 75 ++--- .../customizable/test_generic_inference.rb | 8 + .../customizable/test_host_callables.rb | 238 +++++++++++++++ .../customizable/test_invocation.rb | 31 ++ .../function_calls/customizable/test_json.rb | 14 + .../customizable/typed_consumer.rb | 3 + .../ruby_sorbet/test/bridge_call_test.rb | 241 ++++++++++++++- .../ruby_sorbet/test/bridge_encode_test.rb | 278 +++++++++++++++++- .../ruby_exception_value.baml | 10 + .../ref-ruby-state-of-completeness.md | 31 +- baml_language/sdks/ruby/bridge_ruby/README.md | 1 + .../sdks/ruby/bridge_ruby/lib/baml/bridge.rb | 1 + .../ruby/bridge_ruby/lib/baml/bridge/call.rb | 2 +- .../lib/baml/bridge/encode/commit.rb | 10 +- .../lib/baml/bridge/encode/plan.rb | 20 +- .../lib/baml/bridge/encode/read.rb | 6 +- .../lib/baml/bridge/encode/slot.rb | 14 + .../bridge_ruby/lib/baml/bridge/handle.rb | 49 ++- .../lib/baml/bridge/host_callbacks.rb | 194 ++++++++++++ .../bridge_ruby/lib/baml/bridge/native.rb | 9 + .../lib/baml/bridge/process_runtime.rb | 11 +- .../bridge_ruby/lib/baml/bridge/protocol.rb | 73 ++++- .../sdks/ruby/bridge_ruby/rbi/baml.rbi | 12 +- .../sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs | 36 ++- .../sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs | 60 ++++ .../sdks/ruby/sdkgen_ruby_sorbet/src/types.rs | 18 +- 26 files changed, 1352 insertions(+), 93 deletions(-) create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_host_callables.rb create mode 100644 baml_language/sdk_tests/fixtures/function_calls/baml_src/ns_host_callable_tests/ruby_exception_value.baml create mode 100644 baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/host_callbacks.rb diff --git a/baml_language/sdk_tests/crates/parity_analysis.md b/baml_language/sdk_tests/crates/parity_analysis.md index 90c1e330a06..8ed6ba50a8f 100644 --- a/baml_language/sdk_tests/crates/parity_analysis.md +++ b/baml_language/sdk_tests/crates/parity_analysis.md @@ -2,9 +2,9 @@ This report inventories checked-in test declarations. It does not report whether tests passed. -Distinct exact test IDs: 987. IDs with complete required parity: 239. Required gaps: 5624. +Distinct exact test IDs: 988. IDs with complete required parity: 240. Required gaps: 5595. -Baseline ratchet: UNCHANGED. Required gaps: 5624 (baseline: 5624). Present declarations: 2879 (baseline: 2879). Newly missing required pairs: 0. Resolved baseline gaps: 0. Weakened requirements: 0. +Baseline ratchet: UNCHANGED. Required gaps: 5595 (baseline: 5595). Present declarations: 2914 (baseline: 2914). Newly missing required pairs: 0. Resolved baseline gaps: 0. Weakened requirements: 0. ## Python-baselined parity @@ -22,7 +22,7 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | go | 21 / 457 | 4.6% | | java | 307 / 457 | 67.2% | | swift | 191 / 457 | 41.8% | -| ruby_sorbet | 263 / 457 | 57.5% | +| ruby_sorbet | 297 / 457 | 65.0% | | Test case | python_pydantic2 | typescript_node | typescript_web_chromium | typescript_web_cloudflare_workers | cpp | csharp | rust | go | java | swift | ruby_sorbet | Required in | Reason | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | @@ -54,9 +54,9 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/async_host_runs_in_calling_task_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | covers Python asyncio task and event-loop identity | | function_calls/async_reentry_preserves_async_local_storage_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | | function_calls/async_stream_schema_failure_is_parse_failed | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | exercises the Python asyncio streaming bridge | -| function_calls/baml_closure_decodes_multiple_args_and_structured_return_values | y | y | y | y | y | - | y | y | y | y | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, rust, go, java, swift | C# covers this canonical behavior in its native integration harness | -| function_calls/baml_closure_is_a_native_callable_with_host_language_arguments | y | y | y | y | y | - | y | y | y | y | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, rust, go, java, swift | C# covers this canonical behavior in its native integration harness | -| function_calls/baml_closure_is_reusable_and_retains_mutable_captures | y | y | y | y | y | - | y | y | y | y | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, rust, go, java, swift | C# covers this canonical behavior in its native integration harness | +| function_calls/baml_closure_decodes_multiple_args_and_structured_return_values | y | y | y | y | y | - | y | y | y | y | y | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, rust, go, java, swift, ruby_sorbet | C# covers this canonical behavior in its native integration harness | +| function_calls/baml_closure_is_a_native_callable_with_host_language_arguments | y | y | y | y | y | - | y | y | y | y | y | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, rust, go, java, swift, ruby_sorbet | C# covers this canonical behavior in its native integration harness | +| function_calls/baml_closure_is_reusable_and_retains_mutable_captures | y | y | y | y | y | - | y | y | y | y | y | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, rust, go, java, swift, ruby_sorbet | C# covers this canonical behavior in its native integration harness | | function_calls/baml_error_carries_baml_trace | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/baml_time_class_and_field_wire_names_are_exact | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/baml_time_constructors_and_parsers_return_lossless_internal_values | - | - | - | - | - | - | - | y | - | - | - | all | | @@ -127,6 +127,7 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/canonical_json_dynamic_union | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/canonical_json_rejects_extensions_before_dispatch | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/canonical_json_round_trips_at_top_level_and_through_alias | - | - | - | - | - | - | y | y | - | - | - | all | | +| function_calls/caught_ruby_exception_decodes_as_data_and_round_trips_with_identity | - | - | - | - | - | - | - | - | - | - | y | ruby_sorbet | Ruby exceptions returned as HostCallable data retain their original opaque object | | function_calls/child_cancellation_does_not_cancel_input | y | y | y | y | - | - | y | - | - | - | - | all | | | function_calls/clean_exit_helper_process | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/clean_exit_terminates_process_with_code | - | - | - | - | - | - | - | y | - | - | - | all | | @@ -224,8 +225,8 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/generic_host_callbacks_share_the_function_type_parameters | - | - | - | - | - | - | - | y | - | - | - | go | pins Go's generic host-callback adapters | | function_calls/generic_identity_accepts_nested_dynamic_containers | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/generic_identity_inference_and_explicit_types | - | - | - | - | - | - | - | y | - | - | - | all | | -| function_calls/generic_inference_apply_closure_poisons_typevars_must_specify | y | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/generic_inference_apply_closure_typevars_specified_succeeds | y | - | - | - | - | - | - | - | y | - | - | all | | +| function_calls/generic_inference_apply_closure_poisons_typevars_must_specify | y | - | - | - | - | - | - | - | y | - | y | all | | +| function_calls/generic_inference_apply_closure_typevars_specified_succeeds | y | - | - | - | - | - | - | - | y | - | y | all | | | function_calls/generic_inference_body_only_var_still_requires_binding | y | - | - | - | - | - | - | - | y | - | y | all | | | function_calls/generic_inference_choose_divergent_generic_instances_union | y | - | - | - | - | - | - | - | y | - | y | all | | | function_calls/generic_inference_choose_infers_divergent_union | y | - | - | - | - | - | - | - | y | - | y | all | | @@ -317,7 +318,7 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/host_callable_closed_union_round_trips | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/host_callable_declared_throw_is_catchable | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/host_callable_failure_under_open_throws_is_catchable | - | - | - | - | - | - | - | y | - | - | - | all | | -| function_calls/host_callable_hof_receives_a_baml_closure | y | y | y | y | y | - | y | - | y | y | - | all | | +| function_calls/host_callable_hof_receives_a_baml_closure | y | y | y | y | y | - | y | - | y | y | y | all | | | function_calls/host_callable_late_and_uncaught_failures_release_native_identity | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/host_callable_media_round_trip | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/host_callable_nullable_optional_distinguishes_all_three_states | - | - | - | - | - | - | - | y | - | - | - | all | | @@ -328,21 +329,21 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/host_callable_reentrant_call_does_not_deadlock | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/host_callable_repeated_and_concurrent_reuse | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/host_callable_structured_round_trips | - | - | - | - | - | - | - | y | - | - | - | all | | -| function_calls/host_callable_two_cbs_callbacks_named_f_and_f_returns_both_run | y | y | y | y | y | - | y | y | y | y | - | all | | -| function_calls/host_callable_void_signatures | y | y | y | y | y | - | y | y | y | - | - | all | | +| function_calls/host_callable_two_cbs_callbacks_named_f_and_f_returns_both_run | y | y | y | y | y | - | y | y | y | y | y | all | | +| function_calls/host_callable_void_signatures | y | y | y | y | y | - | y | y | y | - | y | all | | | function_calls/host_callables_adopts_a_custom_thenable_exactly_once | - | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/host_callables_adopts_a_promise_from_a_separate_browser_realm | - | - | y | - | - | - | - | - | - | - | - | python_pydantic2, typescript_web_chromium, cpp, csharp, rust, go, java, swift, ruby_sorbet | | | function_calls/host_callables_async_callable_future_completing_exceptionally_round_trips_original | - | - | - | - | - | - | - | - | y | - | - | all | | | function_calls/host_callables_async_callable_returning_future_is_awaited_by_bridge | - | - | - | - | - | - | - | - | y | - | - | all | | -| function_calls/host_callables_async_callable_runs_to_completion | y | - | - | - | - | - | y | - | y | y | - | all | | +| function_calls/host_callables_async_callable_runs_to_completion | y | - | - | - | - | - | y | - | y | y | y | all | | | function_calls/host_callables_awaits_a_promise_returning_callback | - | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/host_callables_baml_error_panicked_with_is_thrown_on | - | - | - | - | - | - | - | y | - | - | - | go | Go's completion classification of a BAML error a callback forwards by panicking with it | -| function_calls/host_callables_call_repeatedly_invokes_callback_n_times | y | y | y | y | y | - | y | - | y | y | - | all | | -| function_calls/host_callables_call_repeatedly_with_zero_n_returns_empty_list | y | y | y | y | y | - | y | - | y | y | - | all | | -| function_calls/host_callables_call_with_throwing_in_baml_catches_host_callable_error | y | y | y | y | y | - | y | - | y | y | - | all | | +| function_calls/host_callables_call_repeatedly_invokes_callback_n_times | y | y | y | y | y | - | y | - | y | y | y | all | | +| function_calls/host_callables_call_repeatedly_with_zero_n_returns_empty_list | y | y | y | y | y | - | y | - | y | y | y | all | | +| function_calls/host_callables_call_with_throwing_in_baml_catches_host_callable_error | y | y | y | y | y | - | y | - | y | y | y | all | | | function_calls/host_callables_cancels_through_the_originating_runtime_after_runtime_replacement | - | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/host_callables_class_callback_round_trips_class_value | - | - | - | - | y | - | - | - | - | y | - | all | | -| function_calls/host_callables_class_callback_round_trips_pydantic_model | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/host_callables_class_callback_round_trips_pydantic_model | y | - | - | - | - | - | y | - | y | - | y | all | | | function_calls/host_callables_completes_a_pending_host_call_after_runtime_replacement | - | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/host_callables_completes_through_the_metadata_fallback_for_a_hostile_thrown_object | - | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/host_callables_concurrent_throws_in_flight_rehydrate_to_their_own_object | - | - | - | - | - | - | - | - | y | - | - | all | | @@ -355,17 +356,17 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/host_callables_forwarded_panic_stays_a_panic_under_a_declared_throws_class | - | - | - | - | - | - | - | y | y | y | - | go, java, swift | pins a typed SDK's completion of a forwarded panic under a single-class throws | | function_calls/host_callables_forwarded_type_mismatch_crosses_as_the_baml_error_it_is | - | - | - | - | - | - | - | - | y | - | - | java | pins the Java completion of a forwarded TypeMismatch, which Java surfaces as an IllegalArgumentException | | function_calls/host_callables_ignores_a_host_promise_settlement_after_its_outer_call_is_cancelled | - | y | y | y | - | - | - | - | - | - | - | all | | -| function_calls/host_callables_int_return_callable_round_trip | y | y | y | y | y | - | y | - | y | y | - | all | | -| function_calls/host_callables_lambda_round_trip | y | - | - | - | y | - | y | - | y | y | - | all | | +| function_calls/host_callables_int_return_callable_round_trip | y | y | y | y | y | - | y | - | y | y | y | all | | +| function_calls/host_callables_lambda_round_trip | y | - | - | - | y | - | y | - | y | y | y | all | | | function_calls/host_callables_literal_union_callback_states_its_literal_types | - | - | - | - | - | - | - | - | - | y | - | all | | | function_calls/host_callables_media_callback_states_the_media_type | - | - | - | - | - | - | - | - | - | y | - | all | | -| function_calls/host_callables_multiple_callable_keys_are_distinct | y | y | y | y | y | - | y | - | y | y | - | all | | -| function_calls/host_callables_multiple_throws_in_flight_do_not_collide_in_registry | y | - | - | - | y | - | y | - | y | y | - | all | | +| function_calls/host_callables_multiple_callable_keys_are_distinct | y | y | y | y | y | - | y | - | y | y | y | all | | +| function_calls/host_callables_multiple_throws_in_flight_do_not_collide_in_registry | y | - | - | - | y | - | y | - | y | y | y | all | | | function_calls/host_callables_non_finite_json_result_is_a_contract_violation | - | - | - | - | - | - | - | y | - | - | - | go | Go's completion classification of a returned value that does not fit its declared type | | function_calls/host_callables_omits_both_optionals_so_the_callback_s_own_defaults_apply | - | y | y | y | - | - | - | - | - | - | - | all | | -| function_calls/host_callables_optional_args_all_set_deliver_both | y | - | - | - | y | - | y | - | y | y | - | all | | -| function_calls/host_callables_optional_args_all_unset_apply_host_defaults | y | - | - | - | y | - | y | - | y | y | - | all | | -| function_calls/host_callables_optional_args_partially_set_deliver_by_name | y | - | - | - | y | - | y | - | y | y | - | all | | +| function_calls/host_callables_optional_args_all_set_deliver_both | y | - | - | - | y | - | y | - | y | y | y | all | | +| function_calls/host_callables_optional_args_all_unset_apply_host_defaults | y | - | - | - | y | - | y | - | y | y | y | all | | +| function_calls/host_callables_optional_args_partially_set_deliver_by_name | y | - | - | - | y | - | y | - | y | y | y | all | | | function_calls/host_callables_overlapping_container_union_callback_states_the_selected_list | - | - | - | - | - | - | - | - | - | y | - | all | | | function_calls/host_callables_passes_a_generated_class_instance_into_the_callback | - | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/host_callables_preserves_a_rejected_promise_reason_by_identity | - | y | y | y | - | - | - | - | - | - | - | all | | @@ -374,30 +375,30 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/host_callables_preserves_number_kind_for_float_bigint_union | - | y | y | y | - | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | exercises JavaScript's distinct number and bigint host representations | | function_calls/host_callables_preserves_same_realm_thrown_object_identity | - | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/host_callables_rejects_callable_args_on_the_generated_sync_path_instead_of_hanging | - | y | y | y | - | - | - | - | - | - | - | all | | -| function_calls/host_callables_release_fires_on_drop_of_callable | y | y | y | y | - | - | y | - | y | - | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, rust, java | callable release coverage depends on host weak-reference support and remains nondeterministic | +| function_calls/host_callables_release_fires_on_drop_of_callable | y | y | y | y | - | - | y | - | y | - | y | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, rust, java, ruby_sorbet | callable release coverage depends on host weak-reference support and remains nondeterministic | | function_calls/host_callables_resolves_integral_number_for_float_class_field | - | y | y | y | - | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | exercises the Node bridge's JavaScript number encoding in generated classes | | function_calls/host_callables_resolves_integral_number_for_float_return | - | y | y | y | - | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | exercises the Node bridge's JavaScript number encoding for float host returns | | function_calls/host_callables_returns_and_invokes_a_host_callable_nested_in_a_list | - | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/host_callables_returns_and_invokes_a_nested_host_callable | - | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/host_callables_round_trips_a_typed_baml_error_through_typed_catch_and_propagation | - | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/host_callables_round_trips_an_arrow_function_callback | - | y | y | y | - | - | - | - | - | - | - | all | | -| function_calls/host_callables_simple_sync_callable_returns_string | y | y | y | y | y | - | y | - | y | y | - | all | | +| function_calls/host_callables_simple_sync_callable_returns_string | y | y | y | y | y | - | y | - | y | y | y | all | | | function_calls/host_callables_surfaces_a_throwing_callback_as_a_baml_error | - | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/host_callables_surfaces_a_wrong_callback_return_type_as_host_contract_violation | - | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/host_callables_throwing_async_callable_round_trips_original_error | - | - | - | - | - | - | - | - | - | y | - | all | | -| function_calls/host_callables_throwing_async_callable_round_trips_original_python_exception | y | - | - | - | - | - | y | - | y | - | - | all | | -| function_calls/host_callables_throwing_callable_bamlerror_propagates_back_with_typed_fields | y | - | - | - | - | - | y | y | y | - | - | all | | -| function_calls/host_callables_throwing_callable_bamlerror_wrapping_codegenned_class_is_caught_in_baml | y | - | - | - | - | - | y | y | y | - | - | all | | +| function_calls/host_callables_throwing_async_callable_round_trips_original_python_exception | y | - | - | - | - | - | y | - | y | - | y | all | | +| function_calls/host_callables_throwing_callable_bamlerror_propagates_back_with_typed_fields | y | - | - | - | - | - | y | y | y | - | y | all | | +| function_calls/host_callables_throwing_callable_bamlerror_wrapping_codegenned_class_is_caught_in_baml | y | - | - | - | - | - | y | y | y | - | y | all | | | function_calls/host_callables_throwing_callable_custom_host_exception_round_trips_with_identity | - | - | - | - | y | - | - | - | - | y | - | all | | -| function_calls/host_callables_throwing_callable_custom_python_exception_round_trips_with_identity | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/host_callables_throwing_callable_custom_python_exception_round_trips_with_identity | y | - | - | - | - | - | y | - | y | - | y | all | | | function_calls/host_callables_throwing_callable_hostthrow_codegenned_class_is_caught_in_baml | - | - | - | - | y | - | - | - | - | y | - | all | | | function_calls/host_callables_throwing_callable_hostthrow_propagates_back_with_typed_fields | - | - | - | - | y | - | - | - | - | y | - | all | | -| function_calls/host_callables_throwing_callable_keyerror_round_trips_with_identity | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/host_callables_throwing_callable_keyerror_round_trips_with_identity | y | - | - | - | - | - | y | - | y | - | y | all | | | function_calls/host_callables_throwing_callable_out_of_range_round_trips_with_identity | - | - | - | - | y | - | - | - | - | - | - | all | | | function_calls/host_callables_throwing_callable_round_trips_original_host_exception | - | - | - | - | y | - | - | - | - | y | - | all | | -| function_calls/host_callables_throwing_callable_round_trips_original_python_exception | y | - | - | - | - | - | y | - | y | - | - | all | | +| function_calls/host_callables_throwing_callable_round_trips_original_python_exception | y | - | - | - | - | - | y | - | y | - | y | all | | | function_calls/host_callables_thrown_value_of_another_go_type_is_a_contract_violation | - | - | - | - | - | - | - | y | - | - | - | go | Go's completion classification of a value thrown as a Go type other than the declared one | -| function_calls/host_callables_two_arg_callable_unpacks_positional_args | y | y | y | y | y | - | y | - | y | y | - | all | | +| function_calls/host_callables_two_arg_callable_unpacks_positional_args | y | y | y | y | y | - | y | - | y | y | y | all | | | function_calls/host_callables_typed_throw_too_deep_is_a_bridge_failure | - | - | - | - | y | - | - | - | - | - | - | cpp | C++ throws a BAML value with baml::host_throw. | | function_calls/host_callables_unfit_callback_result_is_a_contract_violation | - | - | - | - | y | - | - | - | - | - | - | cpp | C++ can return only a malformed bigint as unfit. | | function_calls/host_callback_cleanup_retains_context | y | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | @@ -448,10 +449,10 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/invocation_surfaces_returned_callable_accepts_controls | - | - | - | - | y | - | - | - | - | - | - | all | | | function_calls/json_bigint_round_trips_as_bigint | - | - | - | - | - | - | - | - | y | - | - | all | | | function_calls/json_nested_at_the_wire_limit_crosses_and_one_level_more_is_refused | - | - | - | - | - | - | - | - | y | y | - | java, swift | pins a typed SDK encoder's exact message count at the wire's limit | -| function_calls/json_nested_deeper_than_the_wire_carries_is_refused_as_a_callback_result | y | y | y | y | y | - | y | y | y | y | - | all | | +| function_calls/json_nested_deeper_than_the_wire_carries_is_refused_as_a_callback_result | y | y | y | y | y | - | y | y | y | y | y | all | | | function_calls/json_nested_deeper_than_the_wire_carries_is_refused_as_an_argument | y | y | y | y | y | - | y | y | y | y | y | all | | | function_calls/json_nested_to_the_wire_limit_crosses_and_one_level_more_does_not | - | - | - | - | y | - | - | - | - | - | - | cpp | pins the C++ encoder's exact wire measure at its cut. | -| function_calls/json_returned_from_host_callback_supports_typed_narrowing | y | y | y | y | y | - | y | y | y | y | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, rust, go, java, swift | C# declares no function_calls suite (its native coverage is Rust-wrapped integration tests) | +| function_calls/json_returned_from_host_callback_supports_typed_narrowing | y | y | y | y | y | - | y | y | y | y | y | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, rust, go, java, swift, ruby_sorbet | C# declares no function_calls suite (its native coverage is Rust-wrapped integration tests) | | function_calls/late_callback_keeps_async_local_storage_after_abort_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node callback Promises can outlive an aborted SDK waiter | | function_calls/layered_callback_context_inheritance_and_restoration_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python sync/async re-entry and ContextVar propagation | | function_calls/layered_callback_context_inheritance_and_restoration_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node async carrier, native signals, and entry-time snapshots are specific to TypeScript | @@ -503,7 +504,7 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/parse_json_successful_value_uses_generated_json_projection | - | - | - | - | - | - | - | y | - | - | - | all | | | function_calls/pre_aborted_call_does_not_dispatch_callback_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | | function_calls/pre_aborted_native_signal_does_not_enter_callback_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node async carrier, native signals, and entry-time snapshots are specific to TypeScript | -| function_calls/pre_cancelled_call_does_not_enter_callback | y | y | y | y | - | - | y | - | y | - | - | all | | +| function_calls/pre_cancelled_call_does_not_enter_callback | y | y | y | y | - | - | y | - | y | - | y | all | | | function_calls/primitive_thrown_error | - | - | - | - | - | - | - | - | - | - | y | ruby_sorbet | Ruby error wrapper for a thrown string | | function_calls/promise_all_failure_with_explicit_sibling_abort_preserves_error_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | | function_calls/promise_callback_preserves_async_local_storage_across_suspension_typescript_only | - | y | - | - | - | - | - | - | - | - | - | typescript_node | Node event loop, AsyncLocalStorage, or cooperative Promise cancellation | @@ -539,7 +540,7 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/retained_effective_token_stays_live_after_callback | y | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/retained_host_callback_uses_later_invocations_application_context_python_only | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | Python callback task and event loop ownership | | function_calls/retained_invocation_resolves_token_after_callback | y | - | - | - | - | - | - | - | - | - | - | all | | -| function_calls/returned_callable_accepts_controls | y | y | y | y | - | - | y | - | y | - | - | all | | +| function_calls/returned_callable_accepts_controls | y | y | y | y | - | - | y | - | y | - | y | all | | | function_calls/returned_callable_accepts_controls_async | - | - | - | - | - | - | y | - | - | - | - | all | | | function_calls/returned_closure_preserves_callback_error_and_remains_reusable | y | y | y | y | - | - | - | - | - | - | - | all | | | function_calls/returned_closure_retains_host_callback | y | y | y | y | - | - | - | - | - | - | - | all | | @@ -618,7 +619,7 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | function_calls/web_sysops_supports_sync_and_async_baml_fs_read_through_node_fs_read_file_sync | - | - | - | y | - | - | - | - | - | - | - | python_pydantic2, typescript_web_cloudflare_workers, cpp, csharp, rust, go, java, swift, ruby_sorbet | | | function_calls/web_sysops_trampolines_baml_http_fetch_to_global_fetch_and_buffers_the_response | - | - | y | y | - | - | - | - | - | - | - | python_pydantic2, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, csharp, rust, go, java, swift, ruby_sorbet | | | function_calls/web_sysops_trampolines_baml_http_send_with_method_headers_and_body | - | - | y | y | - | - | - | - | - | - | - | python_pydantic2, typescript_web_chromium, typescript_web_cloudflare_workers, cpp, csharp, rust, go, java, swift, ruby_sorbet | | -| function_calls/zero_timeout_does_not_enter_callback | y | y | y | y | - | - | y | - | y | - | - | all | | +| function_calls/zero_timeout_does_not_enter_callback | y | y | y | y | - | - | y | - | y | - | y | all | | | host_reflect/a_runtime_type_is_one_declaration_and_rejects_serialization | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | BEP-066 host reflection is currently exposed only by Python, TypeScript, and Go | | host_reflect/compiled_package_returns_class_graph | y | y | y | y | - | - | - | y | - | - | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers, go | BEP-066 host reflection is currently exposed only by Python, TypeScript, and Go | | host_reflect/generated_class_subclasses_resolve_to_declared_type | y | y | y | y | - | - | - | - | - | - | - | python_pydantic2, typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | Python and TypeScript have generated-class subclass tokens; Go has no subclass construct | diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_generic_inference.rb b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_generic_inference.rb index 03542140f25..0d14b1dbb86 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_generic_inference.rb +++ b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_generic_inference.rb @@ -17,6 +17,14 @@ def assert_type_error(*needles) needles.each { |needle| assert_includes error.message, needle } end + def test_generic_inference_apply_closure_poisons_typevars_must_specify + assert_type_error("could not infer a type", "apply") { G.apply(->(value) { value + 1 }, 5) } + end + + def test_generic_inference_apply_closure_typevars_specified_succeeds + assert_equal 6, G.apply(->(value) { value + 1 }, 5, _types: { T: Integer, R: Integer }) + end + def test_generic_inference_identity_infers_primitives assert_equal 5, G.identity(5) assert_equal "hi", G.identity("hi") diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_host_callables.rb b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_host_callables.rb new file mode 100644 index 00000000000..c9c17a94b19 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_host_callables.rb @@ -0,0 +1,238 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "baml_sdk" + +ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") + +class HostCallablesTest < Minitest::Test + Fns = BamlSdk::HostCallableTests + + class MyDomainError < StandardError + attr_reader :code + + def initialize(message, code) + @code = code + super(message) + end + end + + def test_host_callables_simple_sync_callable_returns_string + cb = Object.new + cb.define_singleton_method(:call) { |x| "got #{x}" } + assert_equal "got 5", Fns.call_with_callback(cb, 5) + assert_equal [0, 2, 4], BamlSdk::Baml::Array.generate(3, ->(index) { index * 2 }, _types: { T: Integer, E: [:never] }) + end + + def test_host_callables_two_arg_callable_unpacks_positional_args + assert_equal "answer:7", Fns.call_with_two_args(->(x, prefix) { "#{prefix}:#{x}" }, 7, "answer") + end + + def test_host_callables_int_return_callable_round_trip + assert_equal 42, Fns.call_int_callback(->(x) { x * 2 }, 21) + end + + def test_host_callable_two_cbs_callbacks_named_f_and_f_returns_both_run + assert_equal 210, Fns.two_cbs(->(x) { x * 10 }, ->(x) { x * 100 }) + end + + def test_host_callable_hof_receives_a_baml_closure + received = [] + cb = ->(f) { + received << f + assert_instance_of Baml::Closure, f + f.call(41) * 100 + f.call(x: 1) * 2 + } + assert_equal 4204, Fns.hof(cb) + assert_equal 1, received.length + ensure + received&.each(&:close) + end + + def test_baml_closure_is_a_native_callable_with_host_language_arguments + add_ten = T.let(Fns.make_adder(10), T.any(T.proc.params(value: Integer).returns(Integer), Baml::Closure)) + assert_respond_to add_ten, :call + assert_instance_of Baml::Closure, add_ten + assert_equal 15, add_ten.call(5) + assert_equal 17, add_ten.call_async(7).wait + ensure + add_ten&.close + end + + def test_baml_closure_decodes_multiple_args_and_structured_return_values + build = Fns.make_pair_builder(30) + first = build.call(12, "Ada") + assert_instance_of Fns::Person, first + assert_equal ["Ada", 42], [first.name, first.age] + second = build.call(delta: 5, label: "Grace") + assert_instance_of Fns::Person, second + assert_equal ["Grace", 35], [second.name, second.age] + ensure + build&.close + end + + def test_baml_closure_is_reusable_and_retains_mutable_captures + next_value = Fns.make_counter(40) + assert_equal 41, next_value.call + assert_equal 42, next_value.call + ensure + next_value&.close + end + + def test_host_callable_void_signatures + seen = [] + one = ->(value) { seen << [value]; nil } + two = ->(value, label) { seen << [value, label]; nil } + three = ->(value, label, flag) { seen << [value, label, flag]; nil } + assert_equal 1, Fns.call_void_callback(one, 1) + assert_equal 2, Fns.call_void_callback_two_args(two, 2, "two") + assert_equal 3, Fns.call_void_callback_three_args(three, 3, "three", true) + assert_nil Fns.call_throwing_void_callback(one, 4) + raised = ArgumentError.new("void failed") + assert_same raised, assert_raises(ArgumentError) { + Fns.call_throwing_void_callback(->(_value) { raise raised }, 4) + } + void = Fns.make_void_callback + assert_nil void.call + forward = Fns.make_void_forwarder(two) + assert_nil forward.call(5, "five") + assert_nil forward.call(value: 6, label: "six") + assert_equal [[1], [2, "two"], [3, "three", true], [4], [5, "five"], [6, "six"]], seen + ensure + void&.close + forward&.close + end + + # SDK_PARITY_LINT(skip): Ruby exceptions returned as HostCallable data retain their original opaque object + def test_caught_ruby_exception_decodes_as_data_and_round_trips_with_identity + raised = MyDomainError.new("caught as data", 17) + model = Fns.catch_ruby_exception(-> { raise raised }) + assert_instance_of BamlSdk::Baml::Errors::HostCallable, model + assert_same raised, model._handle + received = nil + assert_equal 42, Fns.forward_ruby_exception(model, ->(value) { + received = value._handle + 42 + }) + assert_same raised, received + end + + def test_host_callables_throwing_callable_round_trips_original_python_exception + raised = ArgumentError.new("nope") + caught = assert_raises(ArgumentError) { Fns.call_with_callback(->(_x) { raise raised }, 1) } + assert_same raised, caught + assert_equal "nope", caught.message + end + + def test_host_callables_throwing_callable_keyerror_round_trips_with_identity + raised = KeyError.new("missing") + assert_same raised, assert_raises(KeyError) { Fns.call_with_callback(->(_x) { raise raised }, 1) } + end + + def test_host_callables_throwing_callable_custom_python_exception_round_trips_with_identity + raised = MyDomainError.new("custom domain failure", 42) + caught = assert_raises(MyDomainError) { Fns.call_with_callback(->(_x) { raise raised }, 1) } + assert_same raised, caught + assert_equal 42, caught.code + end + + def test_host_callables_throwing_callable_bamlerror_wrapping_codegenned_class_is_caught_in_baml + cb = ->(_x) { + raise Baml::Error.new(Fns::ValidationError.new(code: 4, message: "bad shape", fields: %w[name age email phone])) + } + assert_equal "caught: bad shape", Fns.call_with_typed_throws(cb, 1) + end + + def test_host_callables_throwing_callable_bamlerror_propagates_back_with_typed_fields + raised = Baml::Error.new(Fns::ValidationError.new(code: 7, message: "propagated through", fields: %w[x y])) + caught = assert_raises(Baml::Error) { Fns.call_with_typed_throws_propagating(->(_x) { raise raised }, 1) } + decoded = caught.value + assert_instance_of Fns::ValidationError, decoded + assert_equal 7, decoded.code + assert_equal "propagated through", decoded.message + assert_equal %w[x y], decoded.fields + end + + def test_host_callables_throwing_async_callable_round_trips_original_python_exception + # Like Java, join asynchronous work inside the callback; Ruby has no async def. + raised = ArgumentError.new("async nope") + cb = ->(_x) { + worker = Thread.new { Thread.current.report_on_exception = false; raise raised } + worker.value + } + assert_same raised, assert_raises(ArgumentError) { Fns.call_with_callback(cb, 1) } + end + + def test_host_callables_multiple_throws_in_flight_do_not_collide_in_registry + first = ArgumentError.new("first") + second = ArgumentError.new("second") + caught_first = assert_raises(ArgumentError) { Fns.call_with_callback(->(_x) { raise first }, 1) } + caught_second = assert_raises(ArgumentError) { Fns.call_with_callback(->(_x) { raise second }, 2) } + assert_same first, caught_first + assert_same second, caught_second + refute_same caught_first, caught_second + end + + # Ruby GC does not trigger engine GC, matching Python's xfail and Java's Disabled. + # SDK_PARITY_LINT(skip): callable release coverage depends on host weak-reference support and remains nondeterministic + def test_host_callables_release_fires_on_drop_of_callable + skip "Callable release follows engine GC, not Ruby GC or call completion" + end + + def test_host_callables_lambda_round_trip + assert_equal "lambda-99", Fns.call_with_callback(->(x) { "lambda-#{x}" }, 99) + end + + def test_host_callables_async_callable_runs_to_completion + # Resolve asynchronous work inside the callback, as the Java port does. + cb = ->(x) { Thread.new { Thread.pass; "async-#{x}" }.value } + assert_equal "async-4", Fns.call_with_callback(cb, 4) + end + + def test_host_callables_multiple_callable_keys_are_distinct + counter = { a: 0, b: 0 } + a = ->(x) { counter[:a] += 1; "a:#{x}" } + b = ->(x) { counter[:b] += 1; "b:#{x}" } + assert_equal "a:1", Fns.call_with_callback(a, 1) + assert_equal "b:2", Fns.call_with_callback(b, 2) + assert_equal({ a: 1, b: 1 }, counter) + end + + def test_host_callables_class_callback_round_trips_pydantic_model + person = Fns::Person.new(name: "Ada", age: 37) + assert_equal "Ada is 37", Fns.call_with_class_callback(->(p) { "#{p.name} is #{p.age}" }, person) + end + + def test_host_callables_call_repeatedly_invokes_callback_n_times + invocations = [] + cb = ->(x) { invocations << x; "item-#{x}" } + assert_equal 5.times.map { |i| "item-#{i}" }, Fns.call_repeatedly(cb, 5) + assert_equal (0...5).to_a, invocations + end + + def test_host_callables_call_repeatedly_with_zero_n_returns_empty_list + invocations = [] + assert_equal [], Fns.call_repeatedly(->(x) { invocations << x; "" }, 0) + assert_equal [], invocations + end + + def test_host_callables_call_with_throwing_in_baml_catches_host_callable_error + assert_equal "caught:RuntimeError", Fns.call_with_throwing(->(_x) { raise "boom from host" }, 1) + end + + def optional_args_cb + ->(x, y: 8, z: 9) { x * 100 + y * 10 + z } + end + + def test_host_callables_optional_args_all_unset_apply_host_defaults + assert_equal [589], Fns.call_callback_with_optional_args_all_unset(optional_args_cb, 5) + end + + def test_host_callables_optional_args_partially_set_deliver_by_name + assert_equal [529, 583], Fns.call_callback_with_optional_args_partially_set(optional_args_cb, 5) + end + + def test_host_callables_optional_args_all_set_deliver_both + assert_equal [523], Fns.call_callback_with_optional_args_all_set(optional_args_cb, 5) + end +end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_invocation.rb b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_invocation.rb index e7537e3ed00..5388817def3 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_invocation.rb +++ b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_invocation.rb @@ -54,6 +54,37 @@ def test_methods_accept_controls assert_equal [8, 2, nil], box.probe(2, opt1: nil, _baml: options) end + def test_returned_callable_accepts_controls + add = BamlSdk::HostCallableTests.make_adder(3) + options = Baml::Options.new + assert_equal 7, add.call(4, _baml: options) + assert_equal 7, add.call_async(4, _baml: options).wait(timeout: 5) + ensure + add&.close + end + + def test_pre_cancelled_call_does_not_enter_callback + token = BamlSdk::Baml::Spawn::CancelToken.baml_new + token.cancel + calls = [] + callback = ->(value) { calls << value; value } + error = assert_raises(Baml::CancelledError) do + BamlSdk::HostCallableTests.call_int_callback_async(callback, 1, _baml: Baml::Options.new(cancel: token)).wait(timeout: 5) + end + assert_instance_of BamlSdk::Baml::Panics::Cancelled, error.value + assert_empty calls + end + + def test_zero_timeout_does_not_enter_callback + calls = [] + callback = ->(value) { calls << value; value } + error = assert_raises(Baml::CancelledError) do + BamlSdk::HostCallableTests.call_int_callback_async(callback, 1, _baml: Baml::Options.new(timeout_ms: 0)).wait(timeout: 5) + end + assert_instance_of BamlSdk::Baml::Panics::Cancelled, error.value + assert_empty calls + end + def test_composite_token_observes_every_source [0, 1].each do |index| sources = Array.new(2) { BamlSdk::Baml::Spawn::CancelToken.baml_new } diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_json.rb b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_json.rb index 833e1bb378c..022f093369d 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_json.rb +++ b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_json.rb @@ -5,6 +5,10 @@ ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") class JsonTest < Minitest::Test + def test_json_returned_from_host_callback_supports_typed_narrowing + assert_equal "object", BamlSdk::GoJsonTests.json_callback_kind(->(value) { { "wrapped" => value } }, "payload") + end + def test_host_supplied_json_supports_typed_narrowing json = BamlSdk::GoJsonTests obj = { "type" => "ok", "nested" => { "list" => [1, { "deep" => "found" }] } } @@ -27,6 +31,16 @@ def test_json_nested_deeper_than_the_wire_carries_is_refused_as_an_argument end end + def test_json_nested_deeper_than_the_wire_carries_is_refused_as_a_callback_result + json = BamlSdk::GoJsonTests + assert_equal nested_json(30), json.call_json_callback(->(_) { nested_json(30) }, 1) + [60, 200].each do |depth| + error = assert_raises(Baml::Panic) { json.call_json_callback(->(_) { nested_json(depth) }, 1) } + assert_instance_of BamlSdk::Baml::Panics::SdkPanic, error.value + assert_includes error.value.message, "nested too deeply" + end + end + private def nested_json(depth) diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/typed_consumer.rb b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/typed_consumer.rb index b967e531a97..9de8f498667 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/typed_consumer.rb +++ b/baml_language/sdk_tests/crates/ruby_sorbet/function_calls/customizable/typed_consumer.rb @@ -9,6 +9,9 @@ T.let(BamlSdk::Baml::String.from_code_points([65]), String) T.let(BamlSdk::Baml::Spawn::CancelToken.baml_new_async, Baml::Call[BamlSdk::Baml::Spawn::CancelToken]) T.let(BamlSdk.single_required_arg("hello"), String) +T.let(BamlSdk::HostCallableTests.call_int_callback(->(value) { value * 2 }, 21), Integer) +adder = T.let(BamlSdk::HostCallableTests.make_adder(10), T.any(T.proc.params(value: Integer).returns(Integer), Baml::Closure)) +T.let(adder.call(5), Integer) person = BamlSdk::Person.new(person: "person", name: "Ryan", age: 30) T.let(person.person, String) T.let(person.name, String) diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_call_test.rb b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_call_test.rb index 69d726c8ada..4e571318678 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_call_test.rb +++ b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_call_test.rb @@ -13,13 +13,14 @@ class BridgeCallTest < Minitest::Test class FakeApi attr_reader :callback, :initialize_count, :released_calls, :cancelled_calls attr_accessor :dispatch - attr_reader :released_handles + attr_reader :host_dispatch, :host_release, :host_cancel, :host_completions, :released_handles def initialize @initialize_count = 0 @engine_id = 2**40 @released_calls = [] @cancelled_calls = [] + @host_completions = Queue.new @released_handles = Queue.new end @@ -40,6 +41,14 @@ def register_result_callback(callback) @callback = callback end + def register_host_callbacks(dispatch, release, cancel) + @host_dispatch, @host_release, @host_cancel = dispatch, release, cancel + end + + def complete_host_call(id, bytes) + @host_completions << [id, Wire::HostCallCompletion.decode(bytes)] + end + def handle_release(key) @released_handles << key end @@ -288,7 +297,7 @@ def test_interrupted_callback_registration_is_retained_and_completed_once api = FakeApi.new attempts = Hash.new(0) { register_bridge!: nil, register_result_callback: :@result_callback, - register_unhandled_spawn_error_callback: :@spawn_callback }.each do |method, owner| + register_host_callbacks: :@host_callbacks, register_unhandled_spawn_error_callback: :@spawn_callback }.each do |method, owner| api.define_singleton_method(method) do |*args| raise "callback was not retained" if owner && !runtime.instance_variable_get(owner) attempts[method] += 1 @@ -297,16 +306,149 @@ def test_interrupted_callback_registration_is_retained_and_completed_once end end Native::Api.stub(:new, api) do - 3.times { assert_raises(Interrupt) { runtime.initialize! } } + 4.times { assert_raises(Interrupt) { runtime.initialize! } } runtime.initialize! end - assert_equal [1, 1, 1], attempts.values + assert_equal [1, 1, 1, 1], attempts.values assert_equal 1, api.initialize_count assert_same runtime.instance_variable_get(:@result_callback).function, api.callback + refute_nil api.host_dispatch + refute_nil api.host_release + refute_nil api.host_cancel + end + + def test_callback_argument_decode_releases_unadopted_live_types_and_later_handles + protocol = Baml::Bridge::Protocol + scope = Baml::Bridge::EncodingScope.new + key = Baml::Bridge::HostValues.register(->(*) { flunk "decoding must fail before invoking Ruby" }, scope, + [:function, [], [:int], [:never]]) + scope.submitted! + retained = [] + decode = protocol.method(:decode_value) + protocol.stub(:decode_value, ->(*args, **kwargs) { value = decode.call(*args, **kwargs); retained << value if value.is_a?(Baml::Handle); value }) do + frame = Wire::HostInvocation.new(host_value_key: key, callback_id: 123, effective_state: 77, + application_args: Wire::BamlToHostCall.encode(Wire::BamlToHostCall.new(args: [ + { value: { handle_value: { key: 40, handle_type: :ADT_TAGGED_HEAP_HANDLE } } }, + { value: { ty_value: { class_ty: { live: 41 } } } }, + { value: { handle_value: { key: 42, handle_type: :ADT_TAGGED_HEAP_HANDLE } } } + ]))) + Baml::Bridge::HostCallbacks.new(@api).send(:invoke, frame) + end + id, completion = @api.host_completions.pop + assert_equal 123, id + assert_equal :bridge_failure, completion.outcome + assert_includes completion.bridge_failure, "ty_value is not yet supported" + assert_equal [41, 42, 77], 3.times.map { released_handle }.sort + GC.start + assert @api.released_handles.empty?, "adopted handle must stay owned by its Ruby wrapper" + assert_equal 1, retained.length + retained.first.close + assert_equal 40, released_handle + assert @api.released_handles.empty? + ensure + retained&.each(&:close) + Baml::Bridge::HostValues.handle_release(key) if key + scope&.close + end + + class CallbackField < T::Struct + const :callback, T.any(T.proc.params(value: Integer).returns(Integer), Baml::Closure) + end + + def test_non_proc_host_callable_in_a_struct_field_decodes_as_a_proc + callable = Object.new + callable.define_singleton_method(:call) { |value| value + 1 } + scope = Baml::Bridge::EncodingScope.new + key = Baml::Bridge::HostValues.register(callable, scope, [:function, [[nil, [:int], false]], [:int], [:never]]) + scope.submitted! + Baml::Bridge.define_class("user.CallbackField", CallbackField, + fields: { "callback" => [:callback, [:function, [[nil, [:int], false]], [:int], [:never]]] }) + handle = { handle_value: { key: key, handle_type: :HOST_VALUE_CALLABLE } } + @api.dispatch = ->(_args, id) { @api.deliver(id, ok: { class_value: { + name: "user.CallbackField", fields: [{ key: "callback", value: handle }] + } }) } + result = call + Baml::Bridge::HostValues.handle_release(key) + key = nil + assert_instance_of CallbackField, result + assert_instance_of Proc, result.callback + assert_equal 42, result.callback.call(41) + assert_same callable, result.callback.binding.receiver + ensure + Baml::Bridge::HostValues.handle_release(key) if key + scope&.close + end + + def test_callback_returns_and_closure_arguments_preserve_bytes + Baml::Bridge.define_function("user.bytes_callback", params: [["cb", [:function, [], [:bytes], [:never]]]]) + @api.dispatch = lambda do |args, id| + key = args.kwargs.first.value.handle.key + frame = Wire::HostInvocation.encode(Wire::HostInvocation.new( + host_value_key: key, callback_id: 123, + application_args: Wire::BamlToHostCall.encode(Wire::BamlToHostCall.new) + )) + pointer = FFI::MemoryPointer.from_string(frame) + @api.host_dispatch.call(pointer, frame.bytesize) + _, completion = Timeout.timeout(5) { @api.host_completions.pop } + assert_equal :returned, completion.outcome + value = completion.returned + assert_equal :uint8array_value, value.value + @api.host_release.call(key) + @api.deliver(id, ok: { uint8array_value: value.uint8array_value }) + end + assert_equal "\0bytes".b, @runtime.call_async("user.bytes_callback", { "cb" => -> { "\0bytes".b } }).wait + + @api.dispatch = lambda do |_args, id| + @api.deliver(id, ok: { handle_value: { key: 77, handle_type: :FUNCTION_REF, ty: { function: { + params: [{ name: "data", mode: :BAML_TY_FUNCTION_PARAM_MODE_REQUIRED, + ty: { primitive: { kind: :BAML_TY_PRIMITIVE_BYTES } } }], + ret: { primitive: { kind: :BAML_TY_PRIMITIVE_BYTES } }, throws: { never: {} } + } } } }) + end + closure = call + @api.dispatch = lambda do |args, id| + assert_equal :uint8array_value, args.kwargs.first.value.value + @api.deliver(id, ok: { uint8array_value: args.kwargs.first.value.uint8array_value }) + end + Baml::Bridge.stub(:call_async, ->(target, args, **options) { @runtime.call_async(target, args, **options) }) do + assert_equal "\0arg".b, closure.call("\0arg".b) + end + ensure + closure&.close + end + + def test_closure_parameters_keep_names_modes_and_complete_descriptors + @api.dispatch = lambda do |_args, id| + @api.deliver(id, ok: { handle_value: { key: 77, handle_type: :FUNCTION_REF, ty: { function: { + params: [{ mode: :BAML_TY_FUNCTION_PARAM_MODE_REQUIRED, ty: { primitive: { kind: :BAML_TY_PRIMITIVE_FLOAT } } }, + { name: "count", mode: :BAML_TY_FUNCTION_PARAM_MODE_OPTIONAL, ty: { primitive: { kind: :BAML_TY_PRIMITIVE_INT } } }], + ret: { list: { item: { primitive: { kind: :BAML_TY_PRIMITIVE_FLOAT } } } }, throws: { never: {} } + } } } }) + end + closure = call + assert_equal({ "arg0" => [:float], "count" => [:int] }, closure.parameter_descriptors) + @api.dispatch = lambda do |args, id| + assert_equal 77, args.function_handle + assert_equal 5.0, args.kwargs.first.value.float_value + @api.deliver(id, ok: { int_value: args.kwargs.length }) + end + Baml::Bridge.stub(:call_async, ->(target, args, **options) { @runtime.call_async(target, args, **options) }) do + assert_equal 1, closure.call(5) + assert_equal 2, closure.call(arg0: 5, count: 2) + assert_raises(ArgumentError) { closure.call(5, 2) } + assert_raises(TypeError) { closure.call(5, count: "wrong") } + assert @api.released_handles.size == 2, "refused arguments must not clone the closure target" + end + ensure + closure&.close end class GenericValue < T::Struct const :value, String + + def call + raise "a registered class must not be encoded as a callback" + end end def test_decoded_generic_preserves_string_literal_and_alias_hints @@ -359,9 +501,18 @@ def test_callback_capture_errors_reach_caller end def test_dispatch_failure_removes_pending_call - @api.dispatch = ->(_args, _id) { raise "dispatch failed" } - error = assert_raises(RuntimeError) { call } + Baml::Bridge.define_function("user.namespace.OriginalName", params: [["callback", [:function, [["value", [:int], false]], [:int], [:never]]]]) + callback = ->(value) { value } + registry = Baml::Bridge.const_get(:HostValues, false) + key = nil + @api.dispatch = lambda do |args, _id| + key = args.kwargs.first.value.handle.key + assert_same callback, registry.fetch(key) + raise "dispatch failed" + end + error = assert_raises(RuntimeError) { call("callback" => callback) } assert_equal "dispatch failed", error.message + assert_raises(Baml::Bridge::Error) { registry.fetch(key) } assert_equal [(2**40) + 1], @api.released_calls assert_empty pending end @@ -438,6 +589,84 @@ def test_call_waiters_cache_outcomes_and_survive_interrupt_and_cancel assert_empty pending end + def test_thread_start_failure_releases_callback_arguments_and_completes + frame = Wire::HostInvocation.new( + host_value_key: 999, callback_id: 124, effective_state: 77, + application_args: Wire::BamlToHostCall.encode(Wire::BamlToHostCall.new(args: [ + { value: { handle_value: { key: 66, handle_type: :ADT_TAGGED_HEAP_HANDLE } } } + ])), + cancel: { handle_value: { key: 88, handle_type: :ADT_TAGGED_HEAP_HANDLE } } + ) + bytes = Wire::HostInvocation.encode(frame) + pointer = FFI::MemoryPointer.new(:uint8, bytes.bytesize) + pointer.put_bytes(0, bytes) + Thread.stub(:new, ->(*) { raise ThreadError, "thread limit" }) do + @api.host_dispatch.call(pointer, bytes.bytesize) + end + id, completion = @api.host_completions.pop + assert_equal 124, id + assert_equal :bridge_failure, completion.outcome + assert_includes completion.bridge_failure, "thread limit" + assert_equal [66, 77, 88], 3.times.map { released_handle }.sort + assert @api.released_handles.empty? + assert @api.host_completions.empty? + end + + def test_cancelled_host_callback_keeps_resources_until_proc_returns + registry = Baml::Bridge.const_get(:HostValues, false) + scope = Baml::Bridge.const_get(:EncodingScope, false).new + entered = Queue.new + unblock = Queue.new + exited = Queue.new + cb = ->(value) { entered << value; unblock.pop; exited << true; "finished" } + Baml::Bridge.define_function("user.cancel_callback", params: [["cb", [:function, [["value", [:int], false]], [:string], [:never]]]]) + request = Wire::CallFunctionArgs.decode(Baml::Bridge::Protocol.encode_call(1, "user.cancel_callback", { "cb" => cb }, scope: scope)) + key = request.kwargs.first.value.handle.key + scope.submitted! + frame = Wire::HostInvocation.new( + host_value_key: key, callback_id: 123, effective_state: 77, + application_args: Wire::BamlToHostCall.encode(Wire::BamlToHostCall.new(args: [{ value: { int_value: 5 } }])), + cancel: { class_value: { name: "baml.spawn.CancelToken", fields: [ + { key: "inner", value: { handle_value: { key: 88, handle_type: :ADT_TAGGED_HEAP_HANDLE } } } + ] } } + ) + bytes = Wire::HostInvocation.encode(frame) + pointer = FFI::MemoryPointer.new(:uint8, bytes.bytesize) + pointer.put_bytes(0, bytes) + Timeout.timeout(5) do + # Dispatch must return while the proc is blocked. Overwrite the borrowed + # buffer immediately to prove the invocation owns its request bytes. + @api.host_dispatch.call(pointer, bytes.bytesize) + pointer.put_bytes(0, "\0" * bytes.bytesize) + assert_equal 5, entered.pop + @api.host_cancel.call(123) + assert_same cb, registry.fetch(key) + assert @api.released_handles.empty? + assert @api.host_completions.empty? + assert exited.empty? + unblock << true + assert exited.pop + id, completion = @api.host_completions.pop + assert_equal [123, :returned, "finished"], [id, completion.outcome, completion.returned.string_value] + assert_equal [77, 88], 2.times.map { released_handle }.sort + @api.host_cancel.call(123) + assert @api.host_completions.empty?, "late cancellation must not complete twice" + assert @api.released_handles.empty?, "late cancellation must not release twice" + # Host keys in results are borrowed too: pin before engine release, + # even when the first waiter does not run until after that release. + @api.dispatch = lambda do |_args, id| + @api.deliver(id, ok: { handle_value: { key: key, handle_type: :HOST_VALUE_CALLABLE } }) + @api.host_release.call(key) + end + result = @runtime.call_async("user.return_callback", {}) + assert_raises(Baml::Bridge::Error) { registry.fetch(key) } + assert_same cb, result.wait + end + ensure + unblock << true if unblock + registry&.handle_release(key) if key + end + private def call(arguments = {}) diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_encode_test.rb b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_encode_test.rb index 23636ee516b..bbc63bb0d44 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_encode_test.rb +++ b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_encode_test.rb @@ -72,11 +72,16 @@ def enum_values(_name) end class Api - attr_reader :cloned, :released + attr_reader :cloned, :released, :completions def initialize @cloned = [] @released = [] + @completions = [] + end + + def complete_host_call(id, bytes) + @completions << [id, Wire::HostCallCompletion.decode(bytes)] end def handle_clone(key) @@ -90,6 +95,7 @@ def handle_release(key) end def setup + @host_keys = [] @registry = Registry.new @registry.define(Color, "user.Color") @registry.define(Box, "user.Box", { @@ -234,6 +240,258 @@ def test_generic_class_field_alias_normalization_spends_the_budget end end + def teardown + @host_keys.each { |key| Baml::Bridge::HostValues.handle_release(key) } + end + + def test_each_opaque_host_send_survives_release_of_the_other_sends + object = StandardError.new("opaque") + registry = Baml::Bridge::HostValues + scope = Baml::Bridge::EncodingScope.new + original = registry.register(object, scope) + @host_keys << original + scope.submitted! + first_scope = Baml::Bridge::EncodingScope.new + second_scope = Baml::Bridge::EncodingScope.new + first = encode(object, [:handle], scope: first_scope).handle + second = encode(object, [:handle], scope: second_scope).handle + @host_keys.concat([first.key, second.key]) + first_scope.submitted! + second_scope.submitted! + assert_equal :HOST_VALUE_OPAQUE, first.handle_type + assert_equal :HOST_VALUE_OPAQUE, second.handle_type + assert_equal 3, [original, first.key, second.key].uniq.length + registry.handle_release(original) + assert_same object, registry.fetch(first.key) + assert_same object, registry.fetch(second.key) + registry.handle_release(first.key) + assert_same object, registry.fetch(second.key) + registry.handle_release(second.key) + retry_scope = Baml::Bridge::EncodingScope.new + retry_key = encode(object, [:handle], scope: retry_scope).handle.key + assert_same object, registry.fetch(retry_key) + retry_scope.close + assert_raises(Baml::Bridge::Error) { registry.fetch(retry_key) } + ensure + scope&.close + first_scope&.close + second_scope&.close + retry_scope&.close + end + + def test_opaque_host_send_rollback_releases_only_its_own_key + object = StandardError.new("opaque") + registry = Baml::Bridge::HostValues + original_scope = Baml::Bridge::EncodingScope.new + original = registry.register(object, original_scope) + original_scope.submitted! + registry.handle_release(original) + first_scope = Baml::Bridge::EncodingScope.new + second_scope = Baml::Bridge::EncodingScope.new + first = encode(object, [:handle], scope: first_scope).handle.key + second = encode(object, [:handle], scope: second_scope).handle.key + @host_keys << second + refute_equal first, second + second_scope.submitted! + second_scope.close + first_scope.close + assert_raises(Baml::Bridge::Error) { registry.fetch(first) } + assert_same object, registry.fetch(second) + ensure + original_scope&.close + first_scope&.close + second_scope&.close + end + + def test_ordinary_values_do_not_check_the_opaque_host_registry + Baml::Bridge::HostValues.stub(:opaque?, ->(*) { flunk "ordinary value checked the host registry" }) do + encode([nil, true, false, 1, 2.5, "text", "bytes".b, [], {}]) + end + end + + def test_callback_argument_result_failure_completes_with_a_bridge_failure + api = Api.new + frame = Wire::HostInvocation.new(callback_id: 7, application_args: "\xff".b) + Baml::Bridge::HostCallbacks.new(api).send(:invoke, frame) + assert_equal 1, api.completions.length + assert_equal 7, api.completions.first.first + assert_equal :bridge_failure, api.completions.first.last.outcome + end + + def test_a_callable_binds_to_one_function_member_and_is_ambiguous_among_several + cb = ->(value) { value } + signature = [:function, [["value", [:int], false], [nil, [:bytes], true]], [:int], [:never]] + other = [:function, [["value", [:string], false]], [:string], [:never]] + scope = Baml::Bridge::EncodingScope.new + result = encode(cb, union([:unknown], signature), scope: scope) + assert_equal signature, Encode::Slot.from_wire(result.value_type) + assert_equal :HOST_VALUE_CALLABLE, result.handle.handle_type + assert_equal [cb, signature], Baml::Bridge::HostValues.entry(result.handle.key) + assert_raises(Encode::Ambiguous) { encode(cb, union(signature, other), scope: scope) } + throws_param = [:function, [], [:int], [:list, [:param, 0, "E"]]] + result = encode(cb, throws_param, scope: scope) + assert_equal [:function, [], [:int], [:unknown]], Encode::Slot.from_wire(result.value_type) + [[:function, [["x", [:param, 0, "T"], false]], [:int], [:never]], + [:function, [], [:param, 0, "T"], [:never]]].each do |slot| + assert_raises(Encode::UnboundTypeArgs) { encode(cb, slot, scope: scope) } + end + ensure + scope&.close + end + + def test_reordered_unions_inside_function_signatures_are_one_candidate + first = union([:int], [:string]) + reordered = union([:string], [:int]) + signature = ->(ty) { [:function, [["value", ty, false]], [:list, ty], ty] } + scope = Baml::Bridge::EncodingScope.new + result = encode(->(value) { [value] }, union(signature.call(first), signature.call(reordered)), scope: scope) + assert_equal signature.call(first), Encode::Slot.from_wire(result.value_type) + ensure + scope&.close + end + + def test_a_losing_member_creates_nothing_and_a_refusal_creates_nothing + signature = [:function, [], [:int], [:never]] + cb = -> { 1 } + scope = Baml::Bridge::EncodingScope.new + registry = Baml::Bridge::HostValues + registrations = [] + register = registry.method(:register) + registry.stub(:register, ->(*args) { key = register.call(*args); registrations << key; key }) do + result = encode([cb, 1], union([:list, signature], [:list, union(signature, [:int])]), scope: scope) + assert_equal 1, registrations.length + assert_same cb, registry.fetch(result.list_value.values.first.handle.key) + assert_raises(Encode::UntypedCallable) { encode(cb, scope: scope) } + Protocol.define_function("user.untyped_callback", params: [["typed", signature]]) + assert_raises(Encode::UntypedCallable) do + Protocol.encode_call(1, "user.untyped_callback", { "typed" => cb, "untyped" => cb }, scope: scope) + end + assert_raises(Encode::Ambiguous) do + encode(cb, union(signature, [:function, [], [:string], [:never]]), scope: scope) + end + assert_equal 1, registrations.length + end + scope.close + assert_raises(Baml::Bridge::Error) { registry.fetch(registrations.first) } + ensure + scope&.close + end + + def test_a_hosts_own_exception_is_raised_as_it_is + signature = [:function, [], [:unknown], [:string]] + error = ArgumentError.new("host failed") + completion = complete(-> { raise error }, signature) + assert_equal :raised, completion.outcome + assert_equal ["host failed", "ArgumentError", "ruby"], + [completion.raised.message, completion.raised.class_name, completion.raised.language] + assert_same error, Baml::Bridge::HostValues.fetch(completion.raised.handle) + refute_empty completion.raised.traceback + + type = Class.new { attr_accessor :error; def value; raise error; end } + Protocol.define_class("user.CallbackGetter", type, fields: { "value" => [:value, [:int]] }) + value = type.new + value.error = error + completion = complete(-> { value }, signature) + assert_equal :raised, completion.outcome + assert_same error, Baml::Bridge::HostValues.fetch(completion.raised.handle) + value.error = Baml::Error.new("typed throw") + completion = complete(-> { value }, signature) + assert_equal :thrown, completion.outcome + assert_equal "typed throw", completion.thrown.string_value + completion = complete(-> { raise Baml::Panic.new("panic") }, signature) + assert_equal :thrown, completion.outcome + assert_equal "panic", completion.thrown.string_value + completion = complete(-> { raise Baml::CancelledError.new("cancelled") }, signature) + assert_equal :thrown, completion.outcome + assert_equal "cancelled", completion.thrown.string_value + map = Class.new(Hash) { define_method(:each) { |&_| raise error } }.new + completion = complete(-> { map }, signature) + assert_equal :raised, completion.outcome + assert_same error, Baml::Bridge::HostValues.fetch(completion.raised.handle) + end + + def test_binary_or_invalid_host_exception_text_is_scrubbed_without_losing_identity + error = IOError.new("café".b) + error.set_backtrace(["café".b + "\xff".b]) + completion = complete(-> { raise error }, [:function, [], [:int], [:never]]) + assert_equal :raised, completion.outcome + assert_equal "café", completion.raised.message + assert_equal "café�", completion.raised.traceback + assert completion.raised.message.valid_encoding? + assert completion.raised.traceback.valid_encoding? + assert_same error, Baml::Bridge::HostValues.fetch(completion.raised.handle) + end + + def test_invalid_callback_text_is_a_contract_violation_not_a_bridge_failure + ["\xff".b, "\xff".b.force_encoding(Encoding::UTF_8)].each do |value| + completion = complete(-> { value }, [:function, [], [:string], [:never]]) + assert_equal :contract_violation, completion.outcome + end + end + + def test_a_value_the_bridge_cannot_state_is_the_bridges_failure_not_the_callables + [ [[], union([:list, [:int]], [:list, [:string]])], + [-> { 1 }, [:unknown]], [[], [:list, [:param, 0, "T"]]], + [60.times.reduce(1) { |value, _| [value] }, [:unknown]], + [[], [:list, [:other]]], [[], [:list, [:handle]]] ].each do |value, returns| + completion = complete(-> { value }, [:function, [], returns, [:never]]) + assert_equal :bridge_failure, completion.outcome + end + phantom = Class.new(Struct.new(:value)) + Protocol.define_class("user.CallbackPhantom", phantom, fields: { "value" => [:value, [:int]] }, type_params: ["T"]) + assert_equal :bridge_failure, complete(-> { phantom.new(1) }, [:function, [], [:unknown], [:never]]).outcome + completion = complete(-> { "not int" }, [:function, [], [:int], [:never]]) + assert_equal :contract_violation, completion.outcome + completion = complete(-> { raise Baml::Error.new(1) }, [:function, [], [:int], [:string]]) + assert_equal :contract_violation, completion.outcome + null = complete(-> { nil }, [:function, [], [:null], [:never]]) + assert_equal :returned, null.outcome + assert_nil null.returned.value + end + + def test_callable_results_state_their_signature_and_wire_depth_precedes_registration + signature = [:function, [["x", [:float], false]], [:float], [:never]] + cb = ->(x) { x } + completion = complete(-> { cb }, [:function, [], signature, [:never]]) + assert_equal :returned, completion.outcome + assert_equal signature, Encode::Slot.from_wire(completion.returned.value_type) + key = completion.returned.handle.key + @host_keys << key + assert_equal [cb, signature], Baml::Bridge::HostValues.entry(key) + deep = 49.times.reduce([:int]) { |ty, _| [:list, ty] } + scope = Baml::Bridge::EncodingScope.new + Baml::Bridge::HostValues.stub(:register, ->(*) { flunk "too-deep signature registered" }) do + assert_raises(Encode::Fault) { encode(cb, [:function, [], deep, [:never]], scope: scope) } + end + api = Api.new + frame = Wire::HostInvocation.new(host_value_key: 0, callback_id: 7) + Baml::Bridge::HostCallbacks.new(api).send(:invoke, frame) + assert_equal :bridge_failure, api.completions.first.last.outcome + assert_includes api.completions.first.last.bridge_failure, "Unknown BAML host-value key" + ensure + scope&.close + end + + def test_commit_failures_roll_back_callback_results_and_are_bridge_failures + api = Api.new + handle = Baml::Handle.new(api, 41, :ADT_TAGGED_HEAP_HANDLE) + api.define_singleton_method(:handle_clone) { |_| raise ArgumentError, "clone failed" } + signature = [:function, [], [:int], [:never]] + registry = Baml::Bridge::HostValues + registrations = [] + register = registry.method(:register) + registry.stub(:register, ->(*args) { key = register.call(*args); registrations << key; key }) do + value = [-> { 1 }, handle] + completion = complete(-> { value }, [:function, [], [:list, signature], [:never]]) + assert_equal :bridge_failure, completion.outcome + assert_includes completion.bridge_failure, "clone failed" + assert_equal 2, registrations.length + registrations.each { |key| assert_raises(Baml::Bridge::Error) { registry.fetch(key) } } + end + ensure + handle&.close + end + def test_scalars_follow_the_slot_and_the_hosts_rules [[nil, :null, nil], [true, :bool, :bool_value], [false, :bool, :bool_value], [7, :int, :int_value], [2.5, :float, :float_value], ["text", :string, :string_value]].each do |value, kind, arm| @@ -1154,6 +1412,24 @@ def test_wire_depth_counts_statements_entries_and_the_argument_envelope private + def complete(callback, signature) + scope = Baml::Bridge::EncodingScope.new + key = Baml::Bridge::HostValues.register(callback, scope, signature) + scope.submitted! + api = Api.new + frame = Wire::HostInvocation.new(host_value_key: key, callback_id: 7, + application_args: Wire::BamlToHostCall.encode(Wire::BamlToHostCall.new)) + Baml::Bridge::HostCallbacks.new(api).send(:invoke, frame) + id, completion = api.completions.fetch(0) + assert_equal 7, id + assert_equal 1, api.completions.length + @host_keys << completion.raised.handle if completion.outcome == :raised + completion + ensure + Baml::Bridge::HostValues.handle_release(key) if key + scope&.close + end + def union(*members) [:union, members] end diff --git a/baml_language/sdk_tests/fixtures/function_calls/baml_src/ns_host_callable_tests/ruby_exception_value.baml b/baml_language/sdk_tests/fixtures/function_calls/baml_src/ns_host_callable_tests/ruby_exception_value.baml new file mode 100644 index 00000000000..36ec920069d --- /dev/null +++ b/baml_language/sdk_tests/fixtures/function_calls/baml_src/ns_host_callable_tests/ruby_exception_value.baml @@ -0,0 +1,10 @@ +function catch_ruby_exception(callback: () -> int throws baml.errors.HostCallable) -> baml.errors.HostCallable { + callback() catch (e) { + baml.errors.HostCallable => { return e; } + }; + throw "expected callback to throw" +} + +function forward_ruby_exception(value: baml.errors.HostCallable, callback: (baml.errors.HostCallable) -> int throws never) -> int { + callback(value) +} diff --git a/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md b/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md index 6f888803ef3..ffbabacef51 100644 --- a/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md +++ b/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md @@ -4,11 +4,11 @@ repository: baml4 --- # State of BAML↔Ruby completeness -The Ruby/Sorbet SDK generates typed functions, classes and enums for user code and supported standard-library symbols. It runs sync and async calls through the V1 C bridge, with call controls, generics and media. The tables below separate tested support from remaining gaps. +The Ruby/Sorbet SDK generates typed functions, classes and enums for user code and supported standard-library symbols. It runs sync and async calls through the V1 C bridge, with call controls, generics, host callbacks, callable BAML closures and media. The tables below separate tested support from remaining gaps. ✅ means a named Ruby test proves the stated support, 🚧 means only part of the row is tested or supported, and ❌ means unsupported or not yet parity-verified. The Python column is copied from `ref-python-state-of-completeness.md` at commit `b74b83a3ab`, including its qualifications; it is not a claim about today's Python SDK. Links to `customizable/test_*.rb` identify real-engine tests; streaming tests use the keyless replay server. Fake-bridge tests are identified separately. -Validation: `cargo nextest run -p sdk_test_ruby_sorbet` passes all 15 harness tests, including minitest and Sorbet checks for all five shared fixtures, the fixture manifest and runtime checks. Individual Ruby tests still skip callable-subscript arity; a passing harness does not make those capabilities supported. +Validation: `cargo nextest run -p sdk_test_ruby_sorbet` passes all 16 harness tests, including minitest and Sorbet checks for all five shared fixtures, the fixture manifest and runtime checks. Individual Ruby tests still skip callable release on engine GC and callable-subscript arity; a passing harness does not make those capabilities supported. ## Function-call forms @@ -18,8 +18,8 @@ Validation: `cargo nextest run -p sdk_test_ruby_sorbet` passes all 15 harness te | Free function (async) | ✅ | ✅ | `_async` siblings return `Baml::Call[T]`; `.wait` returns the result: [`test_cancellation_async_call_returns_none`][cancellation], [`test_four_call_forms_async`][invocation]. | | Static method | ✅ | ✅ | Generated class methods and `_async` siblings: [`test_methods_on_classes_static_create_round_trips`][methods], [`test_methods_on_classes_static_create_async_round_trips`][methods]. | | Instance method | ✅ | ✅ | Generated instance methods encode the receiver: [`test_methods_on_classes_instance_greet_with_arg_round_trips`][methods], [`test_methods_on_classes_instance_greet_async_with_arg_round_trips`][methods]. | -| Required args (positional) | ✅ | ✅ | Required parameters are positional: [`test_main_single_required_arg_round_trips`][function-main]. | -| Required args (keyword) | ✅ | n/a | Generated methods require positional arguments. | +| Required args (positional) | ✅ | ✅ | Required parameters are positional: [`test_main_single_required_arg_round_trips`][function-main]. Multiple arguments: [`test_host_callables_two_arg_callable_unpacks_positional_args`][host-callables]. | +| Required args (keyword) | ✅ | n/a | Generated methods require positional arguments. Returned closures also accept named arguments, as tested by [`test_baml_closure_decodes_multiple_args_and_structured_return_values`][host-callables]. | | Optional args (omitted → default) | ✅ | ✅ | Keywords default to `Baml::UNSET`; omitted values let BAML evaluate defaults: [`test_optional_args_runtime_matrix`][optional-args]. | | Optional args (supplied) | ✅ | ✅ | Explicit values and `nil` differ from omission: [`test_optional_args_python_unset_and_none_differ_in_one_call`][optional-args], [`test_optional_args_opt_box_method_matrix`][optional-args]. Nullable parameters without defaults remain required: [`test_optional_args_negative_runtime_cases_reject`][optional-args]. | | Streaming | ✅ | ❌ | No host stream wrapper or generated stream companions. BAML-internal collection remains supported: [`test_streaming_e2e_stream_collect_in_baml`][streaming], [`test_streaming_e2e_stream_doc_collect_in_baml`][streaming]. | @@ -27,9 +27,9 @@ Validation: `cargo nextest run -p sdk_test_ruby_sorbet` passes all 15 harness te | Generic function / method (inferred) | ✅ | ✅ | Engine inference from values and receivers: [`test_generic_inference_identity_infers_primitives`][generic-inference], [`test_generic_inference_genericbox_pair_with_unbound_receiver_recovers_class_var`][generic-inference]. | | Generic function / method (`_types=` kwarg) | ✅ | ✅ | Named tokens, e.g. `_types: { T: Integer }`: [`test_generic_calls_identity_explicit`][generic-calls], [`test_generic_calls_identity_async_explicit`][generic-calls], [`test_generic_calls_genericbox_pair_with_explicit`][generic-calls]. | | Generic function / method (subscript) | ✅ | ❌ | No callable-subscript API; use `_types:`. [`test_generic_inference_make_triple_partial_subscript_requires_full_arity`][generic-inference] explicitly skips this surface. | -| Host callback param | ✅ | ❌ | Declarations requiring function types are not generated. | +| Host callback param | ✅ | ✅ | Objects responding to `#call`, including lambdas, receive decoded arguments: [`test_host_callables_simple_sync_callable_returns_string`][host-callables], [`test_host_callables_class_callback_round_trips_pydantic_model`][host-callables]. Optional arguments use Ruby keywords: [`test_host_callables_optional_args_partially_set_deliver_by_name`][host-callables]. | -`_baml: Baml::Options.new(timeout_ms:, cancel:)` supplies per-call controls to free functions and methods: [`test_four_call_forms`][invocation], [`test_methods_accept_controls`][invocation]. Trace options are unsupported: only `trace: nil` is accepted, and [`test_invalid_timeout_rejected`][invocation] checks rejection of a non-nil trace. Watcher declarations are not generated. +`_baml: Baml::Options.new(timeout_ms:, cancel:)` supplies per-call controls to free functions, methods and returned closures: [`test_four_call_forms`][invocation], [`test_methods_accept_controls`][invocation], [`test_returned_callable_accepts_controls`][invocation]. Trace options are unsupported: only `trace: nil` is accepted, and [`test_invalid_timeout_rejected`][invocation] checks rejection of a non-nil trace. Watcher declarations are not generated. Sorbet erases `GenericBox[Integer]` at runtime. Use `Baml.bind_types(instance, Integer)` when a host-built instance must carry concrete type arguments; decoded instances retain them automatically. Explicit method bindings require a bound receiver: [`test_generic_calls_genericbox_pair_with_explicit`][generic-calls], [`test_generic_calls_instance_method_unparameterized_receiver_raises`][generic-calls]. `T::Struct#with` makes a new instance and drops the binding; bind the copy again when needed. @@ -42,11 +42,11 @@ These rows describe what happens when a call returns, throws, or terminates. | Normal return | ✅ | ✅ | Decodes the `ok` value: [`test_main_hello_world_returns_literal`][function-main], [`test_class_refs_round_trip_inner`][class-refs], [`test_streaming_e2e_stream_doc_collect_in_baml`][streaming]. | | BAML error | ✅ (docs-only) | ✅ | Raises `Baml::Error < StandardError` with decoded `value`, `type_name`, `message` and `baml_trace`: [`test_errors_union_throws_preserves_class_name`][errors], [`test_errors_baml_error_carries_baml_trace`][errors]. BAML frames appear in Ruby's backtrace: [`test_errors_baml_trace_spliced_into_python_traceback`][errors]. | | BAML panic | ✅ | ✅ | Raises `Baml::Panic < Exception`, not `StandardError`, with a decoded payload: [`test_errors_user_panic_surfaces_as_baml_panic`][errors]. Ordinary `rescue => e` does not catch it. | -| Host-callback error | ✅ | ❌ | No host-callback dispatch or exception round trip. | +| Host-callback error | ✅ | ✅ | Re-raises the original Ruby exception by identity: [`test_host_callables_throwing_callable_keyerror_round_trips_with_identity`][host-callables]. A callback's `Baml::Error` can be caught in BAML or return with typed fields: [`test_host_callables_throwing_callable_bamlerror_wrapping_codegenned_class_is_caught_in_baml`][host-callables], [`test_host_callables_throwing_callable_bamlerror_propagates_back_with_typed_fields`][host-callables]. | | Cancellation | ✅ (async only) | ✅ | Tokens from `BamlSdk::Baml::Spawn::CancelToken.baml_new` cancel sync and async calls; `Call#cancel` cancels an async call. Cancellation raises `Baml::CancelledError < Baml::Panic`: [`test_cancellation_sync_cancel_via_call_context`][cancellation], [`test_cancellation_async_cancel_via_call_context`][cancellation], [`test_cancellation_async_cancel_via_task_cancel`][cancellation]. A `wait(timeout:)` timeout raises `Baml::WaitTimeout` without cancelling; `timeout_ms` cancels the engine call: [`test_cancellation_async_cancel_via_asyncio_timeout`][cancellation]. | | OS exit | ✅ | ✅ | `baml.sys.exit` terminates the process without Ruby `at_exit` hooks: [`test_errors_clean_exit_terminates_process_with_code_0`][errors], [`test_errors_clean_exit_terminates_process_with_code_7`][errors]. | -The fake-bridge test [`test_call_waiters_cache_outcomes_and_survive_interrupt_and_cancel`][bridge-call] checks multiple waiters, cached results and errors, an interrupted waiter, foreign-thread completion and a cancellation race. +The fake-bridge test [`test_call_waiters_cache_outcomes_and_survive_interrupt_and_cancel`][bridge-call] checks multiple waiters, cached results and errors, an interrupted waiter, foreign-thread completion and a cancellation race. Host callbacks run on their own Ruby threads. Cancellation does not kill a running callback or free its resources before it returns: fake-bridge [`test_cancelled_host_callback_keeps_resources_until_proc_returns`][bridge-call]. Nested calls do not inherit the callback's invocation context. Generated packages initialize on their first call or through `initialize!`. Loading a different program in the same process and using an inherited runtime after fork raise errors: fixture-backed [`test_different_program`][loader], [`test_pid_check`][loader]. @@ -56,11 +56,11 @@ Gem packaging, clean-app installation and native-library cache lookup are unsupp The [shared Ruby harness][static-gate] runs `srb tc` on each of the five generated packages and its tests with Sorbet **0.6.13506**, pinned in a separate [test bundle][static-gemfile]. Every generated Ruby file, including bytecode and model declarations, uses `# typed: strict`. No SDK functions or models are recreated in test RBIs. -Strict consumers in the fixture overlays cover sync and async returns, stdlib methods, optional arguments, call controls, generics, bigint, bytes, media, unions, aliases, classes and enums. One negative consumer requires exactly one wrong-argument diagnostic (`7002`), so a regression to `T.untyped` fails the gate. +Strict consumers in the fixture overlays cover sync and async returns, stdlib methods, callbacks and returned callables, optional arguments, call controls, generics, bigint, bytes, media, unions, aliases, classes and enums. One negative consumer requires exactly one wrong-argument diagnostic (`7002`), so a regression to `T.untyped` fails the gate. The [boundary RBI][static-bridge] describes the runtime-facing API, including call and generic helpers; it does not recreate generated SDK declarations. The bridge's decoded return remains dynamic, so this gate proves generated declarations and consumer checking, not the runtime's adherence to BAML return types. Real-engine and replay tests cover runtime behavior separately, including recursion, forward references and symbol collisions. -Typing still has limits: literal values use their Ruby primitive types, recursive alias back-edges and JSON use `T.untyped`, and `_types:` token values are dynamic. Declarations requiring function types are not generated. Method signatures retain Sorbet's lazy validation. +Typing still has limits: literal values use their Ruby primitive types, recursive alias back-edges and JSON use `T.untyped`, and `_types:` token values are dynamic. Callable signatures accept typed Procs or `Baml::Closure`. Generated methods with callable parameters or results disable Sorbet's runtime signature checks so other callable objects also work. Other method signatures retain Sorbet's lazy validation. The pinned checker runs on x86_64/arm64 macOS and x86_64/aarch64 glibc Linux. Windows, 32-bit Linux and musl hosts explicitly ignore the static test because no compatible binary is supplied; their runtime tests still run. Setup installs the checker under `target/ruby-sorbet-static-bundle` only on supported hosts, and installation failures there fail setup. @@ -88,14 +88,14 @@ Encode means Ruby → BAML; decode means BAML → Ruby. The generated-package te | BAML interface | ❌ | ❌ | No interface value support; affected declarations are not generated. | | Media | ✅ | ✅ | Frozen value objects `Baml::Image`, `Audio`, `Video` and `Pdf`, not engine handles. URL round trips: [`test_media_round_trip_media`][media]. Base64, file contents, names, MIME types and local-file loading: [`test_handles_image_from_base64_roundtrips_payload`][handles]. | | Stream | ✅ | ❌ | No host stream wrapper; declarations requiring stream values are not generated. | -| Host callable | ✅ | ❌ | No host-value registry or dispatch; declarations requiring function types are not generated. | -| Host callable (async) | ✅ (works, but returned coroutine is driven to completion on new asyncio event loop) | ❌ | No host-callback dispatch. | -| BAML closure (function reference) | ❌ | ❌ | No callable closure wrapper; declarations requiring function types are not generated. | -| BAML closure (closure / bound method / generic function / function) | ❌ | ❌ | No callable closure wrapper; declarations requiring function types are not generated. | +| Host callable | ✅ | ✅ | Ruby callable objects and lambdas are registered and dispatched with decoded values: [`test_host_callables_simple_sync_callable_returns_string`][host-callables], [`test_host_callables_class_callback_round_trips_pydantic_model`][host-callables]. Release on engine GC remains unverified; [`test_host_callables_release_fires_on_drop_of_callable`][host-callables] is skipped. | +| Host callable (async) | ✅ (works, but returned coroutine is driven to completion on new asyncio event loop) | ❌ | Returned async work is not awaited by the bridge. [`test_host_callables_async_callable_runs_to_completion`][host-callables] joins its own thread with `.value`; it does not prove automatic async callback support. | +| BAML closure (function reference) | ❌ | ❌ | The runtime maps `FUNCTION_REF` handles to `Baml::Closure`, but no named Ruby test covers a bare BAML function reference. | +| BAML closure (closure / bound method / generic function / function) | ❌ | 🚧 | Returned closures support `call`, `call_async`, positional/keyword arguments and mutable captures: [`test_baml_closure_is_a_native_callable_with_host_language_arguments`][host-callables], [`test_baml_closure_decodes_multiple_args_and_structured_return_values`][host-callables], [`test_baml_closure_is_reusable_and_retains_mutable_captures`][host-callables]. Bound-method and generic-function values are not parity-verified; specialized closures reject `_types:`. | | BAML type reference values | ❌ | ❌ | No first-class BAML type-value encoding or decoding. `Baml::Types` tokens are call metadata, not ordinary argument values. | | BAML type definition values | ❌ | ❌ | No type-definition values. | | BAML `$rust_type` values: files, sockets, etc. | ✅ | 🚧 | Generated resource structs retain `Baml::Handle` fields. Files preserve cursor state: [`test_handles_open_file_returns_file_handle`][handles], [`test_handles_file_cursor_state_persists_across_calls`][handles]. HTTP response fields and methods: [`test_handles_http_get_response_fields_and_methods`][handles]. Socket values are not parity-verified. | -| Native exception thrown by a host callback | ✅ | ❌ | No opaque host-value registry or exception round trip. | +| Native exception thrown by a host callback | ✅ | ✅ | Opaque host-value keys preserve the original exception object and custom fields: [`test_host_callables_throwing_callable_custom_python_exception_round_trips_with_identity`][host-callables]. | | Unused SDK heap handles (`Handle`) | 🚧 | 🚧 | Handles work inside tested resources: [`test_handles_file_cursor_state_persists_across_calls`][handles]. Standalone unused heap-handle ownership and GC release are not parity-verified. | | Unused SDK collector values (`Collector`) | 🚧 | ❌ | No collector wrapper or verified collector-value path. | | Unused SDK prompt values (`PromptAst`) | 🚧 | ❌ | No prompt-value support. | @@ -118,6 +118,7 @@ Encode means Ruby → BAML; decode means BAML → Ruby. The generated-package te [invocation]: ../../../sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_invocation.rb [generic-calls]: ../../../sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_generic_calls.rb [generic-inference]: ../../../sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_generic_inference.rb +[host-callables]: ../../../sdk_tests/crates/ruby_sorbet/function_calls/customizable/test_host_callables.rb [primitives]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_primitives.rb [lists]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_lists.rb [maps]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_maps.rb diff --git a/baml_language/sdks/ruby/bridge_ruby/README.md b/baml_language/sdks/ruby/bridge_ruby/README.md index 8fc2415bf29..054224be639 100644 --- a/baml_language/sdks/ruby/bridge_ruby/README.md +++ b/baml_language/sdks/ruby/bridge_ruby/README.md @@ -27,6 +27,7 @@ Generated functions initialize the runtime on their first call. `BamlSdk.initial it alongside your generated `baml_sdk.rb` and `baml_sdk/` sources. Generated structs compare by their fields and bound generic type arguments. +Function types accept either a typed Proc or a `Baml::Closure` returned by BAML. ## Generic values and calls diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge.rb index b2f7381205d..e3952418229 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge.rb @@ -9,6 +9,7 @@ require_relative "bridge/types" require_relative "value_equality" require_relative "bridge/protocol" +require_relative "bridge/host_callbacks" require_relative "bridge/process_runtime" module Baml diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/call.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/call.rb index ab633b2d6ef..3c325ffd9a3 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/call.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/call.rb @@ -68,7 +68,7 @@ def done? end # Internal: the callback delivers an envelope owning its handle references - # (or an error). Waiters build and cache Ruby values. + # and pinned host values (or an error). Waiters build and cache Ruby values. def complete(outcome) @mutex.synchronize do return if @state == :settled diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/commit.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/commit.rb index b54c8bb3472..8b067ece3e5 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/commit.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/commit.rb @@ -9,9 +9,9 @@ module Commit module_function def depth(node) - statement = %i[list map class].include?(node.kind) ? 1 + Slot.wire_depth(Slot.to_wire(node.ty)) : 0 + statement = %i[list map class callable].include?(node.kind) ? 1 + Slot.wire_depth(Slot.to_wire(node.ty)) : 0 payload = case node.kind - when :enum, :handle then 1 + when :enum, :handle, :host_object, :callable then 1 when :media then node.value.name ? 2 : 1 when :list then 1 + (node.children.map { |child| 1 + depth(child) }.max || 0) when :map, :class then 1 + (node.children.map { |_, child| 2 + depth(child) }.max || 0) @@ -47,6 +47,12 @@ def commit(node, scope: nil) when :media then { media_value: media(node) } when :enum { enum_value: Wire::InboundEnumValue.new(name: node.ty[1], value: node.value) } + when :callable + key = HostValues.register(node.value, scope, node.ty) + { value_type: Slot.to_wire(node.ty), handle: Wire::BamlHandle.new(key: key, handle_type: :HOST_VALUE_CALLABLE) } + when :host_object + key = HostValues.register(node.value, scope) + { handle: Wire::BamlHandle.new(key: key, handle_type: :HOST_VALUE_OPAQUE) } when :handle raise Error, "BAML handles can only be sent inside a call" unless scope diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/plan.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/plan.rb index b3492711e29..e2e98f2179d 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/plan.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/plan.rb @@ -109,7 +109,7 @@ def initialize(registry, budget: STEP_BUDGET) def plan_root(tree, slot) planned = plan(tree, slot) - if planned.kind != :handle && Slot.open?(planned.ty, &@work) && Slot.mentions_param?(slot, &@work) + if !%i[handle host_object].include?(planned.kind) && Slot.open?(planned.ty, &@work) && Slot.mentions_param?(slot, &@work) name = Slot.params(slot, &@work).first[2] raise UnboundTypeArgs, "#{tree.path}: could not infer a type for `#{name}`; pass the call's type arguments" end @@ -128,7 +128,7 @@ def plan_normalized(tree, slot) spend(1, tree.path) slot = Slot.unfold(slot, @registry, &@work) return choose(tree, slot[1]) if slot[0] == :union - return node(tree, [:opaque]) if tree.kind == :handle + return node(tree, [:opaque]) if %i[handle host_object].include?(tree.kind) if %i[unknown interface].include?(slot[0]) own = natural(tree) return restate(own, fill_type(own.ty)) @@ -175,6 +175,13 @@ def plan_normalized(tree, slot) end when :class return instance(tree, slot) if tree.kind == :class && tree.name == slot[1] + when :function + if tree.kind == :callable + throws = Slot.mentions_param?(slot[3], &@work) ? [:unknown] : slot[3] + signature = [:function, slot[1], slot[2], throws] + holes = Slot.params(signature, &@work).to_h { |param| [param[1], [:hole]] } + return node(tree, Slot.substitute(signature, holes, &@work)) + end end return node(tree, [:media, tree.name]) if tree.kind == :media && media_fits?(tree, slot) refuse(tree, "cannot encode #{tree.kind} as #{slot.inspect}") @@ -272,7 +279,9 @@ def natural(tree) end node(tree, [:enum, tree.name]) when :class then instance(tree) - when :handle then node(tree, [:opaque]) + when :callable + raise UntypedCallable, "#{tree.path}: cannot encode an untyped callable; it needs a function slot" + when :handle, :host_object then node(tree, [:opaque]) when :list children = tree.children.map { |child| natural(child) } spend(children.length, tree.path) @@ -312,7 +321,8 @@ def rank(tree, slot) slot = Slot.unfold(slot, @registry, &@work) return 0 if tree.kind == :media && media_fits?(tree, slot) return 0 if tree.kind == :enum && %i[enum enum_variant].include?(slot[0]) && tree.name == slot[1] - return 2 if %i[unknown interface param hole opaque].include?(slot[0]) || tree.kind == :handle + return 2 if %i[unknown interface param hole opaque].include?(slot[0]) || %i[handle host_object].include?(tree.kind) + return 1 if tree.kind == :callable && slot[0] == :function return 0 if tree.kind == :null && slot[0] == :null return 0 if %i[class enum].include?(tree.kind) && tree.kind == slot[0] && tree.name == slot[1] return 1 if %i[list map].include?(tree.kind) && tree.kind == slot[0] @@ -367,7 +377,7 @@ def choose(tree, members) preferred = fits.find { |candidate| own && Slot.equivalent?(candidate.ty, own, &@work) } return preferred if preferred - refuse(tree, "fits more than one union member") + raise Ambiguous, "#{tree.path}: fits more than one union member" end raise failures.first if failures.length == 1 diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/read.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/read.rb index a8a1aa7bed7..48029f42ab4 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/read.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/read.rb @@ -44,6 +44,8 @@ def read(value, path, depth = 1) [wire, read(value.public_send(ruby), "#{path}.#{wire}", depth + 1)] end :class + elsif (scalar = Rules.kind(value)) != :other + scalar elsif value.is_a?(Array) children = [] value.each_with_index { |item, index| children << read(item, "#{path}[#{index}]", depth + 1) } @@ -56,8 +58,10 @@ def read(value, path, depth = 1) children << [key_tree, read(item, entry_path, depth + 1)] end :map + elsif HostValues.opaque?(value) + :host_object else - Rules.kind(value) + value.respond_to?(:call) ? :callable : :other end if value.is_a?(String) unless value.encoding == Encoding::BINARY || value.valid_encoding? diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/slot.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/slot.rb index 207bc5e1d76..7dfdecffeed 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/slot.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/encode/slot.rb @@ -10,6 +10,8 @@ class Fault < Refusal; end class UnboundTypeArgs < Refusal; end class MissingTypeArgs < Refusal; end class TypeArgConflict < Refusal; end + class Ambiguous < Refusal; end + class UntypedCallable < Refusal; end module Slot module_function @@ -238,6 +240,13 @@ def to_wire(slot) when :list then { list: Wire::BamlTyList.new(item: to_wire(args[0])) } when :map then { map: Wire::BamlTyMap.new(key: to_wire(args[0]), value: to_wire(args[1])) } when :union then { union: Wire::BamlTyUnion.new(options: args[0].map { |item| to_wire(item) }) } + when :function + params = args[0].map do |name, ty, optional| + fields = { ty: to_wire(ty), mode: optional ? :BAML_TY_FUNCTION_PARAM_MODE_OPTIONAL : :BAML_TY_FUNCTION_PARAM_MODE_REQUIRED } + fields[:name] = name if name + Wire::BamlTyFunctionParam.new(**fields) + end + { function: Wire::BamlTyFunction.new(params: params, ret: to_wire(args[1]), throws: to_wire(args[2])) } when :literal literal_kind = literal_kind(slot) unless %i[int bigint string bool].include?(literal_kind) @@ -263,6 +272,11 @@ def from_wire(ty) %i[int bigint float string bool null bytes].include?(kind) ? [kind] : [:other] when :unknown then [:unknown] when :never then [:never] + when :function + return [:other] unless body.generic_params.empty? + + [:function, body.params.map { |param| [param.has_name? ? param.name : nil, from_wire(param.ty), + param.mode == :BAML_TY_FUNCTION_PARAM_MODE_OPTIONAL] }, from_wire(body.ret), from_wire(body.throws)] when :class_ty then [:class, body.name, body.type_args.map { |item| from_wire(item) }] when :enum then [:enum, body.name] when :enum_variant then [:enum_variant, body.name, body.variant] diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/handle.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/handle.rb index 21cac1a1bdb..dd4b148da14 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/handle.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/handle.rb @@ -37,36 +37,79 @@ def close nil end - def clone_for_wire(scope) + def clone_for_wire(scope, borrowed: false) Thread.handle_interrupt(Exception => :never) do @mutex.synchronize do raise Bridge::Error, "BAML handle is closed" unless @key[0] key = @api.handle_clone(@key[0]) - scope.own(@api, key) + borrowed ? scope.borrow(@api, key) : scope.own(@api, key) [key, @handle_type] end end end end + class Closure < Handle + attr_reader :parameter_descriptors + + def initialize(api, key, handle_type, function_type = nil) + wire = Bridge::Protocol::Wire + descriptor = Bridge::Encode::Slot.from_wire(wire::BamlTy.new(function: function_type)) + @parameters = descriptor[0] == :function ? descriptor[1].each_with_index.map do |(name, ty, optional), index| + [name || "arg#{index}", ty, optional] + end : [] + @parameter_descriptors = @parameters.to_h { |name, ty, _optional| [name, ty] } + super(api, key, handle_type) + end + + def call(*args, **kwargs) + call_async(*args, **kwargs).wait + end + + def call_async(*args, _baml: nil, **kwargs) + required = @parameters.reject(&:last).map(&:first) + raise ArgumentError, "Too many positional arguments for BAML closure" if args.length > required.length + raise ArgumentError, "Specialized BAML closures do not accept _types" if kwargs.key?(:_types) + + values = required.zip(args).first(args.length).to_h + kwargs.each do |name, value| + wire = name.to_s + raise ArgumentError, "Unknown BAML closure argument #{name}" unless @parameters.any? { |param, _ty, _optional| param == wire } + raise ArgumentError, "Multiple values for BAML closure argument #{name}" if values.key?(wire) + + values[wire] = value + end + Bridge.call_async(self, values, baml: _baml) + end + end + module Bridge class EncodingScope def initialize @handles = [] + @borrowed = [] end def own(api, key) @handles << [api, key] end + # Callable targets are borrowed, unlike argument handles: retain a clone + # through native submission, then release it on either submission or rollback. + def borrow(api, key) + @borrowed << [api, key] + end + def submitted! @handles.clear + close end def close - handles = @handles.dup + handles = @handles + @borrowed @handles.clear + @borrowed.clear first_error = nil handles.each do |api, key| begin diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/host_callbacks.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/host_callbacks.rb new file mode 100644 index 00000000000..78e366163fe --- /dev/null +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/host_callbacks.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +module Baml + module Bridge + # Host keys are borrowed on the outbound wire. Only the engine's release + # callback (or rollback of an unsubmitted encoding) removes their owners. + module HostValues + @mutex = Mutex.new + @values = {} + @opaque_objects = ObjectSpace::WeakMap.new + @next_key = 1 + + def self.register(value, scope, descriptor = nil) + raise Error, "BAML host values can only be sent inside an encoding scope" unless scope + + Thread.handle_interrupt(Exception => :never) do + @mutex.synchronize do + raise Error, "BAML host-value keys are exhausted" if @next_key > 0xffff_ffff_ffff_ffff + + key = @next_key + @next_key += 1 + @values[key] = [value, descriptor].freeze + @opaque_objects[value] = true unless descriptor + scope.own(self, key) + key + end + end + end + + def self.opaque?(value) + @mutex.synchronize { @opaque_objects.key?(value) } + end + + def self.fetch(key) + entry(key).first + end + + def self.entry(key) + @mutex.synchronize { @values.fetch(key) { raise Error, "Unknown BAML host-value key #{key}" } } + end + + def self.handle_release(key) + @mutex.synchronize { @values.delete(key) } + nil + end + end + + class HostCallbacks + Wire = Protocol::Wire + + def initialize(api) + @api = api + @dispatch = FFI::Function.new(:void, %i[pointer size_t]) do |pointer, length| + frame = nil + arguments = nil + begin + # The engine lends these bytes only until this callback returns. + frame = Wire::HostInvocation.decode(pointer.read_bytes(length)) + arguments = argument_result(frame) + Thread.new { invoke(frame, arguments) } + rescue Exception => error # rubocop:disable Lint/RescueException -- exceptions cannot cross FFI + arguments&.close + finish(frame, bridge_failure(error.message), EncodingScope.new) if frame + end + end + @release = FFI::Function.new(:void, [:uint64]) { |key| HostValues.handle_release(key) } + # Ruby callbacks cancel cooperatively: never kill their thread or drop + # their resources here. complete_host_call reports actual host exit. + @cancel = FFI::Function.new(:void, [:uint32]) { |_id| } + end + + def register! + @api.register_host_callbacks(@dispatch, @release, @cancel) + end + + private + + def argument_result(frame) + application = Wire::BamlToHostCall.decode(frame.application_args) + values = Wire::BamlOutboundValue.new(list_value: { items: application.args.map(&:value) }) + Protocol::Result.new(nil, values, @api) + end + + def invoke(frame, arguments = nil) + scope = EncodingScope.new + completion = nil + begin + arguments ||= argument_result(frame) + application = Wire::BamlToHostCall.decode(frame.application_args) + callable, descriptor = HostValues.entry(frame.host_value_key) + args = [] + kwargs = {} + application.args.each do |arg| + value = Protocol.decode_value(arg.value, @api, host_values: arguments.host_values, handles: arguments) + arg.is_optional_arg ? kwargs[arg.arg_name.to_sym] = value : args << value + end + unless descriptor&.first == :function + raise Error, "BAML callback has no function signature" + end + begin + value = callable.call(*args, **kwargs) + completion = encode_completion(value, descriptor[2], :returned, scope) + rescue Exception => error # rubocop:disable Lint/RescueException -- includes host errors while reading the result + completion = exception_completion(error, descriptor[3], scope) + end + rescue Exception => error # rubocop:disable Lint/RescueException -- infrastructure failures must complete too + scope.close + completion = bridge_failure(error.message) + ensure + arguments&.close + finish(frame, completion, scope) + end + end + + # Reading may run getters or enumerators. Their exceptions leave this + # method as host throws; once a plan is decided, failures are the bridge's. + def encode_completion(value, slot, outcome, scope) + begin + tree = Encode::Read.new(Protocol).read(value, "result") + rescue Encode::Refusal => error + return refused_completion(error) + end + begin + plan = Encode::Plan.new(Protocol).plan_root(tree, slot) + Encode::Commit.check_depth!(Encode::Commit.depth(plan), "result", enclosing: 1) + rescue Encode::Refusal => error + return refused_completion(error) + rescue Exception => error # rubocop:disable Lint/RescueException -- planning runs no host code + return bridge_failure(error.message) + end + begin + encoded = Encode::Commit.commit(plan, scope: scope) + Encode::Commit.check_depth!(Encode::Slot.wire_depth(encoded), "result", enclosing: 1) + Wire::HostCallCompletion.new(**{ outcome => encoded }) + rescue Exception => error # rubocop:disable Lint/RescueException -- commit failures are never host throws + scope.close + bridge_failure(error.message) + end + end + + def refused_completion(error) + if error.is_a?(Encode::Fault) || error.is_a?(Encode::Ambiguous) || + error.is_a?(Encode::MissingTypeArgs) || error.is_a?(Encode::UnboundTypeArgs) || error.is_a?(Encode::UntypedCallable) + bridge_failure(error.message) + else + Wire::HostCallCompletion.new(contract_violation: error.message) + end + end + + def exception_completion(error, throws, scope) + if error.is_a?(::Baml::Error) || error.is_a?(::Baml::Panic) + slot = error.is_a?(::Baml::Panic) ? [:unknown] : throws + begin + return encode_completion(error.value, slot, :thrown, scope) + rescue Exception => reading_error # rubocop:disable Lint/RescueException -- a thrown value's getter may also raise + error = reading_error + end + end + key = HostValues.register(error, scope) + fields = { message: utf8(error.message), class_name: error.class.name || error.class.to_s, language: "ruby", handle: key } + fields[:traceback] = utf8(error.backtrace.join("\n")) if error.backtrace + Wire::HostCallCompletion.new(raised: Wire::HostException.new(**fields)) + end + + def utf8(text) + text = text.dup.force_encoding(Encoding::UTF_8) if text.encoding == Encoding::BINARY + text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) + end + + def bridge_failure(message) + Wire::HostCallCompletion.new(bridge_failure: message) + end + + def finish(frame, completion, scope) + Thread.handle_interrupt(Exception => :never) do + begin + @api.complete_host_call(frame.callback_id, Wire::HostCallCompletion.encode(completion)) + scope.submitted! + ensure + begin + scope.close + ensure + begin + @api.handle_release(frame.effective_state) unless frame.effective_state.zero? + ensure + Protocol.release_outbound_references(frame.cancel, @api) + end + end + end + end + end + end + end +end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/native.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/native.rb index 56abf29eb30..ca1d0aca2bb 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/native.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/native.rb @@ -82,6 +82,9 @@ class ApiV1 < FFI::Struct ], free_buffer: [:void, [Buffer.by_value]], register_callback: [:void, [:pointer]], + register_host_dispatch_v2: [:void, [:pointer]], + register_host_release_callback: [:void, [:pointer]], + register_host_cancel_callback: [:void, [:pointer]], complete_host_call: [:void, %i[uint32 pointer size_t], { blocking: true }], new_function_call: [:uint64, []], release_function_call: [:int32, [:uint64]], @@ -126,6 +129,12 @@ def shutdown_runtime raise Error, "BAML runtime shutdown failed: #{diagnostic}" unless diagnostic.empty? end + def register_host_callbacks(dispatch, release, cancel) + function(:register_host_dispatch_v2).call(dispatch) + function(:register_host_release_callback).call(release) + function(:register_host_cancel_callback).call(cancel) + end + def initialize_runtime(bytecode, metadata = nil) pointer = memory_for(bytecode) operation = metadata.nil? ? :initialize_runtime_from_blob : :initialize_runtime_from_blob_with_metadata diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/process_runtime.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/process_runtime.rb index c6f54fbb61f..139d95916aa 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/process_runtime.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/process_runtime.rb @@ -17,6 +17,8 @@ def initialize @bridge_registered = false @result_callback = nil @result_registered = false + @host_callbacks = nil + @host_registered = false @spawn_callback = nil @spawn_registered = false @initialized = false @@ -78,6 +80,13 @@ def initialize! @result_registered = true end end + unless @host_registered + @host_callbacks ||= HostCallbacks.new(@api) + Thread.handle_interrupt(Exception => :never) do + @host_callbacks.register! + @host_registered = true + end + end unless @spawn_registered @spawn_callback ||= FFI::Function.new(:void, %i[pointer size_t int32]) do |pointer, length, cancelled| begin @@ -203,7 +212,7 @@ def receive_result(callback_id, outcome) begin outcome = Protocol.capture_result(outcome, @api) unless outcome.is_a?(Exception) - rescue Exception => error # rubocop:disable Lint/RescueException -- settle parse failures inside Ruby + rescue Exception => error # rubocop:disable Lint/RescueException -- settle parse/pinning failures inside Ruby outcome = error end call.complete(outcome) diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/protocol.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/protocol.rb index 8051e533ca2..a86bd3c59b3 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/protocol.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/protocol.rb @@ -17,16 +17,26 @@ module Protocol # Wait transfers those references to wrappers; an abandoned or failed # decode releases the rest without retaining the captured result itself. class Result - attr_reader :message + attr_reader :message, :host_values def initialize(message, value, api) @message = message + @host_values = {} @keys = [] Protocol.visit_handles(value) do |handle| @keys << handle.key unless %i[HOST_VALUE_CALLABLE HOST_VALUE_OPAQUE].include?(handle.handle_type) end @cleanup = self.class.finalizer(api, @keys, Process.pid) ObjectSpace.define_finalizer(self, @cleanup) + Protocol.visit_handles(value) do |handle| + if %i[HOST_VALUE_CALLABLE HOST_VALUE_OPAQUE].include?(handle.handle_type) + @host_values[handle.key] = HostValues.fetch(handle.key) + end + end + @host_values.freeze + rescue Exception # rubocop:disable Lint/RescueException -- release the inventory if pinning fails + close + raise end def self.finalizer(api, keys, owner_pid) @@ -198,7 +208,7 @@ def prepare_type_args(function_name, arguments, types) end def encode_call(call_id, function_name, arguments, scope: nil, invocation: Wire::InvocationOptions.new, type_args: []) - hints = @functions.fetch(function_name, {}) + hints = function_name.is_a?(::Baml::Closure) ? function_name.parameter_descriptors : @functions.fetch(function_name, {}) bindings = type_args.to_h { |arg| [arg.type_var, Encode::Slot.from_wire(arg.type_value)] } reader = Encode::Read.new(self) planner = Encode::Plan.new(self) @@ -210,19 +220,26 @@ def encode_call(call_id, function_name, arguments, scope: nil, invocation: Wire: [name, planner.plan_root(tree, slot)] end plans.each { |name, plan| Encode::Commit.check_depth!(Encode::Commit.depth(plan), name) } + target = if function_name.is_a?(::Baml::Closure) + { function_handle: function_name.clone_for_wire(scope, borrowed: true)[0] } + else + { function_name: function_name } + end kwargs = plans.map do |name, plan| value = Encode::Commit.commit(plan, scope: scope) Encode::Commit.check_depth!(Encode::Slot.wire_depth(value), name) Wire::InboundMapEntry.new(string_key: name, value: value) end Wire::CallFunctionArgs.encode(Wire::CallFunctionArgs.new( - call_id: call_id, function_name: function_name, kwargs: kwargs, type_args: type_args, + call_id: call_id, **target, kwargs: kwargs, type_args: type_args, invocation: invocation )) rescue Encode::Refusal => error raise error.class, "#{function_name}: #{error.message}" end + # Outbound host keys are borrowed. Pin their Ruby owners before returning + # from the native callback: engine GC may release them before Call#wait. def capture_result(bytes, api = nil) message = Wire::BamlOutboundResult.decode(bytes) if message.result == :panic && message.panic.is_exit_panic @@ -276,7 +293,7 @@ def exit_process(code) def decode_result(bytes, api = nil) captured = bytes.is_a?(Result) ? bytes : capture_result(bytes, api) result = captured.message - owners = { handles: captured } + owners = { host_values: captured.host_values, handles: captured } case result.result when :ok decode_value(result.ok, api, **owners) @@ -325,7 +342,7 @@ def unregistered_class?(value) end end - def decode_value(value, api = nil, handles: nil, error_payload: false) + def decode_value(value, api = nil, host_values: nil, handles: nil, as_proc: false, error_payload: false) raise Error, "BAML returned an empty value" unless value case value.value @@ -339,10 +356,23 @@ def decode_value(value, api = nil, handles: nil, error_payload: false) decode_media(value.media_value) when :handle_value handle = value.handle_value - if %i[HOST_VALUE_CALLABLE HOST_VALUE_OPAQUE].include?(handle.handle_type) - raise UnsupportedTypeError, "BAML host-value handles are not supported" + klass = case handle.handle_type + when :HOST_VALUE_CALLABLE, :HOST_VALUE_OPAQUE + owner = host_values ? host_values.fetch(handle.key) : HostValues.fetch(handle.key) + if as_proc && handle.handle_type == :HOST_VALUE_CALLABLE && !owner.is_a?(Proc) + return owner.method(:call).to_proc + end + return owner + when :FUNCTION_REF then ::Baml::Closure + else ::Baml::Handle + end + build = lambda do + if klass == ::Baml::Closure + klass.new(api, handle.key, handle.handle_type, handle.ty&.function) + else + klass.new(api, handle.key, handle.handle_type) + end end - build = -> { ::Baml::Handle.new(api, handle.key, handle.handle_type) } handles ? handles.adopt(handle.key, &build) : build.call when nil, :null_value # V1 currently emits an absent oneof for null. @@ -351,12 +381,12 @@ def decode_value(value, api = nil, handles: nil, error_payload: false) object = value.class_value if error_payload && unregistered_class?(value) return object.fields.to_h do |field| - [field.key, decode_value(field.value, api, handles: handles, error_payload: true)] + [field.key, decode_value(field.value, api, host_values: host_values, handles: handles, as_proc: true, error_payload: true)] end.freeze end type = registered_type(object.name) fields = @fields.fetch(type) - decoded = object.fields.to_h { |field| [fields.fetch(field.key)[0], decode_value(field.value, api, handles: handles, error_payload: error_payload)] } + decoded = object.fields.to_h { |field| [fields.fetch(field.key)[0], decode_value(field.value, api, host_values: host_values, handles: handles, as_proc: true, error_payload: error_payload)] } instance = type.new(**decoded) unless object.type_args.empty? ::Baml::Types.bind(instance, *object.type_args.map { |arg| Encode::Slot.from_wire(arg) }) @@ -369,12 +399,12 @@ def decode_value(value, api = nil, handles: nil, error_payload: false) end registered_type(enum.name).deserialize(enum.value) when :list_value - value.list_value.items.map { |item| decode_value(item, api, handles: handles, error_payload: error_payload) } + value.list_value.items.map { |item| decode_value(item, api, host_values: host_values, handles: handles, as_proc: as_proc, error_payload: error_payload) } when :map_value map = value.map_value - map.entries.to_h { |entry| [decode_map_key(entry.key, map.key_type), decode_value(entry.value, api, handles: handles, error_payload: error_payload)] } + map.entries.to_h { |entry| [decode_map_key(entry.key, map.key_type), decode_value(entry.value, api, host_values: host_values, handles: handles, as_proc: as_proc, error_payload: error_payload)] } when :union_variant_value - decode_value(value.union_variant_value.value, api, handles: handles, error_payload: error_payload) + decode_value(value.union_variant_value.value, api, host_values: host_values, handles: handles, as_proc: as_proc, error_payload: error_payload) when :literal_value literal = value.literal_value case literal.literal @@ -429,10 +459,15 @@ def decode_bool_key(key) raise Error, "BAML returned an invalid bool map key #{key.inspect}" end - def raise_failure(error_class, value, api = nil, trace: [], handles: nil) + def raise_failure(error_class, value, api = nil, trace: [], host_values: nil, handles: nil) while value&.value == :union_variant_value value = value.union_variant_value.value end + if value&.value == :class_value && value.class_value.name == "baml.errors.HostCallable" + handle = value.class_value.fields.find { |field| field.key == "_handle" }&.value + original = decode_value(handle, api, host_values: host_values, handles: handles) if handle + raise original if original.is_a?(Exception) + end name = if value&.value == :class_value value.class_value.name else @@ -441,10 +476,10 @@ def raise_failure(error_class, value, api = nil, trace: [], handles: nil) end if error_class == ::Baml::Error && name == "baml.errors.TypeMismatch" message = value.class_value.fields.find { |field| field.key == "message" }&.value - error = TypeError.new(message ? decode_value(message, api, handles: handles).to_s : name) + error = TypeError.new(message ? decode_value(message, api, host_values: host_values, handles: handles).to_s : name) else error_class = ::Baml::CancelledError if error_class == ::Baml::Panic && name == "baml.panics.Cancelled" - error = error_class.new(decode_value(value, api, handles: handles, error_payload: true), type_name: name, baml_trace: trace) + error = error_class.new(decode_value(value, api, host_values: host_values, handles: handles, error_payload: true), type_name: name, baml_trace: trace) end # Ruby backtraces use path:line:in `function`, while the wire uses # Python's frame format. Keep the original lines on #baml_trace. @@ -455,6 +490,12 @@ def raise_failure(error_class, value, api = nil, trace: [], handles: nil) raise error end + def release_outbound_references(value, api) + visit_handles(value) do |handle| + api.handle_release(handle.key) unless %i[HOST_VALUE_CALLABLE HOST_VALUE_OPAQUE].include?(handle.handle_type) + end + end + end end end diff --git a/baml_language/sdks/ruby/bridge_ruby/rbi/baml.rbi b/baml_language/sdks/ruby/bridge_ruby/rbi/baml.rbi index d9c448793c6..d84ac129929 100644 --- a/baml_language/sdks/ruby/bridge_ruby/rbi/baml.rbi +++ b/baml_language/sdks/ruby/bridge_ruby/rbi/baml.rbi @@ -140,6 +140,14 @@ module Baml class Video < Media; end class Pdf < Media; end + class Closure < Handle + extend T::Sig + sig { params(args: T.untyped, kwargs: T.untyped).returns(T.untyped) } + def call(*args, **kwargs); end + sig { params(args: T.untyped, _baml: T.untyped, kwargs: T.untyped).returns(::Baml::Call[T.untyped]) } + def call_async(*args, _baml: nil, **kwargs); end + end + module Bridge extend T::Sig @@ -160,7 +168,6 @@ module Baml # [:function, [[name_or_nil, d, optional?], ...], ret, throws] # [:other] unsupported type # Values are planned against these slots before any handles are cloned. - # Callable values are reserved for later runtime support. Descriptor = T.type_alias { T::Array[T.untyped] } # Called once when the generated package loads. Registering a different @@ -205,13 +212,14 @@ module Baml def self.define_function(wire, params:, class_type_params: [], type_params: []); end # Start a call. args: wire parameter name => Ruby value; omitted keys are not sent. + # Returned closures use the same call primitive, with an owned target handle. # types: explicit type bindings (Change D). baml: per-call options (Change C). # UNSET arguments are omitted. Binding names are method/function parameters; # class bindings come from the receiver's hidden runtime arguments and # precede method bindings. sig do params( - wire: String, + wire: T.any(String, ::Baml::Closure), args: T::Hash[String, T.untyped], types: T.nilable(T::Hash[Symbol, T.untyped]), baml: T.nilable(::Baml::Options) diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs index 7a00516656c..34f0703f5d6 100644 --- a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/emit.rs @@ -1,7 +1,8 @@ use std::{collections::BTreeSet, fmt::Write as _}; use baml_sdkgen_types::{ - Class, Enum, Function, FunctionArgument, Name, NamingConvention, SymbolPool, Ty, TypeAlias, + Class, Enum, Function, FunctionArgument, Name, NamingConvention, Symbol, SymbolPool, Ty, + TypeAlias, }; use crate::{ @@ -351,6 +352,17 @@ impl Emitter<'_> { format!("type_parameters({}).", type_params.join(", ")) }; let type_bindings = if generic { ", types: _types" } else { "" }; + let mut has_callable = false; + for ty in std::iter::once(&function.return_type).chain( + function + .arguments + .iter() + .filter(|arg| !omit_injected_argument(arg, self.supported, &self.names)) + .map(|arg| &arg.ty), + ) { + has_callable |= contains_function(ty, self.pool, &mut BTreeSet::new()); + } + let checked = if has_callable { ".checked(:never)" } else { "" }; for asynchronous in [false, true] { let method = format!("{method}{}", if asynchronous { "_async" } else { "" }); let returns = if asynchronous { @@ -359,6 +371,11 @@ impl Emitter<'_> { result.clone() }; let target = if receiver.is_some() { "" } else { "self." }; + if has_callable { + body.push_str( + "# BAML accepts any callable object, not only Proc or Closure handles.\n", + ); + } let mut doc = function.docstring.clone().unwrap_or_default(); if let Some(throws) = &function.throws { let mut names = BTreeSet::new(); @@ -376,11 +393,26 @@ impl Emitter<'_> { } } doc_comment(body, &doc); - writeln!(body, "sig {{ {generic_signature}params({}).returns({returns}) }}\ndef {target}{method}({})\n ::Baml::Bridge.call_async({}, {{{}}}, baml: _baml{type_bindings}){}\nend", params.join(", "), required.join(", "), quote(wire), arguments.join(", "), if asynchronous { "" } else { ".wait" }).unwrap(); + writeln!(body, "sig {{ {generic_signature}params({}).returns({returns}){checked} }}\ndef {target}{method}({})\n ::Baml::Bridge.call_async({}, {{{}}}, baml: _baml{type_bindings}){}\nend", params.join(", "), required.join(", "), quote(wire), arguments.join(", "), if asynchronous { "" } else { ".wait" }).unwrap(); } } } +fn contains_function(ty: &Ty, pool: &SymbolPool, visiting: &mut BTreeSet) -> bool { + let mut found = false; + baml_sdkgen_types::visit_type(ty, &mut |ty| match ty { + Ty::Function { .. } => found = true, + Ty::TypeAlias(name) if visiting.insert(name.clone()) => { + if let Some(Symbol::TypeAlias(alias)) = pool.get(name) { + found |= contains_function(&alias.resolves_to, pool, visiting); + } + visiting.remove(name); + } + _ => {} + }); + found +} + fn doc_comment(body: &mut String, doc: &str) { for line in doc.lines() { if line.is_empty() { diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs index a62adfc00be..bef09a3c604 100644 --- a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs @@ -153,6 +153,66 @@ fn ruby_names_and_container_types() { ); } +#[test] +fn callback_types_use_proc_signatures() { + let names = NameTable::default(); + let callback = Ty::Function { + params: Box::new([baml_sdkgen_types::CallableParam::required(None, Ty::Int)]), + ret: Box::new(Ty::String), + throws: Box::new(Ty::Never), + }; + assert_eq!( + ruby_type(&callback, &BTreeSet::new(), &names).as_deref(), + Some("::T.any(::T.proc.params(arg0: ::Integer).returns(::String), ::Baml::Closure)") + ); + assert_eq!( + crate::descriptors::descriptor(&callback), + "[:function, [[nil, [:int].freeze, false].freeze].freeze, [:string].freeze, [:never].freeze].freeze" + ); + + // Aliases must retain the same callable-object runtime-check exemption. + let alias_name = Name::new("user".into(), vec![], "Callback".into()); + let function_name = Name::new("user".into(), vec![], "apply".into()); + let origin = baml_sdkgen_types::Origin { + source_file_path: "callbacks.baml".into(), + span_start: 0, + }; + let pool = SymbolPool::from([ + ( + alias_name.clone(), + Symbol::TypeAlias(baml_sdkgen_types::TypeAlias { + name: alias_name.clone(), + resolves_to: callback, + recursive: false, + origin: origin.clone(), + }), + ), + ( + function_name, + Symbol::Function(baml_sdkgen_types::Function { + name: "apply".into(), + generic_params: vec![], + docstring: None, + arguments: vec![baml_sdkgen_types::FunctionArgument { + name: "callback".into(), + docstring: None, + ty: Ty::TypeAlias(alias_name.clone()), + default: None, + injected: false, + }], + return_type: Ty::TypeAlias(alias_name), + throws: None, + watchers: vec![], + origin, + }), + ), + ]); + let files = to_source_code_with_bytecode(&pool, &[], NamingConvention::Language, None); + let source = &files[&PathBuf::from("baml_sdk/__root.rb")]; + assert_eq!(source.matches(".checked(:never)").count(), 2, "{source}"); + assert!(!files[&PathBuf::from("baml_sdk.rb")].contains("signature_for_method")); +} + #[test] fn colliding_constants_and_namespaces_keep_wire_identities() { use baml_sdkgen_types::{Enum, EnumVariant, Origin}; diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/types.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/types.rs index fb93bdacfc4..1ea4883706b 100644 --- a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/types.rs +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/types.rs @@ -94,10 +94,26 @@ fn render( .into(), Ty::Bool => "::T::Boolean".into(), Ty::Null => "::NilClass".into(), - Ty::RustType => "::Baml::Handle".into(), + Ty::RustType => "::T.untyped".into(), // JSON is a recursive Ruby value, not a generated wrapper. Ty::TypeAlias(name) if name.to_string() == "baml.json.json" => "::T.untyped".into(), Ty::Never => "::T.noreturn".into(), + Ty::Function { params, ret, .. } => { + let params = params + .iter() + .enumerate() + .map(|(index, param)| Some(format!("arg{index}: {}", child(¶m.ty)?))) + .collect::>>()?; + let builder = if params.is_empty() { + "::T.proc".into() + } else { + format!("::T.proc.params({})", params.join(", ")) + }; + format!( + "::T.any({builder}.returns({}), ::Baml::Closure)", + child(ret)? + ) + } Ty::TypeVar(param) => params .get(param.index() as usize) .cloned() From 67ef68d066ff87838935907afc747ecf9cbe5135 Mon Sep 17 00:00:00 2001 From: Ryan Mazzolini Date: Mon, 2 Nov 2026 14:59:50 +0000 Subject: [PATCH 6/7] feat(ruby): add stream and function spec companions LLM functions' `@stream` and `@spec` companions were skipped, because their return types arrive as bare engine handles. `f_stream` and `f_stream_async` return Baml::Stream[T]: next returns a partial value or the generated Done, final returns the settled value, and each yields non-null partials. `f_spec` and `f_spec_async` return Baml::FunctionSpec with build_request. Both call the BAML methods with the handle as `self`. Tests: ports the shared llm_functions binding and streaming tests against the replay server; 116 nextest targets pass and Ruby declares 307 shared SDK tests, the same as Java. Developed with AI assistance. --- .../sdk_tests/crates/parity_analysis.md | 26 ++--- .../llm_functions/customizable/test_main.rb | 51 ++++++++++ .../customizable/test_streaming_e2e.rb | 98 +++++++++++++++++++ .../customizable/typed_consumer.rb | 8 ++ .../ruby_sorbet/test/bridge_call_test.rb | 16 +++ .../ref-ruby-state-of-completeness.md | 14 +-- .../bridge_ruby/lib/baml/bridge/handle.rb | 44 +++++++++ .../bridge_ruby/lib/baml/bridge/protocol.rb | 3 + .../sdks/ruby/bridge_ruby/rbi/baml.rbi | 37 +++++++ .../sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs | 34 +++++++ .../sdks/ruby/sdkgen_ruby_sorbet/src/types.rs | 6 ++ 11 files changed, 317 insertions(+), 20 deletions(-) diff --git a/baml_language/sdk_tests/crates/parity_analysis.md b/baml_language/sdk_tests/crates/parity_analysis.md index 8ed6ba50a8f..60a623e861a 100644 --- a/baml_language/sdk_tests/crates/parity_analysis.md +++ b/baml_language/sdk_tests/crates/parity_analysis.md @@ -2,9 +2,9 @@ This report inventories checked-in test declarations. It does not report whether tests passed. -Distinct exact test IDs: 988. IDs with complete required parity: 240. Required gaps: 5595. +Distinct exact test IDs: 988. IDs with complete required parity: 240. Required gaps: 5587. -Baseline ratchet: UNCHANGED. Required gaps: 5595 (baseline: 5595). Present declarations: 2914 (baseline: 2914). Newly missing required pairs: 0. Resolved baseline gaps: 0. Weakened requirements: 0. +Baseline ratchet: UNCHANGED. Required gaps: 5587 (baseline: 5587). Present declarations: 2924 (baseline: 2924). Newly missing required pairs: 0. Resolved baseline gaps: 0. Weakened requirements: 0. ## Python-baselined parity @@ -22,7 +22,7 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | go | 21 / 457 | 4.6% | | java | 307 / 457 | 67.2% | | swift | 191 / 457 | 41.8% | -| ruby_sorbet | 297 / 457 | 65.0% | +| ruby_sorbet | 307 / 457 | 67.2% | | Test case | python_pydantic2 | typescript_node | typescript_web_chromium | typescript_web_cloudflare_workers | cpp | csharp | rust | go | java | swift | ruby_sorbet | Required in | Reason | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | @@ -668,9 +668,9 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | llm_functions/keeps_a_portable_prompt_reusable_across_engine_reentry | - | y | y | y | - | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | validates TypeScript's portable Prompt and Image wrappers | | llm_functions/legacy_function_companion_wire_bindings_are_absent | - | - | - | - | - | - | - | y | - | - | - | go | pins the Go generator's emitted wire-binding names. | | llm_functions/main_baml_sdk_lorem_and_baml_sdk_ipsum_are_reachable | - | y | y | y | - | - | - | - | - | - | - | all | | -| llm_functions/main_classify_sentiment_factory_bindings | y | - | - | - | - | - | y | - | y | - | - | all | | -| llm_functions/main_extract_resume_factory_bindings | y | - | - | - | - | - | y | - | y | - | - | all | | -| llm_functions/main_extract_resume_operation_bindings | y | - | - | - | - | - | - | - | y | - | - | python_pydantic2, java | pins Python and Java flat operation binding spellings | +| llm_functions/main_classify_sentiment_factory_bindings | y | - | - | - | - | - | y | - | y | - | y | all | | +| llm_functions/main_extract_resume_factory_bindings | y | - | - | - | - | - | y | - | y | - | y | all | | +| llm_functions/main_extract_resume_operation_bindings | y | - | - | - | - | - | - | - | y | - | y | python_pydantic2, java, ruby_sorbet | pins Python and Java flat operation binding spellings | | llm_functions/main_extract_resume_spec_and_stream_bindings | - | - | - | - | - | - | y | - | - | - | - | rust | pins Rust generated spec and stream binding spellings | | llm_functions/main_flat_stream_controls_live_on_the_stream_options | - | - | - | - | - | - | - | - | y | - | - | all | | | llm_functions/main_function_spec_prompt_is_portable_and_reusable | - | - | - | - | - | - | - | - | y | - | - | all | | @@ -685,13 +685,13 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | llm_functions/main_lorem_streaming_doc_is_reachable | - | y | y | y | - | - | - | - | - | - | - | all | | | llm_functions/main_lorem_streaming_extract_exposes_flat_spec_and_stream_bindings | - | y | y | y | - | - | - | - | - | - | - | typescript_node, typescript_web_chromium, typescript_web_cloudflare_workers | pins TypeScript's dollar-preserving flat streaming binding names | | llm_functions/main_lorem_streaming_extract_sync_plus_async_factories_are_callable | - | y | y | y | - | - | - | - | - | - | - | all | | -| llm_functions/main_namespaces_reachable_via_explicit_import | y | - | - | - | - | - | y | - | y | - | - | all | | +| llm_functions/main_namespaces_reachable_via_explicit_import | y | - | - | - | - | - | y | - | y | - | y | all | | | llm_functions/main_nullable_model_field_can_be_omitted | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | pins the Python generator's nullable-field `= None` default | | llm_functions/main_replay_server_namespace_bindings | y | - | - | - | - | - | y | - | y | - | y | all | | | llm_functions/main_root_imports_cleanly | y | y | y | y | - | - | y | - | y | - | y | all | | | llm_functions/main_spec_replaces_prompt_parse_and_request_companions | - | - | - | - | - | - | y | - | - | - | - | rust | validates Rust's generated FunctionSpec prompt, parse, and request surface | -| llm_functions/main_streaming_extract_factory_bindings | y | - | - | - | - | - | y | - | y | - | - | all | | -| llm_functions/main_streaming_extract_operation_bindings | y | - | - | - | - | - | - | - | y | - | - | python_pydantic2, java | pins Python and Java flat streaming operation binding spellings | +| llm_functions/main_streaming_extract_factory_bindings | y | - | - | - | - | - | y | - | y | - | y | all | | +| llm_functions/main_streaming_extract_operation_bindings | y | - | - | - | - | - | - | - | y | - | y | python_pydantic2, java, ruby_sorbet | pins Python and Java flat streaming operation binding spellings | | llm_functions/main_streaming_extract_spec_and_stream_bindings | - | - | - | - | - | - | y | - | - | - | - | rust | pins Rust generated streaming spec and stream binding spellings | | llm_functions/main_types_and_bindings_reachable | - | - | - | - | - | - | - | - | - | y | - | all | | | llm_functions/on_event_plain_call_delivers_events | y | - | - | - | - | - | - | - | - | - | - | python_pydantic2 | host on_event listener coverage lands Python-first; other SDKs port separately | @@ -718,11 +718,11 @@ Parity is the share of the 457 test IDs declared in `python_pydantic2` that are | llm_functions/streaming_e2e_baml_driven_collect_returns_the_final_doc | - | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | | llm_functions/streaming_e2e_next_yields_10_doc_partials_final_is_a_typed_streaming_doc | - | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | | llm_functions/streaming_e2e_next_yields_10_partials_and_drains_to_stream_finished | - | y | - | - | - | - | - | - | - | - | - | python_pydantic2, typescript_node, cpp, csharp, rust, go, java, swift, ruby_sorbet | | -| llm_functions/streaming_e2e_stream | y | - | - | - | - | - | y | - | y | y | - | all | | -| llm_functions/streaming_e2e_stream_async | y | - | - | - | - | - | y | - | y | y | - | all | | +| llm_functions/streaming_e2e_stream | y | - | - | - | - | - | y | - | y | y | y | all | | +| llm_functions/streaming_e2e_stream_async | y | - | - | - | - | - | y | - | y | y | y | all | | | llm_functions/streaming_e2e_stream_collect_in_baml | y | - | - | - | - | - | y | - | y | y | y | all | | -| llm_functions/streaming_e2e_stream_doc | y | - | - | - | - | - | y | - | y | y | - | all | | -| llm_functions/streaming_e2e_stream_doc_async | y | - | - | - | - | - | y | - | y | y | - | all | | +| llm_functions/streaming_e2e_stream_doc | y | - | - | - | - | - | y | - | y | y | y | all | | +| llm_functions/streaming_e2e_stream_doc_async | y | - | - | - | - | - | y | - | y | y | y | all | | | llm_functions/streaming_e2e_stream_doc_collect_in_baml | y | - | - | - | - | - | y | - | y | y | y | all | | | package_edges/compile_cross_package_types_compile | - | - | - | - | - | - | - | y | - | - | - | all | | | package_edges/compile_llm_projection_default_overrides | - | - | - | - | - | - | - | y | - | - | - | go | pins Go generator options for authored Spec and Stream defaults. | diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_main.rb b/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_main.rb index b35aa82027d..c8fa48931f2 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_main.rb +++ b/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_main.rb @@ -30,10 +30,61 @@ def test_main_ipsum_sentiment_enum_shape assert_instance_of String, BamlSdk::Ipsum::Sentiment::POSITIVE.serialize end + def test_main_namespaces_reachable_via_explicit_import + require "baml_sdk/lorem" + require "baml_sdk/ipsum" + assert_kind_of Module, BamlSdk::Lorem + assert_kind_of Module, BamlSdk::Ipsum + end + + def test_main_extract_resume_factory_bindings + assert_respond_to BamlSdk::Lorem, :extract_resume + assert_respond_to BamlSdk::Lorem, :extract_resume_async + end + + def test_main_extract_resume_operation_bindings + assert_operation_bindings(:extract_resume) + spec = BamlSdk::Lorem.extract_resume_spec("Ada Lovelace") + assert_instance_of Baml::FunctionSpec, spec + assert_respond_to spec, :build_request + assert_respond_to spec, :build_request_async + spec.close + call = BamlSdk::Lorem.extract_resume_spec_async("Ada Lovelace") + assert_instance_of Baml::Call, call + assert_instance_of Baml::FunctionSpec, call.wait + call.wait.close + end + + def test_main_streaming_extract_factory_bindings + assert_respond_to BamlSdk::Lorem, :streaming_extract + assert_respond_to BamlSdk::Lorem, :streaming_extract_async + end + + def test_main_streaming_extract_operation_bindings + assert_operation_bindings(:streaming_extract) + end + + def test_main_classify_sentiment_factory_bindings + assert_respond_to BamlSdk::Ipsum, :classify_sentiment + assert_respond_to BamlSdk::Ipsum, :classify_sentiment_async + end + def test_main_replay_server_namespace_bindings %i[replay_serve_until_shutdown replay_serve_detached].each do |name| assert_respond_to BamlSdk::Replay, name assert_respond_to BamlSdk::Replay, "#{name}_async" end end + + private + + def assert_operation_bindings(name) + %w[spec spec_async stream stream_async].each do |suffix| + assert_respond_to BamlSdk::Lorem, "#{name}_#{suffix}" + end + %w[render_prompt build_request parse].each do |removed| + refute_respond_to BamlSdk::Lorem, "#{name}__#{removed}" + refute_respond_to BamlSdk::Lorem, "#{name}__#{removed}_async" + end + end end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_streaming_e2e.rb b/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_streaming_e2e.rb index 79ac2661645..e4c3c70a00a 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_streaming_e2e.rb +++ b/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/test_streaming_e2e.rb @@ -6,6 +6,81 @@ ENV["BAML_BRIDGE_PATH"] = ENV.fetch("BAML_RUBY_TEST_REAL_RUNTIME") class StreamingE2ETest < Minitest::Test + def test_streaming_e2e_stream + ReplayHarness.with_server("replay_extract_string") do + stream = BamlSdk::Lorem.stream_e2e_extract_stream("ignored-by-replay-server") + assert_instance_of Baml::Stream, stream + assert_partials(stream, String) + assert_instance_of String, stream.final + stream.close + + stream = BamlSdk::Lorem.stream_e2e_extract_stream("ignored-by-replay-server") + assert_instance_of Enumerator, stream.each + results = 0 + returned = stream.each do |partial| + assert_instance_of String, partial + results += 1 + assert_operator results, :<, 10_000, "stream.each failed to terminate" + end + assert_same stream, returned + assert_operator results, :>=, 10 + assert_instance_of String, stream.final + stream.close + + spec = BamlSdk::Lorem.stream_e2e_extract_spec("ignored-by-replay-server") + request = spec.build_request + assert_instance_of BamlSdk::Baml::Http::Request, request + assert_equal "POST", request.baml_method + assert_equal "#{ENV.fetch('BAML_REPLAY_BASE_URL')}/responses", request.url + spec.close + end + end + + def test_streaming_e2e_stream_async + ReplayHarness.with_server("replay_extract_string") do + call = BamlSdk::Lorem.stream_e2e_extract_stream_async("ignored-by-replay-server") + assert_instance_of Baml::Call, call + stream = call.wait + assert_instance_of Baml::Stream, stream + assert_partials(stream, String, asynchronous: true) + final = stream.final_async + assert_instance_of Baml::Call, final + assert_instance_of String, final.wait + stream.close + + spec = BamlSdk::Lorem.stream_e2e_extract_spec_async("ignored-by-replay-server").wait + request = spec.build_request_async + assert_instance_of Baml::Call, request + assert_instance_of BamlSdk::Baml::Http::Request, request.wait + assert_equal "#{ENV.fetch('BAML_REPLAY_BASE_URL')}/responses", request.wait.url + spec.close + end + end + + def test_streaming_e2e_stream_doc + ReplayHarness.with_server("replay_extract_doc") do + stream = BamlSdk::Lorem.stream_e2e_extract_doc_stream("ignored-by-replay-server") + assert_instance_of Baml::Stream, stream + assert_partials(stream, BamlSdk::Lorem::StreamingDoc) + assert_instance_of BamlSdk::Lorem::StreamingDoc, stream.final + stream.close + end + end + + def test_streaming_e2e_stream_doc_async + ReplayHarness.with_server("replay_extract_doc") do + call = BamlSdk::Lorem.stream_e2e_extract_doc_stream_async("ignored-by-replay-server") + assert_instance_of Baml::Call, call + stream = call.wait + assert_instance_of Baml::Stream, stream + assert_partials(stream, BamlSdk::Lorem::StreamingDoc, asynchronous: true) + final = stream.final_async + assert_instance_of Baml::Call, final + assert_instance_of BamlSdk::Lorem::StreamingDoc, final.wait + stream.close + end + end + def test_streaming_e2e_stream_collect_in_baml ReplayHarness.with_server("replay_extract_string") do result = BamlSdk::Lorem.stream_e2e_collect("ignored-by-replay-server") @@ -26,4 +101,27 @@ def test_streaming_e2e_stream_doc_collect_in_baml assert doc.body.nil? || doc.body.is_a?(String) end end + + private + + def assert_partials(stream, type, asynchronous: false) + Timeout.timeout(30) do + results = 0 + loop do + value = if asynchronous + call = stream.next_async + assert_instance_of Baml::Call, call + call.wait + else + stream.next + end + break if value.is_a?(BamlSdk::Ai::Stream::Done) + + results += 1 + assert_instance_of type, value + assert_operator results, :<, 10_000, "stream.next failed to terminate" + end + assert_operator results, :>=, 10, "expected at least 10 partials" + end + end end diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/typed_consumer.rb b/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/typed_consumer.rb index 330b7d94888..05337110e1f 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/typed_consumer.rb +++ b/baml_language/sdk_tests/crates/ruby_sorbet/llm_functions/customizable/typed_consumer.rb @@ -11,5 +11,13 @@ T.let(collected.next_calls, T::Array[String]) T.let(BamlSdk::Lorem.stream_e2e_collect("text"), BamlSdk::Lorem::StreamE2ECollectResult) T.let(BamlSdk::Lorem.streaming_extract("text"), BamlSdk::Lorem::StreamingDoc) +stream = BamlSdk::Lorem.streaming_extract_stream("text") +T.let(stream, Baml::Stream[BamlSdk::Lorem::StreamingDoc]) +T.let(stream.final, BamlSdk::Lorem::StreamingDoc) +T.let(stream.final_async, Baml::Call[BamlSdk::Lorem::StreamingDoc]) +stream.each { |partial| T.let(partial, BamlSdk::Lorem::StreamingDoc) } +T.let(BamlSdk::Lorem.streaming_extract_stream_async("text"), Baml::Call[Baml::Stream[BamlSdk::Lorem::StreamingDoc]]) +T.let(BamlSdk::Lorem.extract_resume_spec("text"), Baml::FunctionSpec) +T.let(BamlSdk::Lorem.extract_resume_spec_async("text"), Baml::Call[Baml::FunctionSpec]) T.let(BamlSdk::Ipsum::Sentiment::POSITIVE, BamlSdk::Ipsum::Sentiment) T.let(BamlSdk::Ipsum.classify_sentiment("text"), BamlSdk::Ipsum::Sentiment) diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_call_test.rb b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_call_test.rb index 4e571318678..eed1db84ccc 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_call_test.rb +++ b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_call_test.rb @@ -218,6 +218,22 @@ def test_unregistered_error_message_uses_detail_then_type_name end end + def test_stream_each_propagates_stop_iteration_from_the_consumer + stream = Baml::Stream.new(@api, 99, :ADT_TAGGED_HEAP_HANDLE) + done = Class.new + values = [nil, "partial", done.new] + stream.define_singleton_method(:next) { values.shift } + Baml::Bridge::Protocol.stub(:registered_type, done) do + failure = StopIteration.new("consumer failed") + assert_same failure, assert_raises(StopIteration) { stream.each { raise failure } } + assert_equal 1, values.length + assert_same stream, stream.each { flunk "done must not be yielded" } + assert_instance_of Enumerator, stream.each + end + ensure + stream&.close + end + def test_callback_ids_wrap_and_skip_pending_ids first = Object.new last = Object.new diff --git a/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md b/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md index ffbabacef51..2a3215d7499 100644 --- a/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md +++ b/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md @@ -4,7 +4,7 @@ repository: baml4 --- # State of BAML↔Ruby completeness -The Ruby/Sorbet SDK generates typed functions, classes and enums for user code and supported standard-library symbols. It runs sync and async calls through the V1 C bridge, with call controls, generics, host callbacks, callable BAML closures and media. The tables below separate tested support from remaining gaps. +The Ruby/Sorbet SDK generates typed functions, classes and enums for user code and supported standard-library symbols. It runs sync and async calls through the V1 C bridge, with call controls, generics, host callbacks, callable BAML closures, media and host-driven streaming. The tables below separate tested support from remaining gaps. ✅ means a named Ruby test proves the stated support, 🚧 means only part of the row is tested or supported, and ❌ means unsupported or not yet parity-verified. The Python column is copied from `ref-python-state-of-completeness.md` at commit `b74b83a3ab`, including its qualifications; it is not a claim about today's Python SDK. Links to `customizable/test_*.rb` identify real-engine tests; streaming tests use the keyless replay server. Fake-bridge tests are identified separately. @@ -22,8 +22,8 @@ Validation: `cargo nextest run -p sdk_test_ruby_sorbet` passes all 16 harness te | Required args (keyword) | ✅ | n/a | Generated methods require positional arguments. Returned closures also accept named arguments, as tested by [`test_baml_closure_decodes_multiple_args_and_structured_return_values`][host-callables]. | | Optional args (omitted → default) | ✅ | ✅ | Keywords default to `Baml::UNSET`; omitted values let BAML evaluate defaults: [`test_optional_args_runtime_matrix`][optional-args]. | | Optional args (supplied) | ✅ | ✅ | Explicit values and `nil` differ from omission: [`test_optional_args_python_unset_and_none_differ_in_one_call`][optional-args], [`test_optional_args_opt_box_method_matrix`][optional-args]. Nullable parameters without defaults remain required: [`test_optional_args_negative_runtime_cases_reject`][optional-args]. | -| Streaming | ✅ | ❌ | No host stream wrapper or generated stream companions. BAML-internal collection remains supported: [`test_streaming_e2e_stream_collect_in_baml`][streaming], [`test_streaming_e2e_stream_doc_collect_in_baml`][streaming]. | -| `$build_request` companion | ✅ | ❌ | No function-spec wrapper or generated spec companions. | +| Streaming | ✅ | ✅ | `_stream` / `_stream_async` return `Baml::Stream[T]`. `next` returns a partial or generated `Done`; `each` and `final` are available: [`test_streaming_e2e_stream`][streaming], [`test_streaming_e2e_stream_async`][streaming]. Structured partials: [`test_streaming_e2e_stream_doc`][streaming], [`test_streaming_e2e_stream_doc_async`][streaming]. | +| `$build_request` companion | ✅ | ✅ | Ruby uses `_spec` / `_spec_async`, then `Baml::FunctionSpec#build_request` / `build_request_async`, not a direct `$build_request` method: [`test_streaming_e2e_stream`][streaming], [`test_streaming_e2e_stream_async`][streaming]. | | Generic function / method (inferred) | ✅ | ✅ | Engine inference from values and receivers: [`test_generic_inference_identity_infers_primitives`][generic-inference], [`test_generic_inference_genericbox_pair_with_unbound_receiver_recovers_class_var`][generic-inference]. | | Generic function / method (`_types=` kwarg) | ✅ | ✅ | Named tokens, e.g. `_types: { T: Integer }`: [`test_generic_calls_identity_explicit`][generic-calls], [`test_generic_calls_identity_async_explicit`][generic-calls], [`test_generic_calls_genericbox_pair_with_explicit`][generic-calls]. | | Generic function / method (subscript) | ✅ | ❌ | No callable-subscript API; use `_types:`. [`test_generic_inference_make_triple_partial_subscript_requires_full_arity`][generic-inference] explicitly skips this surface. | @@ -56,9 +56,9 @@ Gem packaging, clean-app installation and native-library cache lookup are unsupp The [shared Ruby harness][static-gate] runs `srb tc` on each of the five generated packages and its tests with Sorbet **0.6.13506**, pinned in a separate [test bundle][static-gemfile]. Every generated Ruby file, including bytecode and model declarations, uses `# typed: strict`. No SDK functions or models are recreated in test RBIs. -Strict consumers in the fixture overlays cover sync and async returns, stdlib methods, callbacks and returned callables, optional arguments, call controls, generics, bigint, bytes, media, unions, aliases, classes and enums. One negative consumer requires exactly one wrong-argument diagnostic (`7002`), so a regression to `T.untyped` fails the gate. +Strict consumers in the fixture overlays cover sync and async returns, stdlib methods, callbacks and returned callables, optional arguments, call controls, generics, streams, specs, bigint, bytes, media, unions, aliases, classes and enums. One negative consumer requires exactly one wrong-argument diagnostic (`7002`), so a regression to `T.untyped` fails the gate. -The [boundary RBI][static-bridge] describes the runtime-facing API, including call and generic helpers; it does not recreate generated SDK declarations. The bridge's decoded return remains dynamic, so this gate proves generated declarations and consumer checking, not the runtime's adherence to BAML return types. Real-engine and replay tests cover runtime behavior separately, including recursion, forward references and symbol collisions. +The [boundary RBI][static-bridge] describes the runtime-facing API, including call, stream and generic helpers; it does not recreate generated SDK declarations. The bridge's decoded return remains dynamic, so this gate proves generated declarations and consumer checking, not the runtime's adherence to BAML return types. Real-engine and replay tests cover runtime behavior separately, including recursion, forward references and symbol collisions. Typing still has limits: literal values use their Ruby primitive types, recursive alias back-edges and JSON use `T.untyped`, and `_types:` token values are dynamic. Callable signatures accept typed Procs or `Baml::Closure`. Generated methods with callable parameters or results disable Sorbet's runtime signature checks so other callable objects also work. Other method signatures retain Sorbet's lazy validation. @@ -87,7 +87,7 @@ Encode means Ruby → BAML; decode means BAML → Ruby. The generated-package te | Union | ✅ (union metadata dropped) | ✅ | Decodes to the Ruby variant value; metadata is dropped. Ambiguous inputs, such as `[]` for `int[] \| string[]`, raise `TypeError` instead of choosing an arm. Generated signatures use `T.any` / `T.nilable`: [`test_unions_round_trip_null_to_end`][unions], [`test_unions_round_trip_optional_plus_null`][unions], [`test_unions_round_trip_union_container`][unions]. | | BAML interface | ❌ | ❌ | No interface value support; affected declarations are not generated. | | Media | ✅ | ✅ | Frozen value objects `Baml::Image`, `Audio`, `Video` and `Pdf`, not engine handles. URL round trips: [`test_media_round_trip_media`][media]. Base64, file contents, names, MIME types and local-file loading: [`test_handles_image_from_base64_roundtrips_payload`][handles]. | -| Stream | ✅ | ❌ | No host stream wrapper; declarations requiring stream values are not generated. | +| Stream | ✅ | ✅ | `Baml::Stream[T]` owns an engine handle and supports sync/async `next` and `final`, plus `each` and `close`: [`test_streaming_e2e_stream`][streaming], [`test_streaming_e2e_stream_async`][streaming]. | | Host callable | ✅ | ✅ | Ruby callable objects and lambdas are registered and dispatched with decoded values: [`test_host_callables_simple_sync_callable_returns_string`][host-callables], [`test_host_callables_class_callback_round_trips_pydantic_model`][host-callables]. Release on engine GC remains unverified; [`test_host_callables_release_fires_on_drop_of_callable`][host-callables] is skipped. | | Host callable (async) | ✅ (works, but returned coroutine is driven to completion on new asyncio event loop) | ❌ | Returned async work is not awaited by the bridge. [`test_host_callables_async_callable_runs_to_completion`][host-callables] joins its own thread with `.value`; it does not prove automatic async callback support. | | BAML closure (function reference) | ❌ | ❌ | The runtime maps `FUNCTION_REF` handles to `Baml::Closure`, but no named Ruby test covers a bare BAML function reference. | @@ -96,7 +96,7 @@ Encode means Ruby → BAML; decode means BAML → Ruby. The generated-package te | BAML type definition values | ❌ | ❌ | No type-definition values. | | BAML `$rust_type` values: files, sockets, etc. | ✅ | 🚧 | Generated resource structs retain `Baml::Handle` fields. Files preserve cursor state: [`test_handles_open_file_returns_file_handle`][handles], [`test_handles_file_cursor_state_persists_across_calls`][handles]. HTTP response fields and methods: [`test_handles_http_get_response_fields_and_methods`][handles]. Socket values are not parity-verified. | | Native exception thrown by a host callback | ✅ | ✅ | Opaque host-value keys preserve the original exception object and custom fields: [`test_host_callables_throwing_callable_custom_python_exception_round_trips_with_identity`][host-callables]. | -| Unused SDK heap handles (`Handle`) | 🚧 | 🚧 | Handles work inside tested resources: [`test_handles_file_cursor_state_persists_across_calls`][handles]. Standalone unused heap-handle ownership and GC release are not parity-verified. | +| Unused SDK heap handles (`Handle`) | 🚧 | 🚧 | Handles work inside tested streams and resources: [`test_streaming_e2e_stream`][streaming], [`test_handles_file_cursor_state_persists_across_calls`][handles]. Standalone unused heap-handle ownership and GC release are not parity-verified. | | Unused SDK collector values (`Collector`) | 🚧 | ❌ | No collector wrapper or verified collector-value path. | | Unused SDK prompt values (`PromptAst`) | 🚧 | ❌ | No prompt-value support. | | BAML builtin type (`Future` / `UnscheduledFuture`) | ❌ | ❌ | No first-class engine future values. `Baml::Call` is the host call promise, not a BAML future value. | diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/handle.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/handle.rb index dd4b148da14..5049cefc653 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/handle.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/handle.rb @@ -50,6 +50,50 @@ def clone_for_wire(scope, borrowed: false) end end + class Stream < Handle + extend T::Generic + Value = type_member + + def next(_baml: nil) + next_async(_baml: _baml).wait + end + + def next_async(_baml: nil) + Bridge.call_async("ai.stream.Stream.next", { "self" => self }, baml: _baml) + end + + def final(_baml: nil) + final_async(_baml: _baml).wait + end + + def final_async(_baml: nil) + Bridge.call_async("ai.stream.Stream.final", { "self" => self }, baml: _baml) + end + + def each + return enum_for(:each) unless block_given? + + # Resolve the generated sentinel through the registry, not an SDK namespace. + done = Bridge::Protocol.registered_type("ai.stream.Done") + while true + partial = self.next + break if partial.is_a?(done) + + yield partial unless partial.nil? + end + self + end + end + + class FunctionSpec < Handle + def build_request(client: nil, _baml: nil) + build_request_async(client: client, _baml: _baml).wait + end + + def build_request_async(client: nil, _baml: nil) + Bridge.call_async("ai.FunctionSpec.build_request", { "self" => self, "client" => client }, baml: _baml) + end + end class Closure < Handle attr_reader :parameter_descriptors diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/protocol.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/protocol.rb index a86bd3c59b3..a7580010c1c 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/protocol.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/protocol.rb @@ -364,6 +364,9 @@ def decode_value(value, api = nil, host_values: nil, handles: nil, as_proc: fals end return owner when :FUNCTION_REF then ::Baml::Closure + when :ADT_FUNCTION_SPEC then ::Baml::FunctionSpec + when :ADT_TAGGED_HEAP_HANDLE + handle.ty&.class_ty&.name == "ai.stream.Stream" ? ::Baml::Stream : ::Baml::Handle else ::Baml::Handle end build = lambda do diff --git a/baml_language/sdks/ruby/bridge_ruby/rbi/baml.rbi b/baml_language/sdks/ruby/bridge_ruby/rbi/baml.rbi index d84ac129929..2bcc5736d2c 100644 --- a/baml_language/sdks/ruby/bridge_ruby/rbi/baml.rbi +++ b/baml_language/sdks/ruby/bridge_ruby/rbi/baml.rbi @@ -140,6 +140,43 @@ module Baml class Video < Media; end class Pdf < Media; end + # Whole-object engine handles. The runtime never imports the generated SDK. + class Stream < Handle + extend T::Sig + extend T::Generic + Value = type_member + + # Returns a Value partial or the generated ai.stream.Done sentinel. + # The generated sentinel's Ruby name is not known to the gem. + sig { params(_baml: T.untyped).returns(T.untyped) } + def next(_baml: nil); end + + sig { params(_baml: T.untyped).returns(::Baml::Call[T.untyped]) } + def next_async(_baml: nil); end + + sig { params(_baml: T.untyped).returns(Value) } + def final(_baml: nil); end + + sig { params(_baml: T.untyped).returns(::Baml::Call[Value]) } + def final_async(_baml: nil); end + + # Yield non-null partials until Done; without a block, return an enumerator. + sig do + params(blk: T.nilable(T.proc.params(value: Value).void)) + .returns(T.any(::Baml::Stream[Value], T::Enumerator[Value])) + end + def each(&blk); end + end + + class FunctionSpec < Handle + extend T::Sig + + sig { params(client: T.untyped, _baml: T.untyped).returns(T.untyped) } + def build_request(client: nil, _baml: nil); end + + sig { params(client: T.untyped, _baml: T.untyped).returns(::Baml::Call[T.untyped]) } + def build_request_async(client: nil, _baml: nil); end + end class Closure < Handle extend T::Sig sig { params(args: T.untyped, kwargs: T.untyped).returns(T.untyped) } diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs index bef09a3c604..649553f51f5 100644 --- a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/tests.rs @@ -153,6 +153,40 @@ fn ruby_names_and_container_types() { ); } +#[test] +fn stream_and_spec_types_use_runtime_handles() { + let names = NameTable::default(); + let supported = BTreeSet::new(); + let stream = Name::new("ai".into(), vec!["stream".into()], "Stream".into()); + let spec = Name::new("ai".into(), vec![], "FunctionSpec".into()); + assert_eq!( + ruby_type( + &Ty::Class(stream.clone(), Box::new([Ty::String])), + &supported, + &names + ), + Some("::Baml::Stream[::String]".into()) + ); + assert_eq!( + ruby_type(&Ty::Class(spec, Box::new([Ty::String])), &supported, &names), + Some("::Baml::FunctionSpec".into()) + ); + assert_eq!( + ruby_type( + &Ty::Class(stream, Box::new([Ty::Unknown])), + &supported, + &names + ), + None + ); + // Matching uses the whole BAML name, never just a class's leaf name. + let user = Name::new("user".into(), vec![], "Stream".into()); + assert_eq!( + ruby_type(&Ty::Class(user, Box::new([Ty::String])), &supported, &names), + None + ); +} + #[test] fn callback_types_use_proc_signatures() { let names = NameTable::default(); diff --git a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/types.rs b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/types.rs index 1ea4883706b..0e2bb0883fd 100644 --- a/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/types.rs +++ b/baml_language/sdks/ruby/sdkgen_ruby_sorbet/src/types.rs @@ -120,6 +120,12 @@ fn render( .unwrap_or_else(|| "::T.untyped".into()), Ty::List(t) => format!("::T::Array[{}]", child(t)?), Ty::Map { key, value } => format!("::T::Hash[{}, {}]", child(key)?, child(value)?), + Ty::Class(n, args) if n.to_string() == "ai.stream.Stream" && args.len() == 1 => { + format!("::Baml::Stream[{}]", child(&args[0])?) + } + Ty::Class(n, args) if n.to_string() == "ai.FunctionSpec" && args.len() == 1 => { + "::Baml::FunctionSpec".into() + } Ty::Class(n, args) if supported.contains(n) => { let base = format!("::{}", names.qualified(n)); if args.is_empty() { From a62041005b2dc191e4f554742c5da53cbe20b129 Mon Sep 17 00:00:00 2001 From: Ryan Mazzolini Date: Mon, 2 Nov 2026 14:59:50 +0000 Subject: [PATCH 7/7] feat(ruby): package the runtime as an installable gem The runtime could only be loaded from the repository checkout. Adds the pure-Ruby `baml-bridge` gem: the runtime, committed protobuf clients and the Sorbet RBI, depending on ffi, google-protobuf and sorbet-runtime. The `baml` name on RubyGems belongs to the legacy BAML v0 client, so this follows the other new SDKs, which publish as baml-bridge. The bridge library is found through BAML_BRIDGE_PATH or, when unset, the shared $BAML_HOME/bridges/// cache. It is not downloaded. Tests: a clean-app check builds the gem, installs it offline outside the checkout, generates an SDK with baml-cli and calls it through the cache. Where the pinned Sorbet runs, it also type-checks the app with the installed RBI and Tapioca's default vendor/ ignore; 117 nextest targets pass. Developed with AI assistance. --- .../sdk_tests/crates/ruby_sorbet/setup.ps1 | 3 + .../sdk_tests/crates/ruby_sorbet/setup.sh | 1 + .../sdk_tests/crates/ruby_sorbet/src/gem.rs | 66 ++++++ .../sdk_tests/crates/ruby_sorbet/src/lib.rs | 3 + .../ruby_sorbet/test/bridge_loader_test.rb | 34 +++ .../crates/ruby_sorbet/test/gem_smoke.rb | 106 +++++++++ .../ref-ruby-state-of-completeness.md | 15 +- baml_language/sdks/ruby/bridge_ruby/LICENSE | 201 ++++++++++++++++++ baml_language/sdks/ruby/bridge_ruby/README.md | 40 +++- .../sdks/ruby/bridge_ruby/baml-bridge.gemspec | 21 ++ .../lib/baml/bridge/process_runtime.rb | 49 ++++- 11 files changed, 518 insertions(+), 21 deletions(-) create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/src/gem.rs create mode 100644 baml_language/sdk_tests/crates/ruby_sorbet/test/gem_smoke.rb create mode 100644 baml_language/sdks/ruby/bridge_ruby/LICENSE create mode 100644 baml_language/sdks/ruby/bridge_ruby/baml-bridge.gemspec diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/setup.ps1 b/baml_language/sdk_tests/crates/ruby_sorbet/setup.ps1 index 379918cb42e..ac0ffd73686 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/setup.ps1 +++ b/baml_language/sdk_tests/crates/ruby_sorbet/setup.ps1 @@ -16,6 +16,9 @@ try { cargo build -p bridge_cffi if ($LASTEXITCODE -ne 0) { throw "cargo build -p bridge_cffi failed" } + cargo build -p baml_cli --bin baml-cli + if ($LASTEXITCODE -ne 0) { throw "cargo build -p baml_cli failed" } + cargo run --quiet -p sdk_test_codegen -- ruby_sorbet if ($LASTEXITCODE -ne 0) { throw "ruby_sorbet SDK generation failed" } } finally { diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/setup.sh b/baml_language/sdk_tests/crates/ruby_sorbet/setup.sh index 1ad0ab7ead2..d8dd970d7f8 100755 --- a/baml_language/sdk_tests/crates/ruby_sorbet/setup.sh +++ b/baml_language/sdk_tests/crates/ruby_sorbet/setup.sh @@ -15,6 +15,7 @@ mkdir -p "$FIXTURE_DIR" ( cd "$WORKSPACE_ROOT" cargo build -p bridge_cffi + cargo build -p baml_cli --bin baml-cli cargo run --quiet -p sdk_test_codegen -- ruby_sorbet ) diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/src/gem.rs b/baml_language/sdk_tests/crates/ruby_sorbet/src/gem.rs new file mode 100644 index 00000000000..18b39b825b6 --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/src/gem.rs @@ -0,0 +1,66 @@ +use std::process::Command; + +#[test] +fn clean_app() { + let arch = std::env::consts::ARCH; + let target = if cfg!(target_os = "macos") { + format!("{arch}-apple-darwin") + } else if cfg!(target_os = "windows") { + format!("{arch}-pc-windows-msvc") + } else if cfg!(target_os = "linux") { + let libc = if cfg!(target_env = "musl") { + "musl" + } else { + "gnu" + }; + format!("{arch}-unknown-linux-{libc}") + } else { + panic!("unsupported Ruby bridge platform"); + }; + let engine = sdk_test_harness_runner::engine_library(); + let cli = engine + .parent() + .expect("engine directory") + .join(if cfg!(windows) { + "baml-cli.exe" + } else { + "baml-cli" + }); + let mut command = Command::new("ruby"); + command + .args(["-S", "bundle", "exec", "ruby", "test/gem_smoke.rb"]) + .arg(&engine) + .arg(target) + .arg(cli) + .current_dir(env!("CARGO_MANIFEST_DIR")); + // Same hosts as the Sorbet gate; setup installs the checker only there. + let sorbet = cfg!(all( + any(target_arch = "x86_64", target_arch = "aarch64"), + any( + target_os = "macos", + all(target_os = "linux", target_env = "gnu") + ) + )); + if sorbet { + let target_dir = engine + .parent() + .and_then(|profile| profile.parent()) + .expect("target directory"); + command.arg(target_dir.join("ruby-sorbet-static-bundle")); + } + let output = sdk_test_harness_runner::run_command( + &mut command, + "installed baml-bridge gem in a clean app", + ); + sdk_test_harness_runner::assert_stdout_contains( + &output, + "Installed baml-bridge gem: clean app returned hello world", + ); + if sorbet { + sdk_test_harness_runner::assert_stdout_contains( + &output, + "Sorbet accepted the clean app with the installed RBI", + ); + } + print!("{}", String::from_utf8_lossy(&output.stdout)); +} diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/src/lib.rs b/baml_language/sdk_tests/crates/ruby_sorbet/src/lib.rs index f94f0e42a52..873ce98759a 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/src/lib.rs +++ b/baml_language/sdk_tests/crates/ruby_sorbet/src/lib.rs @@ -7,6 +7,9 @@ sdk_test_harness_runner::ruby_sorbet::test_suite! { fixture unsupported_only { minitest: on, sorbet: on } } +#[cfg(test)] +mod gem; + #[cfg(test)] mod wrong_argument { sdk_test_harness_runner::__ruby_sorbet_gate!(on, sorbet, { diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_loader_test.rb b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_loader_test.rb index a55d8ce8415..1c438453fbd 100644 --- a/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_loader_test.rb +++ b/baml_language/sdk_tests/crates/ruby_sorbet/test/bridge_loader_test.rb @@ -4,6 +4,8 @@ require "open3" require "rbconfig" require "timeout" +require "fileutils" +require "tmpdir" $LOAD_PATH.unshift File.expand_path("../../../../sdks/ruby/bridge_ruby/lib", __dir__) require "baml/bridge" @@ -64,8 +66,40 @@ def test_fork_during_native_open_rejects_before_touching_the_inherited_mutex assert status.success?, "failed native open must permit a child retry" end + def test_cache_targets_match_the_shared_bridge_layout + targets = [ + ["x86_64", "linux", "x86_64-linux", "x86_64-unknown-linux-gnu", "libbridge_cffi.so"], + ["aarch64", "linux", "aarch64-linux-musl", "aarch64-unknown-linux-musl", "libbridge_cffi.so"], + ["x86_64", "linux-musl", "x86_64-linux", "x86_64-unknown-linux-musl", "libbridge_cffi.so"], + ["aarch64", "linux", "aarch64-linux", "aarch64-unknown-linux-gnu", "libbridge_cffi.so"], + ["x86_64", "darwin25", "x86_64-darwin25", "x86_64-apple-darwin", "libbridge_cffi.dylib"], + ["arm64", "darwin25", "arm64-darwin25", "aarch64-apple-darwin", "libbridge_cffi.dylib"], + ["x64", "mingw32", "x64-mingw-ucrt", "x86_64-pc-windows-msvc", "bridge_cffi.dll"], + ["arm64", "mswin64", "arm64-mswin64", "aarch64-pc-windows-msvc", "bridge_cffi.dll"] + ] + with_cache do |runtime, home| + targets.each do |cpu, os, arch, target, filename| + RbConfig::CONFIG.stub(:values_at, [cpu, os, arch]) do + assert_equal File.join(home, "bridges", Baml::Bridge::TOOLCHAIN_VERSION, target, filename), + runtime.send(:cached_runtime_path).to_s + end + end + end + end + private + def with_cache + original = ENV.to_h.slice("BAML_HOME", "BAML_BRIDGE_PATH") + Dir.mktmpdir("baml-loader-") do |home| + ENV["BAML_HOME"] = home + ENV.delete("BAML_BRIDGE_PATH") + yield Baml::Bridge.const_get(:ProcessRuntime, false).new, home + end + ensure + %w[BAML_HOME BAML_BRIDGE_PATH].each { |key| ENV[key] = original[key] } + end + def run_scenario(name) scenario = File.expand_path("bridge_scenario.rb", __dir__) stdout, stderr, status = Open3.capture3(RbConfig.ruby, scenario, name) diff --git a/baml_language/sdk_tests/crates/ruby_sorbet/test/gem_smoke.rb b/baml_language/sdk_tests/crates/ruby_sorbet/test/gem_smoke.rb new file mode 100644 index 00000000000..f9a5f1c031e --- /dev/null +++ b/baml_language/sdk_tests/crates/ruby_sorbet/test/gem_smoke.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "bundler" +require "fileutils" +require "open3" +require "rbconfig" +require "tmpdir" + +runtime, target, cli, sorbet_bundle = ARGV +unless runtime && target && cli + raise "usage: gem_smoke.rb [Sorbet bundle path]" +end + +test_root = File.expand_path("..", __dir__) +repo_root = File.expand_path("../../../..", test_root) +gem_root = File.join(repo_root, "baml_language/sdks/ruby/bridge_ruby") +# Use the bundle's exact dependency archives, including transitive dependencies. +# The install itself must not see the bundle or any already-installed gems. +archives = Bundler.load.specs.reject { |spec| spec.name == "bundler" }.map(&:cache_file) + +Dir.mktmpdir("baml-gem-") do |root| + raise "clean app must be outside the checkout" if root.start_with?("#{repo_root}/") + + app = File.join(root, "app") + gem_home = File.join(root, "gems") + baml_home = File.join(root, "baml") + packages = File.join(root, "packages") + FileUtils.mkdir_p([app, gem_home, packages]) + FileUtils.cp(archives, packages) + File.write(File.join(app, "baml.toml"), "[package]\nname = \"ruby_smoke\"\n") + FileUtils.mkdir_p(File.join(app, "baml_src")) + File.write(File.join(app, "baml_src/main.baml"), 'function HelloWorld() -> string { "hello world" }') + + Bundler.with_unbundled_env do + env = { + "GEM_HOME" => gem_home, "GEM_PATH" => gem_home, + "RUBYLIB" => nil, "RUBYOPT" => nil, "RUBYGEMS_GEMDEPS" => nil, + "BAML_HOME" => baml_home, "BAML_BRIDGE_PATH" => nil, + "DEV_BAML_CLI_DISABLE_AGENT_DETECTION" => "1", "BAML_AGENT_SKILL_CHECK" => "off" + } + run = lambda do |directory, *args| + stdout, stderr, status = Open3.capture3(env, RbConfig.ruby, *args, chdir: directory) + raise "#{args.inspect} failed (#{status.exitstatus}):\n#{stdout}\n#{stderr}" unless status.success? + + stdout + end + + [["generate", "add", "ruby/sorbet"], ["generate", "--output-dir", app]].each do |args| + stdout, stderr, status = Open3.capture3(env, cli, *args, chdir: app) + raise "baml-cli #{args.join(' ')} failed (#{status.exitstatus}):\n#{stdout}\n#{stderr}" unless status.success? + end + raise "CLI did not generate Ruby" unless File.file?(File.join(app, "baml_sdk.rb")) + + package = File.join(packages, "baml-bridge.gem") + run.call(gem_root, "-S", "gem", "build", "--norc", "baml-bridge.gemspec", "--output", package) + run.call(packages, "-S", "gem", "install", "--norc", "--local", "--no-document", package) + + version = run.call(app, "-rbaml/bridge/version", "-e", "print Baml::Bridge::TOOLCHAIN_VERSION") + cached_library = File.join(baml_home, "bridges", version, target, File.basename(runtime)) + FileUtils.mkdir_p(File.dirname(cached_library)) + FileUtils.cp(runtime, cached_library) + + output = run.call(app, "-I", app, "-e", <<~RUBY) + require "baml_sdk" + raise "missing embedded metadata" unless BamlSdk::METADATA&.include?("ruby/sorbet") + puts BamlSdk.hello_world + RUBY + raise "unexpected clean-app output: #{output.inspect}" unless output == "hello world\n" + + # Check the actual loaded files as well as the isolated environment. + run.call(app, "-I", app, "-e", <<~RUBY) + require "baml_sdk" + raise "repository load path" if ($LOAD_PATH + $LOADED_FEATURES).any? { |path| path.start_with?(#{repo_root.dump}) } + %w[baml-bridge ffi google-protobuf sorbet-runtime].each do |name| + raise "external gem: \#{name}" unless Gem.loaded_specs.fetch(name).full_gem_path.start_with?(#{gem_home.dump}) + end + spec = Gem.loaded_specs.fetch("baml-bridge") + raise "gem version does not match stamped runtime" unless spec.version == Gem::Version.new(Baml::Bridge::BRIDGE_RUNTIME_VERSION) + %w[README.md LICENSE rbi/baml.rbi lib/baml_bridge/cffi/v1/baml_inbound_pb.rb].each do |file| + raise "missing packaged file: \#{file}" unless File.file?(File.join(spec.full_gem_path, file)) + end + RUBY + + if sorbet_bundle + # Check the app as a Tapioca user would: Tapioca's default config ignores + # every vendor/ directory, and the installed gem supplies the runtime RBI. + gem_path = run.call(app, "-e", 'print Gem::Specification.find_by_name("baml-bridge").full_gem_path') + FileUtils.mkdir_p(File.join(app, "sorbet")) + File.write(File.join(app, "sorbet/config"), "--dir\n.\n--ignore=vendor/\n#{File.join(gem_path, 'rbi/baml.rbi')}\n") + File.write(File.join(app, "typed.rb"), <<~RUBY) + # typed: strict + T.let(BamlSdk.hello_world, String) + T.let(BamlSdk.hello_world_async, Baml::Call[String]) + RUBY + sorbet_env = { + "BUNDLE_GEMFILE" => File.join(test_root, "test/static/Gemfile"), + "BUNDLE_PATH" => sorbet_bundle, "BUNDLE_FROZEN" => "true" + } + stdout, stderr, status = Open3.capture3(sorbet_env, RbConfig.ruby, "-S", "bundle", "exec", "srb", "tc", chdir: app) + raise "Sorbet rejected the clean app (#{status.exitstatus}):\n#{stdout}\n#{stderr}" unless status.success? + end + end +end + +puts "Installed baml-bridge gem: clean app returned hello world" +puts "Sorbet accepted the clean app with the installed RBI" if sorbet_bundle diff --git a/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md b/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md index 2a3215d7499..5e94d031d9d 100644 --- a/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md +++ b/baml_language/sdks/agent-docs/bridge-ref/ref-ruby-state-of-completeness.md @@ -8,7 +8,7 @@ The Ruby/Sorbet SDK generates typed functions, classes and enums for user code a ✅ means a named Ruby test proves the stated support, 🚧 means only part of the row is tested or supported, and ❌ means unsupported or not yet parity-verified. The Python column is copied from `ref-python-state-of-completeness.md` at commit `b74b83a3ab`, including its qualifications; it is not a claim about today's Python SDK. Links to `customizable/test_*.rb` identify real-engine tests; streaming tests use the keyless replay server. Fake-bridge tests are identified separately. -Validation: `cargo nextest run -p sdk_test_ruby_sorbet` passes all 16 harness tests, including minitest and Sorbet checks for all five shared fixtures, the fixture manifest and runtime checks. Individual Ruby tests still skip callable release on engine GC and callable-subscript arity; a passing harness does not make those capabilities supported. +Validation: `cargo nextest run -p sdk_test_ruby_sorbet` passes all 17 harness tests, including minitest and Sorbet checks for all five shared fixtures, the fixture manifest, runtime and CLI-generated local-gem checks. Individual Ruby tests still skip callable release on engine GC and callable-subscript arity; a passing harness does not make those capabilities supported. ## Function-call forms @@ -48,9 +48,9 @@ These rows describe what happens when a call returns, throws, or terminates. The fake-bridge test [`test_call_waiters_cache_outcomes_and_survive_interrupt_and_cancel`][bridge-call] checks multiple waiters, cached results and errors, an interrupted waiter, foreign-thread completion and a cancellation race. Host callbacks run on their own Ruby threads. Cancellation does not kill a running callback or free its resources before it returns: fake-bridge [`test_cancelled_host_callback_keeps_resources_until_proc_returns`][bridge-call]. Nested calls do not inherit the callback's invocation context. -Generated packages initialize on their first call or through `initialize!`. Loading a different program in the same process and using an inherited runtime after fork raise errors: fixture-backed [`test_different_program`][loader], [`test_pid_check`][loader]. +Generated packages initialize on their first call or through `initialize!`. The [clean-app smoke script][gem-smoke] generates with `baml generate`, then proves the first call initializes with embedded `baml.toml` metadata. Loading a different program in the same process and using an inherited runtime after fork raise errors: fixture-backed [`test_different_program`][loader], [`test_pid_check`][loader]. -Gem packaging, clean-app installation and native-library cache lookup are unsupported. Supply the native bridge through `BAML_BRIDGE_PATH`. Publishing and automatic bridge download remain out of scope. +The `baml-bridge` gem is pure Ruby, with committed protobuf clients and dependencies on `ffi`, `google-protobuf` and `sorbet-runtime`. The [clean-app smoke script][gem-smoke], run by `gem::clean_app`, builds and installs it locally outside the checkout and calls an SDK generated by `baml-cli generate` with `output_type = "ruby/sorbet"`. The gem neither bundles nor downloads the native bridge; supply it through `BAML_BRIDGE_PATH` or the versioned `$BAML_HOME/bridges/` cache. Publishing and automatic bridge download remain out of scope. ## Static typing @@ -58,7 +58,7 @@ The [shared Ruby harness][static-gate] runs `srb tc` on each of the five generat Strict consumers in the fixture overlays cover sync and async returns, stdlib methods, callbacks and returned callables, optional arguments, call controls, generics, streams, specs, bigint, bytes, media, unions, aliases, classes and enums. One negative consumer requires exactly one wrong-argument diagnostic (`7002`), so a regression to `T.untyped` fails the gate. -The [boundary RBI][static-bridge] describes the runtime-facing API, including call, stream and generic helpers; it does not recreate generated SDK declarations. The bridge's decoded return remains dynamic, so this gate proves generated declarations and consumer checking, not the runtime's adherence to BAML return types. Real-engine and replay tests cover runtime behavior separately, including recursion, forward references and symbol collisions. +The gem ships the [boundary RBI][static-bridge], which describes the runtime-facing API, including call, stream and generic helpers; it does not recreate generated SDK declarations. On supported hosts, the [clean-app smoke script][gem-smoke] also runs `srb tc` on the installed app, using the installed gem's RBI and Tapioca's default `--ignore=vendor/`. Generated vendor packages live under `baml_sdk/__vendor/`, so that ignore does not hide them. The bridge's decoded return remains dynamic, so this gate proves generated declarations and consumer checking, not the runtime's adherence to BAML return types. Real-engine and replay tests cover runtime behavior separately, including recursion, forward references and symbol collisions. Typing still has limits: literal values use their Ruby primitive types, recursive alias back-edges and JSON use `T.untyped`, and `_types:` token values are dynamic. Callable signatures accept typed Procs or `Baml::Closure`. Generated methods with callable parameters or results disable Sorbet's runtime signature checks so other callable objects also work. Other method signatures retain Sorbet's lazy validation. @@ -76,13 +76,13 @@ Encode means Ruby → BAML; decode means BAML → Ruby. The generated-package te | Bigint | ✅ | ✅ | Arbitrary-precision `Integer`: [`test_primitives_round_trip_bigint`][primitives] covers positive and negative 2⁸⁰ and the 2⁶⁴ boundary; [`test_primitives_return_bigint`][primitives] checks decode. | | Float | ✅ | ✅ | `Float`: [`test_primitives_round_trip_float`][primitives]. Integer inputs widen only when exactly representable (`2**53 + 1` raises `TypeError`): [`test_primitives_round_trip_float_accepts_int`][primitives]. | | String | ✅ | ✅ | `String`: [`test_primitives_round_trip_string`][primitives]. | -| Bytes | ✅ | ✅ | Binary `String` is naturally bytes, including under open slots. Text strings convert to bytes in a bytes slot. When a union allows both string and bytes, binary encoding selects bytes: [`test_primitives_round_trip_uint8_array`][primitives] covers a real round trip and protocol selection through nested containers and aliases. | +| Bytes | ✅ | ✅ | Binary `String`, encoded using the declared bytes hint. When a union allows both string and bytes, binary encoding selects bytes: [`test_primitives_round_trip_uint8_array`][primitives] covers a real round trip and protocol selection through nested containers and aliases. | | List | ✅ | ✅ | `Array`, including empty and mixed-element lists: [`test_lists_round_trip_ints`][lists], [`test_lists_round_trip_empty_list`][lists], [`test_lists_round_trip_union_list`][lists]. | -| Map | ✅ | ✅ | `Hash`; encoding refuses non-string keys with "map keys must be string": [`test_maps_round_trip_simple_map`][maps], [`test_maps_round_trip_list_valued_map`][maps]. | +| Map | ✅ | ✅ | `Hash`: [`test_maps_round_trip_simple_map`][maps], [`test_maps_round_trip_list_valued_map`][maps]. | | Enum | ✅ | ✅ | Generated `T::Enum`: [`test_enums_round_trip_sentiment`][enums], [`test_enums_round_trip_sentiment_positive`][enums]. Dynamic enums are unsupported. | | Class | ✅ | ✅ | Generated `T::Struct`, registered by BAML type name: [`test_class_refs_round_trip_inner`][class-refs], [`test_streaming_e2e_stream_doc_collect_in_baml`][streaming]. | | Generic explicitly reified by BAML-known type | ✅ | ✅ | `Baml.bind_types` attaches runtime type arguments to generated generic structs: [`test_generic_calls_identity_explicit`][generic-calls]. Decoded type arguments are retained, including nested ones: [`test_generic_calls_make_int_box_reified`][generic-calls], [`test_generic_calls_make_nested_box_reified`][generic-calls]. | -| Generic implicitly reified by BAML-known type | ✅ | ✅ | The Ruby walker solves unbound instance arguments from their fields before encoding: [`test_generic_inference_identity_unbound_generic_instance_round_trips`][generic-inference], [`test_generic_inference_extract_fully_unbound_nested_pair_recovers_all_vars`][generic-inference]. | +| Generic implicitly reified by BAML-known type | ✅ | ✅ | The engine infers from unbound instance fields: [`test_generic_inference_identity_unbound_generic_instance_round_trips`][generic-inference], [`test_generic_inference_extract_fully_unbound_nested_pair_recovers_all_vars`][generic-inference]. | | Generic reified by host-only type | ❌ (need more rules for serializing Python-only types) | ❌ | Unregistered Ruby objects cannot be encoded: [`test_generic_inference_host_only_object_not_encodable_from_python`][generic-inference]. | | Union | ✅ (union metadata dropped) | ✅ | Decodes to the Ruby variant value; metadata is dropped. Ambiguous inputs, such as `[]` for `int[] \| string[]`, raise `TypeError` instead of choosing an arm. Generated signatures use `T.any` / `T.nilable`: [`test_unions_round_trip_null_to_end`][unions], [`test_unions_round_trip_optional_plus_null`][unions], [`test_unions_round_trip_union_container`][unions]. | | BAML interface | ❌ | ❌ | No interface value support; affected declarations are not generated. | @@ -127,3 +127,4 @@ Encode means Ruby → BAML; decode means BAML → Ruby. The generated-package te [media]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_media.rb [handles]: ../../../sdk_tests/crates/ruby_sorbet/type_shapes/customizable/test_handles.rb [loader]: ../../../sdk_tests/crates/ruby_sorbet/test/bridge_loader_test.rb +[gem-smoke]: ../../../sdk_tests/crates/ruby_sorbet/test/gem_smoke.rb diff --git a/baml_language/sdks/ruby/bridge_ruby/LICENSE b/baml_language/sdks/ruby/bridge_ruby/LICENSE new file mode 100644 index 00000000000..261eeb9e9f8 --- /dev/null +++ b/baml_language/sdks/ruby/bridge_ruby/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/baml_language/sdks/ruby/bridge_ruby/README.md b/baml_language/sdks/ruby/bridge_ruby/README.md index 054224be639..d93cf85789c 100644 --- a/baml_language/sdks/ruby/bridge_ruby/README.md +++ b/baml_language/sdks/ruby/bridge_ruby/README.md @@ -1,8 +1,8 @@ # BAML Ruby/Sorbet runtime -The Ruby runtime runs generated BAML Ruby/Sorbet SDKs. It contains pure Ruby code and the committed protobuf clients, and depends on `ffi`, `google-protobuf` and `sorbet-runtime`. Ruby 3.2 or newer is required. +The `baml-bridge` gem runs generated BAML Ruby/Sorbet SDKs. It contains pure Ruby code and the committed protobuf clients, and depends on `ffi`, `google-protobuf` and `sorbet-runtime`. Ruby 3.2 or newer is required. -Add a Ruby generator to your BAML project and generate the SDK: +Add `gem "baml-bridge"` to your application's Gemfile. Add a Ruby generator to your BAML project and generate the SDK: ```sh baml generate add ruby/sorbet @@ -23,8 +23,16 @@ Generated functions initialize the runtime on their first call. `BamlSdk.initial ## Sorbet types -`rbi/baml.rbi` is the runtime contract used to check generated code. Check -it alongside your generated `baml_sdk.rb` and `baml_sdk/` sources. +The gem ships `rbi/baml.rbi`, the runtime contract used to check generated code. +Tapioca includes it when it generates the gem's RBI. Without Tapioca, copy it to +your application's `sorbet/rbi/` directory, or add its installed path to +`sorbet/config`: + +```sh +echo "$(bundle info --path baml-bridge)/rbi/baml.rbi" >> sorbet/config +``` + +Check it alongside your generated `baml_sdk.rb` and `baml_sdk/` sources. Generated structs compare by their fields and bound generic type arguments. Function types accept either a typed Proc or a `Baml::Closure` returned by BAML. @@ -55,14 +63,34 @@ an unbound receiver's fields. ## Native library lookup -Set `BAML_BRIDGE_PATH` to the absolute filename of a native bridge library built for the runtime's canonical BAML toolchain version. The loader checks the bridge ABI and canonical toolchain version before use. +The gem does not include or download the native bridge library. Install a bridge built for the gem's canonical BAML toolchain version in one of these locations: + +1. Set `BAML_BRIDGE_PATH` to the absolute library filename. This overrides the cache. An invalid override is an error, with no fallback. +2. Otherwise, install the library at `$BAML_HOME/bridges///`. `BAML_HOME` defaults to `~/.baml`. An empty environment variable is treated as unset. + +For example, version `0.23.0` on x86-64 glibc Linux uses: + +```text +~/.baml/bridges/0.23.0/x86_64-unknown-linux-gnu/libbridge_cffi.so +``` + +Targets match the Rust bridge cache: `x86_64` or `aarch64`, followed by `apple-darwin`, `unknown-linux-gnu`, `unknown-linux-musl`, or `pc-windows-msvc`. The filename is `libbridge_cffi.dylib` on macOS, `libbridge_cffi.so` on Linux, or `bridge_cffi.dll` on Windows. The loader derives the target from Ruby's `RbConfig` and checks the bridge ABI and canonical toolchain version before use. ## Build locally +From this directory: + +```sh +gem build baml-bridge.gemspec +gem install --local baml-bridge-*.gem +``` + +The gemspec reads `lib/baml/bridge/version.rb`, which `scripts/baml-language-version` stamps during releases. RubyGems normalizes prerelease versions for the package; the native cache still uses the canonical version, such as `0.23.1-nightly.20261101.a`. + The committed `google-protobuf` clients under `lib/baml_bridge/cffi/v1/` come from the authoritative CFFI V1 schemas. Regenerate them from `baml_language/` with: ```sh cargo build -p bridge_ctypes ``` -A clean regeneration must leave these clients unchanged. +A clean regeneration must leave these clients unchanged. Publishing the gem and downloading the bridge are separate release work. diff --git a/baml_language/sdks/ruby/bridge_ruby/baml-bridge.gemspec b/baml_language/sdks/ruby/bridge_ruby/baml-bridge.gemspec new file mode 100644 index 00000000000..ad017eb26dc --- /dev/null +++ b/baml_language/sdks/ruby/bridge_ruby/baml-bridge.gemspec @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_relative "lib/baml/bridge/version" + +Gem::Specification.new do |spec| + spec.name = "baml-bridge" + spec.version = Baml::Bridge::BRIDGE_RUNTIME_VERSION + spec.authors = ["BoundaryML"] + spec.email = ["contact@boundaryml.com"] + spec.summary = "Runtime for generated BAML Ruby/Sorbet SDKs" + spec.homepage = "https://github.com/BoundaryML/baml" + spec.license = "Apache-2.0" + spec.required_ruby_version = ">= 3.2" + + spec.files = Dir.chdir(__dir__) { Dir["lib/**/*", "rbi/**/*", "README.md", "LICENSE"].select { |path| File.file?(path) } } + spec.require_paths = ["lib"] + + spec.add_dependency "ffi", ">= 1.17.4", "< 2" + spec.add_dependency "google-protobuf", "~> 4.0" + spec.add_dependency "sorbet-runtime", "~> 0.5" +end diff --git a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/process_runtime.rb b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/process_runtime.rb index 139d95916aa..f24501d13c1 100644 --- a/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/process_runtime.rb +++ b/baml_language/sdks/ruby/bridge_ruby/lib/baml/bridge/process_runtime.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "pathname" +require "rbconfig" require "thread" module Baml @@ -190,22 +191,54 @@ def ensure_not_forked! def configured_runtime_path! value = ENV.fetch(RUNTIME_PATH_ENV, "") - if value.empty? - raise RuntimeConfigurationError, - "#{RUNTIME_PATH_ENV} must name the bridge_cffi library" - end + unless value.empty? + path = Pathname.new(value) + unless path.absolute? + raise RuntimeConfigurationError, "#{RUNTIME_PATH_ENV} must be an absolute path: #{value.inspect}" + end + unless path.file? + raise RuntimeConfigurationError, "#{RUNTIME_PATH_ENV} does not name a file: #{value.inspect} (no cache fallback)" + end - path = Pathname.new(value) - unless path.absolute? - raise RuntimeConfigurationError, "#{RUNTIME_PATH_ENV} must be an absolute path: #{value.inspect}" + return path.to_s.freeze end + + path = cached_runtime_path unless path.file? - raise RuntimeConfigurationError, "#{RUNTIME_PATH_ENV} does not name a file: #{value.inspect}" + raise RuntimeConfigurationError, + "Unable to find BAML runtime: #{RUNTIME_PATH_ENV} is not set; " \ + "cache path #{path.to_s.inspect} does not name a file. " \ + "Install the library there or set #{RUNTIME_PATH_ENV}. Automatic download is not supported." end path.to_s.freeze end + def cached_runtime_path + cpu, os, arch, host = RbConfig::CONFIG.values_at("host_cpu", "host_os", "arch", "host") + cpu = case cpu + when "x86_64", "amd64", "x64" then "x86_64" + when "aarch64", "arm64" then "aarch64" + end + target, filename = case os + when /darwin/ + ["#{cpu}-apple-darwin", "libbridge_cffi.dylib"] + when /linux/ + libc = [os, arch, host].join.include?("musl") ? "musl" : "gnu" + ["#{cpu}-unknown-linux-#{libc}", "libbridge_cffi.so"] + when /mswin|mingw/ + ["#{cpu}-pc-windows-msvc", "bridge_cffi.dll"] + end + unless cpu && target + raise RuntimeConfigurationError, + "No cached BAML runtime target for #{arch}; set #{RUNTIME_PATH_ENV} explicitly" + end + + home = ENV.fetch("BAML_HOME", "") + home = File.join(Dir.home, ".baml") if home.empty? + Pathname.new(home).join("bridges", TOOLCHAIN_VERSION, target, filename).expand_path + end + def receive_result(callback_id, outcome) call = @pending_mutex.synchronize { @pending.delete(callback_id) } return unless call