Skip to content

feat(language): a component template loads and prints as math on its own - #507

Closed
FBumann wants to merge 3 commits into
claude/mathspec-gems-energy-vnisegfrom
claude/mathspec-merge-given
Closed

FBumann wants to merge 3 commits into
claude/mathspec-gems-energy-vnisegfrom
claude/mathspec-merge-given

Conversation

@FBumann

@FBumann FBumann commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: "If we could keep a fragment typesetting etc, that would be a big improvement!!! If given: lets us achieve this, it will be the way to go" — and then "I think we should implement it stacked onto merge."

Note

The following content was generated by AI.

What this changes

A component file was a load error on its own, so the unit a library ships was the one unit nobody could review as math. given_variables: ends that: the file names the column it reads, and then it loads and prints.

given_variables:
  flow: { dims: [snapshot, port] } # the surface fragment introduces this column
constraints:
  gen_injects:
    dims: [snapshot, generator]
    expression: at(flow, by=gen_port) == gen_p

merge composes the fragments as peers. It folds each given declaration into the declaration that introduces it, so a composed model carries none of them and no consumer downstream ever sees one. The engine needs no change.

The three doors, and which is shut

Verb A file with given_variables:
to_spec loads it
typeset prints it, under a Given heading in the legend
to_program refuses it, and names the composition that fixes it

That last row is the whole safety story. A program builds every column it carries, and this file says the opposite about one of its own:

this file reads a variable it does not introduce: 'flow'. A program builds every column it carries, so compose the file with the ones that declare them first — to_program(merge({...})). The file loads and prints on its own either way.

Because the refusal is at lowering, a given declaration never reaches an engine. That is what makes this cheaper than #452, which carried given declarations into the program and had to invent bounds for them.

⚠ This asks for a decision on the limits

Read this before the rest. given_variables: is a declaration section, which is none of the three kinds limits.md admits — not a macro, not a primitive, not a formulation. #452 raised this and closed without it being settled.

This PR widens the taxonomy with a fourth kind and states the test it has to pass: a section enters where it says something no section already says, where a file decides it without data, and where the typesetter prints it. given_variables: passes all three — nothing else states that a column belongs to another file, which is exactly what lets a component file stand alone.

The ten declaration keys become eleven. That is the cost, and it is the thing to accept or refuse before reviewing the diff.

What the section carries, and what it refuses

dims (required), domain, description. No bounds: and no where: — the file that introduces the column owns both, and a second spelling here would be a second home for one fact. Both are refused by the closed schema, which names the valid keys itself.

Folding checks agreement: what the reader states must match the introducer, or say less. description is prose rather than a claim, so it is excluded from every comparison in merge — otherwise two fragments describing one shared axis in their own words would be a disagreement.

fragment 'generator' reads given variable 'flow' as {'dims': ['snapshot', 'generator']}, where 'surface' introduces it as {'dims': ['snapshot', 'port'], …}. A given declaration is what the file expects of a column somebody else owns, so it says the same as the declaration it is folded into, or less.
`merge`, ported forward from #244

Peers, at the current spellings — relations: and dims:, which #244 predates:

  • dimensions and relations are the coordinate space, shared on purpose, and two fragments saying different things about one is the error;
  • everything else is owned, so a name two fragments declare is a collision naming both and the rewrite it usually is — two rows of a dimension;
  • objectives are summed, each term parenthesised, and senses must agree;
  • merging is order-independent, which is a test.

merge and override are two verbs rather than one with a flag, because peers and layers obey opposite laws. They compose: override(merge({…}), {…}) builds the model and then configures the run.

every_variable was proposed and dropped. spec.variables and spec.given_variables stay separate, and a pass that wants both writes {**spec.variables, **spec.given_variables} where a reader sees it. A property hides which of the two a pass meant, which is how #452's piecewise.py came to read the wrong one.

Verified

