Skip to content

fix(types): get_enum_from_literal/get_enum_from_choice drop values that collide under str()#1926

Merged
RobinPicard merged 1 commit into
dottxt-ai:mainfrom
chuenchen309:fix/enum-from-literal-str-collision
Jul 20, 2026
Merged

fix(types): get_enum_from_literal/get_enum_from_choice drop values that collide under str()#1926
RobinPicard merged 1 commit into
dottxt-ai:mainfrom
chuenchen309:fix/enum-from-literal-str-collision

Conversation

@chuenchen309

Copy link
Copy Markdown
Contributor

Problem

Both get_enum_from_literal and get_enum_from_choice built their Enum's name→value mapping with {str(x): x for x in values}. Since str(1) == str("1") == "1", Literal["1", 1] or Choice(["1", 1]) silently collapsed to a single enum member — whichever value came last in iteration order won, and the other was overwritten in the dict comprehension before Enum ever saw it.

Reachable in production via models/gemini.py's structured-output enum construction (Gemini text/x.enum response schemas) for any Literal/Choice mixing a string and a numeric value that happen to stringify the same way.

Fix

Only disambiguate when a genuine str()-collision is detected, prefixing the colliding values' keys with their type name (e.g. "str_1" vs "int_1"). Non-colliding values keep their original str()-based member name unchanged, preserving existing behavior and the existing test's attribute-access assertions (getattr(complex_enum, "1"), "True", "None", etc.) — this was checked and deliberately preserved rather than switching to an always-prefixed scheme, since that would've silently changed the public member-naming contract for the (much more common) non-colliding case.

Testing

  • Added two new tests covering the str()-collision case for both functions, verifying both values survive as distinct members.
  • Confirmed red→green: reverting only utils.py reproduces {1} == {1, '1'} (the string value silently dropped, only the int survives); reapplying passes.
  • Full tests/types/ suite: 234 passed.
  • ruff and mypy clean via pre-commit run.
  • xref: no open PR touches types/utils.py.

AI-Generated disclosure

Found via an AI-assisted code review pass (Claude Code) over outlines/src/outlines/types/. I personally reproduced the value-dropping collision, checked the existing test's attribute-name assertions to make sure my fix wouldn't silently break the non-colliding naming contract, verified the fix, and ran the relevant test suite plus lint/type checks before submitting.

…at collide under str()

Both functions built their Enum's name->value mapping with
`{str(x): x for x in values}`. Since str(1) == str("1") == "1",
Literal["1", 1] or Choice(["1", 1]) silently collapsed to a single
enum member -- whichever value came last in iteration order won, the
other was overwritten in the dict comprehension before Enum ever saw
it. Reachable in production via models/gemini.py's structured-output
enum construction (Gemini text/x.enum response schemas) for any
Literal/Choice mixing a string and a numeric value that happen to
stringify the same way.

Fix: only disambiguate when a genuine str()-collision is detected,
prefixing the colliding values' keys with their type name
(e.g. "str_1" vs "int_1"). Non-colliding values keep their original
str()-based member name unchanged, preserving existing behavior and
the existing test's attribute-access assertions
(getattr(complex_enum, "1"), "True", "None", etc.).

Added two new tests covering the str()-collision case for both
functions, verifying both values survive as distinct members. TDD
red->green verified: reverting only utils.py reproduces
`{1} == {1, '1'}` (the string value silently dropped); reapplying
passes. Full tests/types/ suite: 234 passed. ruff/mypy clean via
pre-commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ErenAta16

Copy link
Copy Markdown
Contributor

Traced the collision mechanism: {str(arg): arg for arg in get_args(value)} silently drops one member whenever two values share a str() representation (str("1") == str(1) == "1"), since dict comprehensions overwrite on duplicate keys with no warning. _unique_str_keys groups by str(value) first and only falls back to type(value).__name__-prefixed keys for groups with more than one member, so non-colliding cases keep their original readable keys unchanged. Confirmed the disambiguated keys don't need to be valid Python identifiers (the existing test already relies on getattr(enum, "SampleEnum.A") with a dotted name via the functional dict-based Enum API), so there's no risk there either. Tests cover both entry points (get_enum_from_literal, get_enum_from_choice) with the exact str/int collision case. LGTM.

else:
for value in group:
result[f"{type(value).__name__}_{value}"] = value
return result

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.

This fallback key can still collide when two values have the same type name and string form. For example, Choice accepts two enum members from classes both named State with a member named READY; both become State_State.READY, so one is still dropped. Could the disambiguator use an index rather than another value-derived key?

@github-actions

Copy link
Copy Markdown

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

Preview updates automatically with each commit.

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

Good idea, thanks!

@RobinPicard
RobinPicard merged commit c4a6788 into dottxt-ai:main Jul 20, 2026
5 of 6 checks passed
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.

4 participants