Skip to content

feat(constraint): any string as a key or enum value, any type at the schema root - #137

Merged
magicnight merged 5 commits into
mainfrom
feat/schema-roots-unicode
Oct 6, 2026
Merged

magicnight merged 5 commits into
mainfrom
feat/schema-roots-unicode

Conversation

@magicnight

@magicnight magicnight commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

Two gaps Apple's @Generable hits, from the roadmap's interoperability axis.

Any string as a key, enum or const value. The decoder used to refuse non-ASCII characters, quotes, backslashes and control characters in declared keys and literal values with a 400, because the automaton matched them as literal bytes and a tokenizer may have no token that spells a multi-byte scalar whole (a token cut inside a scalar decodes with U+FFFD and is unusable under the constraint), so a required key could have deadlocked a generation. Apple's TripPlanner sample, whose destination enum holds "Lençóis Maranhenses", was a 400 for exactly that reason.

The automaton now matches a key or enum value scalar by scalar (LiteralMatch). A scalar outside ASCII may arrive as its raw UTF-8 bytes, split across tokens or not, or as a JSON escape: \uXXXX in either hex case, or a surrogate pair above the BMP. The quote, the backslash and control characters take their short escape or \uXXXX. Every other ASCII scalar is matched raw only. I first allowed escapes everywhere, and Qwen3.6-27B, nudged off its preferred word by the mask, drifted into spelling the rest of an enum value as \u006f\u0074\u0065…, since \ outranked the raw letter at every step; with ASCII raw-only that cannot start. An escape is ASCII, so a literal the model cannot spell raw is still reachable, and the compile-time ban is gone.

Any type at the root. generating: [T].self, String.self or an enum type produce a non-object root; the decoder refused everything but object. ResponseFormat.jsonSchema now carries a SchemaValueType; the root may be an array, a string, a number, an integer, a boolean, an enum, a const or a $ref as well as an object. Root-only keywords ($defs, definitions, $schema, $id) are read and stripped before the value compiler runs, so they work on every root and are a 400 anywhere below it, including in a $ref target and under a property named "" (a property named "" is written "" in paths, so only the root position has the empty path). A root without type (and without $ref, const or enum) is still an object; a root number is complete in a terminal number state, as under json_object, since nothing terminates it. Diagnostics at the root say "at the schema root"; nested ones are unchanged. No persisted encodings of GenerateRequest exist (the nested-schemas design note checked), so the larger Codable shape is safe; a round-trip test covers it.

A few smaller things the work turned up:

  • required names, the program's member index and state equality are compared scalar by scalar, like the keys themselves. Swift's String equality is canonical, so a decomposed "é" in required passed for a precomposed key it could never match (it is a 400 now), and two states compiled from schemas that differ only in a key's normalisation compared equal (they compare their tables now).
  • An unbounded array's item count holds at minItems, past which nothing reads it, so such an array has finitely many automaton states and the trap search can finish; the search now asserts that it did for every hand-written schema instead of silently stopping at its cap, and counts the seeded schemas too wide to finish. In diagnostics the held count reads ≥N.
  • A property named "" is written "" in diagnostic paths, so only the root has the empty path, and a problem inside a $ref target names the reference that led there.

