Skip to content

fix(types): JSON-quote string-shaped built-in terms inside containers - #1975

Open
feiiiiii5 wants to merge 4 commits into
dottxt-ai:mainfrom
feiiiiii5:fix/json-quote-builtin-terms-in-containers
Open

fix(types): JSON-quote string-shaped built-in terms inside containers#1975
feiiiiii5 wants to merge 4 commits into
dottxt-ai:mainfrom
feiiiiii5:fix/json-quote-builtin-terms-in-containers

Conversation

@feiiiiii5

Copy link
Copy Markdown
Contributor

Root Cause

Built-in Regex terms from outlines.types handed in directly to a container annotation (e.g. list[types.email], dict[str, types.uuid4]) take the isinstance(ptype, Term) shortcut in python_types_to_terms and come back bare from _ensure_json_quoted. The generated regex matches [a@b.com] and rejects ["a@b.com"] — output that json.loads cannot parse (issue #1962).

Dict keys already work (quote_regex=True); the gap is list items and dict values.

Fix

Invert the quoting rule in _ensure_json_quoted (per the issue's suggested design): identity-match the built-in terms and quote every string-shaped one, while leaving the special groups untouched:

  • JSON scalars stay bare (identity): integer, number, boolean, digit.
  • Control-character terms stay bare (identity): newline, whitespace, paragraph, sentence — quoting them would look fixed while still failing json.loads (raw control chars / unescaped quotes); escaping them is follow-up work documented in the code.
  • Temporals are left to fix(types): JSON-quote date/time/datetime inside DSL containers #1961 (date, time, datetime).
  • All other built-in string-shaped terms are quoted: uuid4, ipv4, ipv6, mac_address, semver, slug, hex_color, hex_str, credit_card, char.

Identity matching (not pattern equality) means a user-supplied Regex that happens to share a built-in pattern is left alone.

Test

  • test_e2e_string_shaped_builtin_terms_quoted_in_containers — parametrized over the 10 string-shaped terms: list items and dict values now match the quoted spelling and reject the bare spelling; dict keys keep working. (types.email/types.isbn are excluded because their patterns contain ^/$ anchors that cannot be embedded in a container regex at all — a pre-existing limitation independent of quoting.)
  • test_e2e_json_scalar_builtin_terms_stay_bare_in_containersinteger/number/digit stay bare.
  • test_e2e_control_char_builtin_terms_left_bare_in_containers — the four control-char terms are unchanged.
  • test_e2e_user_regex_term_still_bare_in_containers — a user Regex sharing a built-in pattern is untouched.
  • Full tests/types suite: 301 passed.

Diff scope

2 files, +115/-0: src/outlines/types/dsl.py, tests/types/test_dsl.py.

AI Disclosure

AI-assisted root-cause analysis, initial draft, and test scaffolding; human review of the identity-matching design and the scalar/control-char carve-outs.

Not PR-related failures

None; the full types suite passes locally. (This PR deliberately avoids the temporal terms to stay disjoint from #1961.)

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

📚 Documentation preview: https://dottxt-ai.github.io/outlines/pr-preview/pr-1975/

Preview updates automatically with each commit.

Built-in Regex terms handed in directly (list[types.email],
dict[str, types.uuid4], ...) take the isinstance(ptype, Term) shortcut
in python_types_to_terms and come back bare from _ensure_json_quoted,
so containers generated [a@b.com] instead of ["a@b.com"] - output
json.loads rejects (dottxt-ai#1962).

Invert the rule in _ensure_json_quoted: identity-match the built-in
JSON scalars (integer, number, boolean, digit) and the terms that need
escaping before they can be quoted (newline, whitespace, paragraph,
sentence - follow-up work), keep temporals scoped to dottxt-ai#1961, and quote
every other string-shaped built-in term (uuid4, ipv4, ipv6, mac_address,
semver, slug, hex_color, hex_str, credit_card, char). User-supplied
Regex terms are matched by identity only, so a user regex that happens
to share a built-in pattern is left alone.

Closes dottxt-ai#1962

Signed-off-by: fei <204683769+feiiiiii5@users.noreply.github.com>
@ErenAta16

Copy link
Copy Markdown
Contributor

Small mechanical note rather than a review of the logic — this and #1974 overlap in a way that will bite whichever lands second.

Both branches carry the same NoneLiteral block in _ensure_json_quoted, byte for byte:

if isinstance(term, NoneLiteral):
    json_null = Regex("null")
    if quote_regex:
        return Sequence([String('"'), json_null, String('"')])
    return json_null

and both are open against main independently. Comparing the two heads:

6d04c86 (#1974) ... fa6cb81 (#1975)  ->  diverged, ahead 2, behind 1

So this branch started from #1974 and has since drifted — #1974 has one commit this one doesn't. Practical consequences:

Either marking this one as depending on #1974 and rebasing it on top, or pulling the NoneLiteral block out of here entirely and letting #1974 own it, would make both reviewable on their own.

Worth flagging that dsl.py currently has seven open PRs against it (#1941, #1944, #1946, #1947, #1961, #1974, #1975), and _ensure_json_quoted alone is touched by three of them. Given #1962 is already tracking the shape of the built-in-term rule, it might be worth noting a merge order there so these don't serially invalidate each other's diffs — the design discussion is settled enough that the remaining cost is mostly rebasing.

@feiiiiii5
feiiiiii5 force-pushed the fix/json-quote-builtin-terms-in-containers branch from fa6cb81 to d344eb8 Compare August 1, 2026 16:52
@feiiiiii5

Copy link
Copy Markdown
Contributor Author

Thanks for the note — you're right that the stacking would bite whoever lands second, so I've un-stacked this PR: the branch is now based on main and contains only the string-shaped built-in terms change (1 commit, d344eb8). #1974 keeps the NoneLiteral work; the two can now land in any order.

@ErenAta16 ErenAta16 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.

Thanks for un-stacking it — confirmed on d344eb8: one commit, no NoneLiteral block, +115/-0. The two PRs now carry disjoint changes.

Three things from reading it on its own.

They still collide, but only in the tests

I merged the two branches locally to check whether "any order" holds in practice:

$ git merge p1975      (onto p1974)
Auto-merging src/outlines/types/dsl.py
Auto-merging tests/types/test_dsl.py
CONFLICT (content): Merge conflict in tests/types/test_dsl.py

dsl.py auto-merges cleanly now, which is the part that mattered. What's left is that both PRs append their new tests at the same point in test_dsl.py, so whichever lands second still needs a trivial rebase. Not worth restructuring for — just flagging so it isn't a surprise.

Coverage is complete

I checked the two name tuples against every module-level Regex singleton in types/__init__.py rather than trusting the count in #1962:

Regex singletons in types/__init__.py : 24
covered by the two tuples             : 23
not covered                           : ['string']
named in the PR but not in types      : []

string is the one omission and it's already handled — the term.pattern != types.string.pattern guard sits above both _is_builtin_term calls, so it never reaches them. Nothing falls through to the bare path.

Using identity (term is getattr(types, name, None)) rather than pattern equality is also the right call, and the docstring saying so is worth keeping: a user's own Regex(r"[a-z0-9]+(?:-[a-z0-9]+)*") should not silently start behaving like types.slug.

The email exclusion looks wrong, and it's the case the issue was opened about

The test docstring says:

types.email/types.isbn are excluded from the parameter set because their patterns contain ^/$ anchors that cannot be embedded in a container regex at all

That holds for isbn, which really does carry $ inside its lookaheads. It doesn't hold for email. Stripping character classes before looking for anchors:

email        ^=False  $=False
isbn         ^=False  $=True

Every ^ and $ in the email pattern lives inside a character class — [a-z0-9!#$%&'*+/=?^_{|}~-]` — where they're literal characters, not anchors.

And on your branch it works:

list[types.email]   quoted='["a@b.com"]' -> True    bare='[a@b.com]' -> False
list[types.uuid4]   quoted -> True                   bare -> False
list[types.isbn]    quoted -> False                  bare -> False

email behaves exactly like the types that are in the parameter set. isbn is genuinely broken and the exclusion is right.

This matters more than a missing test row, because list[types.email] producing [a@b.com] is the example in the title of #1962. As it stands the PR fixes the motivating case and then leaves it out of its own regression coverage, so a future change could break it without failing anything. I'd move (types.email, "a@b.com") into the parametrize list and narrow the docstring to isbn alone.

…oting

types.email carries no top-level ^/$ anchors (its ^/$ live inside
character classes), so it is quoteable like uuid4/slug/etc. Add it to
the parametrized set and narrow the docstring exclusion note to
types.isbn, whose lookaheads really do contain top-level $.

Signed-off-by: fei <204683769+feiiiiii5@users.noreply.github.com>
@feiiiiii5

Copy link
Copy Markdown
Contributor Author

Addressed in 29ad91a0 — thanks for the precise check on the anchors.

Local run: pytest tests/types/test_dsl.py → 75 passed (including the new email row).

@ErenAta16 ErenAta16 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.

Anchors and parametrize set both look right now.

(types.email, "a@b.com") being in the set means the motivating case from #1962 is pinned directly rather than by analogy, and excluding types.isbn with the reason written down beats silently leaving it out.

The test I want to single out is this one:

def test_e2e_user_regex_term_still_bare_in_containers():
    user_regex = Regex(r"[a-z]+@[a-z]+\.[a-z]+")
    list_pattern = to_regex(python_types_to_terms(list[user_regex]))
    assert _re.fullmatch(list_pattern, "[a@b.com]")

That is the safety net for the whole approach. Since Regex.__eq__ is pattern-based, matching built-ins by equality rather than identity would catch a user's own term that happens to share a pattern and start quoting their output. This test fails the moment someone makes that simplification, and it is the failure that would otherwise be reported months later as "outlines corrupts my custom regex". Worth a comment above it saying that is what it is for, because it reads like a redundant negative case.

Same for test_e2e_control_char_builtin_terms_left_bare_in_containers: excluding newline/whitespace/paragraph/sentence rather than quoting them is the right call, and the docstring gives the reason (quoting would look fixed while still failing json.loads), which is the sort of thing that gets "tidied up" without it.

Nothing further from me.

@bharadwaj-pendyala

Copy link
Copy Markdown

I opened #1962, so I ran this branch rather than read it. Checked against current main instead of the branch base, because two type-adding commits landed in between, and three terms fall through the allowlist.

The branch is based on be2cd151 (2026-07-22), which carries 24 built-in Regex terms. main at 7d068478 carries 29. c641fcc8 added iban and bic on 2026-08-05, and 10141976 added e164, latitude and longitude on 2026-08-06. The two tuples in _ensure_json_quoted name 23 of the 29.

Copying only this branch's src/outlines/types/dsl.py onto main at 7d068478, then matching list[term] against both spellings:

term        bare    quoted   generated form                            json.loads
bic         True    False    [DEUTDEFF]                                FAILS
iban        True    False    [DE89370400440532013000]                  FAILS
e164        True    False    [+14155552671]                            FAILS
latitude    True    False    [45.5]                                    parses
longitude   True    False    [-73.6]                                   parses
email       False   True     ["a@b.com"]                               parses
uuid4       False   True     ["123e4567-e89b-42d3-a456-426614174000"]  parses

latitude and longitude are number-shaped, so bare is the right answer for them, though the code reaches it by falling through rather than by classifying them. bic, iban and e164 are string-shaped and come out in exactly the form #1962 is about. [+14155552671] has no reading under which it parses.

Your test file passes unmodified on main, 75 passed, so this is the three new names only and not a regression in what you wrote.

The thing that worries me more than the three names is that the fall-through case is "leave it bare" and it is silent, so every future type PR can quietly reopen #1962. Two did inside ten days. Lifting the two tuples to module level and adding a guard would fail the next one at test time instead:

def test_every_builtin_regex_term_is_classified():
    builtins = {
        name
        for name in dir(types)
        if not name.startswith("_") and isinstance(getattr(types, name), Regex)
    }
    assert not builtins - (BARE_TERMS | QUOTED_TERMS | {"string"})

Happy to push that as a follow-up on top of yours if you would rather keep this PR to the shape it already has.

…s bare

bharadwaj-pendyala found that three built-in terms added after this
branch's base (iban, bic, e164 on main) fall through the container
quoting allowlist and emit bare forms that fail json.loads, silently
reopening dottxt-ai#1962. latitude/longitude are number-shaped, so bare is
correct, but they reached it by accident rather than classification.

Lift the allowlist tuples to module-level BARE_TERMS/QUOTED_TERMS,
classify the five new terms explicitly, and add
test_every_builtin_regex_term_is_classified so a future type PR fails
at test time instead of quietly reopening dottxt-ai#1962.

tests/types -> 368 passed; ruff 0.9.1 clean.

Signed-off-by: fei <204683769+feiiiiii5@users.noreply.github.com>
@feiiiiii5

Copy link
Copy Markdown
Contributor Author

Thanks for running the branch against current main — the three fall-throughs are fixed in 052726b1 (on top of a merge with current main).

What changed:

  • iban, bic, e164 are now in the quoted allowlist; your three repro rows now emit quoted forms: ["DE89370400440532013000"], ["DEUTDEFF"], ["+14155552671"] all pass json.loads.
  • latitude/longitude are now explicitly classified as bare (number-shaped), instead of reaching the bare path by silent fall-through.
  • The two allowlist tuples are lifted to module-level BARE_TERMS / QUOTED_TERMS so they can be tested directly.
  • Added the guard you proposed, test_every_builtin_regex_term_is_classified: every module-level Regex singleton in outlines.types must be covered by BARE_TERMS | QUOTED_TERMS | {"string"}. A future type PR that adds a singleton without classifying it now fails at test time.
  • Parametrize coverage extended: quoted rows for iban/bic/e164, plus a new bare row set for latitude/longitude.

Verified: pytest tests/types → 368 passed; ruff check (v0.9.1, pinned) clean on the changed files.

You offered to push the guard as a follow-up — no need, it's in here. Happy to split it out if you'd rather review it separately.

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.

3 participants