No pixi here, so the gates ran against python 3.13 with the runtime and docs dependencies installed by pip, not the pinned solve.

  • pytest -q — 1330 passed, 6 skipped on 9c7532e (1302 on feat(language): a patch file extends a model rather than a copy of it #505's head, so 28 new).
  • pyrefly 1.2.0 — nothing in the changed modules. ruff 0.15.8 (repo pins 0.16.1) clean. typos 1.50.2 clean.
  • python -m tools.schema and python -m tests.typesetting.golden regenerated: the schema gains GivenVariableBlock, and the golden output does not move, since the golden model declares no given column.
  • mkdocs build --strict builds, after dropping docs.python.org/objects.inv locally — the proxy here 403s it. The dropped line is not in the commit.
  • The walk-coverage guard runs here (coverage installed) and passes, so the new legend group is not an unreached line.

Not run: reuse, zizmor, taplo, compile-tex.

Mutation table

Each guard deleted in turn, whole suite run, tree restored between them. Taken before the base branch dropped its shell verb, so the totals are that head's.

Mutation Result
a peer collision is not refused 1 failed
two peers may say different things about one axis 2 failed
objective senses are not compared 1 failed
objective terms are joined unparenthesised 1 failed
language versions are not compared 1 failed
a given declaration is not folded into the introducer 2 failed
a folded declaration is not checked against the introducer 1 failed
a fragment lowers as though it introduced everything 2 failed
a name both introduced and given is not a collision 4 failed
a given frame is not held to declared dimensions 1 failed
a given column is not in the namespace an expression resolves against 8 failed
a given column has no frame for the dim checker 8 failed
a given column does not print 1 failed
restored 1333 passed

No survivors this time.

Deliberately not done

  • No given_constraints:. dual() over a row family built elsewhere is the basket case (PyPSA/linopy#954, and @FabianHofmann's comment on #302). It is a separate decision with a separate engine story, since a program would then have to carry the declaration rather than refuse it.
  • A given column cannot gate a piecewise: curve or sit in an sos:. Both read schema.variables, which is correct — this file introduces neither. The refusal message will read oddly for a file that did declare the name under given_variables:.
  • No examples/ entry. A fragment is a model now, so one could go there; the worked library lives on the how-to page and in tests/test_given.py until someone wants it rendered in the gallery.
  • shared math has no composition verb, so a component library ships as generated YAML or a script #302 is not closed by this. It settles the fragment question — a fragment is a Spec, and given_variables: is how — and leaves the rest of the issue where it is.

Stack: on #505, which is the base branch.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CA5v9XJvgYViUHP7hSiKPU


Generated by Claude Code

…component library is files rather than a script

`merge(fragments)` takes templates that each own part of the math and hands
back one mapping to validate, resolve and lower exactly once. A fragment may
name what a sibling declares, because merging happens before validation, so a
template writes `at(flow, by=gen_port)` against a coupling surface another file
owns.

Peers obey the opposite law to patches, which is why this is a second verb
rather than a mode on `override`:

- `dimensions` and `relations` are the coordinate space, shared on purpose, and
  two fragments saying different things about one is the error;
- everything else is owned, so a name two fragments declare is a collision that
  names both and the rewrite it usually is — two rows of a dimension;
- objectives are summed, each term parenthesised, and senses must agree.

The shell front's `compose` verb now takes several models, merged as peers,
before it lays any patches over them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA5v9XJvgYViUHP7hSiKPU
…y file loads and prints on its own

`given_variables:` names a column this file reads and another file introduces.
A component template was a load error on its own before it, which meant the
unit a library ships was the one unit nobody could review as math. Now it
loads, and it prints in all three formats, under a `Given` heading that says
which symbols the file does not introduce.

The section carries a frame and a domain, and no `bounds:` or `where:` — the
file that introduces the column owns those, and a second spelling would be a
second home for one fact.

`merge` folds each given declaration into the declaration that introduces it,
after checking that what the reader states agrees with it, so a composed model
carries none. `to_program` refuses a file that still reads a column nothing in
it introduces, because a program builds every column it carries, and the
message names the composition that fixes it. So no consumer downstream ever
sees a given declaration, and the engine needs no change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA5v9XJvgYViUHP7hSiKPU
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA5v9XJvgYViUHP7hSiKPU
@FabianHofmann

Copy link
Copy Markdown
Contributor

Thanks for the initiative Felix, I think this is very nice. Though I still have to figure out whether the incremental addition of optimization definitions as I do it in linopy is the right way. It feels glumpsy to me and I needed to think about many edge cases. most of them are resolved now and the implementation is highly tested. But I have the fealing that this feature might spoil out packages and we should do it in the clean way. While I am happy to spoil linopy a bit, I would like to keep math-spec as lean as possible. let's discuss that in a second

@FabianHofmann

Copy link
Copy Markdown
Contributor

thanks for the initiative @FBumann , let's close this, I don't think partial definitions is what we want in linopy/pypsa either.

@FabianHofmann

Copy link
Copy Markdown
Contributor

ah this is a stack. should we close all then?

@FBumann

FBumann commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

@FabianHofmann This really grew on me to be honest...

Inthink with a quick call i can convince you it might be a good addition!

@FBumann FBumann reopened this Sep 17, 2026
@FabianHofmann

Copy link
Copy Markdown
Contributor

I would be curious!

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.

3 participants