Repository navigation
feat: tell an absent optional class field from an explicit null - #5294
rossirpaulo wants to merge 6 commits into
Conversation
An instance slot can now hold the VM's "nothing was supplied here" word (`Value::OMITTED_ARG`, aliased `Value::ABSENT`) to mean an optional field that was never given a value, as distinct from one holding `null`. The ordinary readers (`Instance::load_field`, `try_load_field`, `field_values`) report an absent slot as `null`, so field reads, `==`, hashing, display, GC marking, trace capture, and values returned to hosts behave exactly as before. Presence-aware code reads the raw slot through `field_slots` / `is_field_absent`: the GC pointer rewrite and `deep_copy` keep it. Any store makes the field present. `Instance::fields` becomes `pub(crate)` so every reader outside `bex_vm_types` picks one of the two views explicitly. Nothing produces an absent slot yet; later commits do. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…ctors
A class constructor may leave out a field whose type admits `null`. MIR
used to fill that slot with `const null`; it now fills it with
`const <omitted>`, so the field is absent rather than explicitly `null`.
`Patch { name: null }` still stores an explicit `null`.
Reads are unchanged (an absent field reads as `null`), and so is every
encoder at this point.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…ncoding
Decoding (`from_string<T>`, `to<T>`, `from_json<T>`, `deserialize<T>`)
now leaves an optional field absent when its JSON object has no such
member, and stores `null` when the member is an explicit `null`. A missing
required field still throws.
`baml.json.to_json`, `to_string` and `from<T>` take
`omit_absent: bool = false`. By default an absent field is still written
as `null`, so existing output is byte-for-byte unchanged. With
`omit_absent = true` the structural walk leaves absent fields out at every
depth while still writing explicit nulls, so a decoded object encodes back
with the same members:
let p = baml.json.from_string<Patch>("{\"expires_in\":null}");
baml.json.to_string(p, omit_absent = true) // {"expires_in":null}
`to_json` becomes a BAML wrapper over the native `_to_json`, which takes
the option resolved. Call sites of `to_string(x)` now lower with a
trailing `const <omitted>` argument (one MIR golden), and the stdlib item
list gains `_to_json`.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
`reflect.class.is_field_set(value, name)` reports whether a field has been given a value (`null` included), which a read cannot tell since an absent field reads as `null`. `reflect.class.unset_field(value, name)` makes an optional field absent again; it throws for a field whose type does not admit `null`, for an undeclared field, and for a non-instance, the same error `get_field` throws. Adds the `ns_absent_fields` runtime tests covering constructors, assignment, both decoders, encoding with and without `omit_absent` (nested classes, lists, optionals), round trips, `unset_field`, presence-blind `==`, display, and spreads (which copy a field as read, so an absent field arrives as `null`). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
A class value from a host that leaves out an optional field used to fail with "Missing field ... in external Instance". The field now arrives absent, the same rule `map_matches_class_shape` already applies when it picks a class arm for a plain host object, and an explicit `null` entry arrives as `null`. A missing required field is still an error. Values returned to hosts are unchanged: an absent field still leaves the engine as `null`, because the Go and C++ decoders require every declared field. Refs #5108 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The bridge sent every pydantic model field, so a field the caller never set went out as `null`. It now skips a field that is not in `model_fields_set` and still holds its `None` default, so BAML sees it absent; `Patch(name="x", expires_in=None)` still sends an explicit `null`. BAML reads both as `null`; only `reflect.class.is_field_set` and `omit_absent` encoding tell them apart. With every field unset the class entry list is empty, so the encoder now marks the `class_value` oneof present explicitly; otherwise the value would be read as `null`. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Warning Billing warning: we have not been able to collect payment for this subscription for more than 72 hours. Please update the payment method or pay any pending invoices in Billing to avoid service interruption. Comment |
⏭️ Performance benchmarks were skippedPerf benchmarks (CodSpeed) are opt-in on pull requests — they no longer run on every push. They always run automatically after merge to To run them on this PR, do any of the following, then push a commit (or re-run CI):
|
Binary size checks failed❌ 2 violations · ✅ 1 passed
Details & how to fixViolations:
Add/update baselines:
[artifacts.bridge_wasm]
file_bytes = 26038191
gzip_bytes = 7638368
[artifacts.packed-program]
file_bytes = 39800704
gzip_bytes = 16225885Generated by |
|
Closing. The preferred direction is to keep optional fields two-state (an omitted optional field is null) rather than add an absent state. Classes that need a different wire shape can implement |
Problem
A typed BAML class value cannot tell an optional field that is absent from one that holds
null:The two decodes produce the same value, and nothing records which state the program meant. As a result:
null= clear", nor use fields documented as "set to null to reset".nullmembers the input never had.nullis meaningful.Missing field … in external Instance.The untyped tree from
baml.json.parsealready keeps the distinction. This PR is about typed class values.Design
An optional (
T?) class field gets three states: absent (never given a value),null, and a value. There is no new syntax and no change to the host wire format or C ABI.Engine
Value::OMITTED_ARG, aliased asValue::ABSENT. JavaScript'sundefinedplays the same double role, marking both an omitted argument and a missing property.Instance::load_field,try_load_field,field_values) report an absent slot asnull. That coversLoadField,==, hashing,string.from, GC marking, reflection reads, trace capture and outbound conversion, so program code never sees the sentinel.field_slots,is_field_absent) see the raw slot. Only the JSON encoder,deep_copy, the GC pointer rewrite, the heap debugger and reflection use them.null, makes the field present.Instance::fieldsis nowpub(crate), so every reader outsidebex_vm_typeshad to pick one of the two views. The compiler listed about 30 sites, and I decided each one individually.Cost: one compare-and-select per field read. There is no size change to
Instance/Object, no newValueKind, and no limit on field count. A loop doing 60M field reads ran 1.296 s (median) on canary and 1.298 s on this branch: 6 alternating runs each,fasttestprofile, within noise.Language
Patch { name: "x" }), or callreflect.class.unset_field(p, "expires_in")nullPatch { expires_in: null }, orp.expires_in = nullreflect.class.is_field_set(p, "expires_in")isfalseonly when absent;p.expires_in == nullseparatesnullfrom a valuenull. The one compiler change is in MIR: an unspecified constructor slot is nowconst <omitted>rather thanconst null.==, hashing and display ignore presence: an absent field reads asnulland compares equal to an explicitnull. This keeps code such asassert.equal(decoded, Patch { name: "x", expires_in: null })working.Patch { ...base }) copies each field as read, so an absent field ofbasearrives as an explicitnull. A test pins this behavior; see Follow-ups.JSON
Decoding (
from_string<T>,to<T>,from_json<T>,deserialize<T>,T.from_json), at every depth:nullmember decodes asnull;Encoding.
to_json,to_stringandfrom<T>gainomit_absent: bool = false.null, so output is byte-for-byte what it was before.omit_absent = true, absent fields are left out at every depth of the structural walk.nullis always written.baml.ToJsonoverride still owns its whole subtree, so the option does not reach inside an override.Wire and host boundary
The wire format needs no change:
InboundClassValue.fieldsis already a keyed list, so a host expresses "absent" by leaving the key out.Missing field. A missing required field is still an error. This is the same rulemap_matches_class_shapealready uses to pick a class arm for a plain host object.null. This is unchanged on purpose:baml_goField) and C++ decoders reject a missing field;undefinedwhere it seesnulltoday.Per-SDK representation (proposal; only Python inbound is in this PR)
nullmodel_fields_setdecides: a field the caller never set (still at itsNonedefault) is absent;field=Noneisnull. Outbound, later: leave absent keys out of the dict given tomodel_validate, andmodel_fields_set/model_dump(exclude_unset=True)reflect presence for free.note!: string | null; a missing key is not sent (now absent, previously an error); a key holdingundefinedis sent asnullundefinedmeans absent,nullmeansnull(as withJSON.stringify). Outbound behind an opt-in: absent becomes a missing property. Types:note?: string | nullin input positions only, because making output properties optional breaks readers.*T; every field is always sent; decoding requires every field*Twith nil meaningnull, and addAbsentFields []stringto generated structs. This is Google API clients'NullFieldsinverted, so today's nil =nulldefault holds. Decoding fillsAbsentFieldswhen the engine omits keys.Option<T>; every field is always sent; decoding treats a missing field asNonebaml_bridge::Field<T> { Absent, Null, Value(T) }(orOption<Option<T>>) for classes or generators that ask for it; plainOption<T>stays the default. Coordinate with #5287, which is changing the Rust generator now.Turning on outbound three-state needs a switch per call or per generator, so that hosts that cannot read a missing key keep working. See the open questions.
What this PR implements
bex_vm_types,bex_heap,bex_vm): the absent slot state and the two read views. GC rewrite,deep_copyand the heap debugger keep presence;==, hashing and display ignore it.baml_compiler2_mir): an omitted constructor slot isconst <omitted>.bex_vmpackage_baml/json.rs,json.baml): both decoders record absent fields, and the structural encoder takesomit_absent.reflect.class):is_field_set(value, name) -> boolandunset_field(value, name).unset_fieldthrows for a non-nullable field, an undeclared field, or a non-instance.bex_engineconversion.rs): a host value missing an optional field arrives absent. Outbound is unchanged.class_valueoneof is now marked present even when every field is unset; otherwise the value would read asnull.Size: +435 / −107 lines excluding tests and snapshots, of which about 180 are comments and doc comments. Tests add about 490 lines.
Compatibility
Presence tracking is always on, but nothing observable changes unless code asks for it.
Unchanged:
==, hashing,string.fromand prompt rendering;null);baml.json.to_json,to_string,from<T>andserialize.Changed:
null. BAML reads both asnull, so only the new presence-aware APIs can tell the difference.baml.json.to_string(x)call sites now lower asto_string(x, const <omitted>). This is visible in one MIR golden and in the stdlib items snapshot, which gains_to_json.Why
omit_absentis opt-in and not the default. I measured the alternative: with omission as the default, 1 of the 5,464 offline runtime corpus tests changed.llm_openai_decisionspinshttp.Request.to_json()with"timeout": null, "connect_timeout": nullfor a request the stdlib builds without those fields.That is little breakage inside this repo. Outside it, the same switch would silently change the bytes existing programs send and store, with no diagnostic. That affects:
d["key"];Flipping the default later is one line (
omit_absent: bool = true) and belongs at a language-version boundary. I would recommend doing it then, since omission is what the stdlib's own provider clients already do by hand.Tests
New tests:
crates/baml_tests/baml_src/ns_absent_fields/absent_fields.baml(18 runtime tests). Covers:null;omit_absentencoding, including nested classes, lists and optional classes;from/to_jsonwith the option;from_stringandtokeeping a missing member apart fromnullat every depth;unset_field, plus its errors (non-nullable field, unknown field, non-instance);==, display, and the pinned spread behavior.crates/bex_engine/tests/absent_fields.rs(5 host-marshalling tests):nullfields on anInstanceand on a plain host map;null.bex_vm_types: a unit test for the two slot read views.sdks/python/tests/test_absent_fields.py: wire entries for set, unset and assigned fields, plus an end-to-end call through a real runtime (Patch()→{},Patch(expires_in=None)→{"expires_in":null}).Commands, all on macOS arm64 with telemetry off and a throwaway
BAML_HOME:baml-cli test --from crates/baml_tests/baml_src(full runtime corpus)trace*tests that need telemetry; CI runs this corpus withBAML_TELEMETRY=medium.cargo nextest run -p bex_vm_types -p bex_heap -p bex_vm -p baml_compiler2_mir -p baml_compiler2_emitcargo nextest run -p bex_engine -p baml_tests -p bex_project -p baml_execcargo test -p bex_engine --test absent_fieldssdks/python:uv run pytest tests/cargo nextest run -p sdk_test_python_pydantic2function_calls::pytestfails on four tracing tests, identically on canary with telemetry off.cargo fmt --check,cargo clippy --workspace --all-targets --all-features -- -D warningsruff check/ruff format --checkon the changed Python files;pyrightNegative controls. Each one breaks a single layer, rebuilds, and reruns the new tests:
nullabsent_fieldstests failfrom_stringdecodes a missing member asnullto<T>decodes a missing member asnullomit_absentFollow-ups
undefinedhandling: about 30 lines, plus tests and adistrebuild;Patch { ...base, name: "x" }should keepbase's absent fields absent. This needs a spread-specific copy in MIR/emit, because a plain field copy must keep its "a read givesnull" semantics. About 80 lines.omit_absent = trueinstead. Keep explicit-null pruning only where it is intended.null; a$absentmarker could follow.json_to_string_typedwrites absent asnull.Open questions
omit_absentdefault totrueat the next language-version boundary?==right? Pydantic v2's__eq__ignoresmodel_fields_set; TypeScript deep-equal libraries do not ignore missing keys.OMITTED_ARGword, or get its own? A dedicated word means a newValueKindvariant and touching every exhaustive match.reflect.class.is_field_set/unset_fieldare string-keyed, so a misspelled field name is a runtime error. A statically checked form would need syntax.field_presence = truein the generator block), per class, or per call?ARCHITECTURE.mdasks for two reviewers on MIR changes. The change here is one constant inlower_object.Coordination
null, at the same lines ofbex_engine/src/conversion.rs. This PR refines that to "absent". Whichever lands second should take the other's test cases and keep the absent rule.serde_json::Valuemapping) touches no files this PR touches. The Rust follow-up above should build on it.🤖 Generated with Claude Code