Wedge guard. One real-model run of the nested itinerary produced an unpaired surrogate inside a string value ("h\ud83c\udf05…", then food…\ud83c"). The pure automaton rejects that text and so does a tokenizer replay of it; three traced reruns walked every sampled token and passed. I could not reproduce or explain it, so JSONConstraintProcessor now notices when a sampled token does not walk, or is unusable: it keeps the last valid state, logs once at error level, and forces EOS at the next step instead of classifying the vocabulary against a position the text has left. One token still reaches the client before the stream ends, since the sampler runs a token ahead. Two gated tests cover the guard, and the model tests now walk their output through a fresh automaton, so a recurrence will say whether the automaton rejects the final text.

Tests, all under bare swift test:

  • literals: raw, escaped and mixed spellings of "Lençóis Maranhenses" and "日本", a scalar arriving one byte at a time, the wrong lead byte, the wrong digit, a prefix of the key, surrogate pairs and their dead prefixes (\ud83d\ud8, \ud83d\u0, a lone low surrogate), every short escape against its \u00XX spelling and the wrong letter, \/ and \u002f refused for /, escaped plain ASCII refused, raw control characters refused;
  • roots: array of objects with bounds, string, enum, boolean, numbers complete without a terminator and refused with a dangling ./e/sign or a trailing byte;
  • the TripPlanner schema in full, with its destination raw and escaped, and the ASCII-stripped variant no longer equal to it;
  • the trap search over an alphabet that includes every literal's escape digits, for Unicode literals and roots of every type plus seeded roots, with completion asserted for the hand-written ones;
  • the differential test against the reference validator, which decodes escapes (strict UTF-8 for raw runs) and mirrors the ASCII-raw rule, with a generator that spells literals raw or escaped at random, keeps /, tab, backspace, form feed and carriage return in its pools and / in its mutation alphabet, and produces roots of every type (600 rounds × 12 documents);
  • the processor reaching an unspellable literal through its escape (the raw fragment token is unusable, so \ is the highest-ranked legal token);
  • the server accepting the full itinerary and a root array of Unicode enum values;
  • the decoder: any string compiles at every depth and in const; roots of every kind including $ref and $defs on an array root; root-only keywords refused in a $ref target and under a "" property, which takes neither the root's wording nor its empty path; a problem in a $ref target names the reference; required names compared by scalar and compiled names kept scalar-exact; root problems, the size limits included, reported at the root; Codable round trip;
  • state equality and diagnostics: positions below and at minItems of an unbounded array, the ≥N marker, NFC/NFD look-alikes unequal, the key match's progress in the log line.

Mutation check: 20 guards broken one at a time (hex digit unchecked, raw byte unchecked, low surrogate not required, raw control characters allowed, \n and \b dropped from the short escapes, plain ASCII allowed escaped, the root object default dropped, root-only keys not stripped, root-only keys allowed below the root, required compared as String in the decoder and in the program, the unbounded count unclamped, its ≥N marker dropped, a "" property given the empty path, the $ref left out of its diagnostic, state equality ignoring the program, a root number never complete, a dangling . complete, the reference comparing raw bytes), the constraint and decoder suites run against each; the gated unusable-token wedge was broken by hand and its test run under xcodebuild. Results in the first comment.

Real checkpoint: StructuredOutputModelTests (gated) runs the full itinerary schema with a prompt asking for Lençóis Maranhenses and asserts the decoded destinationName, and a root [String] schema with exactly three items. Results, including the one failing run above, in the first comment.


Note

Medium Risk
Changes core constrained-decoding semantics (literal matching, root types, and failure handling); bugs could accept invalid JSON or truncate streams early, but behavior is heavily tested against a reference validator and real-model gates.

Overview
Expands C2 structured output so Apple's @Generable shapes compile and decode correctly: response_format schemas can use any supported type at the root (array, scalar, enum, const, $ref, not only objects), and property keys plus enum/const values may be any Unicode string instead of being rejected at compile time for non-ASCII or characters that need JSON escaping.

The schema automaton gains scalar-by-scalar literal matching (LiteralMatch / SchemaLiteral): non-ASCII and escapable characters can appear raw or as JSON escapes (including surrogate pairs), while printable ASCII in literals must be spelled raw so models do not drift into all-\u00XX spellings. The decoder drops the old requireLiteralMatchable checks, compares required names by Unicode scalar (not canonical String equality), and improves root vs nested diagnostics. SchemaConstraintState and SchemaProgram are rooted on SchemaValueType; unbounded arrays cap their counted length at minItems for a finite state space, and root numbers can complete without a trailing delimiter.

JSONConstraintProcessor adds a wedge guard: if an illegal or unusable token is sampled anyway, it logs once, keeps the last good automaton state, and forces EOS on the next mask step instead of continuing from a stale position. Changelog/README note TripPlanner ("Lençóis Maranhenses") and root-array @Generable cases; tests and server stubs cover literals, roots, wedge behavior, and full itinerary schemas.

Reviewed by Cursor Bugbot for commit 5120634. Configure here.

…schema root

Keys, enum values and const values in a response_format schema were limited
to ASCII without quotes, backslashes or control characters, because the
automaton matched them as literal bytes and a tokenizer may have no token
that spells a multi-byte scalar whole. Apple's TripPlanner sample, whose
destination enum holds "Lençóis Maranhenses", was a 400 for that reason.

The automaton now matches literals scalar by scalar (LiteralMatch): each
scalar may arrive as its raw UTF-8 bytes, split across tokens or not, or as
a JSON escape — \uXXXX in either hex case, a surrogate pair above the BMP, or
a short escape. An escape is ASCII, which every tokenizer can produce, so a
literal the model cannot spell raw is still reachable and a required key can
never strand a generation; the compile-time ban is gone.

The root of a schema may now be an array, a string, a number, an integer, a
boolean, an enum, a const or a $ref as well as an object, which is what
@generable emits for [T].self, String.self or an enum. ResponseFormat
carries a SchemaValueType root; a root number is complete without a
terminator, as under json_object; a root without a type is still an object.
Diagnostics at the root say "at the schema root"; nested ones are unchanged.

Tests: raw, escaped and mixed spellings, byte-split scalars, surrogate pairs
and their dead prefixes, escaped ASCII and control characters; roots of every
type; the TripPlanner schema in full, with its destination raw and escaped;
the trap search over the escape alphabet; the differential test against the
reference validator, which now decodes escapes and generates Unicode
literals and roots of every type; the processor reaching an unspellable
literal through its escape; a root array and the full itinerary accepted
through the server.
On Qwen3.6-27B the first version let a model that wanted "hiking" where the
enum says "hotelAndLodging" take the backslash, since it outranked the raw
"o", and from there it spelled the rest of the value as ot… — valid
JSON, decoded correctly, six tokens a letter and unreadable. An ASCII scalar
in a key or enum value is now matched raw only; the escape path stays for
scalars outside ASCII, which a tokenizer may not spell whole, and for the
quote, the backslash and control characters, which JSON cannot carry raw.
The reference validator and the generator apply the same rule.

One run of the real-model itinerary test produced a value the automaton
rejects when the same text is walked byte by byte (an escaped emoji inside an
enum value, then an unpaired high surrogate before the closing quote), and
three traced reruns did not reproduce it: every sampled token walked. The
mechanism is open. Whatever it is, a sampled token the automaton cannot walk
used to leave the state stale, after which every classification ran against
a position the text had left and arbitrary bytes passed; the processor now
logs that token and its state once and forces EOS at the next step.
…ames by scalar, bound an unbounded array's states

The object allow-list no longer admits `$defs`, `definitions`, `$schema` and `$id` on an empty path: the root's were read and stripped before compilation, so an object that still carries one sits below the root — a `$ref` target, or a property named "" — and used to be accepted silently. `isRoot` is now passed explicitly from the root compiler and only words the diagnostics.

`required` names, and the program's member index, are compared scalar by scalar like the keys themselves. Swift's `String` equality is canonical, so a decomposed "é" in `required` passed for a precomposed key it could never match; it is a 400 now.

An unbounded array's count holds at `minItems`, past which nothing reads it, so such an array has finitely many states and the trap search finishes; the search now asserts that it finished for every hand-written schema instead of silently stopping at the cap.

Also: the key diagnostics show the match's progress; `.hex` drops the value it accumulated and never read; the size-limit messages use the root wording at the root; `\b`, `\f` and `\r` join the generator's short escapes, with `/` and control characters in its pools and `/` in its mutation alphabet, and a table test covers every short escape and `\/`; the wedge test primes the vocabulary table the way the sampler does, since `didSample` before any `process` has nothing to classify against; the docs no longer claim every tokenizer can produce an escape.
…unt, name the `$ref` behind a diagnostic

State equality compared the schema values the programs were compiled from, whose `String` equality is canonical; it now compares the flattened tables scalar by scalar, so two schemas that differ only in a key's normalisation are different automata, as the matcher treats them.

An unbounded array's held count reads `≥N` in diagnostics, so a log line never claims a long array holds `minItems` items. A property named "" is written `""` in paths, so only the root has the empty path and a problem on it is worded as a property's. A problem inside a `$ref` target says which reference led there, since the path names the position, not the definition.

A sampled unusable token wedges the processor like an illegal one: it is never legal, so sampling it means the mask was bypassed. The forced-EOS guard gets its CHANGELOG entry.

The escape rule is stated the same way in every doc comment (a scalar outside ASCII raw or escaped, the quote, the backslash and control characters escaped, the rest raw), and the error doc no longer lists non-object roots as unsupported.

Tests: array positions below and at `minItems`; NFC/NFD pairs in the generator's pools and in the equality, decoder and program tests; `progress:` in the key diagnostics; the size-limit wording at the root and on a property; the count of seeded roots the trap search cannot finish; the gated unusable-token wedge; the model tests walk their output through a fresh automaton.
With a property named "" written `""` in paths, only the root position has the empty path, so the explicit `isRoot` threaded through `compileValue` and `compileObject` duplicated what the path already says: a mutation that read the root off the path survived every test, because the two can no longer disagree. One source of truth now, documented where the path is built.
@magicnight

Copy link
Copy Markdown
Owner Author

Results on the final head.

Pure suite (swift test, no model, no Metal): 1002 tests in 102 suites, all passing on 5120634.

Mutation check: 20 guards broken one at a time (list in the description), the constraint and decoder suites (99 tests) run against each: 20/20 caught, 0 survived, tree restored clean between runs. One earlier mutation, reading the root position off the path instead of an explicit flag, survived once the "" path fix landed — the two could no longer disagree — so the explicit flag went (5120634) and that mutation with it. The unusable-token wedge is gated, so I broke it by hand (wedge(…) → return) and ran its test under xcodebuild test-without-building: failed, as it should; restored and rebuilt.

Gated processor tests (xcodebuild, Metal, scripted tokenizer): 5/5 — forced EOS with no legal token, full mask, greedy fallback, and the two wedge cases (an illegal token, an unusable token).

Real checkpoint (Qwen3.6-27B-4bit, MACMLX_RUN_STRUCTURED_E2E=1), StructuredOutputModelTests, five runs of the class across the three commits of this branch:

run head nested itinerary (destinationName) root [String] ×3 throughput overhead
1 745abff failed: unpaired surrogate inside a string value (see below) passed 10.9%
2–4 745abff passed, "Lençóis Maranhenses" passed — (traced reruns)
5 745abff passed passed 10.9%
6, 7 4ebfddb passed, passed passed, passed 32.3%, 31.8% (mutation loop compiling alongside)
8 fa760e4 passed, with the new automaton-walk assertion passed 7.4%
9 5120634 (final) passed passed 16.9% (mutation loop alongside)

Standalone reruns of the throughput test on 4ebfddb read 31.7% and 19.3% while Spotlight was indexing the mutation loop's build output; the figure is informational and not asserted, and the uncontended readings are 7–11%.

The run-1 failure is the one the wedge guard answers. The output contained "type": "h\ud83c\udf05…" and later food…\ud83c" — a high surrogate escape with no low half. The pure automaton rejects that text (walk returns nil at the lone surrogate's closing quote), and replaying the same text through the real tokenizer's token boundaries rejects it too, so the mask could not have allowed it from a correct state. Three traced reruns logged every sampled token walking (0 failures) and passed. I could not reproduce it and do not know the cause; the guard now logs the exact token and automaton state if it happens again, forces EOS at the next step, and the model test walks its final output through a fresh automaton so a recurrence says whether the automaton accepts the text.

@magicnight
magicnight merged commit 1376fb5 into main Oct 6, 2026
5 checks passed
@magicnight
magicnight deleted the feat/schema-roots-unicode branch October 6, 2026 01:13
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