Skip to content

fix(jsonschema): preserve multi-branch anyOf/oneOf unions - #173

Merged
Oaklight merged 2 commits into
masterfrom
fix/preserve-multi-branch-unions
Sep 24, 2026
Merged

Oaklight merged 2 commits into
masterfrom
fix/preserve-multi-branch-unions

Conversation

@Oaklight

Copy link
Copy Markdown
Owner

Summary

Fix _simplify_node() to preserve genuine multi-branch anyOf/oneOf unions instead of collapsing them to the first non-null branch.

What changed

_simplify_node() previously unwrapped all anyOf/oneOf nodes to the first non-null branch. This was correct for simple nullable patterns like T | None but was lossy for genuine unions like float | list[float] | None — it would silently discard all branches except the first.

Now the function checks the count of non-null branches:

  • 1 non-null branch: collapse as before (simple nullable unwrap)
  • 2+ non-null branches: strip the null branch but preserve the remaining branches as anyOf

Version bumped from 0.4.0 to 0.4.1. Tests updated to cover multi-branch union behavior.

Ref: Oaklight/ToolRegistry#269

_simplify_node() previously collapsed all anyOf/oneOf to the first
non-null branch, which was correct for simple nullable patterns
(T | None) but lossy for genuine unions like float | list[float] | None.

Now only collapses when there is exactly one non-null branch. When there
are 2+ non-null branches, they are preserved as anyOf with the null
branch stripped.

Ref: Oaklight/ToolRegistry#269

@clementine-oaklight clementine-oaklight Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Clean, well-scoped fix. The old behavior silently discarding all but the first non-null branch was a real correctness bug for genuine unions — good catch from the ToolRegistry case.

What I checked:

  • Logic change in _simplify_node(): single non-null → collapse (unchanged), multi non-null → preserve as anyOf (new). Correct.
  • Null-branch stripping + nullable: true still works for the multi-branch case. ✅
  • Test coverage: 4 test cases cover the key matrix (with/without null × anyOf/oneOf input, plus the motivating number | array[number] | null case). Solid.
  • Version bump 0.4.0 → 0.4.1 is appropriate for a patch-level bugfix.
  • CI all green across 3.10–3.13.

One minor nit inline about oneOf → anyOf keyword preservation, but it's non-blocking given the LLM-tool-schema target audience.

LGTM 👍

Comment thread jsonschema/jsonschema.py Outdated
elif len(non_null) > 1:
# Lossy but safe for LLM tool schemas: keep first non-null variant.
base = _deep_merge_two(base, non_null[0])
base["anyOf"] = non_null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: this always emits anyOf even when the input was oneOf. In strict JSON Schema semantics they differ (oneOf = exactly one branch matches, anyOf = at least one). For LLM tool schemas it's a non-issue — providers treat them interchangeably — but if you ever want this simplifier to round-trip general schemas more faithfully, you could preserve the original keyword:

base[kw] = non_null  # kw is already bound from the for-loop

Totally non-blocking, just flagging for future awareness.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Good call — fixed in ef8fc4f. Now uses base[keyword] to preserve the original oneOf/anyOf keyword. Updated the test assertion for the oneOf input case too.

@elena-oaklight elena-oaklight Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Clean, correct fix. The old behavior silently discarded all but the first non-null branch — a real correctness bug for genuine unions like float | list[float] | None.

What I checked

Area Status
Core logic change ✅ Single non-null → collapse (unchanged); multi non-null → preserve as anyOf (new)
Null-stripping + nullable: true ✅ Works for both single and multi-branch cases
Tests ✅ 4 cases covering with/without null × anyOf/oneOf, plus the motivating number | array[number] | null pattern
Version bump (0.4.0 → 0.4.1) ✅ Appropriate for patch-level bugfix
CI (lint + 3.10–3.13) ✅ All green

Minor observation (non-blocking)

oneOf inputs are normalized to anyOf in the output (line 410). For LLM tool schemas this is a reasonable default — most providers handle anyOf better — but it's a semantic shift (oneOf = exclusive, anyOf = inclusive). If strict JSON Schema fidelity matters for other consumers, base[keyword] = non_null would preserve the original keyword. Clementine flagged the same nit; fine as-is for the target audience.

LGTM 👍

…h unions

Address review nit from Clementine and Elena: use `base[keyword]` instead
of hardcoded `base["anyOf"]` so oneOf inputs retain their keyword.

@milo-oaklight milo-oaklight Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Clean fix — the change is small and well-scoped.

What I checked:

  • _simplify_node logic: single non-null branch still collapses via _deep_merge_two; 2+ non-null branches now preserved under the original keyword (base[keyword] = non_null). The null-stripping + nullable flag path is unchanged. Correct.
  • ef8fc4f addresses the oneOf→anyOf normalization nit — keyword variable is already in scope from the for loop, so this is the right minimal fix.
  • Test coverage: four multi-branch scenarios (with/without null, anyOf/oneOf, heterogeneous types). All assert the original keyword is preserved. Good.
  • CI green on 3.10–3.13 + lint.
  • Version bump 0.4.0 → 0.4.1: appropriate for a bugfix.

No issues found. LGTM.

@Oaklight
Oaklight merged commit db6265c into master Sep 24, 2026
6 checks passed
@Oaklight
Oaklight deleted the fix/preserve-multi-branch-unions branch September 24, 2026 01:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant