refactor(models): give duplicated private primitives one owner or distinct names - #98
Conversation
|
Pushed Two extractions were made sideways instead of upward.
Going the other way,
Related: #100 stacks on this branch and still merges cleanly. |
…tinct names
Six private names were defined twice each inside core/models. Comparing the
normalised bodies splits them cleanly, and the two halves want opposite fixes.
Byte-identical, so they get one owner:
* `_copy_json_value` and `_validate_nonempty_string` (capabilities +
requirements) move to `core/models/_shared.py`;
* `_validate_top_logprobs` (both OpenAI dialects) moves to
`core/models/dialects/_shared.py`, along with `_choice_index` — its two
copies differed only in whether the message said "chat" or "completion",
so it takes that label as an argument.
Genuinely different, so they get names that say so, and no behaviour change:
* `_usage_stats` -> `_chat_usage_stats` / `_completions_usage_stats`. The
chat copy enforces `total_tokens == prompt_tokens + completion_tokens`;
the completions copy does not. One spelling for two different contracts is
the failure mode the shared name invites.
* `_json_value` -> `_named_json_value` in `model.py`. It threads a dotted
path through its errors and accepts any non-str/bytes `Iterable`, where
`ir.py`'s copy is unlabelled and takes only list/tuple.
The shared functions are public inside private modules rather than underscored
across module lines, which the import policy calls a smell.
Verified by mutation — five breaks, all caught: accepting non-finite floats,
accepting non-JSON values, accepting an empty identifier, accepting a malformed
top_logprobs channel, and dropping the choice-index range check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-up. Two of the four extractions in the previous commit were made
sideways, into a _shared module peered with the dialects, rather than upward
into the layer that owns the rule. That breaks dialect cohesion without gaining
any enforcement: a new dialect can still forget to call the helper.
* validate_top_logprobs checks the IR shape tuple[tuple[TopKEntry, ...], ...],
which no dialect may legitimately disagree about. dialect.py already hosts
validate_reasoning, validate_tool_calls, validate_input_scoring and
validate_structured_output, so it goes there with its siblings.
* _choice_index goes back to one copy per dialect. The two copies were never
identical, and sharing them meant reintroducing the difference as a `kind`
argument -- a label that used to be a literal, impossible to get wrong, and
became a parameter nothing checks. Nine lines twice is the cheaper trade.
dialects/_shared.py is empty once both leave, so it is removed.
Going the other way, reconcile's copy of the JSON coercion does fold in. Unlike
the dialect pair it encodes a policy three modules must agree on, and unlike the
dialect pair it had already drifted -- same rejections, different wording. Its
_nonempty stays where it is: three lines encoding no policy, three copies that
never diverged, and folding it would spread an import to save six lines.
_shared.py's docstring claimed a single owner kept the error vocabulary from
drifting while that drift sat one module over. It now describes what the module
actually owns, and why the three-line validator is there on weaker grounds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The module docstring argued for its own existence at paragraph length. That argument belongs in the commit that made the change; the file only needs to say what the three modules must agree on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
40007c3 to
529836d
Compare
Type
Summary
Six private names were defined twice each inside
core/models. I compared the normalised bodies rather than assuming, and they split cleanly into two halves that want opposite fixes:Byte-identical → one owner
_copy_json_value(20 lines ×2) and_validate_nonempty_string(3 ×2), shared bycapabilities.py+requirements.py→core/models/_shared.py_validate_top_logprobs(7 ×2), shared by both OpenAI dialects →core/models/dialects/_shared.py_choice_index— the two copies differed only in whether the message said "chat" or "completion", so it joins the dialect shared module and takes that label as an argumentGenuinely different → names that say so, no behaviour change
_usage_stats→_chat_usage_stats/_completions_usage_stats. The chat copy enforcestotal_tokens == prompt_tokens + completion_tokens; the completions copy does not. One spelling for two different contracts is exactly the failure mode a shared name invites._json_value→_named_json_valueinmodel.py. It threads a dotted path through its errors and accepts any non-str/bytesIterable, whereir.py's copy is unlabelled and takes onlylist/tuple.The shared functions are public names inside private modules, rather than underscored names imported across module lines — the import policy calls the latter a smell.
Related Issues
Refs #25 — follow-up from the PR #45 review (N3).
Test Plan
Automated
ruff check && ruff format --check)ty check)pdm run pytest) — 5237 passed (unit + integration), preflight 24/24, no FAIL/WARNManual
Mutation-tested both shared modules, since a move refactor passes trivially if nothing exercises the moved code. Five breaks, all caught:
copy_json_valueaccepts non-finite floatscopy_json_valueaccepts non-JSON valuesvalidate_nonempty_stringaccepts emptyvalidate_top_logprobsaccepts any shaperesolve_choice_indexdrops the range checkVerified no leftover references to any of the six original names anywhere under
sieval/.Question for review
Is the
_usage_statsasymmetry deliberate? Chat validates that the reported total decomposes into prompt + completion; completions accepts whatever the server reports. Plausibly intentional — an echo-mode completions response may not decompose cleanly — but nothing records that, and the shared name actively hid it. This PR only makes the difference visible; it does not change either behaviour. If the omission is an oversight, the fix belongs in its own PR with a note about which servers it affects.One implementation note:
choice_indexhad to becomeresolve_choice_index, becauseopenai_chat's streaming loop already binds a local variable namedchoice_index. Caught by lint on a self-assignment, not by the tests.Checklist
Required (all PRs)
type(scope): description)AI-Generated Code - <model> (<provider>)in module docstring — both new modules carry itcore/— both shared modules import only fromcore/