Skip to content

Fix three places where constrained JSON generation ignores the model - #180

Open
asaptf wants to merge 3 commits into
huggingface:mainfrom
asaptf:fix/constrained-generation-arbitrary-choices
Open

Fix three places where constrained JSON generation ignores the model#180
asaptf wants to merge 3 commits into
huggingface:mainfrom
asaptf:fix/constrained-generation-arbitrary-choices

Conversation

@asaptf

@asaptf asaptf commented Aug 10, 2026

Copy link
Copy Markdown

Three defects in ConstrainedJSONGenerator where the shape of the generated JSON is decided by arbitrary constants rather than by the model. Each is wrong on any schema, independent of backend or tokenizer, and each has a test that fails without its fix.

Found while trying to use constrained generation for document extraction with runtime-built schemas, on main (f22b78e) as well as 0.8.0.

1. Optional object keys are chosen by a hash of the field name

let hash = key.utf8.reduce(0) { ($0 &* 31) &+ Int($1) }
let combined = hash ^ backend.totalTokenBudget
return combined % 2 == 0

generateObject pre-filters keys with this, so whether an optional property appears depends on its name and a constant budget — the same for every document, decided before the model is consulted. Since 31 is odd and the budget is typically even, it reduces to the parity of the field name's bytes.

Measurable consequence: with a fixed budget, a given optional field is emitted for every input or for none. On a 30-document extraction benchmark, predicting that parity from the field names in advance correctly called which fields would be absent from all outputs (currency, taxTotal, issueDate) and which would always be present (invoiceNumber, grandTotal).

Keys are now chosen by masked sampling: the mask permits any not-yet-emitted key, and permits } once every required property has been emitted.

Visible behaviour change: for schemas with no required properties, {} becomes reachable. That is schema-valid, and it is what the model asked for when it samples the closing brace — but it is a change worth knowing about, so it is called out in the commit message too.

2. Array length is derived from the token budget

let budgetBasedCount = backend.totalTokenBudget / arrayDefaultCountDivisor
let offset = rangeSize > 0 ? backend.totalTokenBudget % rangeSize : 0
count = minItems + offset

The element count is fixed before generation and the loop emits exactly that many, with no way to stop early — a three-item list gets padded with invented elements, a ten-item list is truncated. Same shape of defect as (1).

Length is now chosen by sampling: after each element the mask permits , and ], honouring minItems / maxItems when the schema states them. A budget floor may still force a close once minItems is satisfied.

3. The decimal point is unreachable, so decimals cannot be generated

if text.allSatisfy({ $0.isNumber || $0 == "-" || $0 == "." }),
    text.contains(where: { $0.isNumber })

buildValidDecimalTokens requires every candidate token to contain a digit. Tokenizers that encode . and - as standalone tokens are therefore unable to emit a decimal point at all — Qwen2.5 encodes 473.00 as 4 7 3 . 0 0.

Observed effect: the model emits the integer part, cannot place the point, keeps emitting digits until the token cap, and the result re-serialises as 4.73e+31. Every monetary value in an extraction came back like that.

Standalone . / - are now included, with digits restricted to ASCII 09 so the fullwidth and superscript forms Character.isNumber admits cannot enter JSON numbers.

Verification

  • swift test: 328 tests. The 11 OllamaLanguageModelTests failures are pre-existing and environmental (no local Ollama) — main at f22b78e fails the same 11 in the same environment, with 317 tests.
  • StructuredGenerationTests: 25 tests pass, 11 of them new.
  • Each new test was confirmed to fail against the unfixed code by reverting the source change and re-running.

Deliberately not included

We also carry an API change (letting a runtime GenerationSchema reach the constrained generator — today LanguageModelSession.respond(to:schema:) accepts one and then forwards to GeneratedContent, whose static schema is a placeholder) and a more opinionated change around all-optional objects (required keys with nullable values, the shape strict structured-output modes converged on). Those are design proposals rather than defects, and mixing them in would make this harder to review. Happy to open an issue for either if useful.

One question: the mattt/xgrammar branch suggests structured output might move to XGrammar. It predates current main and does not touch Shared/StructuredGeneration.swift, so these fixes apply to the code in use today — but if the hand-rolled generator is on its way out, say so and we will not push on it.

asaptf added 3 commits August 10, 2026 13:49
ConstrainedJSONGenerator pre-filtered optional properties with a hash of
the field name XOR the token budget:

    let hash = key.utf8.reduce(0) { ($0 &* 31) &+ Int($1) }
    let combined = hash ^ backend.totalTokenBudget
    return combined % 2 == 0

Because 31 is odd and the budget is typically even, this reduces to the
parity of the field name's bytes — identical for every document, decided
before the model is consulted. Optional inclusion must not be a function
of the key string.

Keys are now chosen by masked sampling under the JSON grammar. After each
property the mask permits any not-yet-emitted key, and permits `}` once
every required property has been emitted. A last-resort budget floor still
stops offering further optionals when remaining tokens are nearly exhausted
and all required keys are already present.

Visible behaviour change: for schemas with *no* required properties, `{}`
is now reachable. That is schema-valid JSON and is what the model asked for
when it samples the closing brace immediately.
ConstrainedJSONGenerator derived array element counts from the token budget
before generation:

    let budgetBasedCount = backend.totalTokenBudget / arrayDefaultCountDivisor
    ...
    let offset = rangeSize > 0 ? backend.totalTokenBudget % rangeSize : 0
    count = minItems + offset

The loop then emitted exactly that many elements, with no way to stop early.
A three-item list was padded with invented elements; a ten-item list was
truncated. Same defect shape as pre-filtering optional object keys by name
hash: the length was a function of a constant budget, identical for every
document, decided before the model was consulted.

Array length is now chosen by masked sampling. After each element the mask
permits both `,` and `]`, honouring schema `minItems` / `maxItems` when
stated. Empty arrays (`minItems == 0` / unbounded) are reachable by sampling
`]` immediately after `[`. A last-resort budget floor still force-closes once
`minItems` is satisfied when remaining tokens are nearly exhausted.

Drops the `totalTokenBudget % rangeSize` arithmetic entirely — an arbitrary
count inside a valid range is still arbitrary.
buildValidDecimalTokens required every candidate token to contain a digit:

    if text.allSatisfy({ $0.isNumber || $0 == "-" || $0 == "." }),
        text.contains(where: { $0.isNumber })

Tokenizers that encode `.` and `-` as standalone tokens (Qwen2.5 does;
`473.00` is `4` `7` `3` `.` `0` `0`) therefore could not produce a decimal
point at all. Observed effect: the model emits the integer part, cannot
place the point, keeps emitting digits until the token cap, and the result
re-serialises as something like `4.73e+31`.

Include standalone `.` / `-` in the decimal mask (and standalone `-` in the
integer mask). Restrict accepted digits to ASCII `0`...`9` so fullwidth /
superscript forms that `Character.isNumber` admits cannot enter JSON numbers.
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