refactor(api): Model, and a model that can be written back out - #522
Conversation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Documentation build overview
4 files changed± ARCHITECTURE/index.html± api/index.html± design/ceiling/index.html± design/linopy/index.html |
Merging this PR will degrade performance by 2.18%
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing Footnotes
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…del.py Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rm agrees Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s too Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…inery Two collisions, both spelling against deletion. The Model rename wins where both sides touched a line (MathSchema is gone everywhere else), and the branch's tests win where main's still probed _needs_aggregate and may_share_a_column — machinery this branch removes, its question now answered by the aggregate always running. The _parameter_names and _variable_names wrappers main added have no caller left on this branch's check_divisors_cover, so they go too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes #518 and #527. Three things, one story: making
Modela public type youcan understand — a name that says what it is, a way back out to a file, and one
exception tree behind it.
The vocabulary
The pipeline has three stages and had two good names:
MathSchema, inlanguage/schema.pyModel, inlanguage/model.pyplan.ProgramPolarsExecutorProgramandExecutorsay what they are. "Schema" said how it wasvalidated — and it collides with JSON-schema in the reader's head, which is live
here since #66 is about publishing one.
Model → Program → Executornames thewhole pipeline in three words.
load_schemafollows toload_model.The clash with
linopy.Modelis informative rather than confusing: that one isa built model, this is the declared math, and every reference to linopy's is
already qualified.
Out, three ways, all the same
Hard rule 5 is that the model is the file you review and diff. A model a
framework emitted as a dict — the path #30 and #29 were closed in favour of
— has no such file. This gives it one.
The deliverable is
tests/test_roundtrip.py, not the methods. A dump thatdrifts from what the engine builds is worse than none: a reviewer would be
reading a model that never ran, and nothing about the output would look wrong.
The property is held over every example and every port — which between them
exercise every construct — plus stability, since a review copy that changes per
run is a diff nobody can read.
The rule: drop absence, not defaults
This took three wrong answers, and they are why the rule is worth stating.
First cut —
exclude_defaults=True. Reads beautifully, ~40% shorter, and itneeds a list of which defaults are consequential. That list is a second copy
of the schema, and it drifted on its first day: it kept
versionandsenseand silently dropped
dtype, so a file whose author wrotedtype: strwasreviewed without it.
sensewas the sharper miss — every model in the corpuswrites it, and dropping it lost minimise-or-maximise from the review copy while
round-tripping perfectly.
The rule, one sentence and no list:
An empty list stays: a list carries cardinality here and zero is one of its
values, so
foreach: []is a scalar declaration anddims: []a scalarparameter — both required fields that mean something. Found by a failing round
trip, not predicted.
An infinite bound is absent because it is not a bound — it is the unbounded
side, which is exactly what omitting it already means. That also made JSON
lossless: JSON has no infinity, so an unbounded
-infused to arrive asnulland read as absent, which it is.
Leveraging pydantic
The rule lives on the model's
@model_serializer, not in a helper beside it.Beside it,
model_dump— public, and not ours to remove — described the samemodel with different content than the file, so which answer a consumer got
depended on which name they reached for. On the serializer,
model_dump,model_dump_json,to_dictandto_yamlall agree.One exception tree (#527)
docs/api.mdpromisedLpspecErrorfor the model. It was false for themajority:
pydantic_core.ValidationErrorSchemaErrordtypepydantic_core.ValidationErrorSchemaErrorversionpydantic_core.ValidationErrorSchemaErrorLanguageErrorSchemaErrorLanguageErrorLanguageErrorThree of five escaped the tree the page tells you to catch — with pydantic's
input_value=dump and anerrors.pydantic.devlink attached, neither of whichmeans anything to someone who wrote a YAML file.
errors.pydocumented the leakas deliberate ("a custom class would not survive the trip"), which is true and
not the end of it: it survives if you unwrap at the boundary.
SchemaErrorwas always named for this — its docstring said "unknown key, baddtype" — and was never wired to it. The line is now written down:
SchemaError— the declarations themselves are wrong.LanguageError— the declarations are fine; what they say is not.No test changed for this, which is the useful signal: messages were already
written for the right audience and only the envelope was discarded.
Method
The rename is 38 files, done as a word-boundary identifier pass verified by
ast.parseover every file rather than a text sweep. That mattered:Modelis a common word in prose, so the source token had to be the distinctive one
and searching for the replacement afterwards would have been useless. The
mermaid diagram in
ARCHITECTURE.mdwas updated; the SVG charts, HTML pages andstylesheet carry neither name.
901 passed,
ruff/pyrefly/mkdocs --strictclean.Not here, on purpose
#526 —
Model(**data)still accepts a constraint naming an undeclaredparameter, so the type can exist half-validated. The fix works and is verified,
but it moves 57 tests' failure point: they build an invalid model on a line
before
pytest.raises, and you cannot hold an invalidModelonce invalidModels cannot exist. That diff is entirely "the error moved earlier" — easy toreview as a unit, hard to review buried in this one. It is also unblocked by
this PR: with the boundary unwrapping, its errors arrive in the tree for free.