Skip to content

refactor(models): split model.py by lifetime, not by size - #99

Merged
ethan-scitix merged 2 commits into
mainfrom
refactor/split-model-legacy-bridge
Aug 14, 2026
Merged

refactor(models): split model.py by lifetime, not by size#99
ethan-scitix merged 2 commits into
mainfrom
refactor/split-model-legacy-bridge

Conversation

@ethan-scitix

@ethan-scitix ethan-scitix commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Type

  • refactor — code restructuring, no behavior change

    One scoped exception, called out under Manual: response_to_model_output now copies the caller's model_meta before attaching provenance. Both current call sites pass a fresh meta(), so nothing on disk was ever wrong — the guarantee is restored before a caller can get it wrong.

Rebased onto main after #100 merged. #98 has merged too, so this is no longer a stacked PR. #100 edited two of the definitions this PR relocates, so its work is carried into the new homes and verified rather than assumed — see Manual.

Summary

  • model.py held two things with different lifetimes: the canonical Model composition root, which the plane keeps evolving, and the one-cycle compatibility surface, which is scheduled to die. Kept in one file, removing the compat layer is surgery on a live module; split out, it is a file deletion.

    file lines holds
    model.py 1260 → 664 canonical Model
    _legacy_bridge.py 454 task kwargs ↔ Request/Response, the ModelOutput family
    _legacy_binding.py 170 RuntimeBindingPlan fabricated for the bare ChatModel/GenModel constructors
  • The split works because the bridge was already free-function shaped, as the review measured: _kwargs_to_request (212 lines) touched only self.dialect_id and the static self._validate_n; _response_to_model_output only self.meta(). Those three become parameters. Nothing else changed.

  • Direction is now one-way, which is the property that makes the eventual delete cheap: _legacy_bridge imports deployment + ir and nothing from model. _legacy_binding is reachable only from chat_model/gen_model, so it leaves when the wrappers do.

  • uuid4()'s non-deterministic binding fingerprint (prior review N1(a)) now sits alone in _legacy_binding, visible and removable, instead of buried mid-file. This PR does not change it.

  • Second commit applies this PR's own review findings. The split had made _legacy_bridge the owner of named_json_value, which Model.meta() needs too — so deleting the bridge would have broken the canonical plane, the one thing the split exists to make cheap. The primitive moves to _shared beside copy_json_value, and model's inbound edge on the bridge drops from six names to five. ModelMeta is the one thread still to be cut by hand at deletion time (Model.meta() is public and tasks/ruler_0shot_gen.py calls it outside the legacy path); the module docstring says so now instead of implying the delete is free.

Related Issues

Refs #25 — follow-up from the PR #45 review (N6). Does not close the core/binding/ split, which is still gated on step 5 moving SGLang and on settling whether capabilities.py is vocabulary or binder logic.

Test Plan

Automated

  • Lint/format clean (ruff check && ruff format --check) — 495 files formatted
  • Type check clean (ty check)
  • Unit tests pass (pdm run pytest) — 5336 collected, all green, 5 deselected (unit + integration); tests/unit/core/models/ alone is 815; preflight 25/25, no FAIL/WARN. One repeat run flaked on the two load-sensitive performance tests (test_benchmark_scenarios at 55.5% concurrency efficiency, test_pipeline_memory_scaling at 6.6×) while the box was busy; both re-ran green in isolation and neither imports core/models.

