Skip to content

refactor(api): Model, and a model that can be written back out - #522

Merged
FBumann merged 7 commits into
mainfrom
refactor/model-vocabulary
Aug 10, 2026
Merged

refactor(api): Model, and a model that can be written back out#522
FBumann merged 7 commits into
mainfrom
refactor/model-vocabulary

Conversation

@FBumann

@FBumann FBumann commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Closes #518 and #527. Three things, one story: making Model a public type you
can 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:

stage was now
declared MathSchema, in language/schema.py Model, in language/model.py
lowered plan.Program unchanged
built PolarsExecutor unchanged

Program and Executor say what they are. "Schema" said how it was
validated — and it collides with JSON-schema in the reader's head, which is live
here since #66 is about publishing one. Model → Program → Executor names the
whole pipeline in three words. load_schema follows to load_model.

The clash with linopy.Model is informative rather than confusing: that one is
a built model, this is the declared math, and every reference to linopy's is
already qualified.

Out, three ways, all the same

model = lps.load_model('model.yaml')
model.to_dict()   # the model as data
model.to_yaml()   # that dict as the file a reviewer reads

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 that
drifts 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 it
needs 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 version and sense
and silently dropped dtype, so a file whose author wrote dtype: str was
reviewed without it. sense was the sharper miss — every model in the corpus
writes it, and dropping it lost minimise-or-maximise from the review copy while
round-tripping perfectly.

The rule, one sentence and no list:

Every value is written. Only what is absent is dropped — a null, an
infinite bound, or a mapping that declares nothing.

An empty list stays: a list carries cardinality here and zero is one of its
values, so foreach: [] is a scalar declaration and dims: [] a scalar
parameter — 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 -inf used to arrive as null
and 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 same
model 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_dict and to_yaml all agree.

One exception tree (#527)

docs/api.md promised LpspecError for the model. It was false for the
majority:

the mistake was now
unknown key pydantic_core.ValidationError SchemaError
bad dtype pydantic_core.ValidationError SchemaError
unknown version pydantic_core.ValidationError SchemaError
two objectives LanguageError SchemaError
undeclared name LanguageError LanguageError

Three of five escaped the tree the page tells you to catch — with pydantic's
input_value= dump and an errors.pydantic.dev link attached, neither of which
means anything to someone who wrote a YAML file. errors.py documented the leak
as 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.

SchemaError was always named for this — its docstring said "unknown key, bad
dtype"
— 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.parse over every file
rather than a text sweep. That mattered: Model
is 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.md was updated; the SVG charts, HTML pages and
stylesheet carry neither name.

901 passed, ruff / pyrefly / mkdocs --strict clean.

Not here, on purpose

#526Model(**data) still accepts a constraint naming an undeclared
parameter, 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 invalid Model once invalid
Models cannot exist. That diff is entirely "the error moved earlier" — easy to
review 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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@read-the-docs-community

read-the-docs-community Bot commented Aug 9, 2026

Copy link
Copy Markdown

Documentation build overview

📚 lpspec | 🛠️ Build #33988208 | 📁 Comparing 9f2407c against latest (e3eff99)

  🔍 Preview build  

4 files changed
± ARCHITECTURE/index.html
± api/index.html
± design/ceiling/index.html
± design/linopy/index.html

@codspeed-hq

codspeed-hq Bot commented Aug 9, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 2.18%

⚡ 2 improved benchmarks
❌ 3 regressed benchmarks
✅ 9 untouched benchmarks
⏩ 24 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Memory test_emit[sector-s-lpspec-highs] 6.5 MB 7.3 MB -10.37%
Memory test_emit[profiled-s-lpspec-highs] 32.4 MB 35.1 MB -7.62%
Memory test_emit[dispatch-s-lpspec-lp] 36.3 MB 38.3 MB -5.2%
Memory test_emit[profiled-s-lpspec-lp] 34.7 MB 32 MB +8.57%
Memory test_emit[transport-s-lpspec-lp] 24.1 MB 22.9 MB +5.07%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing refactor/model-vocabulary (9f2407c) with main (5ca09f0)2

Open in CodSpeed

Footnotes

  1. 24 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (e3eff99) during the generation of this report, so 5ca09f0 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

FBumann and others added 4 commits August 9, 2026 21:33
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>
@FBumann FBumann changed the title refactor(api): Model, load_model, and Model.to_yaml refactor(api): Model, and a model that can be written back out Aug 9, 2026
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@FBumann
FBumann enabled auto-merge (squash) August 10, 2026 06:52
@FBumann
FBumann merged commit ccefd98 into main Aug 10, 2026
6 checks passed
FBumann added a commit that referenced this pull request Aug 10, 2026
…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>
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.

to_yaml: give a dict-built model a file to review

1 participant