Manual

  • No behaviour change, checked rather than asserted. Every moved definition was compared against its post-fix(models): compute usage totals, and reject bad builder defaults at bind time #100 origin/main source (d0d2027f), not against the pre-fix(models): compute usage totals, and reject bad builder defaults at bind time #100 tree. Identity is measured on the first commit, the one that does the moving; the second commit's single deliberate change is listed separately in the last row of this section.

    group result
    ModelUsage, ModelMeta, ModelCallMeta, ModelOutput, _optional_float, _optional_int, _pop_compatible_alias, _LegacyOpenAIBinding, _legacy_runtime_plan, build_legacy_openai_binding byte-identical
    _named_json_valuenamed_json_value, _coerce_structured_output identical modulo the documented rename
    validate_n, kwargs_to_request, response_to_model_output AST-identical after the documented self → parameter substitutions (73 top-level body statements: 4 + 55 + 14)
    _checked_builder_defaults (new in fix(models): compute usage totals, and reject bad builder defaults at bind time #100, stays in model.py) identical modulo the rename — only its call site is repointed
  • fix(models): compute usage totals, and reject bad builder defaults at bind time #100's work is carried, not silently reverted. fix(models): compute usage totals, and reject bad builder defaults at bind time #100 narrowed _named_json_value's sequence branch and _kwargs_to_request's stop branch to list/tuple. Because the rebase relocated both definitions, git had no conflict to mark at their new locations — so this was checked by reverse-mutation: widening each branch back where it now lives makes fix(models): compute usage totals, and reject bad builder defaults at bind time #100's own guards fail (3 cases on the coercer, 2 on stop). fix(models): compute usage totals, and reject bad builder defaults at bind time #100's five test names are all present and the models suite went 73 → 74 tests.

  • Zero runtime import cycles across all 27 modules in core/models (TYPE_CHECKING excluded), same as before. _legacy_bridge out-edges: _shared, deployment, ir.

  • The relocated bridge is live code, not an unreferenced copy — three mutations, all caught by the suite: validate_n accepting n < 1, kwargs_to_request dropping the binding-resource guard, and response_to_model_output dropping the provenance attachment (that last one is caught by tests/unit/core/runners/test_runner.py, outside the model tests).

  • The one deliberate behaviour change is guarded. As a method, _response_to_model_output built the model_meta mapping itself, so mutating in place was safe by construction; as a free function it receives one, and writing into it leaked the first response's provenance into every later output built from the same mapping — into ModelCallMeta, which is persisted. The new test asserts the caller's mapping is untouched and that two outputs do not share it, and was confirmed to fail without the .copy().

Checklist

Required (all PRs)

  • PR title follows conventional format (type(scope): description)
  • No internal paths, credentials, or personal info in committed files — also scanned the two commit messages
  • AI-generated code has AI-Generated Code - <model> (<provider>) in module docstring — both new modules carry it
  • No new upper-layer dependencies added to core/
  • Deleted code verified — no remaining call sites depend on it. Six test files imported the record types from sieval.core.models.model and now use the public sieval.core.models path; one test reached for the private _response_to_model_output and now calls the free function. tests/unit/cli/test_resolution.py resolved ModelOutput through sieval.core.models.model, which only re-exports it — six paths that would have broken at bridge-deletion time for reasons unrelated to the resolver; they use Model now, which that module actually defines.

Follow-up (not in this PR)

  • copy_json_value and named_json_value are still two functions. After fix(models): compute usage totals, and reject bad builder defaults at bind time #100 they no longer disagree about containers — both take list/tuple — so the only difference left is how an error names a rejected leaf: one indexes into the list, the other stops at the enclosing key. Folding them together changes the messages callers see, so it belongs in its own change rather than in a move.

@ethan-scitix

Copy link
Copy Markdown
Collaborator Author

⚠️ This PR has no CI yet, and its green "CLEAN" status is vacuous. .github/workflows/ci.yml triggers on pull_request: branches: [main], and this PR targets refactor/dedup-model-primitives. GitHub will auto-retarget it to main when #98 merges, and CI runs then.

It is not unverified — it was verified locally on this exact branch, which includes #98's changes: 5237 tests passed (unit + integration), ruff check / ruff format --check / ty check clean, preflight 25/25 with no FAIL or WARN. That is the same set CI runs. But please merge #98 first and let CI go green here before merging this one.

@ethan-scitix
ethan-scitix force-pushed the refactor/dedup-model-primitives branch from 40007c3 to 529836d Compare August 13, 2026 17:34
Base automatically changed from refactor/dedup-model-primitives to main August 13, 2026 17:42
@ethan-scitix
ethan-scitix force-pushed the refactor/split-model-legacy-bridge branch from e919f6e to d2234d4 Compare August 14, 2026 03:29
ethan-scitix and others added 2 commits August 14, 2026 11:56
`model.py` held two things with different lifetimes: the canonical `Model`
composition root, which the plane keeps evolving, and the one-cycle
compatibility surface, which is scheduled to die. Kept in one file, removing
the compat layer is surgery on a live module; split out, it is a file deletion.

  model.py           1260 -> 664
  _legacy_bridge.py       476   task kwargs <-> Request/Response, ModelOutput
  _legacy_binding.py      170   RuntimeBindingPlan for the bare wrappers

The split holds because the bridge was already free-function shaped, as the
review measured: `_kwargs_to_request` (212 lines) touched only `self.dialect_id`
and the static `self._validate_n`, and `_response_to_model_output` only
`self.meta()`. Those three become parameters; nothing else changed.

Direction is now one-way, which is the property that makes the eventual delete
cheap: `_legacy_bridge` imports `deployment` and `ir` and nothing from `model`,
while `model` imports six names from it. `_legacy_binding` is reachable only
from `chat_model`/`gen_model`, so it goes when the wrappers go. Still zero
runtime import cycles across the 27 modules.

`uuid4()`'s non-deterministic binding fingerprint now sits alone in
`_legacy_binding`, where it is visible and removable, instead of buried
mid-file.

No behaviour change, and this is checked rather than asserted: every relocated
definition is byte-identical to its `main` source, or AST-identical once the
four renames this commit documents are applied (`_named_json_value`,
`_validate_n`, `_kwargs_to_request` and `_response_to_model_output` lose the
leading underscore on leaving the class), and the three self-to-parameter
conversions are AST-identical after the substitutions above.

That equivalence is measured against the current `main`, which matters because
#100 landed between the two and edited two of the definitions relocated here:
it narrowed `_named_json_value`'s sequence branch and `_kwargs_to_request`'s
`stop` branch to `list`/`tuple`. Both narrowings are carried into the new
homes, and the tests #100 added to guard them were confirmed to still fail when
either branch is widened back where it now lives -- three cases on the coercer,
two on `stop`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review of the split found `_legacy_bridge` had become the owner of
`named_json_value`, which `Model.meta()` needs too — so deleting the file
would have broken the canonical plane, the one thing the split exists to make
cheap. The primitive moves to `_shared`, beside `copy_json_value`, and both
callers import it from there. `_legacy_bridge` now holds only what dies with
it, and its inbound edge from `model` drops from six names to five.

The two coercers stay two functions, but the reason for it has narrowed. #100
brought `named_json_value` down to `list`/`tuple`, which is what
`copy_json_value` always took, so the only difference left is how an error
names a rejected leaf: one indexes into the list, the other stops at the
enclosing key. Folding them together still changes the messages callers see,
so it belongs in its own change rather than in a move. The module docstring
records what is left of the split, not the container disagreement that #100
removed.

`ModelMeta` is the one thread still to be cut by hand when the bridge goes:
`Model.meta()` is public and `tasks/ruler_0shot_gen.py` calls it outside the
legacy path. The module docstring says so now, instead of claiming the delete
costs nothing.

One deliberate behaviour change. `response_to_model_output` copies
`model_meta` before attaching provenance. As a method it built that mapping
itself, so mutating in place was safe by construction; as a free function it
receives one, and writing into it leaked the first response's provenance into
every later output built from the same mapping — into `ModelCallMeta`, which
is persisted. Both current call sites pass a fresh `meta()`, so nothing was
wrong on disk; the guarantee is restored before a caller can get it wrong. The
new test asserts the caller's mapping is untouched and that two outputs do not
share it, and fails without the copy.

`tests/unit/cli/test_resolution.py` resolved `ModelOutput` through
`sieval.core.models.model`, which only re-exports it — six paths that would
have broken at bridge-deletion time for reasons unrelated to the resolver.
They use `Model` now, which that module actually defines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ethan-scitix
ethan-scitix force-pushed the refactor/split-model-legacy-bridge branch from d2234d4 to a9ce8ef Compare August 14, 2026 04:07
@ethan-scitix
ethan-scitix merged commit 30c2ead into main Aug 14, 2026
9 checks passed
@ethan-scitix
ethan-scitix deleted the refactor/split-model-legacy-bridge branch August 14, 2026 06:04
ethan-scitix added a commit that referenced this pull request Aug 14, 2026
)

`UsageStats` declared `reasoning_tokens` and `cached_tokens` and no dialect
ever populated them, so a converted checkpoint that held its score while
burning twice the reasoning budget was invisible -- the delivery regression no
accuracy metric catches. Both OpenAI-shaped dialects now lift
`prompt_tokens_details` / `completion_tokens_details`, and sglang surfaces the
`cached_tokens` it was already reading twenty lines below for its radix-cache
guard.

The optional counts are `int | None`, not `int = 0`. Most OpenAI-compatible
servers omit the detail objects entirely, so a zero default makes "no cache
hits" and "never said" the same number, and any average over a mixed fleet
silently wrong. The distinction is carried to disk: `ModelUsage` gains
`NotRequired` keys written only where the server reported one, so an absent key
means unreported and a present `0` is a real measurement.

The same rule holds one layer up: `profile.json`'s `share` is absent when its
parent count is 0. A server counting reasoning outside its completion count can
report reasoning against 0 completion tokens, and `0.0` there is not a measured
0% but an undefined ratio -- the same lie the counts are optional to avoid.
`total` still carries what was reported.

No subset relation is enforced. `reasoning <= completion` is an OpenAI
convention rather than a wire guarantee, and a server counting reasoning
outside its completion count is exactly the one whose reported total exceeds
the computed one. That case is now recorded instead of discarded:
`reported_total_tokens` is kept only where it disagrees with prompt+completion,
so it is absent on almost every call and, when present, is the only evidence
that a provider's accounting differs from ours. #100 had nowhere to put that
difference and dropped it silently.

The profiler reports the new fields as shares against the tokens of the calls
that reported them -- never against every call, which understates a share by
the non-reporting fraction -- and prints that denominator on every line rather
than only when partial, since a coverage note given sometimes reads as full
coverage the rest of the time. They are never folded into `total_tokens`, which
stays prompt+completion.

`record_model_usage` and `aggregate_token_usage` carried the same accumulation
logic twice and now share one accumulator: a field added to the live path alone
would have vanished on the next resume. They share its admission test too --
the rebuild used to count a usage the live path skips, inflating `calls_total`
after a resume but not on the original run. The rebuild clears the new
accumulators for the same reason: they are additive.

The record projection lands in `_legacy_bridge`, not `model`. #99 moved
`ModelUsage` and `response_to_model_output` there after this work was branched,
and the conflict it raises is only reported against the old home.

Deliberately not collected: `audio_tokens`. Nothing in sieval sends or receives
audio, so it would ship as a permanently absent key.

Both changed record shapes are released -- `ModelUsage` and
`ProfileStageTokenUsage` each ship at v0.7.0 -- so these additions belong in the
release notes. Every new key is `NotRequired` and written only where the server
reported it, so nothing reading the existing keys breaks and no migration is
needed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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