From fc67b0bb44ce72df506c4f932b785b48058f1b52 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:53:38 +0000 Subject: [PATCH 01/13] fix(language)!: a named expression whose dims: names a dimension its body does not carry is refused at load Closes #896. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf --- CHANGELOG.md | 1 + docs/reference/language/named.md | 20 +++++---- docs/reference/notation.md | 8 ++-- schema/mathspec.schema.json | 2 +- src/mathspec/_dimensions.py | 32 +++++++++++--- src/mathspec/spec.py | 7 ++-- tests/test_dimensions.py | 60 ++++++++++++++++++++------- tests/typesetting/golden/latex.out | 2 +- tests/typesetting/golden/markdown.out | 2 +- tests/typesetting/golden/model.yaml | 6 +-- tests/typesetting/golden/typst.out | 2 +- 11 files changed, 98 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bd0476f..c1cdf5f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version +- fix(language)!: a named expression whose `dims:` names a dimension its body does not carry is refused at load ([#PR](https://github.com/energy-models/mathspec/pull/PR)) - docs: the changelog is listed under Reference rather than About ([#895](https://github.com/energy-models/mathspec/pull/895)) - docs(pypsa): an extendable unit has a capacity to choose exactly where its active flag is set, as in PyPSA ([#886](https://github.com/energy-models/mathspec/pull/886)) - refactor(api)!: a module outside spec, program and errors has a name that starts with an underscore ([#878](https://github.com/energy-models/mathspec/pull/878)) diff --git a/docs/reference/language/named.md b/docs/reference/language/named.md index dff42f62..1749d18e 100644 --- a/docs/reference/language/named.md +++ b/docs/reference/language/named.md @@ -31,17 +31,18 @@ expressions: An entry is a bare string, or a mapping with a `description:` and a `dims:`. The `dims:` are the **frame**, the dimensions the quantity is read over. When -`dims:` is left out, the body decides the frame. When `dims:` is declared, the -loader refuses a body that carries a dimension the frame does not name. The -body may carry fewer dimensions, and then the quantity is constant along the -rest, so a constraint over the whole frame reads it at every coordinate. +`dims:` is left out, the body decides the frame. When `dims:` is declared, it +sets the order of the indices, and it names the dimensions the body carries. +The loader refuses a body that carries a dimension the frame does not name, and +a frame that names a dimension the body does not carry. An expression that +reads the entry broadcasts it along the rest of its own dimensions. ```yaml expressions: - cap: - dims: [snapshot, generator] - expression: p_max - description: the nominal capacity, the same in every snapshot + headroom: + dims: [generator, snapshot] + expression: p_max - p + description: the capacity a generator leaves unused in a snapshot ``` [`adds_to:`](parameters-variables-constraints.md#terms) adds this expression as a term to the sum @@ -120,7 +121,8 @@ A named expression carries **exactly one** of `expression:` and `cases:`. only what the case leaves. - Each `when:` and each value sits inside the frame, and a narrower case - broadcasts as a parameter with fewer dimensions does. + broadcasts as a parameter with fewer dimensions does. Each dimension of the + frame is carried by a value or a `when:`. A claimed coordinate can still have no value: the `otherwise:` above has none at the first snapshot, where a case claims every unit. To give such a coordinate a diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 265705a8..2a15721c 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -669,7 +669,7 @@ p_{t,g} \le \mathrm{startup\_cost}_{t,g} \qquad \forall\, t \in \mathcal{T},\ g #### Expression with a declared frame in a constraint -names the expression with a declared frame: the row reads it at every snapshot, though its body has no snapshot +names the expression with a declared frame, read in the row's own order ```yaml constraints: @@ -698,17 +698,17 @@ expressions: #### Named expression with a declared frame -a frame wider than the body, in an order of its own: the value is the same at every snapshot +a declared frame in an order of its own, which the body carries whole ```yaml expressions: rating: dims: [generator, snapshot] - expression: eta * p_max + expression: eta * p_max * sum(load, over=bus) ``` ```math -\mathrm{rating}_{g,t} = \mathrm{eta}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, g \in \mathcal{G},\ t \in \mathcal{T} +\mathrm{rating}_{g,t} = \mathrm{eta}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} \cdot \left( \sum_{b \in \mathcal{B}} \mathrm{load}_{t,b} \right) \qquad \forall\, g \in \mathcal{G},\ t \in \mathcal{T} ``` #### Plain expression that is a signed sum diff --git a/schema/mathspec.schema.json b/schema/mathspec.schema.json index 8b2f3e24..746933f9 100644 --- a/schema/mathspec.schema.json +++ b/schema/mathspec.schema.json @@ -198,7 +198,7 @@ "anyOf": [ { "additionalProperties": false, - "description": "A named quantity: one arithmetic expression, referenced by the math or read back after a solve.\n\nWritten in YAML as a bare string, or as a mapping once it carries a\n``description:`` \u2014 and serialised back to whichever form it was written in,\nso a round trip through [`Spec.to_yaml`][] reproduces the file::\n\n expressions:\n total_generation: sum(p, over=generator)\n emissions:\n expression: sum(p * rate, over=generator)\n description: CO2 released, the quantity the cap bounds\n\n``dims:`` declares the frame the quantity is read over. A plain entry may\nleave it out, and its body then decides the frame; a body that carries a\ndimension the frame does not name is refused, and one that carries fewer\nis constant along the rest. A quantity whose value varies by region is\nwritten as ``cases:`` over a declared ``dims:``, with an ``otherwise:``\nfor the rest \u2014 see the language reference.\n\n``adds_to:`` adds this entry as a term to the sum it names, a\n``given: expressions:`` entry of this file: a write, where the given entry\nis the read.\n[`merge`][mathspec.merge] adds every term to that name by\nname, after the body a file defines where one does. The term is read over at most the\nframe the given entry states, and does not read the name it adds to.", + "description": "A named quantity: one arithmetic expression, referenced by the math or read back after a solve.\n\nWritten in YAML as a bare string, or as a mapping once it carries a\n``description:`` \u2014 and serialised back to whichever form it was written in,\nso a round trip through [`Spec.to_yaml`][] reproduces the file::\n\n expressions:\n total_generation: sum(p, over=generator)\n emissions:\n expression: sum(p * rate, over=generator)\n description: CO2 released, the quantity the cap bounds\n\n``dims:`` declares the frame the quantity is read over. A plain entry may\nleave it out, and its body then decides the frame. A declared frame is\nwhat the body carries: a body that carries a dimension the frame does not\nname is refused, and so is a frame that names one the body does not\ncarry. A quantity whose value varies by region is\nwritten as ``cases:`` over a declared ``dims:``, with an ``otherwise:``\nfor the rest \u2014 see the language reference.\n\n``adds_to:`` adds this entry as a term to the sum it names, a\n``given: expressions:`` entry of this file: a write, where the given entry\nis the read.\n[`merge`][mathspec.merge] adds every term to that name by\nname, after the body a file defines where one does. The term is read over at most the\nframe the given entry states, and does not read the name it adds to.", "properties": { "adds_to": { "anyOf": [ diff --git a/src/mathspec/_dimensions.py b/src/mathspec/_dimensions.py index cf3c7bd0..9584f016 100644 --- a/src/mathspec/_dimensions.py +++ b/src/mathspec/_dimensions.py @@ -96,7 +96,7 @@ def dims_of(node: Expression, spec: Spec, context: str) -> frozenset[str]: def _named_dims(node: ExpressionReference, spec: Spec, context: str) -> frozenset[str]: - """An entry's declared frame where it has one — a narrower arm or body broadcasts along the rest — else its body's.""" + """An entry's declared frame where it has one, else its body's.""" declared = spec.expressions[node.name].dims if declared is not None: return frozenset(declared) @@ -264,13 +264,16 @@ def check_spec(spec: Spec, program: Program) -> None: continue frame = frozenset(declared.dims) if not isinstance(entry.expression, Cases): - _check_body_dims(entry.expression, spec, frame, f"Named expression '{ename}'") + carried = _check_body_dims(entry.expression, spec, frame, f"Named expression '{ename}'") + _check_frame_is_carried(ename, frame, carried, 'the body does not carry') continue + carried = frozenset[str]() for region, label in zip(entry.expression.regions, [*declared.cases, None], strict=True): context = case_context(ename, label) if label is not None: _check_where_dims(region.when, frame, context) - _check_value_dims(region.value, spec, frame, context) + carried |= _check_value_dims(region.value, spec, frame, context) | region.when.dims + _check_frame_is_carried(ename, frame, carried, 'no case value and no `when:` carries') for ename, entry in program.expressions.items(): if entry.adds_to is None: @@ -308,7 +311,7 @@ def check_spec(spec: Spec, program: Program) -> None: ) -def _check_value_dims(node: Expression, spec: Spec, frame: frozenset[str], context: str) -> None: +def _check_value_dims(node: Expression, spec: Spec, frame: frozenset[str], context: str) -> frozenset[str]: """A region's value may only carry dims the frame does — the ``otherwise:`` included. A wider one would give the quantity dims its entry does not, which is @@ -320,16 +323,33 @@ def _check_value_dims(node: Expression, spec: Spec, frame: frozenset[str], conte f'{context}: the value carries dims {sorted(got - frame)} outside the dims: ' f'{sorted(frame)}. Add them to dims:, or take them out of the value.' ) + return got -def _check_body_dims(node: Expression, spec: Spec, frame: frozenset[str], context: str) -> None: - """A plain entry's body may only carry dims its declared frame does; fewer is constant along the rest.""" +def _check_body_dims(node: Expression, spec: Spec, frame: frozenset[str], context: str) -> frozenset[str]: + """A plain entry's body may only carry dims its declared frame does; the dims it carries come back.""" got = dims_of(node, spec, context) if not got <= frame: raise DimensionError( f'{context}: the body carries dims {sorted(got - frame)} outside the dims: {sorted(frame)}. ' f'Add them to dims:, or take them out of the body.' ) + return got + + +def _check_frame_is_carried(name: str, frame: frozenset[str], carried: frozenset[str], carrier: str) -> None: + """A declared frame names only dims the body carries, so the entry stands for its body wherever it is read. + + A dim the body does not carry would make the entry constant along it, and + then the entry would not be its body: ``sum(entry, over=d)`` would load + where the body summed over ``d`` is refused. + """ + if extra := sorted(frame - carried): + raise DimensionError( + f"Named expression '{name}': dims: names {extra}, which {carrier}. A named expression stands for " + f'its body, so its frame is what the body carries. Take {extra} out of dims:; an expression that ' + f"reads '{name}' still broadcasts it along the rest of its own dims." + ) def _check_where_dims( diff --git a/src/mathspec/spec.py b/src/mathspec/spec.py index c09e98ae..9a216362 100644 --- a/src/mathspec/spec.py +++ b/src/mathspec/spec.py @@ -639,9 +639,10 @@ class ExpressionSpec(_StrictSpec): description: CO2 released, the quantity the cap bounds ``dims:`` declares the frame the quantity is read over. A plain entry may - leave it out, and its body then decides the frame; a body that carries a - dimension the frame does not name is refused, and one that carries fewer - is constant along the rest. A quantity whose value varies by region is + leave it out, and its body then decides the frame. A declared frame is + what the body carries: a body that carries a dimension the frame does not + name is refused, and so is a frame that names one the body does not + carry. A quantity whose value varies by region is written as ``cases:`` over a declared ``dims:``, with an ``otherwise:`` for the rest — see the language reference. diff --git a/tests/test_dimensions.py b/tests/test_dimensions.py index 075cfcc7..4f5264c4 100644 --- a/tests/test_dimensions.py +++ b/tests/test_dimensions.py @@ -6,6 +6,7 @@ from __future__ import annotations +import re from typing import TYPE_CHECKING, Any, ClassVar import pytest @@ -664,20 +665,14 @@ def test_names_read_takes_both_sides_of_a_relation_pair(): # a named expression's declared frame # --------------------------------------------------------------------------- -#: A quantity that is one number per generator, read over every snapshot as well. -FRAMED = varied( - DISPATCH_MODEL, - **{ - 'variables.build': {'dims': ['generator']}, - 'expressions.limit': {'dims': ['snapshot', 'generator'], 'expression': 'build * p_max'}, - }, -) +#: A quantity over every snapshot and generator, with its frame declared. +FRAMED = varied(DISPATCH_MODEL, **{'expressions.limit': {'dims': ['snapshot', 'generator'], 'expression': 'p * p_max'}}) def test_a_declared_frame_is_the_frame_as_written(): """A plain entry's frame was its body's, in file order; declared, it is the dims: as written.""" spec = to_spec( - varied(FRAMED, **{'expressions.limit': {'dims': ['generator', 'snapshot'], 'expression': 'build * p_max'}}) + varied(FRAMED, **{'expressions.limit': {'dims': ['generator', 'snapshot'], 'expression': 'p * p_max'}}) ) assert spec.program.expressions['limit'].dims == ('generator', 'snapshot') assert spec.to_dict()['expressions']['limit']['dims'] == ['generator', 'snapshot'], 'and it round-trips' @@ -689,10 +684,45 @@ def test_a_body_outside_its_declared_frame_is_refused(): to_spec(wide) -def test_a_declared_frame_is_read_at_every_coordinate_where_the_body_is_narrower(): - """The row would repeat across `snapshot` on the body's own frame; the declared frame says that is meant.""" - row = {'constraints.capped': {'dims': ['snapshot', 'generator'], 'expression': 'limit <= 10'}} - assert to_spec(varied(FRAMED, **row)).program.constraints['capped'].dims == ('snapshot', 'generator') - undeclared = varied(FRAMED, **row, **{'expressions.limit': 'build * p_max'}) +@pytest.mark.parametrize( + ('patch', 'extra', 'carrier'), + [ + pytest.param( + {'expressions.limit': {'dims': ['snapshot', 'generator'], 'expression': 'p_max'}}, + "['snapshot']", + 'the body does not carry', + id='a-plain-body', + ), + pytest.param( + { + 'parameters.flag': {'dims': ['generator'], 'dtype': 'bool'}, + 'expressions.limit': { + 'dims': ['snapshot', 'generator'], + 'cases': {'flagged': {'when': 'flag', 'expression': 'p_max'}}, + 'otherwise': 5, + }, + }, + "['snapshot']", + 'no case value and no `when:` carries', + id='cases', + ), + ], +) +def test_a_declared_frame_wider_than_the_body_is_refused(patch, extra, carrier): + """A frame wider than the body loaded, and a reference read the body as constant along the rest. + + Substituted where the name stands, the body did not load: `sum(limit, over=snapshot)` loaded where + `sum(p_max, over=snapshot)` is refused, and specsolve could not build it. In a constraint the wider frame + only built the same row once per snapshot. + """ + with pytest.raises( + DimensionError, match=re.escape(f"Named expression 'limit': dims: names {extra}, which {carrier}") + ): + to_spec(varied(DISPATCH_MODEL, **patch)) + + +def test_a_row_over_more_dims_than_the_entry_it_reads_is_refused(): + """A row over `snapshot` that reads an entry over `generator` alone would be the same row once per snapshot.""" + row = {'constraints.capped': {'dims': ['snapshot', 'generator'], 'expression': 'cap <= 10'}} with pytest.raises(DimensionError, match=r"does not carry \['snapshot'\], which its dims: declares"): - to_spec(undeclared) + to_spec(varied(DISPATCH_MODEL, **row, **{'expressions.cap': 'sum(p, over=snapshot) - p_max'})) diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index c3b37b41..b2eece33 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -111,7 +111,7 @@ \begin{align} \text{spend\_cap} && \mathrm{spend}^{\mathrm{cap}}_{g} & = \mathrm{cost}_{g} \cdot 2 && \forall\, g \in \mathcal{G} \\ \text{spend} && \mathit{spend}_{t} & = \sum_{g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} && \forall\, t \in \mathcal{T} \\ -\text{rating} && \mathrm{rating}_{g,t} & = \mathrm{eta}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} && \forall\, g \in \mathcal{G},\ t \in \mathcal{T} \\ +\text{rating} && \mathrm{rating}_{g,t} & = \mathrm{eta}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} \cdot \left( \sum_{b \in \mathcal{B}} \mathrm{load}_{t,b} \right) && \forall\, g \in \mathcal{G},\ t \in \mathcal{T} \\ \text{lcoe} && \mathit{lcoe} & = \frac{\sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g}}{\sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g}} \\ \text{net} && \mathit{net}_{t} & = -\mathit{spill}_{t} + \mathit{slack}_{t} && \forall\, t \in \mathcal{T} \\ \text{marginal\_price} && \mathit{marginal\_price}_{t,b} & = \lambda_{\mathrm{balance},t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \\ diff --git a/tests/typesetting/golden/markdown.out b/tests/typesetting/golden/markdown.out index 6440839f..5193283f 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -201,7 +201,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 **`rating`** ```math -\mathrm{rating}_{g,t} = \mathrm{eta}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, g \in \mathcal{G},\ t \in \mathcal{T} +\mathrm{rating}_{g,t} = \mathrm{eta}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} \cdot \left( \sum_{b \in \mathcal{B}} \mathrm{load}_{t,b} \right) \qquad \forall\, g \in \mathcal{G},\ t \in \mathcal{T} ``` **`lcoe`** diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index 6a788596..6b7a4327 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -144,9 +144,9 @@ expressions: spend: # a plain named expression: its symbol prints where it is used, its body once as a definition description: what a snapshot's dispatch costs expression: sum(p * cost, over=generator) - rating: # a frame wider than the body, in an order of its own: the value is the same at every snapshot + rating: # a declared frame in an order of its own, which the body carries whole dims: [generator, snapshot] - expression: eta * p_max + expression: eta * p_max * sum(load, over=bus) lcoe: sum(p * cost) / sum(p) # nothing in the math reads it, so its divisor may carry a variable net: # a plain expression that opens with a minus description: what a snapshot spills, less what it lacks @@ -173,7 +173,7 @@ constraints: starts: # names the cased expression: its symbol prints here, its block once below dims: [snapshot, generator] expression: p <= startup_cost - under_rating: # names the expression with a declared frame: the row reads it at every snapshot, though its body has no snapshot + under_rating: # names the expression with a declared frame, read in the row's own order dims: [snapshot, generator] expression: p <= rating balance: # sum over a relation diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index 39282871..e0a2d798 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -93,7 +93,7 @@ $ upright("p") & upright("p")^(upright("min"))_(g) <= p_(t,g) & <= upright("p")^ #set math.equation(numbering: "(1)") $ upright("spend_cap") & upright("spend")^(upright("cap"))_(g) & = upright("cost")_(g) dot 2 & forall g in cal(G) \ upright("spend") & italic("spend")_(t) & = sum_(g in cal(G)) p_(t,g) dot upright("cost")_(g) & forall t in cal(T) \ - upright("rating") & upright("rating")_(g,t) & = upright("eta")_(g) dot upright("p")^(upright("max"))_(g) & forall g in cal(G), t in cal(T) \ + upright("rating") & upright("rating")_(g,t) & = upright("eta")_(g) dot upright("p")^(upright("max"))_(g) dot (sum_(b in cal(B)) upright("load")_(t,b)) & forall g in cal(G), t in cal(T) \ upright("lcoe") & italic("lcoe") & = frac(sum_(t in cal(T), g in cal(G)) p_(t,g) dot upright("cost")_(g), sum_(t in cal(T), g in cal(G)) p_(t,g)) \ upright("net") & italic("net")_(t) & = -italic("spill")_(t) + italic("slack")_(t) & forall t in cal(T) \ upright("marginal_price") & italic("marginal_price")_(t,b) & = lambda_(upright("balance"),t,b) & forall t in cal(T), b in cal(B) \ From ca348d139f766598966466af6367b597eb4ff7c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:57:57 +0000 Subject: [PATCH 02/13] docs(changelog): link the line to its pull request Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1cdf5f6..1a08e62d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version -- fix(language)!: a named expression whose `dims:` names a dimension its body does not carry is refused at load ([#PR](https://github.com/energy-models/mathspec/pull/PR)) +- fix(language)!: a named expression whose `dims:` names a dimension its body does not carry is refused at load ([#898](https://github.com/energy-models/mathspec/pull/898)) - docs: the changelog is listed under Reference rather than About ([#895](https://github.com/energy-models/mathspec/pull/895)) - docs(pypsa): an extendable unit has a capacity to choose exactly where its active flag is set, as in PyPSA ([#886](https://github.com/energy-models/mathspec/pull/886)) - refactor(api)!: a module outside spec, program and errors has a name that starts with an underscore ([#878](https://github.com/energy-models/mathspec/pull/878)) From ab7b45971ca2e7c9654c3fb17017eb5988d894ee Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 14:09:06 +0000 Subject: [PATCH 03/13] fix(composition): a sum built only from terms takes no dimension its terms do not carry Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf --- .../parameters-variables-constraints.md | 7 ++++--- src/mathspec/_composition.py | 15 ++++++++++----- tests/test_terms.py | 19 ++++++++++++++++++- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/docs/reference/language/parameters-variables-constraints.md b/docs/reference/language/parameters-variables-constraints.md index a6df5d92..d6cd3f5b 100644 --- a/docs/reference/language/parameters-variables-constraints.md +++ b/docs/reference/language/parameters-variables-constraints.md @@ -376,9 +376,10 @@ added like any other. - A merged spec takes further terms: a merge defines every sum it has terms for, and a later merge adds to that body. So the first merge that holds the terms of a sum also holds a file that reads it. -- Where no file defines the sum, the terms are its body. The sum runs over - the frame its readers state, every reader writes the dims in the same - order, and the sum takes the first description a reader wrote. +- Where no file defines the sum, the terms are its body. Every reader writes + the dims in the same order. The sum runs over the dims of that frame that a + term carries, and it takes the first description a reader wrote. A reader's + row over a dim that no term carries is refused, as it is over a definition. - Where no file defines the sum, some file reads it for more than adding to it. That file reads the sum and adds nothing, or uses it in its math. Terms that only their own files read are refused, and the message names the nearest name, diff --git a/src/mathspec/_composition.py b/src/mathspec/_composition.py index 7cff0331..b2dc3dcb 100644 --- a/src/mathspec/_composition.py +++ b/src/mathspec/_composition.py @@ -42,8 +42,8 @@ body followed by every term, in the order the fragments are given in, so a composed spec takes more terms in a later merge. A definition written as ``cases:`` takes no term. Where no fragment defines the - name, the composed spec defines it as its terms, over the frame the - readers state in one order. Some fragment then has to read the name for + name, the composed spec defines it as its terms, over the dims of the + frame the readers state in one order that a term carries. Some fragment then has to read the name for more than adding to it: read it without adding to it, or use it in its math. A name only its terms read, or only one fragment reads, is what a misspelt ``given:`` entry looks like, so it is refused. A term that @@ -380,8 +380,9 @@ def _summed( The terms join with a plain ``+``, the loosest operator there is, so a sum composed in two merges reads as the one composed in one. Where no - fragment defines the name, the terms are the body, over the frame the - readers state; the composed load holds the terms to it either way. + fragment defines the name, the terms are the body, over the dims of the + readers' frame that a term carries, in the readers' order; the composed + load holds every reader to that frame, as it does to a definer's. """ summed: dict[str, object] = {} defined = _mapping(merged.get('expressions')) @@ -397,7 +398,11 @@ def _summed( summed[key] = {**base, 'expression': ' + '.join([cast('str', base['expression']), *names])} continue _read_elsewhere(loaded, key, contributors) - entry: dict[str, object] = {'dims': _frame(read, key), 'expression': ' + '.join(names)} + carried = _definer_frame(loaded, key) + entry: dict[str, object] = { + 'dims': [d for d in _frame(read, key) if d in carried], + 'expression': ' + '.join(names), + } if said := _mapping(reading).get('description'): entry['description'] = said summed[key] = entry diff --git a/tests/test_terms.py b/tests/test_terms.py index 1848eb01..0c5eb96d 100644 --- a/tests/test_terms.py +++ b/tests/test_terms.py @@ -19,7 +19,7 @@ from mathspec import advice, merge, override, to_markdown, to_spec, typeset, typeset_line from mathspec._canonical import canonical_yaml from mathspec._typesetting import FORMATS -from mathspec.errors import LanguageError +from mathspec.errors import DimensionError, LanguageError from tests.fixtures import BALANCE, BUS_DIMS, BUS_FRAME, INJECTION, section #: A generator fleet: what it puts in is its term. @@ -542,6 +542,23 @@ def test_the_composed_sum_is_read_over_the_readers_frame(): assert merge([BALANCE, FLEET, DEMAND]).expressions['injection'].dims == BUS_FRAME +def test_a_composed_sum_takes_no_dim_its_terms_do_not_carry(): + """A sum built only from terms took the readers' frame whole, and broadcast along a dim no term carried. + + With every term over `bus` alone, the balance built the same row once per snapshot, and once a frame had + to be carried, the composed sum was refused for a `dims:` no file wrote. The sum keeps the dims its terms carry, so the + balance is refused as it is over a definer that carries only `bus`. + """ + fixed = { + 'dimensions': BUS_DIMS, + 'variables': {'fixed_injection': {'dims': ['bus']}}, + 'given': {'expressions': {'injection': {'dims': BUS_FRAME}}}, + 'expressions': {'fixed': {'expression': 'fixed_injection', 'adds_to': 'injection'}}, + } + with pytest.raises(DimensionError, match=r"Constraint 'balance': the expression does not carry \['snapshot'\]"): + merge([BALANCE, fixed]) + + def test_one_term_alone_is_its_name(): composed = merge([BALANCE, STORAGE]) assert composed.expressions['injection'].expression == 'store_injection' From 862f2bd9411f3567d3773252da856efbb0582a52 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 14:09:46 +0000 Subject: [PATCH 04/13] chore(composition): reflow the module docstring Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf --- src/mathspec/_composition.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/mathspec/_composition.py b/src/mathspec/_composition.py index b2dc3dcb..1e87eac2 100644 --- a/src/mathspec/_composition.py +++ b/src/mathspec/_composition.py @@ -43,9 +43,9 @@ so a composed spec takes more terms in a later merge. A definition written as ``cases:`` takes no term. Where no fragment defines the name, the composed spec defines it as its terms, over the dims of the - frame the readers state in one order that a term carries. Some fragment then has to read the name for - more than adding to it: read it without adding to it, or use it in its - math. A name only its terms read, or only one fragment reads, is what a + frame the readers state in one order that a term carries. Some fragment + then has to read the name for more than adding to it: read it without + adding to it, or use it in its math. A name only its terms read, or only one fragment reads, is what a misspelt ``given:`` entry looks like, so it is refused. A term that reads its own sum through another fragment is refused, both named. * **A given entry is folded** into the entry that introduces the From 5372843dba9cd6746234f4d32ac5120494ffcc02 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 14:24:48 +0000 Subject: [PATCH 05/13] feat(program): each expression node states the dimensions it is read over Closes #897. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf --- CHANGELOG.md | 1 + docs/reference/reading.md | 9 ++ src/mathspec/_dimensions.py | 81 ++++++++---------- src/mathspec/_expression_resolver.py | 47 +++++++---- src/mathspec/_lowering.py | 17 ++-- src/mathspec/_resolution.py | 11 ++- src/mathspec/_typesetting/walk.py | 10 ++- src/mathspec/program.py | 26 +++++- tests/test_dimensions.py | 99 +++++++++++++++++++++- tests/test_expansion.py | 5 +- tests/test_lowering.py | 122 +++++++++++++++++++-------- tests/test_piecewise.py | 2 +- 12 files changed, 307 insertions(+), 123 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a08e62d..5835010b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version +- feat(program): each expression node states the dimensions it is read over ([#PR](https://github.com/energy-models/mathspec/pull/PR)) - fix(language)!: a named expression whose `dims:` names a dimension its body does not carry is refused at load ([#898](https://github.com/energy-models/mathspec/pull/898)) - docs: the changelog is listed under Reference rather than About ([#895](https://github.com/energy-models/mathspec/pull/895)) - docs(pypsa): an extendable unit has a capacity to choose exactly where its active flag is set, as in PyPSA ([#886](https://github.com/energy-models/mathspec/pull/886)) diff --git a/docs/reference/reading.md b/docs/reference/reading.md index 9e35cdff..c52bb62c 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -147,6 +147,15 @@ entry's expression, the same object that `program.expressions[name].expression` holds, and its value is the body's value. `children()` steps into the body, so a walk reads through it. +Every expression node has `dims`: the dimensions its value is read over, in +the order `dimensions:` declares them. A sum has the dimensions its operand has, +less the ones it sums over. A `Join` under a sum through a relation has the +dimensions of that sum. A `Join` also opens one `Axis` for each column it +drops, but an axis is not a dimension and is not in `dims`. An +`ExpressionReference` has the dimensions of its body. The order of an entry's +own `dims` can be different: a named expression keeps the order its `dims:` +declares. + A `MaskReference` stands where a `masks:` entry is read. Its `body` is the predicate of the entry, the root of `program.masks[name].where`, and it is true where the body is true. `where_children()` steps into the body, so `.atoms`, diff --git a/src/mathspec/_dimensions.py b/src/mathspec/_dimensions.py index 9584f016..f533e529 100644 --- a/src/mathspec/_dimensions.py +++ b/src/mathspec/_dimensions.py @@ -51,56 +51,38 @@ ) if TYPE_CHECKING: + from collections.abc import Iterable + from mathspec.program import Program from mathspec.spec import Spec def dims_of(node: Expression, spec: Spec, context: str) -> frozenset[str]: - """The dim set of a resolved expression, checking every rule on the way. + """The dims a resolved expression states, with every rule under it checked on the way. + + A named expression's body is checked once, as its entry, by [`check_spec`][]. Raises: DimensionError: On the first rule broken. """ - if isinstance(node, Constant): - return frozenset() - - if isinstance(node, ParameterReference): - return frozenset({**spec.parameters, **spec.given.parameters}[node.name].dims) - - if isinstance(node, VariableReference): - columns = {**spec.variables, **spec.given.variables, **spec.given.expressions} - return frozenset(columns[node.name].dims or ()) - - if isinstance(node, Dual): - return frozenset({**spec.constraints, **spec.given.constraints}[node.constraint].dims) - - if isinstance(node, ExpressionReference): - return _named_dims(node, spec, context) - - if isinstance(node, Cases): - return frozenset().union(*(dims_of(region.value, spec, context) for region in node.regions)) - - if isinstance(node, Negate | Add | Multiply | Power | Divide): - return frozenset().union(*(dims_of(child, spec, context) for child in children(node))) - - if isinstance(node, Sum): - return _sum_dims(node, spec, context) - inner = dims_of(node.operand, spec, context) - if isinstance(node, Join): + if isinstance(node, Constant | ParameterReference | VariableReference | Dual | ExpressionReference): + pass + elif isinstance(node, Cases): + for region in node.regions: + dims_of(region.value, spec, context) + elif isinstance(node, Negate | Add | Multiply | Power | Divide): + for child in children(node): + dims_of(child, spec, context) + elif isinstance(node, Sum): + _check_sum(node, spec, context) + elif isinstance(node, Join): assert not node.columns.axes, 'a join that opens axes is read only by the sum that closes them' - return join_dims(node.columns, inner, context, 'the expression') - if isinstance(node, Translate | WindowSum): - return _translation_dims(node, inner, spec, context) - - assert_never(node) - - -def _named_dims(node: ExpressionReference, spec: Spec, context: str) -> frozenset[str]: - """An entry's declared frame where it has one, else its body's.""" - declared = spec.expressions[node.name].dims - if declared is not None: - return frozenset(declared) - return dims_of(node.body, spec, context) + join_dims(node.columns, dims_of(node.operand, spec, context), context, 'the expression') + elif isinstance(node, Translate | WindowSum): + _check_translation(node, dims_of(node.operand, spec, context), spec, context) + else: + assert_never(node) + return frozenset(node.dims) def _not_carried(context: str, call: str, inner: frozenset[str], rewrite: str) -> str: @@ -108,23 +90,23 @@ def _not_carried(context: str, call: str, inner: frozenset[str], rewrite: str) - return f'{context}: {call} but the expression has only the dims {sorted(inner)}. {rewrite}.' -def _sum_dims(node: Sum, spec: Spec, context: str) -> frozenset[str]: +def _check_sum(node: Sum, spec: Spec, context: str) -> None: """``sum`` reduces each dim in ``over`` away, so the operand carries every one. The axes a join opens never reach a frame: the sum over the join closes - every one, so its frame is the join's frame less them. + every one, and the join's own rules are the sum's. """ if isinstance(node.operand, Join) and node.operand.columns.axes: join = node.operand assert node.over == join.columns.axes, 'a sum through a relation closes exactly the axes its join opens' - return join_dims(join.columns, dims_of(join.operand, spec, context), context, 'the expression') + join_dims(join.columns, dims_of(join.operand, spec, context), context, 'the expression') + return inner = dims_of(node.operand, spec, context) for summed in node.over: if summed.dimension not in inner: raise DimensionError( _not_carried(context, f'sum(over={summed})', inner, 'Remove the sum, or correct the dimension') ) - return inner - {axis.dimension for axis in node.over} def join_dims(columns: JoinColumns, inner: frozenset[str], context: str, operand: str) -> frozenset[str]: @@ -160,14 +142,19 @@ def join_dims(columns: JoinColumns, inner: frozenset[str], context: str, operand f'dimension.' ) _check_joined(call, columns, inner, context) - return (inner - set(columns.joined_dims)) | set(columns.grouped_dims) + return joined(columns, inner) + + +def joined(columns: JoinColumns, inner: Iterable[str]) -> frozenset[str]: + """The dims a join on *columns* leaves of *inner*: the dims joined on go, and the dims grouped by arrive.""" + return (frozenset(inner) - set(columns.joined_dims)) | set(columns.grouped_dims) #: The operator name a file writes for each translation, which its refusals quote. _VERBS: dict[type[Translate | WindowSum], str] = {Translate: 'shift', WindowSum: 'sum_back'} -def _translation_dims(node: Translate | WindowSum, inner: frozenset[str], spec: Spec, context: str) -> frozenset[str]: +def _check_translation(node: Translate | WindowSum, inner: frozenset[str], spec: Spec, context: str) -> None: """``shift`` and ``sum_back`` keep every dim, and a named amount and a partition are checked here.""" verb = _VERBS[type(node)] if node.along not in inner: @@ -183,7 +170,6 @@ def _translation_dims(node: Translate | WindowSum, inner: frozenset[str], spec: if node.partition is not None: within = f'{node.partition.name}[{", ".join(node.partition.grouped)}]' _check_joined(f'{verb}(along={node.along}, within={within})', node.partition, inner, context) - return inner def _check_joined(call: str, use: JoinColumns | Partition, inner: frozenset[str], context: str) -> None: @@ -261,6 +247,7 @@ def check_spec(spec: Spec, program: Program) -> None: for ename, entry in program.expressions.items(): declared = spec.expressions[ename] if declared.dims is None: + dims_of(entry.expression, spec, f"Named expression '{ename}'") continue frame = frozenset(declared.dims) if not isinstance(entry.expression, Cases): diff --git a/src/mathspec/_expression_resolver.py b/src/mathspec/_expression_resolver.py index 0e701fbb..8e4c86dc 100644 --- a/src/mathspec/_expression_resolver.py +++ b/src/mathspec/_expression_resolver.py @@ -15,7 +15,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, assert_never, cast -from mathspec._dimensions import dims_of +from mathspec._dimensions import dims_of, joined from mathspec._expression_parser import ( MAX_DEPTH, ArithmeticNode, @@ -127,14 +127,14 @@ def arith(self, node: ArithmeticNode) -> Expression | None: formal bound to one. """ if isinstance(node, NumberNode): - return Constant(node.value) + return Constant(node.value, dims=()) if isinstance(node, NameNode): return self._name(node) if isinstance(node, UnaryOperatorNode): operand = self.arith(node.operand) if operand is None: return None - return Negate(operand) if node.op == '-' else operand + return Negate(operand, dims=operand.dims) if node.op == '-' else operand if isinstance(node, BinaryOperatorNode): return self._binary(node) if isinstance(node, FunctionCallNode): @@ -164,17 +164,18 @@ def _binary(self, node: BinaryOperatorNode) -> Expression | None: left, right = self.arith(node.left), self.arith(node.right) if left is None or right is None: return None + dims = self.ns.framed(left.dims, right.dims) match node.op: case '+': - return Add(left, right) + return Add(left, right, dims=dims) case '-': - return Add(left, Negate(right)) + return Add(left, Negate(right, dims=right.dims), dims=dims) case '*': - return Multiply(left, right) + return Multiply(left, right, dims=dims) case '/': - return Divide(left, right) + return Divide(left, right, dims=dims) case '**': - return Power(left, right) + return Power(left, right, dims=dims) case _: assert_never(node.op) @@ -199,13 +200,13 @@ def _name(self, node: NameNode) -> Expression | None: return None match self.ns.kind(node.name): case 'variable': - return VariableReference(node.name) + return VariableReference(node.name, dims=self.ns.framed(self.ns.leaf_dims[node.name])) case 'parameter': dtype = self.ns.dtypes.get(node.name) if dtype is not None and dtype not in NUMERIC_DTYPES: self.errors.append(not_a_number(node.name, dtype, self.context)) return None - return ParameterReference(node.name) + return ParameterReference(node.name, dims=self.ns.framed(self.ns.leaf_dims[node.name])) case 'dimension': self.errors.append( f"{self.context}: '{node.name}' is a dimension, and a dimension is " @@ -290,14 +291,23 @@ def _sum(self, operand: Expression, over: tuple[str, ...] | None, columns: Colum if columns is None: if over is None: return self._bare_sum(operand) - return Sum(operand, tuple(Axis(d) for d in over)) + return self._summed(operand, tuple(Axis(d) for d in over)) assert over is not None, 'the call shape requires over= beside by=' grouping = self._grouping(columns, over) if grouping is None: return None join, plain = grouping - grouped = Sum(Join(operand, join), join.axes) - return Sum(grouped, tuple(Axis(d) for d in plain)) if plain else grouped + grouped = self._summed(self._joined(operand, join), join.axes) + return self._summed(grouped, tuple(Axis(d) for d in plain)) if plain else grouped + + def _summed(self, operand: Expression, over: tuple[Axis, ...]) -> Sum: + """A [`Sum`][mathspec.program.Sum] over *over*, which removes the dims its plain axes name; a join's axes are no dims.""" + closed = {axis.dimension for axis in over if axis.column is None} + return Sum(operand, over, dims=tuple(d for d in operand.dims if d not in closed)) + + def _joined(self, operand: Expression, columns: JoinColumns) -> Join: + """A [`Join`][mathspec.program.Join] of *operand* on *columns*, over the dims the join leaves.""" + return Join(operand, columns, dims=self.ns.framed(joined(columns, operand.dims))) def _at(self, operand: Expression, columns: ColumnsNode) -> Expression | None: """``at(x, by=relation[column])``: *operand* read at the value of *columns* each row of the relation holds.""" @@ -307,7 +317,7 @@ def _at(self, operand: Expression, columns: ColumnsNode) -> Expression | None: self.errors.append(str(e)) return None join = self.lookup(columns, inner) - return None if join is None else Join(operand, join) + return None if join is None else self._joined(operand, join) def _translation( self, @@ -328,7 +338,7 @@ def _translation( assert offset is not None if not self._edge_fits(operand, offset, wrap=wrap, fill=fill): return None - return Translate(operand, along, offset, wrap=wrap, fill=fill, partition=partition) + return Translate(operand, along, offset, wrap=wrap, fill=fill, partition=partition, dims=operand.dims) if fill is not None: self.errors.append( f"{self.context}: sum_back(edge=...) takes 'wrap' or nothing. To add a constant, " @@ -337,7 +347,7 @@ def _translation( return None width = amounts['window'] assert width is not None - return WindowSum(operand, along, width, wrap=wrap, partition=partition) + return WindowSum(operand, along, width, wrap=wrap, partition=partition, dims=operand.dims) def _bare_sum(self, operand: Expression) -> Expression | None: """``sum(x)`` with no ``over=`` or ``by=`` reduces every dim the operand carries, which it has to carry some of.""" @@ -352,7 +362,7 @@ def _bare_sum(self, operand: Expression) -> Expression | None: f'Remove the sum.' ) return None - return Sum(operand, tuple(Axis(d) for d in sorted(inner))) + return self._summed(operand, tuple(Axis(d) for d in sorted(inner))) def _edge_fits(self, operand: Expression, offset: int | str, *, wrap: bool, fill: float | None) -> bool: """What a ``shift``'s ``edge=`` may say, and where saying nothing is an answer. @@ -477,7 +487,8 @@ def _dual(self, node: FunctionCallNode) -> Dual | None: if value.name not in self.ns.constraints: self.errors.append(self.ns.unknown_constraint(value.name, self.context, formals=self.formals)) return None - return Dual(value.name) + declared = {**self.ns.spec.constraints, **self.ns.spec.given.constraints}[value.name] + return Dual(value.name, dims=self.ns.framed(declared.dims)) def _over(self, value: ArithmeticNode, operator: str, *, relation: bool) -> tuple[str, ...] | None: """``over=``: a dimension or a list of them, and beside ``by=`` the columns of the relation too, each a bare name. diff --git a/src/mathspec/_lowering.py b/src/mathspec/_lowering.py index 1585850a..acb41baa 100644 --- a/src/mathspec/_lowering.py +++ b/src/mathspec/_lowering.py @@ -17,7 +17,7 @@ from typing import TYPE_CHECKING -from mathspec._dimensions import check_spec, dims_of +from mathspec._dimensions import check_spec from mathspec._expansion import expand, parse_template from mathspec._piecewise import assumptions_of, curve_frame, lp_domain_refusal, refused_under_points, resolve_links from mathspec._resolution import ( @@ -143,9 +143,9 @@ def lower(spec: Spec) -> Program: if where is not None and (refusal := self_existence(where, vname, f"Variable '{vname}'")) is not None: errors.append(refusal) if vdef.domain == 'binary': - lower_bound, upper_bound = Constant(0.0), Constant(1.0) + lower_bound, upper_bound = Constant(0.0, dims=()), Constant(1.0, dims=()) else: - lower_bound, upper_bound = _bound(vdef.bounds.lower), _bound(vdef.bounds.upper) + lower_bound, upper_bound = _bound(vdef.bounds.lower, ns), _bound(vdef.bounds.upper, ns) variables[vname] = Variable( tuple(vdef.dims), where=mask_of(where), @@ -341,18 +341,15 @@ def _loop(target: str, entry: ExpressionReference, sums: Mapping[str, list[Expre def _frame_of(name: str, entry: ExpressionReference, spec: Spec) -> tuple[str, ...]: """The dims an entry is read over: the ``dims:`` it declares, as written, else the body's in file order.""" declared = spec.expressions[name].dims - if declared is not None: - return tuple(declared) - carried = dims_of(entry.body, spec, f"Named expression '{name}'") - return tuple(d for d in spec.dimensions if d in carried) + return entry.dims if declared is None else tuple(declared) -def _bound(value: float | str | None) -> Constant | ParameterReference | None: +def _bound(value: float | str | None, ns: Namespace) -> Constant | ParameterReference | None: if value is None: return None if isinstance(value, str): - return ParameterReference(value) - return Constant(value) + return ParameterReference(value, dims=ns.framed(ns.leaf_dims[value])) + return Constant(value, dims=()) def _assumption(name: str, entry: AssumptionSpec, ns: Namespace, errors: list[str]) -> Assumption | None: diff --git a/src/mathspec/_resolution.py b/src/mathspec/_resolution.py index 4eea8724..e825c25e 100644 --- a/src/mathspec/_resolution.py +++ b/src/mathspec/_resolution.py @@ -142,6 +142,11 @@ def __init__(self, spec: Spec) -> None: #: the first time anything reads the name. self._masks: dict[str, tuple[MaskReference | None, tuple[str, ...]]] = {} + def framed(self, *frames: Iterable[str]) -> tuple[str, ...]: + """The dims *frames* carry together, in the order ``dimensions:`` declares them: what a node states as ``dims``.""" + carried = set().union(*frames) + return tuple(d for d in self.spec.dimensions if d in carried) + def named(self, name: str, context: str) -> ExpressionReference: """The ``expressions:`` entry *name* as the node that stands where its name is written. @@ -492,7 +497,7 @@ def _named(name: str, entry: ExpressionSpec, ns: Namespace, errors: list[str]) - if not entry.cases: assert entry.expression is not None body = resolve_expression_text(entry.expression, ns, context, errors, ceiling=None) - return None if body is None else ExpressionReference(name, body) + return None if body is None else ExpressionReference(name, body, dims=body.dims) found = len(errors) regions: list[Region] = [] @@ -515,7 +520,9 @@ def _named(name: str, entry: ExpressionSpec, ns: Namespace, errors: list[str]) - if len(errors) > found: return None left_over = Region(remainder(region.when for region in regions), fallback) - return ExpressionReference(name, Cases((*regions, left_over))) + every = (*regions, left_over) + cases = Cases(every, dims=ns.framed(*(r.value.dims for r in every), *(r.when.dims for r in every))) + return ExpressionReference(name, cases, dims=cases.dims) def _mask(name: str, entry: MaskSpec, ns: Namespace, errors: list[str]) -> MaskReference | None: diff --git a/src/mathspec/_typesetting/walk.py b/src/mathspec/_typesetting/walk.py index 32932476..a990188a 100644 --- a/src/mathspec/_typesetting/walk.py +++ b/src/mathspec/_typesetting/walk.py @@ -209,7 +209,11 @@ def _unsigned(node: Expression) -> Expression | None: if isinstance(node, Multiply | Divide): first, second = (node.left, node.right) if isinstance(node, Multiply) else (node.numerator, node.divisor) if (head := _unsigned(first)) is not None: - return Multiply(head, second) if isinstance(node, Multiply) else Divide(head, second) + return ( + Multiply(head, second, dims=node.dims) + if isinstance(node, Multiply) + else Divide(head, second, dims=node.dims) + ) return None @@ -385,7 +389,9 @@ def _binary(self, node: Add | Multiply | Divide | Power, ctx: _Context) -> tuple precedence = _PRECEDENCE[op] operand = self._substituted(node.right) if op == '+' and isinstance(operand, Add): - return self._binary(Add(Add(node.left, operand.left), operand.right), ctx) + head = {*node.left.dims, *operand.left.dims} + joined = Add(node.left, operand.left, dims=tuple(d for d in node.dims if d in head)) + return self._binary(Add(joined, operand.right, dims=node.dims), ctx) left = self._expression(node.left, ctx, need=precedence) if op == '+': while (unsigned := _unsigned(operand)) is not None: diff --git a/src/mathspec/program.py b/src/mathspec/program.py index 68f75d95..54f21658 100644 --- a/src/mathspec/program.py +++ b/src/mathspec/program.py @@ -22,7 +22,7 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass, fields, replace +from dataclasses import dataclass, field, fields, replace from functools import cached_property from typing import TYPE_CHECKING, Literal, assert_never @@ -188,6 +188,7 @@ class Constant: """A scalar constant.""" value: float + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) @@ -195,6 +196,7 @@ class ParameterReference: """A parameter reference — contributes to the constant part.""" name: str + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) @@ -202,6 +204,7 @@ class VariableReference: """A variable reference — one term per existing variable row.""" name: str + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) @@ -217,17 +220,20 @@ class Dual: """ constraint: str + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) class Negate: operand: Expression + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) class Add: left: Expression right: Expression + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) @@ -240,6 +246,7 @@ class Multiply: left: Expression right: Expression + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) @@ -253,6 +260,7 @@ class Power: base: Expression exponent: Expression + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) @@ -261,6 +269,7 @@ class Divide: numerator: Expression divisor: Expression + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) @@ -306,6 +315,7 @@ class Sum: operand: Expression over: tuple[Axis, ...] + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) @@ -323,10 +333,15 @@ class Join: join opens no axis, and the bare ``Join`` is ``at(x, by=relation[a])``: one value per row of the result, fanned out where several key tuples share the values joined on. + + An axis is never a dim: a join that opens axes states the ``dims`` of the + sum that closes them, and that sum removes only the dims its plain axes + name. """ operand: Expression columns: JoinColumns + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) @@ -354,6 +369,7 @@ class Translate: wrap: bool fill: float | None = None partition: Partition | None = None + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) @@ -382,6 +398,7 @@ class WindowSum: width: int | str wrap: bool partition: Partition | None = None + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) @@ -406,6 +423,7 @@ class Cases: """ regions: tuple[Region, ...] + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) @@ -421,6 +439,7 @@ class ExpressionReference: name: str body: Expression + dims: tuple[str, ...] = field(kw_only=True) #: Every expression node, as one type — what a walk takes. The set is @@ -432,7 +451,10 @@ class ExpressionReference: #: that is ``in_math`` — affine but where a [`QuadraticPosition`][] admits a #: [`Multiply`][] of two variable-carrying operands. A #: [`NamedExpression`][] the math never reads is held to none of them. -#: No node records which tree it stands in. +#: No node records which tree it stands in. Every node states ``dims``, the +#: dimensions its value is read over, in the order ``dimensions:`` declares +#: them, so a consumer reads a node's frame rather than deriving it. A +#: [`ExpressionReference`][] states its body's frame. Expression = ( Constant | ParameterReference diff --git a/tests/test_dimensions.py b/tests/test_dimensions.py index 4f5264c4..7b0a426a 100644 --- a/tests/test_dimensions.py +++ b/tests/test_dimensions.py @@ -7,7 +7,10 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, Any, ClassVar +from collections.abc import Iterator, Mapping +from dataclasses import fields, is_dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, ClassVar, cast, get_args import pytest @@ -15,8 +18,28 @@ from mathspec._resolution import Namespace from mathspec._validation import to_spec from mathspec.errors import SchemaError -from mathspec.program import Axis, Column, Join, Mask, RelationPairComparison, Sum -from tests.fixtures import DISPATCH_MODEL, expression_of, spec_of, varied, where_of +from mathspec.program import ( + Axis, + Cases, + Column, + Constant, + Dual, + Expression, + ExpressionReference, + Join, + Mask, + ParameterReference, + RelationPairComparison, + Sum, + Translate, + VariableReference, + WindowSum, + children, +) +from tests.fixtures import DISPATCH_MODEL, SPECS, expression_of, spec_of, varied, where_of + +#: Every class an expression node is an instance of. +EXPRESSION_NODES = get_args(Expression) if TYPE_CHECKING: from mathspec.spec import Spec @@ -726,3 +749,73 @@ def test_a_row_over_more_dims_than_the_entry_it_reads_is_refused(): row = {'constraints.capped': {'dims': ['snapshot', 'generator'], 'expression': 'cap <= 10'}} with pytest.raises(DimensionError, match=r"does not carry \['snapshot'\], which its dims: declares"): to_spec(varied(DISPATCH_MODEL, **row, **{'expressions.cap': 'sum(p, over=snapshot) - p_max'})) + + +# --------------------------------------------------------------------------- +# every expression node states its frame +# --------------------------------------------------------------------------- + +#: Every spec in the repository, before and after each expansion pair. +FRAMED_SPECS = [ + *SPECS, + *sorted((Path(__file__).parent / 'expand').glob('*/*.yaml')), + Path(__file__).parent / 'typesetting' / 'golden' / 'model.yaml', +] + + +def _derived(node: Expression, spec: Spec) -> frozenset[str]: + """The frame of *node* by the rules of the language reference, derived from the spec without reading ``dims``.""" + if isinstance(node, Constant): + return frozenset() + if isinstance(node, ParameterReference): + return frozenset({**spec.parameters, **spec.given.parameters}[node.name].dims) + if isinstance(node, VariableReference): + return frozenset({**spec.variables, **spec.given.variables, **spec.given.expressions}[node.name].dims or ()) + if isinstance(node, Dual): + return frozenset({**spec.constraints, **spec.given.constraints}[node.constraint].dims) + if isinstance(node, ExpressionReference): + return _derived(node.body, spec) + if isinstance(node, Cases): + return frozenset().union(*(_derived(r.value, spec) | r.when.dims for r in node.regions)) + if isinstance(node, Join): + joined = _derived(node.operand, spec) - set(node.columns.joined_dims) + return joined | set(node.columns.grouped_dims) + if isinstance(node, Sum): + return _derived(node.operand, spec) - {axis.dimension for axis in node.over if axis.column is None} + if isinstance(node, Translate | WindowSum): + return _derived(node.operand, spec) + return frozenset().union(*(_derived(child, spec) for child in children(node))) + + +def _expressions(root: object) -> Iterator[Expression]: + """Every expression node reachable from *root*, a program or any part of one, each once.""" + seen: set[int] = set() + stack = [root] + while stack: + item = stack.pop() + if id(item) in seen or isinstance(item, str | int | float | bool | None): + continue + seen.add(id(item)) + if isinstance(item, EXPRESSION_NODES): + yield cast('Expression', item) + if isinstance(item, Mapping): + stack.extend(item.values()) + elif isinstance(item, tuple | list | frozenset): + stack.extend(item) + elif is_dataclass(item): + stack.extend(getattr(item, f.name) for f in fields(item)) + + +@pytest.mark.parametrize('path', FRAMED_SPECS, ids=lambda path: str(path.relative_to(Path(__file__).parent.parent))) +def test_every_expression_node_states_its_frame_in_declaration_order(path): + """A consumer read a node's frame by deriving it again, as `dims_of` did at load and threw away.""" + spec = spec_of(path) + for loaded in (spec, spec.expand()): + nodes = list(_expressions(loaded.program)) + assert nodes, f'{path.name} lowers to no expression node' + for node in nodes: + derived = _derived(node, loaded) + assert node.dims == tuple(d for d in loaded.dimensions if d in derived), ( + f'{type(node).__name__} in {path.name} states {node.dims}, and the rules give {sorted(derived)} in ' + f'the order dimensions: declares' + ) diff --git a/tests/test_expansion.py b/tests/test_expansion.py index a44755d5..251e7f66 100644 --- a/tests/test_expansion.py +++ b/tests/test_expansion.py @@ -141,9 +141,10 @@ def test_a_call_expands_to_core_ast(expressions, macros, call, want): def test_a_named_expression_arrives_under_the_node_carrying_its_name(): ns = Namespace(spec(expressions={'gen_cost': 'p * cost'})) resolved = expression_of('sum(gen_cost, over=generator)', ns, 'e') + frame = ('snapshot', 'generator') + body = Multiply(VariableReference('p', dims=frame), ParameterReference('cost', dims=('generator',)), dims=frame) assert resolved == Sum( - ExpressionReference('gen_cost', Multiply(VariableReference('p'), ParameterReference('cost'))), - (Axis('generator'),), + ExpressionReference('gen_cost', body, dims=frame), (Axis('generator'),), dims=('snapshot',) ), 'the body is inlined resolved and the name kept, for the typesetter to define it once' assert isinstance(resolved, Sum) assert resolved.operand is expression_of('gen_cost', ns, 'another use'), 'every use reads the one node' diff --git a/tests/test_lowering.py b/tests/test_lowering.py index f0114b20..168f4c7e 100644 --- a/tests/test_lowering.py +++ b/tests/test_lowering.py @@ -137,18 +137,26 @@ def test_program_structure(dispatch_program): assert vname == 'dispatch' assert v.dims == ('snapshot', 'generator'), 'the frame is the dims, in the order the file wrote it' assert v.where == Mask(CAPACITY_POSITIVE) - assert v.upper == ParameterReference('capacity') + assert v.upper == ParameterReference('capacity', dims=('generator',)) ((cname, c),) = dispatch_program.constraints.items() assert cname == 'power_balance' assert c.dims == ('snapshot',), 'the frame is the dims, in the order the file wrote it' - assert c.lhs == Sum(VariableReference('dispatch'), (Axis('generator'),)) + assert c.lhs == Sum( + VariableReference('dispatch', dims=('snapshot', 'generator')), (Axis('generator'),), dims=('snapshot',) + ) assert c.sense == '==', "the comparison crosses as the file's own operator, untranslated" - assert c.rhs == ParameterReference('load') + assert c.rhs == ParameterReference('load', dims=('snapshot',)) assert dispatch_program.objective.sense == 'minimize', "the program carries the language's spelling, untranslated" assert dispatch_program.objective.expression == Sum( - Multiply(VariableReference('dispatch'), ParameterReference('cost')), (Axis('generator'), Axis('snapshot')) + Multiply( + VariableReference('dispatch', dims=('snapshot', 'generator')), + ParameterReference('cost', dims=('generator',)), + dims=('snapshot', 'generator'), + ), + (Axis('generator'), Axis('snapshot')), + dims=(), ), 'the objective carries the sum the file wrote, over the dims it named none of' @@ -414,7 +422,10 @@ def test_a_comparison_of_expressions_lowers_to_program_expressions_on_both_sides where = program.variables['p'].where assert where is not None assert where.root == ExpressionComparison( - ParameterReference('c'), '<=', Multiply(Constant(0.5), ParameterReference('k')), ('g',) + ParameterReference('c', dims=('g',)), + '<=', + Multiply(Constant(0.5, dims=()), ParameterReference('k', dims=()), dims=()), + ('g',), ), 'the sides are lowered as a constraint side is, and the dims are what either side carries' mask = program.constraints['w'].where assert mask is not None and isinstance(mask.root, ExpressionComparison) @@ -435,7 +446,10 @@ def test_a_predicate_a_leaf_carries_is_lowered_like_any_other_mask(): mask = program.constraints['w'].where assert mask is not None and isinstance(mask.root, CountComparison) assert mask.root.predicate.root == ExpressionComparison( - ParameterReference('c'), '<=', Multiply(Constant(0.5), ParameterReference('k')), ('g',) + ParameterReference('c', dims=('g',)), + '<=', + Multiply(Constant(0.5, dims=()), ParameterReference('k', dims=()), dims=()), + ('g',), ), 'the counted predicate is rebuilt, not handed through with the resolved comparison still in it' assert mask.names_read == frozenset({'c', 'k'}), 'what the counted predicate reads is data the consumer attaches' @@ -478,7 +492,10 @@ def test_a_predicate_read_through_a_relation_is_lowered_and_keeps_the_relation_i mask = program.constraints['w'].where assert mask is not None and isinstance(mask.root, JoinedPredicate) assert mask.root.operand.root == ExpressionComparison( - ParameterReference('zcap'), '<=', Multiply(Constant(0.5), ParameterReference('k')), ('z',) + ParameterReference('zcap', dims=('z',)), + '<=', + Multiply(Constant(0.5, dims=()), ParameterReference('k', dims=()), dims=()), + ('z',), ), 'the read predicate is rebuilt, not handed through with the resolved comparison still in it' assert mask.names_read == frozenset({'zcap', 'k', 'lk2'}) assert sorted(mask.dims) == ['g'], 'z is read at lk2(g), so the mask is over g alone' @@ -512,7 +529,10 @@ def test_an_assumption_lowers_both_of_its_masks(): assert assumption == Assumption( Mask( ExpressionComparison( - ParameterReference('c'), '<=', Multiply(Constant(0.5), ParameterReference('k')), ('g',) + ParameterReference('c', dims=('g',)), + '<=', + Multiply(Constant(0.5, dims=()), ParameterReference('k', dims=()), dims=()), + ('g',), ) ), Mask(ParameterDefined('flag', ('g',))), @@ -576,72 +596,80 @@ def test_a_power_resolves_to_a_node_of_its_own(dispatch_spec): [ pytest.param( 'sum(q)', - Sum(VariableReference('q'), (Axis('g'), Axis('h'))), + Sum(VariableReference('q', dims=('g', 'h')), (Axis('g'), Axis('h')), dims=()), id='a-bare-sum-sums-away-every-dim-the-operand-carries', ), pytest.param( - 'sum(q, over=h)', Sum(VariableReference('q'), (Axis('h'),)), id='an-over-sums-away-the-dim-it-names' + 'sum(q, over=h)', + Sum(VariableReference('q', dims=('g', 'h')), (Axis('h'),), dims=('g',)), + id='an-over-sums-away-the-dim-it-names', ), pytest.param( 'sum(p, over=g, by=lk[h])', - Sum(Join(VariableReference('p'), LK_JOIN), (Axis('g', Column('lk', 'g')),)), + Sum( + Join(VariableReference('p', dims=('g',)), LK_JOIN, dims=('h',)), + (Axis('g', Column('lk', 'g')),), + dims=('h',), + ), id='a-grouped-sum-is-a-sum-over-the-axis-its-join-opens', ), pytest.param( 'at(r, by=lk[h])', - Join(VariableReference('r'), JoinColumns('lk', LK, ('h',), ('g',))), + Join(VariableReference('r', dims=('h',)), JoinColumns('lk', LK, ('h',), ('g',)), dims=('g',)), id='an-at-is-the-same-join-the-other-way-with-no-sum-over-it', ), pytest.param( "shift(p, along=g, offset=1, edge='wrap')", - Translate(VariableReference('p'), 'g', offset=1, wrap=True, fill=None), + Translate(VariableReference('p', dims=('g',)), 'g', offset=1, wrap=True, fill=None, dims=('g',)), id='a-wrapping-translation-fills-nothing', ), pytest.param( 'shift(p, along=g, offset=-2, edge=0)', - Translate(VariableReference('p'), 'g', offset=-2, wrap=False, fill=0.0), + Translate(VariableReference('p', dims=('g',)), 'g', offset=-2, wrap=False, fill=0.0, dims=('g',)), id='a-lead-is-a-negative-offset-and-the-edge-is-what-it-fills-with', ), pytest.param( 'shift(p, along=g, offset=lead, edge=0)', - Translate(VariableReference('p'), 'g', offset='lead', wrap=False, fill=0.0), + Translate(VariableReference('p', dims=('g',)), 'g', offset='lead', wrap=False, fill=0.0, dims=('g',)), id='a-named-offset-crosses-as-the-parameter-name', ), pytest.param( "shift(p, along=g, offset=+lead, edge='wrap')", - Translate(VariableReference('p'), 'g', offset='lead', wrap=True, fill=None), + Translate(VariableReference('p', dims=('g',)), 'g', offset='lead', wrap=True, fill=None, dims=('g',)), id='a-named-offset-written-with-a-plus-is-the-parameter', ), pytest.param( 'shift(p, along=g, offset=1, within=lk[h], edge=0)', Translate( - VariableReference('p'), + VariableReference('p', dims=('g',)), 'g', offset=1, wrap=False, fill=0.0, partition=Partition('lk', LK, 'g', ('h',), ()), + dims=('g',), ), id='a-translation-stops-at-the-edges-of-the-relation-it-names', ), pytest.param( 'sum_back(p, along=g, window=3)', - WindowSum(VariableReference('p'), 'g', width=3, wrap=False), + WindowSum(VariableReference('p', dims=('g',)), 'g', width=3, wrap=False, dims=('g',)), id='a-window-is-one-node-rather-than-a-fold-of-translations', ), pytest.param( 'sum_back(p, along=g, window=lead)', - WindowSum(VariableReference('p'), 'g', width='lead', wrap=False), + WindowSum(VariableReference('p', dims=('g',)), 'g', width='lead', wrap=False, dims=('g',)), id='a-named-width-crosses-as-the-parameter-name', ), pytest.param( 'sum_back(p, along=g, window=2, within=lk[h])', WindowSum( - VariableReference('p'), + VariableReference('p', dims=('g',)), 'g', width=2, wrap=False, partition=Partition('lk', LK, 'g', ('h',), ()), + dims=('g',), ), id='a-window-stops-at-the-edges-of-the-relation-it-names', ), @@ -723,7 +751,11 @@ def test_a_relation_lowers_with_the_join_each_call_names(): assert program.relations == {'zone_of': declared}, 'the relation sits once in the program, under its name' zonal = program.constraints['zonal'].lhs columns = JoinColumns('zone_of', declared, ('generator', 'snapshot'), ('zone', 'snapshot')) - assert zonal == Sum(Join(VariableReference('p'), columns), (Axis('generator', Column('zone_of', 'generator')),)), ( + assert zonal == Sum( + Join(VariableReference('p', dims=('snapshot', 'generator')), columns, dims=('snapshot', 'zone')), + (Axis('generator', Column('zone_of', 'generator')),), + dims=('snapshot', 'zone'), + ), ( 'a grouped sum is a sum over a join: the join names the column over the over= dim and the unnamed key ' 'column as joined on, the by= column and that key column as grouped by, and the sum stands over the axis ' 'the join opens for the column it drops' @@ -739,13 +771,18 @@ def test_a_relation_lowers_with_the_join_each_call_names(): ) assert program.constraints['history'].lhs == Sum( Join( - VariableReference('p'), JoinColumns('zone_of', declared, ('snapshot', 'generator'), ('zone', 'generator')) + VariableReference('p', dims=('snapshot', 'generator')), + JoinColumns('zone_of', declared, ('snapshot', 'generator'), ('zone', 'generator')), + dims=('generator', 'zone'), ), (Axis('snapshot', Column('zone_of', 'snapshot')),), + dims=('generator', 'zone'), ), 'the same table joined on its other key column' priced = program.constraints['priced'].rhs assert priced == Join( - ParameterReference('price'), JoinColumns('zone_of', declared, ('zone', 'snapshot'), ('generator', 'snapshot')) + ParameterReference('price', dims=('snapshot', 'zone')), + JoinColumns('zone_of', declared, ('zone', 'snapshot'), ('generator', 'snapshot')), + dims=('snapshot', 'generator'), ), 'and an at is the bare join, on the value column, grouped by the key columns' assert isinstance(priced, Join) assert (priced.columns.dropped_dims, priced.columns.added_dims, priced.columns.kept) == ( @@ -771,33 +808,46 @@ def test_a_binary_variable_lowers_to_a_binary_domain(): def test_a_divisor_under_a_join_is_still_named(): """`children` has to descend through every node, or a refusal loses its name.""" - quotient = Divide(VariableReference('x'), ParameterReference('rate')) + quotient = Divide( + VariableReference('x', dims=('component',)), + ParameterReference('rate', dims=('component',)), + dims=('component',), + ) component_of = Relation((('flow', 'flow'), ('component', 'component')), ('flow',)) - looked_up = Join(quotient, JoinColumns('component_of', component_of, ('component',), ('flow',))) + looked_up = Join(quotient, JoinColumns('component_of', component_of, ('component',), ('flow',)), dims=('flow',)) assert parameters_of(looked_up) == frozenset({'rate'}), 'the walk descends through `Join`' - assert parameters_of(Sum(looked_up, (Axis('flow'),))) == frozenset({'rate'}), 'and through a `Sum` over it' + assert parameters_of(Sum(looked_up, (Axis('flow'),), dims=())) == frozenset({'rate'}), 'and through a `Sum` over it' def test_a_divisor_under_a_power_is_still_named(): """`children` had no branch for `Power`, so every walk stopped at it and a divisor written `d ** 2` was reported with no parameter at all (#403).""" - quotient = Divide(VariableReference('x'), Power(ParameterReference('d'), Constant(2.0))) + d, two = ParameterReference('d', dims=()), Constant(2.0, dims=()) + quotient = Divide(VariableReference('x', dims=()), Power(d, two, dims=()), dims=()) - assert children(quotient.divisor) == (ParameterReference('d'), Constant(2.0)), 'the base first, then the exponent' + assert children(quotient.divisor) == (d, two), 'the base first, then the exponent' assert parameters_of(quotient) == frozenset({'d'}), 'the walk descends through `Power`' OUTER = Mask(ParameterDefined('committable', ('g',))) INNER = Mask(ParameterDefined('flag', ('g',))) +X, P, ZERO, Q = ( + VariableReference('x', dims=('g',)), + VariableReference('p', dims=('g',)), + Constant(0.0, dims=()), + ParameterReference('q', dims=('g',)), +) NESTED = Add( - VariableReference('x'), + X, Cases( ( - Region(OUTER, Cases((Region(INNER, VariableReference('p')), Region(~INNER, Constant(0.0))))), - Region(~OUTER, ParameterReference('q')), - ) + Region(OUTER, Cases((Region(INNER, P), Region(~INNER, ZERO)), dims=('g',))), + Region(~OUTER, Q), + ), + dims=('g',), ), + dims=('g',), ) @@ -806,12 +856,12 @@ def test_walk_regions_carries_the_regions_a_node_stands_under(): and every consumer recursed for it on its own (#473).""" assert list(walk_regions(NESTED)) == [ (NESTED, ()), - (VariableReference('x'), ()), + (X, ()), (NESTED.right, ()), (NESTED.right.regions[0].value, (OUTER,)), - (VariableReference('p'), (OUTER, INNER)), - (Constant(0.0), (OUTER, ~INNER)), - (ParameterReference('q'), (~OUTER,)), + (P, (OUTER, INNER)), + (ZERO, (OUTER, ~INNER)), + (Q, (~OUTER,)), ], ( 'parents first; a node outside any `cases:` carries nothing; a `Cases` carries only the regions ' 'above it; a value under two `cases:` carries both, the outer one first' diff --git a/tests/test_piecewise.py b/tests/test_piecewise.py index 4097b966..bd188002 100644 --- a/tests/test_piecewise.py +++ b/tests/test_piecewise.py @@ -167,7 +167,7 @@ def test_a_program_mirrors_the_model_it_was_lowered_from(): assert [link.values for link in curve.links] == ['bp_x', 'bp_y'] and curve.frame == ('snapshot',), ( 'the curve as the file states it, with its links typed and its frame decided' ) - assert curve.links[0].expression == VariableReference('p'), 'a link is the tree the file wrote' + assert curve.links[0].expression == VariableReference('p', dims=('snapshot',)), 'a link is the tree the file wrote' assert 'cost_curve_lam' not in program.variables, 'the rows are on the expansion' assert not rows.piecewise and {'cost_curve_lam', 'p', 'op_cost'} <= set(rows.variables), ( 'the expansion carries the rows and no curve' From dbb17ea0609653e00888ff29c542c6eee870cce0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 14:29:56 +0000 Subject: [PATCH 06/13] docs(changelog): link the line to its pull request Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5835010b..47301042 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version -- feat(program): each expression node states the dimensions it is read over ([#PR](https://github.com/energy-models/mathspec/pull/PR)) +- feat(program): each expression node states the dimensions it is read over ([#899](https://github.com/energy-models/mathspec/pull/899)) - fix(language)!: a named expression whose `dims:` names a dimension its body does not carry is refused at load ([#898](https://github.com/energy-models/mathspec/pull/898)) - docs: the changelog is listed under Reference rather than About ([#895](https://github.com/energy-models/mathspec/pull/895)) - docs(pypsa): an extendable unit has a capacity to choose exactly where its active flag is set, as in PyPSA ([#886](https://github.com/energy-models/mathspec/pull/886)) From cc867009c14e994335bac7026c2be3c2e42bb42c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:13:54 +0000 Subject: [PATCH 07/13] refactor(dimensions): each node is held to its operator's dim rule as it is built Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf --- src/mathspec/_dimensions.py | 75 ++++++++++------------------ src/mathspec/_expression_resolver.py | 40 ++++++++------- src/mathspec/_piecewise.py | 3 +- src/mathspec/_where_resolver.py | 4 +- tests/test_dimensions.py | 43 ++++------------ tests/test_lowering.py | 4 +- tests/test_validation.py | 6 +-- 7 files changed, 67 insertions(+), 108 deletions(-) diff --git a/src/mathspec/_dimensions.py b/src/mathspec/_dimensions.py index f533e529..97ecdc79 100644 --- a/src/mathspec/_dimensions.py +++ b/src/mathspec/_dimensions.py @@ -4,10 +4,12 @@ """Static dim-set checking — a type system whose type is a set of dim names. -Every node's dim set is computable before any data is attached, so this pass runs -at load on the resolved tree. The per-node rules are the "Dim algebra" table in -``docs/reference/language/expressions.md``; a constraint's two sides together -must equal its ``dims``, and a where or a bound may not exceed the frame. +Every node states its dims, so every rule is decided at load. Resolution holds +each node to the rule of its operator as it builds the node ([`check_node`][]); +the rules are the "Dim algebra" table in +``docs/reference/language/expressions.md``. [`check_spec`][] holds each entry +to its own: a constraint's two sides together equal its ``dims``, and a where +or a bound may not exceed the frame. """ from __future__ import annotations @@ -16,28 +18,19 @@ from mathspec.errors import DimensionError, case_context from mathspec.program import ( - Add, Cases, - Constant, CountComparison, DimensionComparison, DimensionPosition, - Divide, - Dual, Expression, ExpressionComparison, - ExpressionReference, Join, JoinColumns, JoinedPredicate, Mask, - Multiply, - Negate, ParameterComparison, ParameterDefined, - ParameterReference, Partition, - Power, RelationComparison, RelationDefined, RelationPairComparison, @@ -45,9 +38,7 @@ Translate, TranslatedPredicate, VariableDefined, - VariableReference, WindowSum, - children, ) if TYPE_CHECKING: @@ -57,32 +48,22 @@ from mathspec.spec import Spec -def dims_of(node: Expression, spec: Spec, context: str) -> frozenset[str]: - """The dims a resolved expression states, with every rule under it checked on the way. +def check_node(node: Sum | Join | Translate | WindowSum, spec: Spec, context: str) -> None: + """The dim rule *node*'s own operator holds it to, on the frames its operands state. - A named expression's body is checked once, as its entry, by [`check_spec`][]. + Resolution calls it on each node it builds, so a tree is checked once, + bottom up, and no pass walks it again. Raises: - DimensionError: On the first rule broken. + DimensionError: *node* breaks the rule. """ - if isinstance(node, Constant | ParameterReference | VariableReference | Dual | ExpressionReference): - pass - elif isinstance(node, Cases): - for region in node.regions: - dims_of(region.value, spec, context) - elif isinstance(node, Negate | Add | Multiply | Power | Divide): - for child in children(node): - dims_of(child, spec, context) - elif isinstance(node, Sum): - _check_sum(node, spec, context) + inner = frozenset(node.operand.dims) + if isinstance(node, Sum): + _check_sum(node, inner, context) elif isinstance(node, Join): - assert not node.columns.axes, 'a join that opens axes is read only by the sum that closes them' - join_dims(node.columns, dims_of(node.operand, spec, context), context, 'the expression') - elif isinstance(node, Translate | WindowSum): - _check_translation(node, dims_of(node.operand, spec, context), spec, context) + join_dims(node.columns, inner, context, 'the expression') else: - assert_never(node) - return frozenset(node.dims) + _check_translation(node, inner, spec, context) def _not_carried(context: str, call: str, inner: frozenset[str], rewrite: str) -> str: @@ -90,18 +71,15 @@ def _not_carried(context: str, call: str, inner: frozenset[str], rewrite: str) - return f'{context}: {call} but the expression has only the dims {sorted(inner)}. {rewrite}.' -def _check_sum(node: Sum, spec: Spec, context: str) -> None: +def _check_sum(node: Sum, inner: frozenset[str], context: str) -> None: """``sum`` reduces each dim in ``over`` away, so the operand carries every one. The axes a join opens never reach a frame: the sum over the join closes every one, and the join's own rules are the sum's. """ if isinstance(node.operand, Join) and node.operand.columns.axes: - join = node.operand - assert node.over == join.columns.axes, 'a sum through a relation closes exactly the axes its join opens' - join_dims(join.columns, dims_of(join.operand, spec, context), context, 'the expression') + assert node.over == node.operand.columns.axes, 'a sum through a relation closes exactly the axes its join opens' return - inner = dims_of(node.operand, spec, context) for summed in node.over: if summed.dimension not in inner: raise DimensionError( @@ -247,11 +225,10 @@ def check_spec(spec: Spec, program: Program) -> None: for ename, entry in program.expressions.items(): declared = spec.expressions[ename] if declared.dims is None: - dims_of(entry.expression, spec, f"Named expression '{ename}'") continue frame = frozenset(declared.dims) if not isinstance(entry.expression, Cases): - carried = _check_body_dims(entry.expression, spec, frame, f"Named expression '{ename}'") + carried = _check_body_dims(entry.expression, frame, f"Named expression '{ename}'") _check_frame_is_carried(ename, frame, carried, 'the body does not carry') continue carried = frozenset[str]() @@ -259,7 +236,7 @@ def check_spec(spec: Spec, program: Program) -> None: context = case_context(ename, label) if label is not None: _check_where_dims(region.when, frame, context) - carried |= _check_value_dims(region.value, spec, frame, context) | region.when.dims + carried |= _check_value_dims(region.value, frame, context) | region.when.dims _check_frame_is_carried(ename, frame, carried, 'no case value and no `when:` carries') for ename, entry in program.expressions.items(): @@ -276,7 +253,7 @@ def check_spec(spec: Spec, program: Program) -> None: frame = frozenset(constraint.dims) context = f"Constraint '{cname}'" _check_where_dims(constraint.where, frame, context) - got = dims_of(constraint.lhs, spec, context) | dims_of(constraint.rhs, spec, context) + got = frozenset((*constraint.lhs.dims, *constraint.rhs.dims)) if got != frame: stray, missing = sorted(got - frame), sorted(frame - got) detail = ( @@ -289,7 +266,7 @@ def check_spec(spec: Spec, program: Program) -> None: if program.objective is not None: context = 'The objective' - got = dims_of(program.objective.expression, spec, context) + got = program.objective.expression.dims if got: raise DimensionError( f'{context}: the expression carries dims {sorted(got)}, and an objective is one ' @@ -298,13 +275,13 @@ def check_spec(spec: Spec, program: Program) -> None: ) -def _check_value_dims(node: Expression, spec: Spec, frame: frozenset[str], context: str) -> frozenset[str]: +def _check_value_dims(node: Expression, frame: frozenset[str], context: str) -> frozenset[str]: """A region's value may only carry dims the frame does — the ``otherwise:`` included. A wider one would give the quantity dims its entry does not, which is the second answer a ``dims:`` exists to avoid. """ - got = dims_of(node, spec, context) + got = frozenset(node.dims) if not got <= frame: raise DimensionError( f'{context}: the value carries dims {sorted(got - frame)} outside the dims: ' @@ -313,9 +290,9 @@ def _check_value_dims(node: Expression, spec: Spec, frame: frozenset[str], conte return got -def _check_body_dims(node: Expression, spec: Spec, frame: frozenset[str], context: str) -> frozenset[str]: +def _check_body_dims(node: Expression, frame: frozenset[str], context: str) -> frozenset[str]: """A plain entry's body may only carry dims its declared frame does; the dims it carries come back.""" - got = dims_of(node, spec, context) + got = frozenset(node.dims) if not got <= frame: raise DimensionError( f'{context}: the body carries dims {sorted(got - frame)} outside the dims: {sorted(frame)}. ' diff --git a/src/mathspec/_expression_resolver.py b/src/mathspec/_expression_resolver.py index 8e4c86dc..6821fcb0 100644 --- a/src/mathspec/_expression_resolver.py +++ b/src/mathspec/_expression_resolver.py @@ -15,7 +15,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, assert_never, cast -from mathspec._dimensions import dims_of, joined +from mathspec._dimensions import check_node, joined from mathspec._expression_parser import ( MAX_DEPTH, ArithmeticNode, @@ -297,26 +297,32 @@ def _sum(self, operand: Expression, over: tuple[str, ...] | None, columns: Colum if grouping is None: return None join, plain = grouping - grouped = self._summed(self._joined(operand, join), join.axes) - return self._summed(grouped, tuple(Axis(d) for d in plain)) if plain else grouped + if (through := self._joined(operand, join)) is None: + return None + grouped = self._summed(through, join.axes) + return self._summed(grouped, tuple(Axis(d) for d in plain)) if plain and grouped is not None else grouped - def _summed(self, operand: Expression, over: tuple[Axis, ...]) -> Sum: + def _summed(self, operand: Expression, over: tuple[Axis, ...]) -> Sum | None: """A [`Sum`][mathspec.program.Sum] over *over*, which removes the dims its plain axes name; a join's axes are no dims.""" closed = {axis.dimension for axis in over if axis.column is None} - return Sum(operand, over, dims=tuple(d for d in operand.dims if d not in closed)) + return self._checked(Sum(operand, over, dims=tuple(d for d in operand.dims if d not in closed))) - def _joined(self, operand: Expression, columns: JoinColumns) -> Join: + def _joined(self, operand: Expression, columns: JoinColumns) -> Join | None: """A [`Join`][mathspec.program.Join] of *operand* on *columns*, over the dims the join leaves.""" - return Join(operand, columns, dims=self.ns.framed(joined(columns, operand.dims))) + return self._checked(Join(operand, columns, dims=self.ns.framed(joined(columns, operand.dims)))) - def _at(self, operand: Expression, columns: ColumnsNode) -> Expression | None: - """``at(x, by=relation[column])``: *operand* read at the value of *columns* each row of the relation holds.""" + def _checked[N: Sum | Join | Translate | WindowSum](self, node: N) -> N | None: + """*node*, or ``None`` with its refusal appended where it breaks the dim rule of its operator.""" try: - inner = dims_of(operand, self.ns.spec, self.context) + check_node(node, self.ns.spec, self.context) except DimensionError as e: self.errors.append(str(e)) return None - join = self.lookup(columns, inner) + return node + + def _at(self, operand: Expression, columns: ColumnsNode) -> Expression | None: + """``at(x, by=relation[column])``: *operand* read at the value of *columns* each row of the relation holds.""" + join = self.lookup(columns, frozenset(operand.dims)) return None if join is None else self._joined(operand, join) def _translation( @@ -338,7 +344,9 @@ def _translation( assert offset is not None if not self._edge_fits(operand, offset, wrap=wrap, fill=fill): return None - return Translate(operand, along, offset, wrap=wrap, fill=fill, partition=partition, dims=operand.dims) + return self._checked( + Translate(operand, along, offset, wrap=wrap, fill=fill, partition=partition, dims=operand.dims) + ) if fill is not None: self.errors.append( f"{self.context}: sum_back(edge=...) takes 'wrap' or nothing. To add a constant, " @@ -347,15 +355,11 @@ def _translation( return None width = amounts['window'] assert width is not None - return WindowSum(operand, along, width, wrap=wrap, partition=partition, dims=operand.dims) + return self._checked(WindowSum(operand, along, width, wrap=wrap, partition=partition, dims=operand.dims)) def _bare_sum(self, operand: Expression) -> Expression | None: """``sum(x)`` with no ``over=`` or ``by=`` reduces every dim the operand carries, which it has to carry some of.""" - try: - inner = dims_of(operand, self.ns.spec, self.context) - except DimensionError as e: - self.errors.append(str(e)) - return None + inner = operand.dims if not inner: self.errors.append( f'{self.context}: sum() has no over= or by=, and its operand has no dimensions to sum over. ' diff --git a/src/mathspec/_piecewise.py b/src/mathspec/_piecewise.py index 19aa1e37..f3119e7e 100644 --- a/src/mathspec/_piecewise.py +++ b/src/mathspec/_piecewise.py @@ -19,7 +19,6 @@ from typing import TYPE_CHECKING, Literal import mathspec._sos as sos -from mathspec._dimensions import dims_of from mathspec._resolution import resolve_expression_text from mathspec.errors import DimensionError from mathspec.program import Piecewise, PiecewiseMethod, Variable, carries_variable @@ -317,7 +316,7 @@ def curve_frame(spec: Spec, name: str, pw: PiecewiseSpec, links: Iterable[Expres expression carries. """ context = f"piecewise '{name}'" - carried = [(f'link {i} expression', dims_of(node, spec, f'{context} link {i}')) for i, node in enumerate(links)] + carried = [(f'link {i} expression', frozenset(node.dims)) for i, node in enumerate(links)] if pw.activity is not None: carried.append(('activity', frozenset(spec.variables[pw.activity].dims))) frame: list[str] = [] diff --git a/src/mathspec/_where_resolver.py b/src/mathspec/_where_resolver.py index 304538d7..9b499fc0 100644 --- a/src/mathspec/_where_resolver.py +++ b/src/mathspec/_where_resolver.py @@ -18,7 +18,7 @@ from typing import TYPE_CHECKING, NamedTuple, assert_never, cast import mathspec._degree as degree -from mathspec._dimensions import dims_of, join_dims +from mathspec._dimensions import join_dims from mathspec._expansion import expand from mathspec._expression_parser import ( ArithmeticNode, @@ -408,7 +408,7 @@ def _expression_comparison(self, node: UnresolvedComparisonNode) -> ExpressionCo else: try: degree.check_expression(side, context) - dims |= dims_of(side, ns.spec, context) + dims |= set(side.dims) except LanguageError as e: self.errors.append(str(e)) if len(self.errors) > found: diff --git a/tests/test_dimensions.py b/tests/test_dimensions.py index 7b0a426a..bd218a08 100644 --- a/tests/test_dimensions.py +++ b/tests/test_dimensions.py @@ -14,7 +14,7 @@ import pytest -from mathspec._dimensions import DimensionError, _check_where_dims, dims_of +from mathspec._dimensions import DimensionError, _check_where_dims from mathspec._resolution import Namespace from mathspec._validation import to_spec from mathspec.errors import SchemaError @@ -93,7 +93,7 @@ def _spec(**overrides) -> Spec: def _dims(expr: str) -> frozenset[str]: s = _spec() - return dims_of(expression_of(expr, Namespace(s), 't'), s, 't') + return frozenset(expression_of(expr, Namespace(s), 't').dims) @pytest.fixture @@ -225,7 +225,7 @@ def test_dim_inference(expr, expected): def _dims_with(expr: str, **overrides) -> frozenset[str]: s = _spec(**overrides) - return dims_of(expression_of(expr, Namespace(s), 't'), s, 't') + return frozenset(expression_of(expr, Namespace(s), 't').dims) @pytest.mark.parametrize( @@ -285,7 +285,7 @@ def test_a_join_opens_an_axis_for_the_column_it_drops_and_the_sum_over_it_closes node = expression_of('sum(p, over=snapshot, by=rep_of[rep])', Namespace(s), 't') assert isinstance(node, Sum) and isinstance(node.operand, Join) assert node.over == (Axis('snapshot', Column('rep_of', 'snapshot')),), 'the sum stands over the axis the join opens' - assert dims_of(node, s, 't') == {'generator', 'snapshot'}, 'and the sum over it leaves the frame the row keeps' + assert set(node.dims) == {'generator', 'snapshot'}, 'and the sum over it leaves the frame the row keeps' def test_a_sum_joins_on_a_key_column_and_a_value_column_together(): @@ -307,114 +307,93 @@ def test_a_bare_name_reaches_the_variable_a_dual_the_same_named_constraint(): @pytest.mark.parametrize( - ('expr', 'error', 'match'), + ('expr', 'match'), [ pytest.param( 'sum(p, over=bus)', - DimensionError, r'sum\(over=bus\) but the expression has only the dims', id='sum-over-an-absent-dim-is-an-error-not-a-noop', ), pytest.param( 'sum(p, over=[generator, bus])', - DimensionError, r'sum\(over=bus\) but the expression has only the dims', id='and-so-is-one-absent-dim-in-a-list', ), pytest.param( 'sum(sum(p))', - SchemaError, r'its operand has no dimensions to sum over', id='a-bare-sum-of-a-scalar-is-an-error-not-a-noop', ), pytest.param( 'sum(sum(p, over=bus))', - SchemaError, r'sum\(over=bus\) but the expression has only the dims', id='a-dim-fault-under-a-bare-sum-is-met-while-the-sum-is-built', ), pytest.param( 'sum(sum(p, over=bus), over=generator)', - DimensionError, r'sum\(over=bus\) but the expression has only the dims', - id='and-the-same-fault-under-a-sum-over-a-named-dim-is-the-dim-rules', + id='and-the-same-fault-under-a-sum-over-a-named-dim', ), pytest.param( 'sum(load, over=generator, by=gen_bus[bus])', - DimensionError, r"sum\(by=gen_bus\[bus\]\) joins on \['generator'\] to sum it away", id='sum-requires-the-grouped-dim', ), pytest.param( "shift(cost, along=snapshot, offset=1, edge='wrap')", - DimensionError, r'shift\(along=snapshot\) but the expression has only the dims', id='shift-requires-the-dim', ), pytest.param( "shift(p, along=snapshot, offset=cost, edge='wrap')", - SchemaError, r'declared dtype: float', id='a-named-offset-is-integral-58', ), pytest.param( "shift(p, along=snapshot, offset=horizon, edge='wrap')", - DimensionError, r"Declare 'horizon' over dims without 'snapshot'", id='a-named-offset-does-not-span-the-axis-it-steps-along', ), pytest.param( 'sum_back(p, along=snapshot, window=cost)', - SchemaError, r'declared dtype: float', id='a-named-width-is-integral', ), pytest.param( 'sum_back(p, along=snapshot, window=horizon)', - DimensionError, r"sum_back\(window=horizon\) steps along 'snapshot'", id='a-named-width-does-not-span-the-summed-axis', ), pytest.param( "shift(p, along=snapshot, offset=-spinup, edge='wrap')", - SchemaError, r'negates a named offset', id='a-named-offset-is-not-negated-at-the-call-62', ), pytest.param( 'sum_back(p, along=snapshot, window=-spinup)', - SchemaError, r'A width has no sign', id='a-named-width-has-no-direction-to-negate', ), pytest.param( "shift(p, along=snapshot, offset=bus_lead, edge='wrap')", - DimensionError, r"varies over \['bus'\], which the expression does not carry", id='a-named-offset-is-read-where-the-expression-has-a-coordinate', ), pytest.param( 'sum(cost, over=generator, by=gen_zone[zone])', - DimensionError, r"sum\(by=gen_zone\[zone\]\) joins on \['snapshot'\]", id='a-grouped-sum-needs-the-keys-it-joins-on', ), pytest.param( "shift(cost, along=generator, offset=1, edge='wrap', within=gen_zone[zone])", - DimensionError, r"within=gen_zone\[zone\]\) joins on \['snapshot'\]", id='a-partition-needs-the-keys-it-joins-on', ), ], ) -def test_an_ill_dimensioned_expression_is_rejected(expr, error, match): - """The class says which pass refused: what resolution needs to build a node is a `SchemaError`, and a rule on a built tree's dims a `DimensionError`. - - A dim fault under a bare `sum()` is met while the sum is built, since the - dims it reduces are the operand's, and the same fault under a sum over a - named dim is met by the dim rules. - """ - with pytest.raises(error, match=match): +def test_an_ill_dimensioned_expression_is_rejected(expr, match): + """Each node is held to its operator's rule as it is built, so a dim fault is a `SchemaError` beside the others.""" + with pytest.raises(SchemaError, match=match): _dims(expr) @@ -430,7 +409,7 @@ def test_an_ill_dimensioned_expression_is_rejected(expr, error, match): ) def test_two_joined_columns_do_not_share_a_dimension(expr, diag): """The operand carries one coordinate per dimension, so the `over=` column and an unnamed key column cannot share one.""" - with pytest.raises(DimensionError, match=r"joins 'diag' on \[.*\] through more than one column"): + with pytest.raises(SchemaError, match=r"joins 'diag' on \[.*\] through more than one column"): _dims_with(expr, **{'relations.diag': diag}) @@ -808,7 +787,7 @@ def _expressions(root: object) -> Iterator[Expression]: @pytest.mark.parametrize('path', FRAMED_SPECS, ids=lambda path: str(path.relative_to(Path(__file__).parent.parent))) def test_every_expression_node_states_its_frame_in_declaration_order(path): - """A consumer read a node's frame by deriving it again, as `dims_of` did at load and threw away.""" + """A consumer read a node's frame by deriving it again, as the loader did to check it and then threw away.""" spec = spec_of(path) for loaded in (spec, spec.expand()): nodes = list(_expressions(loaded.program)) diff --git a/tests/test_lowering.py b/tests/test_lowering.py index 168f4c7e..a8bb98dc 100644 --- a/tests/test_lowering.py +++ b/tests/test_lowering.py @@ -88,7 +88,7 @@ LK_JOIN = JoinColumns('lk', LK, ('g',), ('h',)) AT_BUS = Relation((('g', 'g'), ('bus', 'bus')), ('g',)) -#: `fixtures.SMALL_MODEL` plus a second relation and a per-entity +#: `fixtures.SMALL_MODEL` plus a second relation and an integer #: offset. Which node a construct becomes is mostly a claim about the dim it #: joins on and the dim it groups by, and stating that needs a third dimension #: and two relations over one of them. @@ -97,7 +97,7 @@ **{ 'dimensions.z': {'dtype': 'str'}, 'relations.lk2': {'key': 'g', 'values': 'z'}, - 'parameters.lead': {'dims': ['g'], 'dtype': 'int'}, + 'parameters.lead': {'dims': [], 'dtype': 'int'}, }, ) diff --git a/tests/test_validation.py b/tests/test_validation.py index 452beb88..3cebe143 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -2341,10 +2341,10 @@ def record(*args, **kwargs): ) def test_a_plain_entry_that_breaks_a_dim_rule_is_refused_at_load_under_its_own_name(constraints): """An entry nothing read loaded and failed only when printed, and one a constraint read was - refused under the constraint's name. The program reads an entry's frame off its body at - load, so the fault is the entry's, wherever it is read.""" + refused under the constraint's name. Resolution checks each node as it builds the entry's + body, so the fault is the entry's, wherever it is read.""" model = varied(SMALL_MODEL, expressions={'bad': {'expression': 'sum(k, over=g)'}}, constraints=constraints) - with pytest.raises(DimensionError, match=r"^Named expression 'bad': sum\(over=g\)"): + with pytest.raises(SchemaError, match=r"(?m)^Named expression 'bad': sum\(over=g\)"): to_spec(model) From 152a73ab8007707a08f73c4f36ba48d613108f22 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 21:15:17 +0000 Subject: [PATCH 08/13] fix(dimensions): a dim fault resolution meets is a DimensionError again, listed with every other Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf --- src/mathspec/_expression_resolver.py | 2 +- src/mathspec/_lowering.py | 7 +-- src/mathspec/_resolution.py | 29 ++++++++++- src/mathspec/_where_resolver.py | 2 +- tests/fixtures.py | 7 ++- tests/test_dimensions.py | 75 +++++++++++++++++++++++++--- tests/test_validation.py | 2 +- 7 files changed, 107 insertions(+), 17 deletions(-) diff --git a/src/mathspec/_expression_resolver.py b/src/mathspec/_expression_resolver.py index 6821fcb0..a85d4295 100644 --- a/src/mathspec/_expression_resolver.py +++ b/src/mathspec/_expression_resolver.py @@ -316,7 +316,7 @@ def _checked[N: Sum | Join | Translate | WindowSum](self, node: N) -> N | None: try: check_node(node, self.ns.spec, self.context) except DimensionError as e: - self.errors.append(str(e)) + self.errors.append(self.ns.refused_dims(e)) return None return node diff --git a/src/mathspec/_lowering.py b/src/mathspec/_lowering.py index acb41baa..32f0d00a 100644 --- a/src/mathspec/_lowering.py +++ b/src/mathspec/_lowering.py @@ -97,8 +97,9 @@ def lower(spec: Spec) -> Program: SchemaError: Listing every problem found, one per line. A name a set or curve writes that the file declares is listed once every other problem is gone, since it is read off the curve as lowered. - DimensionError: The first dim rule an entry breaks, once every - name resolves. + DimensionError: Listing every dim rule an operator breaks, where + nothing else is wrong; else the first rule an entry breaks, once + every name resolves. """ errors = reference_errors(spec) if errors: @@ -187,7 +188,7 @@ def lower(spec: Spec) -> Program: curves[pname] = links if errors: - raise SchemaError('\n'.join(errors)) + raise ns.error(errors) roots = [side for c in constraints.values() for side in (c.lhs, c.rhs)] if objective is not None: diff --git a/src/mathspec/_resolution.py b/src/mathspec/_resolution.py index e825c25e..304c7a98 100644 --- a/src/mathspec/_resolution.py +++ b/src/mathspec/_resolution.py @@ -34,7 +34,7 @@ parse_where, ) from mathspec._where_resolver import WhereResolver -from mathspec.errors import LanguageError, SchemaError, case_context, prefixed +from mathspec.errors import DimensionError, LanguageError, SchemaError, case_context, prefixed from mathspec.program import ( BooleanLiteral, Cases, @@ -77,8 +77,10 @@ class Namespace: 'bodies', 'constraints', 'defaults', + 'dimension_refusals', 'dimensions', 'dtypes', + 'follow_ons', 'given_masks', 'leaf_dims', 'masks', @@ -141,6 +143,29 @@ def __init__(self, spec: Spec) -> None: #: mask -> its resolved node, or ``None``, and its refusals; filled #: the first time anything reads the name. self._masks: dict[str, tuple[MaskReference | None, tuple[str, ...]]] = {} + #: The refusals a dim rule wrote, which [`error`][] reports as a + #: ``DimensionError`` when nothing else is wrong. + self.dimension_refusals: set[str] = set() + #: The refusals that only point at an entry's own, which leave the + #: class of the error to that entry's. + self.follow_ons: set[str] = set() + + def refused_dims(self, refusal: DimensionError) -> str: + """*refusal*'s text, recorded as a dim rule's.""" + self.dimension_refusals.add(text := str(refusal)) + return text + + def error(self, errors: list[str]) -> SchemaError | DimensionError: + """The one error that lists every refusal in *errors*. + + It is a ``DimensionError`` where every fault is a dim rule's, so the + class says what is wrong wherever resolution met it, and a + ``SchemaError`` where anything else is. + """ + faults = [e for e in errors if e not in self.follow_ons] + if faults and all(e in self.dimension_refusals for e in faults): + return DimensionError('\n'.join(errors)) + return SchemaError('\n'.join(errors)) def framed(self, *frames: Iterable[str]) -> tuple[str, ...]: """The dims *frames* carry together, in the order ``dimensions:`` declares them: what a node states as ``dims``.""" @@ -161,6 +186,7 @@ def named(self, name: str, context: str) -> ExpressionReference: node, _ = self.named_entry(name) if node is None: msg = f"{context}: named expression '{name}' does not load. Fix the error listed for '{name}' first." + self.follow_ons.add(msg) raise SchemaError(msg) return node @@ -185,6 +211,7 @@ def mask(self, name: str, context: str) -> MaskReference: node, _ = self.mask_entry(name) if node is None: msg = f"{context}: mask '{name}' does not load. Its refusal is listed with it." + self.follow_ons.add(msg) raise SchemaError(msg) return node diff --git a/src/mathspec/_where_resolver.py b/src/mathspec/_where_resolver.py index 9b499fc0..b962d880 100644 --- a/src/mathspec/_where_resolver.py +++ b/src/mathspec/_where_resolver.py @@ -266,7 +266,7 @@ def _joined(self, node: UnresolvedPredicateCallNode, mask: Mask) -> Predicate | try: dims = join_dims(by, mask.dims, context, 'the predicate') except DimensionError as refusal: - self.errors.append(str(refusal)) + self.errors.append(self.ns.refused_dims(refusal)) return node return JoinedPredicate(mask, by, tuple(sorted(dims))) diff --git a/tests/fixtures.py b/tests/fixtures.py index 3ca8bb8f..1c3e32e9 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -15,7 +15,6 @@ from mathspec._resolution import Namespace, mask_of, resolve_expression, resolve_where_text from mathspec._validation import to_spec from mathspec._yaml import parse_yaml, read_yaml -from mathspec.errors import SchemaError from mathspec.spec import Spec if TYPE_CHECKING: @@ -131,7 +130,7 @@ def expression_of(text: str, ns: Namespace, context: str) -> Expression: assert not isinstance(ast, ComparisonNode), 'a comparison is a constraint, which comparison_of reads' resolved = resolve_expression(ast, ns, context, errors) if errors: - raise SchemaError('\n'.join(errors)) + raise ns.error(errors) assert resolved is not None return resolved @@ -143,7 +142,7 @@ def comparison_of(text: str, ns: Namespace, context: str) -> tuple[Expression, s assert isinstance(ast, ComparisonNode), 'a value is an expression, which expression_of reads' left, right = (resolve_expression(side, ns, context, errors) for side in (ast.left, ast.right)) if errors: - raise SchemaError('\n'.join(errors)) + raise ns.error(errors) assert left is not None and right is not None return left, ast.op, right @@ -153,5 +152,5 @@ def where_of(text: str | None, ns: Namespace, context: str, self_variable: str | errors: list[str] = [] resolved = resolve_where_text(text, ns, context, errors, self_variable) if errors: - raise SchemaError('\n'.join(errors)) + raise ns.error(errors) return mask_of(resolved) diff --git a/tests/test_dimensions.py b/tests/test_dimensions.py index bd218a08..a6c04c0e 100644 --- a/tests/test_dimensions.py +++ b/tests/test_dimensions.py @@ -307,93 +307,114 @@ def test_a_bare_name_reaches_the_variable_a_dual_the_same_named_constraint(): @pytest.mark.parametrize( - ('expr', 'match'), + ('expr', 'error', 'match'), [ pytest.param( 'sum(p, over=bus)', + DimensionError, r'sum\(over=bus\) but the expression has only the dims', id='sum-over-an-absent-dim-is-an-error-not-a-noop', ), pytest.param( 'sum(p, over=[generator, bus])', + DimensionError, r'sum\(over=bus\) but the expression has only the dims', id='and-so-is-one-absent-dim-in-a-list', ), pytest.param( 'sum(sum(p))', + SchemaError, r'its operand has no dimensions to sum over', id='a-bare-sum-of-a-scalar-is-an-error-not-a-noop', ), pytest.param( 'sum(sum(p, over=bus))', + DimensionError, r'sum\(over=bus\) but the expression has only the dims', - id='a-dim-fault-under-a-bare-sum-is-met-while-the-sum-is-built', + id='a-dim-fault-under-a-bare-sum', ), pytest.param( 'sum(sum(p, over=bus), over=generator)', + DimensionError, r'sum\(over=bus\) but the expression has only the dims', id='and-the-same-fault-under-a-sum-over-a-named-dim', ), pytest.param( 'sum(load, over=generator, by=gen_bus[bus])', + DimensionError, r"sum\(by=gen_bus\[bus\]\) joins on \['generator'\] to sum it away", id='sum-requires-the-grouped-dim', ), pytest.param( "shift(cost, along=snapshot, offset=1, edge='wrap')", + DimensionError, r'shift\(along=snapshot\) but the expression has only the dims', id='shift-requires-the-dim', ), pytest.param( "shift(p, along=snapshot, offset=cost, edge='wrap')", + SchemaError, r'declared dtype: float', id='a-named-offset-is-integral-58', ), pytest.param( "shift(p, along=snapshot, offset=horizon, edge='wrap')", + DimensionError, r"Declare 'horizon' over dims without 'snapshot'", id='a-named-offset-does-not-span-the-axis-it-steps-along', ), pytest.param( 'sum_back(p, along=snapshot, window=cost)', + SchemaError, r'declared dtype: float', id='a-named-width-is-integral', ), pytest.param( 'sum_back(p, along=snapshot, window=horizon)', + DimensionError, r"sum_back\(window=horizon\) steps along 'snapshot'", id='a-named-width-does-not-span-the-summed-axis', ), pytest.param( "shift(p, along=snapshot, offset=-spinup, edge='wrap')", + SchemaError, r'negates a named offset', id='a-named-offset-is-not-negated-at-the-call-62', ), pytest.param( 'sum_back(p, along=snapshot, window=-spinup)', + SchemaError, r'A width has no sign', id='a-named-width-has-no-direction-to-negate', ), pytest.param( "shift(p, along=snapshot, offset=bus_lead, edge='wrap')", + DimensionError, r"varies over \['bus'\], which the expression does not carry", id='a-named-offset-is-read-where-the-expression-has-a-coordinate', ), pytest.param( 'sum(cost, over=generator, by=gen_zone[zone])', + DimensionError, r"sum\(by=gen_zone\[zone\]\) joins on \['snapshot'\]", id='a-grouped-sum-needs-the-keys-it-joins-on', ), pytest.param( "shift(cost, along=generator, offset=1, edge='wrap', within=gen_zone[zone])", + DimensionError, r"within=gen_zone\[zone\]\) joins on \['snapshot'\]", id='a-partition-needs-the-keys-it-joins-on', ), ], ) -def test_an_ill_dimensioned_expression_is_rejected(expr, match): - """Each node is held to its operator's rule as it is built, so a dim fault is a `SchemaError` beside the others.""" - with pytest.raises(SchemaError, match=match): +def test_an_ill_dimensioned_expression_is_rejected(expr, error, match): + """The class says what is wrong: a dim rule broken is a `DimensionError`, and anything else a `SchemaError`. + + A dim fault under a bare `sum()` was a `SchemaError`, because the bare sum + met it while it was built, and the same fault under a sum over a named dim + was a `DimensionError`. + """ + with pytest.raises(error, match=match): _dims(expr) @@ -409,7 +430,7 @@ def test_an_ill_dimensioned_expression_is_rejected(expr, match): ) def test_two_joined_columns_do_not_share_a_dimension(expr, diag): """The operand carries one coordinate per dimension, so the `over=` column and an unnamed key column cannot share one.""" - with pytest.raises(SchemaError, match=r"joins 'diag' on \[.*\] through more than one column"): + with pytest.raises(DimensionError, match=r"joins 'diag' on \[.*\] through more than one column"): _dims_with(expr, **{'relations.diag': diag}) @@ -798,3 +819,45 @@ def test_every_expression_node_states_its_frame_in_declaration_order(path): f'{type(node).__name__} in {path.name} states {node.dims}, and the rules give {sorted(derived)} in ' f'the order dimensions: declares' ) + + +#: Two operator faults, each in an entry of its own. +TWO_DIM_FAULTS = { + 'constraints.over_bus': {'dims': [], 'expression': 'sum(sum(p, over=bus), over=generator) <= 1'}, + 'constraints.along_bus': { + 'dims': ['generator'], + 'expression': "shift(cost, along=snapshot, offset=1, edge='wrap') <= 1", + }, +} + + +@pytest.mark.parametrize( + ('patch', 'error', 'listed'), + [ + pytest.param(TWO_DIM_FAULTS, DimensionError, ['sum(over=bus)', 'shift(along=snapshot)'], id='two-dim-faults'), + pytest.param( + {**TWO_DIM_FAULTS, 'constraints.typo': {'dims': [], 'expression': 'sum(nothing) <= 1'}}, + SchemaError, + ['sum(over=bus)', 'shift(along=snapshot)', "'nothing'"], + id='and-a-name-nothing-declares', + ), + pytest.param( + { + 'constraints.read': { + 'dims': ['generator'], + 'where': 'at(cost > 0, by=gen_bus[bus])', + 'expression': 'sum(p, over=snapshot) <= 1', + } + }, + DimensionError, + ["joins on ['bus'], which the predicate does not carry"], + id='a-predicate-read-through-a-dim-it-lacks', + ), + ], +) +def test_every_fault_resolution_meets_is_listed_under_the_class_of_what_is_wrong(patch, error, listed): + """A dim fault met while a node was built was a `SchemaError`, and the same fault met later a `DimensionError`.""" + with pytest.raises(error) as raised: + _spec(**patch) + missing = [text for text in listed if text not in str(raised.value)] + assert not missing, f'every fault is listed, not the first: {missing} are absent' diff --git a/tests/test_validation.py b/tests/test_validation.py index 3cebe143..256c32dd 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -2344,7 +2344,7 @@ def test_a_plain_entry_that_breaks_a_dim_rule_is_refused_at_load_under_its_own_n refused under the constraint's name. Resolution checks each node as it builds the entry's body, so the fault is the entry's, wherever it is read.""" model = varied(SMALL_MODEL, expressions={'bad': {'expression': 'sum(k, over=g)'}}, constraints=constraints) - with pytest.raises(SchemaError, match=r"(?m)^Named expression 'bad': sum\(over=g\)"): + with pytest.raises(DimensionError, match=r"(?m)^Named expression 'bad': sum\(over=g\)"): to_spec(model) From d1bd5f050c804a6c063d35d550f2a74047a0ffc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 21:19:09 +0000 Subject: [PATCH 09/13] docs(errors): say which class a load with several faults raises Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf --- docs/reference/language/errors.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/reference/language/errors.md b/docs/reference/language/errors.md index c4e6bd19..0c3d1d25 100644 --- a/docs/reference/language/errors.md +++ b/docs/reference/language/errors.md @@ -57,6 +57,10 @@ variable with no constraint row. | `SchemaError` | `LanguageError` | Something in the file: an unknown key, a malformed entry, or a bad symbol table | | `DimensionError` | `LanguageError` | Dimensions that disagree, such as a constraint whose expression does not equal its `dims` | +One load lists every fault it finds in the file, one to a line. The error is a +`DimensionError` when every fault breaks a dimension rule, and a `SchemaError` +when any fault is of another kind. + You can reproduce each of these errors from the YAML alone. ## What the language will not express From 8f75e7b132942114babb550f033d34991dbc3e63 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 21:19:34 +0000 Subject: [PATCH 10/13] docs(errors): a rule on a whole entry still names the first entry that breaks it Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf --- docs/reference/language/errors.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/reference/language/errors.md b/docs/reference/language/errors.md index 0c3d1d25..46eb6639 100644 --- a/docs/reference/language/errors.md +++ b/docs/reference/language/errors.md @@ -57,9 +57,12 @@ variable with no constraint row. | `SchemaError` | `LanguageError` | Something in the file: an unknown key, a malformed entry, or a bad symbol table | | `DimensionError` | `LanguageError` | Dimensions that disagree, such as a constraint whose expression does not equal its `dims` | -One load lists every fault it finds in the file, one to a line. The error is a -`DimensionError` when every fault breaks a dimension rule, and a `SchemaError` -when any fault is of another kind. +A load reads every expression and `where:` in the file, and then lists each +fault it found, one to a line. The error is a `DimensionError` when every fault +breaks a dimension rule, and a `SchemaError` when any fault is of another kind. +After that, the load checks the rules on whole entries, such as a constraint +whose expression does not carry its `dims:`. That `DimensionError` names the +first entry that breaks a rule. You can reproduce each of these errors from the YAML alone. From b00f562df86299655281f4828008ece30b62ca0d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:46:28 +0000 Subject: [PATCH 11/13] docs(language): say which dimensions a named expression is indexed over, rather than its frame Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf --- CHANGELOG.md | 2 +- docs/reference/language/named.md | 41 ++++++++++--------- .../parameters-variables-constraints.md | 7 ++-- docs/reference/notation.md | 4 +- schema/mathspec.schema.json | 2 +- src/mathspec/_composition.py | 16 ++++---- src/mathspec/_dimensions.py | 33 +++++++-------- src/mathspec/spec.py | 11 ++--- tests/test_dimensions.py | 21 +++++----- tests/test_terms.py | 11 ++--- tests/typesetting/golden/model.yaml | 4 +- 11 files changed, 80 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a08e62d..70d85bdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version -- fix(language)!: a named expression whose `dims:` names a dimension its body does not carry is refused at load ([#898](https://github.com/energy-models/mathspec/pull/898)) +- fix(language)!: a named expression whose `dims:` gives a dimension its expression is not indexed over is refused at load ([#898](https://github.com/energy-models/mathspec/pull/898)) - docs: the changelog is listed under Reference rather than About ([#895](https://github.com/energy-models/mathspec/pull/895)) - docs(pypsa): an extendable unit has a capacity to choose exactly where its active flag is set, as in PyPSA ([#886](https://github.com/energy-models/mathspec/pull/886)) - refactor(api)!: a module outside spec, program and errors has a name that starts with an underscore ([#878](https://github.com/energy-models/mathspec/pull/878)) diff --git a/docs/reference/language/named.md b/docs/reference/language/named.md index 1749d18e..7ecf2b27 100644 --- a/docs/reference/language/named.md +++ b/docs/reference/language/named.md @@ -30,12 +30,14 @@ expressions: ``` An entry is a bare string, or a mapping with a `description:` and a `dims:`. -The `dims:` are the **frame**, the dimensions the quantity is read over. When -`dims:` is left out, the body decides the frame. When `dims:` is declared, it -sets the order of the indices, and it names the dimensions the body carries. -The loader refuses a body that carries a dimension the frame does not name, and -a frame that names a dimension the body does not carry. An expression that -reads the entry broadcasts it along the rest of its own dimensions. +The `dims:` are the dimensions over which the quantity is indexed. When +`dims:` is left out, the expression itself decides them. When `dims:` is +declared, it sets the order of the indices, and it names the dimensions over +which the expression is indexed. The loader refuses an expression that is +indexed over a dimension not given in `dims:`, and a `dims:` that gives a +dimension over which the expression is not indexed. An expression that reads +the entry repeats its value along each of its own dimensions that the entry is +not indexed over. ```yaml expressions: @@ -89,11 +91,11 @@ $$\mathit{previous\_status}_{t,g} = \begin{cases} 1 & \text{if } \neg \mathrm{co A named expression carries **exactly one** of `expression:` and `cases:`. -| Key | | -| ----------- | ---------------------------------------------------------------------------- | -| `dims` | required with `cases:`. The **frame**: the dimensions every case ranges over | -| `cases` | a map of named cases, each with a `when:` mask and an `expression:` | -| `otherwise` | required. The value at every coordinate the cases leave | +| Key | | +| ----------- | ----------------------------------------------------------------------- | +| `dims` | required with `cases:`. The dimensions over which every case is indexed | +| `cases` | a map of named cases, each with a `when:` mask and an `expression:` | +| `otherwise` | required. The value at every coordinate the cases leave | ### The rules that keep the cases apart @@ -120,9 +122,10 @@ A named expression carries **exactly one** of `expression:` and `cases:`. `cases:` with one case may compare expressions, because its `otherwise:` claims only what the case leaves. -- Each `when:` and each value sits inside the frame, and a narrower case - broadcasts as a parameter with fewer dimensions does. Each dimension of the - frame is carried by a value or a `when:`. +- Each `when:` and each value is indexed only over dimensions given in + `dims:`. A value indexed over fewer of them is repeated along the others, + the same as a parameter with fewer dimensions. Each dimension in `dims:` is + one over which a value or a `when:` is indexed. A claimed coordinate can still have no value: the `otherwise:` above has none at the first snapshot, where a case claims every unit. To give such a coordinate a @@ -240,11 +243,11 @@ An entry with no `description:` may be the bare `where` string. - A bare mask name stands for its predicate in a `where:`, a `when:` and a `holds:`, under `NOT`, and inside `count`, `shift` and `at`. It selects the same rows as the predicate written out. -- The predicate gives the frame, so a mask declares no `dims:`. A mask runs - over the dimensions its predicate reads, in the order `dimensions:` declares - them, and the symbol prints with those indices. A use over more dimensions - reads the mask as constant along the rest, and `shift` reads it back only - along a dimension of its frame. +- A mask declares no `dims:`. It is indexed over the dimensions its predicate + reads, in the order `dimensions:` declares them, and the symbol prints with + those indices. A use over more dimensions repeats the mask along the others, + and `shift` reads it back only along a dimension over which the mask is + indexed. - A mask may read another mask, and a named expression that reads only data. The loader refuses a cycle, and the message names its chain: diff --git a/docs/reference/language/parameters-variables-constraints.md b/docs/reference/language/parameters-variables-constraints.md index d6cd3f5b..76ca5c83 100644 --- a/docs/reference/language/parameters-variables-constraints.md +++ b/docs/reference/language/parameters-variables-constraints.md @@ -377,9 +377,10 @@ added like any other. terms for, and a later merge adds to that body. So the first merge that holds the terms of a sum also holds a file that reads it. - Where no file defines the sum, the terms are its body. Every reader writes - the dims in the same order. The sum runs over the dims of that frame that a - term carries, and it takes the first description a reader wrote. A reader's - row over a dim that no term carries is refused, as it is over a definition. + the dims in the same order. The sum is indexed over each of those dims over + which a term is indexed, and it takes the first description a reader wrote. + A reader's row over a dimension over which no term is indexed is refused, as + it is over a definition. - Where no file defines the sum, some file reads it for more than adding to it. That file reads the sum and adds nothing, or uses it in its math. Terms that only their own files read are refused, and the message names the nearest name, diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 2a15721c..8584033c 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -669,7 +669,7 @@ p_{t,g} \le \mathrm{startup\_cost}_{t,g} \qquad \forall\, t \in \mathcal{T},\ g #### Expression with a declared frame in a constraint -names the expression with a declared frame, read in the row's own order +names the expression with declared dims, read in the row's own order ```yaml constraints: @@ -698,7 +698,7 @@ expressions: #### Named expression with a declared frame -a declared frame in an order of its own, which the body carries whole +dims declared in an order of their own, each one the expression is indexed over ```yaml expressions: diff --git a/schema/mathspec.schema.json b/schema/mathspec.schema.json index 746933f9..891d4c65 100644 --- a/schema/mathspec.schema.json +++ b/schema/mathspec.schema.json @@ -198,7 +198,7 @@ "anyOf": [ { "additionalProperties": false, - "description": "A named quantity: one arithmetic expression, referenced by the math or read back after a solve.\n\nWritten in YAML as a bare string, or as a mapping once it carries a\n``description:`` \u2014 and serialised back to whichever form it was written in,\nso a round trip through [`Spec.to_yaml`][] reproduces the file::\n\n expressions:\n total_generation: sum(p, over=generator)\n emissions:\n expression: sum(p * rate, over=generator)\n description: CO2 released, the quantity the cap bounds\n\n``dims:`` declares the frame the quantity is read over. A plain entry may\nleave it out, and its body then decides the frame. A declared frame is\nwhat the body carries: a body that carries a dimension the frame does not\nname is refused, and so is a frame that names one the body does not\ncarry. A quantity whose value varies by region is\nwritten as ``cases:`` over a declared ``dims:``, with an ``otherwise:``\nfor the rest \u2014 see the language reference.\n\n``adds_to:`` adds this entry as a term to the sum it names, a\n``given: expressions:`` entry of this file: a write, where the given entry\nis the read.\n[`merge`][mathspec.merge] adds every term to that name by\nname, after the body a file defines where one does. The term is read over at most the\nframe the given entry states, and does not read the name it adds to.", + "description": "A named quantity: one arithmetic expression, referenced by the math or read back after a solve.\n\nWritten in YAML as a bare string, or as a mapping once it carries a\n``description:`` \u2014 and serialised back to whichever form it was written in,\nso a round trip through [`Spec.to_yaml`][] reproduces the file::\n\n expressions:\n total_generation: sum(p, over=generator)\n emissions:\n expression: sum(p * rate, over=generator)\n description: CO2 released, the quantity the cap bounds\n\n``dims:`` gives the dimensions over which the quantity is indexed. A plain\nentry may leave it out, and its expression then decides them. A declared\n``dims:`` gives exactly the dimensions over which the expression is\nindexed: an expression indexed over a dimension ``dims:`` does not give is\nrefused, and so is a ``dims:`` that gives one over which the expression is\nnot indexed. A quantity whose value varies by region is\nwritten as ``cases:`` over a declared ``dims:``, with an ``otherwise:``\nfor the rest \u2014 see the language reference.\n\n``adds_to:`` adds this entry as a term to the sum it names, a\n``given: expressions:`` entry of this file: a write, where the given entry\nis the read.\n[`merge`][mathspec.merge] adds every term to that name by\nname, after the body a file defines where one does. The term is read over at most the\nframe the given entry states, and does not read the name it adds to.", "properties": { "adds_to": { "anyOf": [ diff --git a/src/mathspec/_composition.py b/src/mathspec/_composition.py index 1e87eac2..747cd1d3 100644 --- a/src/mathspec/_composition.py +++ b/src/mathspec/_composition.py @@ -42,9 +42,9 @@ body followed by every term, in the order the fragments are given in, so a composed spec takes more terms in a later merge. A definition written as ``cases:`` takes no term. Where no fragment defines the - name, the composed spec defines it as its terms, over the dims of the - frame the readers state in one order that a term carries. Some fragment - then has to read the name for more than adding to it: read it without + name, the composed spec defines it as its terms, indexed over each dim + the readers state, in their one order, that a term is indexed over. Some + fragment then has to read the name for more than adding to it: read it without adding to it, or use it in its math. A name only its terms read, or only one fragment reads, is what a misspelt ``given:`` entry looks like, so it is refused. A term that reads its own sum through another fragment is refused, both named. @@ -380,9 +380,9 @@ def _summed( The terms join with a plain ``+``, the loosest operator there is, so a sum composed in two merges reads as the one composed in one. Where no - fragment defines the name, the terms are the body, over the dims of the - readers' frame that a term carries, in the readers' order; the composed - load holds every reader to that frame, as it does to a definer's. + fragment defines the name, the terms are the body, indexed over each dim + the readers state that a term is indexed over, in the readers' order; the + composed load holds every reader to those dims, as it does to a definer's. """ summed: dict[str, object] = {} defined = _mapping(merged.get('expressions')) @@ -398,9 +398,9 @@ def _summed( summed[key] = {**base, 'expression': ' + '.join([cast('str', base['expression']), *names])} continue _read_elsewhere(loaded, key, contributors) - carried = _definer_frame(loaded, key) + indexed = _definer_frame(loaded, key) entry: dict[str, object] = { - 'dims': [d for d in _frame(read, key) if d in carried], + 'dims': [d for d in _frame(read, key) if d in indexed], 'expression': ' + '.join(names), } if said := _mapping(reading).get('description'): diff --git a/src/mathspec/_dimensions.py b/src/mathspec/_dimensions.py index 9584f016..4cbb1017 100644 --- a/src/mathspec/_dimensions.py +++ b/src/mathspec/_dimensions.py @@ -96,7 +96,7 @@ def dims_of(node: Expression, spec: Spec, context: str) -> frozenset[str]: def _named_dims(node: ExpressionReference, spec: Spec, context: str) -> frozenset[str]: - """An entry's declared frame where it has one, else its body's.""" + """The dims an entry declares where it declares them, else those of its expression.""" declared = spec.expressions[node.name].dims if declared is not None: return frozenset(declared) @@ -264,16 +264,16 @@ def check_spec(spec: Spec, program: Program) -> None: continue frame = frozenset(declared.dims) if not isinstance(entry.expression, Cases): - carried = _check_body_dims(entry.expression, spec, frame, f"Named expression '{ename}'") - _check_frame_is_carried(ename, frame, carried, 'the body does not carry') + indexed = _check_body_dims(entry.expression, spec, frame, f"Named expression '{ename}'") + _check_dims_are_indexed(ename, frame, indexed, 'the expression is not') continue - carried = frozenset[str]() + indexed = frozenset[str]() for region, label in zip(entry.expression.regions, [*declared.cases, None], strict=True): context = case_context(ename, label) if label is not None: _check_where_dims(region.when, frame, context) - carried |= _check_value_dims(region.value, spec, frame, context) | region.when.dims - _check_frame_is_carried(ename, frame, carried, 'no case value and no `when:` carries') + indexed |= _check_value_dims(region.value, spec, frame, context) | region.when.dims + _check_dims_are_indexed(ename, frame, indexed, 'no case value and no `when:` is') for ename, entry in program.expressions.items(): if entry.adds_to is None: @@ -327,7 +327,7 @@ def _check_value_dims(node: Expression, spec: Spec, frame: frozenset[str], conte def _check_body_dims(node: Expression, spec: Spec, frame: frozenset[str], context: str) -> frozenset[str]: - """A plain entry's body may only carry dims its declared frame does; the dims it carries come back.""" + """A plain entry's expression is indexed only over dims its ``dims:`` gives; the dims it is indexed over come back.""" got = dims_of(node, spec, context) if not got <= frame: raise DimensionError( @@ -337,18 +337,19 @@ def _check_body_dims(node: Expression, spec: Spec, frame: frozenset[str], contex return got -def _check_frame_is_carried(name: str, frame: frozenset[str], carried: frozenset[str], carrier: str) -> None: - """A declared frame names only dims the body carries, so the entry stands for its body wherever it is read. +def _check_dims_are_indexed(name: str, declared: frozenset[str], indexed: frozenset[str], subject: str) -> None: + """A declared ``dims:`` gives only dims the expression is indexed over, so the entry stands for its expression. - A dim the body does not carry would make the entry constant along it, and - then the entry would not be its body: ``sum(entry, over=d)`` would load - where the body summed over ``d`` is refused. + A dim the expression is not indexed over would make the entry constant + along it, and then the entry would not be its expression: + ``sum(entry, over=d)`` would load where the expression summed over ``d`` + is refused. """ - if extra := sorted(frame - carried): + if extra := sorted(declared - indexed): raise DimensionError( - f"Named expression '{name}': dims: names {extra}, which {carrier}. A named expression stands for " - f'its body, so its frame is what the body carries. Take {extra} out of dims:; an expression that ' - f"reads '{name}' still broadcasts it along the rest of its own dims." + f"Named expression '{name}': dims: gives {extra}, but {subject} indexed over {extra}. A named " + f'expression stands for its expression, so dims: gives only the dimensions it is indexed over. Take ' + f"{extra} out of dims:; an expression that reads '{name}' still repeats it along its own other dims." ) diff --git a/src/mathspec/spec.py b/src/mathspec/spec.py index 9a216362..e38d80f7 100644 --- a/src/mathspec/spec.py +++ b/src/mathspec/spec.py @@ -638,11 +638,12 @@ class ExpressionSpec(_StrictSpec): expression: sum(p * rate, over=generator) description: CO2 released, the quantity the cap bounds - ``dims:`` declares the frame the quantity is read over. A plain entry may - leave it out, and its body then decides the frame. A declared frame is - what the body carries: a body that carries a dimension the frame does not - name is refused, and so is a frame that names one the body does not - carry. A quantity whose value varies by region is + ``dims:`` gives the dimensions over which the quantity is indexed. A plain + entry may leave it out, and its expression then decides them. A declared + ``dims:`` gives exactly the dimensions over which the expression is + indexed: an expression indexed over a dimension ``dims:`` does not give is + refused, and so is a ``dims:`` that gives one over which the expression is + not indexed. A quantity whose value varies by region is written as ``cases:`` over a declared ``dims:``, with an ``otherwise:`` for the rest — see the language reference. diff --git a/tests/test_dimensions.py b/tests/test_dimensions.py index 4f5264c4..5c80a19e 100644 --- a/tests/test_dimensions.py +++ b/tests/test_dimensions.py @@ -665,7 +665,7 @@ def test_names_read_takes_both_sides_of_a_relation_pair(): # a named expression's declared frame # --------------------------------------------------------------------------- -#: A quantity over every snapshot and generator, with its frame declared. +#: A quantity over every snapshot and generator, with its dims declared. FRAMED = varied(DISPATCH_MODEL, **{'expressions.limit': {'dims': ['snapshot', 'generator'], 'expression': 'p * p_max'}}) @@ -685,13 +685,13 @@ def test_a_body_outside_its_declared_frame_is_refused(): @pytest.mark.parametrize( - ('patch', 'extra', 'carrier'), + ('patch', 'extra', 'subject'), [ pytest.param( {'expressions.limit': {'dims': ['snapshot', 'generator'], 'expression': 'p_max'}}, "['snapshot']", - 'the body does not carry', - id='a-plain-body', + 'the expression is not', + id='a-plain-expression', ), pytest.param( { @@ -703,20 +703,21 @@ def test_a_body_outside_its_declared_frame_is_refused(): }, }, "['snapshot']", - 'no case value and no `when:` carries', + 'no case value and no `when:` is', id='cases', ), ], ) -def test_a_declared_frame_wider_than_the_body_is_refused(patch, extra, carrier): - """A frame wider than the body loaded, and a reference read the body as constant along the rest. +def test_a_dims_that_gives_a_dim_the_expression_is_not_indexed_over_is_refused(patch, extra, subject): + """Such a `dims:` loaded, and a reference read the expression as constant along the extra dim. - Substituted where the name stands, the body did not load: `sum(limit, over=snapshot)` loaded where - `sum(p_max, over=snapshot)` is refused, and specsolve could not build it. In a constraint the wider frame + Substituted where the name stands, the expression did not load: `sum(limit, over=snapshot)` loaded where + `sum(p_max, over=snapshot)` is refused, and specsolve could not build it. In a constraint the extra dim only built the same row once per snapshot. """ with pytest.raises( - DimensionError, match=re.escape(f"Named expression 'limit': dims: names {extra}, which {carrier}") + DimensionError, + match=re.escape(f"Named expression 'limit': dims: gives {extra}, but {subject} indexed over {extra}"), ): to_spec(varied(DISPATCH_MODEL, **patch)) diff --git a/tests/test_terms.py b/tests/test_terms.py index 0c5eb96d..d5668e70 100644 --- a/tests/test_terms.py +++ b/tests/test_terms.py @@ -542,12 +542,13 @@ def test_the_composed_sum_is_read_over_the_readers_frame(): assert merge([BALANCE, FLEET, DEMAND]).expressions['injection'].dims == BUS_FRAME -def test_a_composed_sum_takes_no_dim_its_terms_do_not_carry(): - """A sum built only from terms took the readers' frame whole, and broadcast along a dim no term carried. +def test_a_composed_sum_is_indexed_only_over_dims_a_term_is_indexed_over(): + """A sum built only from terms took every dim its readers state, and repeated along one no term is indexed over. - With every term over `bus` alone, the balance built the same row once per snapshot, and once a frame had - to be carried, the composed sum was refused for a `dims:` no file wrote. The sum keeps the dims its terms carry, so the - balance is refused as it is over a definer that carries only `bus`. + With every term over `bus` alone, the balance built the same row once per snapshot, and once a named + expression had to be indexed over each dim of its `dims:`, the composed sum was refused for a `dims:` no + file wrote. The sum keeps the dims its terms are indexed over, so the balance is refused as it is over a + definer indexed over `bus` alone. """ fixed = { 'dimensions': BUS_DIMS, diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index 6b7a4327..97f27aac 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -144,7 +144,7 @@ expressions: spend: # a plain named expression: its symbol prints where it is used, its body once as a definition description: what a snapshot's dispatch costs expression: sum(p * cost, over=generator) - rating: # a declared frame in an order of its own, which the body carries whole + rating: # dims declared in an order of their own, each one the expression is indexed over dims: [generator, snapshot] expression: eta * p_max * sum(load, over=bus) lcoe: sum(p * cost) / sum(p) # nothing in the math reads it, so its divisor may carry a variable @@ -173,7 +173,7 @@ constraints: starts: # names the cased expression: its symbol prints here, its block once below dims: [snapshot, generator] expression: p <= startup_cost - under_rating: # names the expression with a declared frame, read in the row's own order + under_rating: # names the expression with declared dims, read in the row's own order dims: [snapshot, generator] expression: p <= rating balance: # sum over a relation From 30ab0b1849119e0bf08301839ae6d006aee3ba2c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 14:02:19 +0000 Subject: [PATCH 12/13] docs(reading): a node states its dims in the order dimensions: declares, whatever order its entry declares Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A2crTbivTGt2WzGpTebdmf --- docs/reference/reading.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/reference/reading.md b/docs/reference/reading.md index 077eae59..d5465849 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -152,9 +152,14 @@ the order `dimensions:` declares them. A sum has the dimensions its operand has, less the ones it sums over. A `Join` under a sum through a relation has the dimensions of that sum. A `Join` also opens one `Axis` for each column it drops, but an axis is not a dimension and is not in `dims`. An -`ExpressionReference` has the dimensions of its body. The order of an entry's -own `dims` can be different: a named expression keeps the order its `dims:` -declares. +`ExpressionReference` has the dimensions of its body. + +A node's `dims` are always in the order `dimensions:` declares, also where a +named expression declares its `dims:` in another order. The entry keeps the +order of its `dims:`. With `dimensions:` in the order `snapshot`, `generator`, +an entry that declares `dims: [generator, snapshot]` has +`program.expressions[name].dims == ('generator', 'snapshot')`, and each +`ExpressionReference` to it has `dims == ('snapshot', 'generator')`. A `MaskReference` stands where a `masks:` entry is read. Its `body` is the predicate of the entry, the root of `program.masks[name].where`, and it is true From 677fcba68830198326dc06da332269363c839034 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:40:23 +0000 Subject: [PATCH 13/13] feat(program): a node states its dims sorted by name The order of dimensions: no longer reaches a node, which is the rule #911 set for the dims the loader derives. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01D2S9sN1jLGGMCCwqwVyGD6 --- docs/reference/reading.md | 15 +++++++-------- src/mathspec/_dimensions.py | 5 +++++ src/mathspec/_expression_resolver.py | 12 ++++++------ src/mathspec/_lowering.py | 4 ++-- src/mathspec/_resolution.py | 8 ++------ src/mathspec/program.py | 4 ++-- tests/test_dimensions.py | 8 ++++---- tests/test_expansion.py | 2 +- tests/test_lowering.py | 12 ++++++------ 9 files changed, 35 insertions(+), 35 deletions(-) diff --git a/docs/reference/reading.md b/docs/reference/reading.md index 0dd8be47..20883a7d 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -147,19 +147,18 @@ entry's expression, the same object that `program.expressions[name].expression` holds, and its value is the body's value. `children()` steps into the body, so a walk reads through it. -Every expression node has `dims`: the dimensions over which it is indexed, in -the order `dimensions:` declares them. A sum has the dimensions its operand has, +Every expression node has `dims`: the dimensions over which it is indexed, +sorted by name. A sum has the dimensions its operand has, less the ones it sums over. A `Join` under a sum through a relation has the dimensions of that sum. A `Join` also opens one `Axis` for each column it drops, but an axis is not a dimension and is not in `dims`. An `ExpressionReference` has the dimensions of its body. -A node's `dims` are always in the order `dimensions:` declares, also where a -named expression declares its `dims:` in another order. The entry keeps the -order of its `dims:`. With `dimensions:` in the order `snapshot`, `generator`, -an entry that declares `dims: [generator, snapshot]` has -`program.expressions[name].dims == ('generator', 'snapshot')`, and each -`ExpressionReference` to it has `dims == ('snapshot', 'generator')`. +A node's `dims` are always sorted by name. The order of `dimensions:` and the +order of an entry's `dims:` do not change them. An entry that declares +`dims: [snapshot, generator]` has +`program.expressions[name].dims == ('snapshot', 'generator')`, and each +`ExpressionReference` to it has `dims == ('generator', 'snapshot')`. A `ConditionReference` stands where a `conditions:` entry is read. Its `body` is the condition of the entry, the root of `program.conditions[name].condition`, and it is true diff --git a/src/mathspec/_dimensions.py b/src/mathspec/_dimensions.py index 2feb41c3..ea43df1e 100644 --- a/src/mathspec/_dimensions.py +++ b/src/mathspec/_dimensions.py @@ -123,6 +123,11 @@ def join_dims(columns: JoinColumns, inner: frozenset[str], context: str, operand return joined(columns, inner) +def sorted_dims(*dims: Iterable[str]) -> tuple[str, ...]: + """Every dim in *dims*, once, sorted by name: what a node states as ``dims``.""" + return tuple(sorted(set().union(*dims))) + + def joined(columns: JoinColumns, inner: Iterable[str]) -> frozenset[str]: """The dims a join on *columns* leaves of *inner*: the dims joined on go, and the dims grouped by arrive.""" return (frozenset(inner) - set(columns.joined_dims)) | set(columns.grouped_dims) diff --git a/src/mathspec/_expression_resolver.py b/src/mathspec/_expression_resolver.py index 6c2d7718..6d2edb27 100644 --- a/src/mathspec/_expression_resolver.py +++ b/src/mathspec/_expression_resolver.py @@ -15,7 +15,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, assert_never, cast -from mathspec._dimensions import check_node, joined +from mathspec._dimensions import check_node, joined, sorted_dims from mathspec._expression_parser import ( MAX_DEPTH, ArithmeticNode, @@ -164,7 +164,7 @@ def _binary(self, node: BinaryOperatorNode) -> Expression | None: left, right = self.arith(node.left), self.arith(node.right) if left is None or right is None: return None - dims = self.ns.in_declaration_order(left.dims, right.dims) + dims = sorted_dims(left.dims, right.dims) match node.op: case '+': return Add(left, right, dims=dims) @@ -200,13 +200,13 @@ def _name(self, node: NameNode) -> Expression | None: return None match self.ns.kind(node.name): case 'variable': - return VariableReference(node.name, dims=self.ns.in_declaration_order(self.ns.leaf_dims[node.name])) + return VariableReference(node.name, dims=sorted_dims(self.ns.leaf_dims[node.name])) case 'parameter': dtype = self.ns.dtypes.get(node.name) if dtype is not None and dtype not in NUMERIC_DTYPES: self.errors.append(not_a_number(node.name, dtype, self.context)) return None - return ParameterReference(node.name, dims=self.ns.in_declaration_order(self.ns.leaf_dims[node.name])) + return ParameterReference(node.name, dims=sorted_dims(self.ns.leaf_dims[node.name])) case 'dimension': self.errors.append( f"{self.context}: '{node.name}' is a dimension, and a dimension is " @@ -309,7 +309,7 @@ def _summed(self, operand: Expression, over: tuple[Axis, ...]) -> Sum | None: def _joined(self, operand: Expression, columns: JoinColumns) -> Join | None: """A [`Join`][mathspec.program.Join] of *operand* on *columns*, over the dims the join leaves.""" - return self._checked(Join(operand, columns, dims=self.ns.in_declaration_order(joined(columns, operand.dims)))) + return self._checked(Join(operand, columns, dims=sorted_dims(joined(columns, operand.dims)))) def _checked[N: Sum | Join | Translate | WindowSum](self, node: N) -> N | None: """*node*, or ``None`` with its refusal appended where it breaks the dim rule of its operator.""" @@ -492,7 +492,7 @@ def _dual(self, node: FunctionCallNode) -> Dual | None: self.errors.append(self.ns.unknown_constraint(value.name, self.context, formals=self.formals)) return None declared = {**self.ns.spec.constraints, **self.ns.spec.given.constraints}[value.name] - return Dual(value.name, dims=self.ns.in_declaration_order(declared.dims)) + return Dual(value.name, dims=sorted_dims(declared.dims)) def _over(self, value: ArithmeticNode, operator: str, *, relation: bool) -> tuple[str, ...] | None: """``over=``: a dimension or a list of them, and beside ``by=`` the columns of the relation too, each a bare name. diff --git a/src/mathspec/_lowering.py b/src/mathspec/_lowering.py index 36df0c14..4229c98d 100644 --- a/src/mathspec/_lowering.py +++ b/src/mathspec/_lowering.py @@ -17,7 +17,7 @@ from typing import TYPE_CHECKING -from mathspec._dimensions import check_spec +from mathspec._dimensions import check_spec, sorted_dims from mathspec._expansion import expand, parse_template from mathspec._piecewise import assumptions_of, curve_frame, lp_domain_refusal, refused_under_points, resolve_links from mathspec._resolution import ( @@ -348,7 +348,7 @@ def _bound(value: float | str | None, ns: Namespace) -> Constant | ParameterRefe if value is None: return None if isinstance(value, str): - return ParameterReference(value, dims=ns.in_declaration_order(ns.leaf_dims[value])) + return ParameterReference(value, dims=sorted_dims(ns.leaf_dims[value])) return Constant(value, dims=()) diff --git a/src/mathspec/_resolution.py b/src/mathspec/_resolution.py index 288033c6..66de83b4 100644 --- a/src/mathspec/_resolution.py +++ b/src/mathspec/_resolution.py @@ -25,6 +25,7 @@ parse_condition, ) from mathspec._condition_resolver import ConditionResolver +from mathspec._dimensions import sorted_dims from mathspec._exclusivity import overlapping from mathspec._expansion import expand, parse_and_expand from mathspec._expression_parser import ( @@ -167,11 +168,6 @@ class says what is wrong wherever resolution met it, and a return DimensionError('\n'.join(errors)) return SchemaError('\n'.join(errors)) - def in_declaration_order(self, *dims: Iterable[str]) -> tuple[str, ...]: - """Every dim in *dims*, once, in the order ``dimensions:`` declares them: what a node states as ``dims``.""" - given = set().union(*dims) - return tuple(d for d in self.spec.dimensions if d in given) - def named(self, name: str, context: str) -> ExpressionReference: """The ``expressions:`` entry *name* as the node that stands where its name is written. @@ -548,7 +544,7 @@ def _named(name: str, entry: ExpressionSpec, ns: Namespace, errors: list[str]) - return None left_over = Region(remainder(region.when for region in regions), fallback) every = (*regions, left_over) - cases = Cases(every, dims=ns.in_declaration_order(*(r.value.dims for r in every), *(r.when.dims for r in every))) + cases = Cases(every, dims=sorted_dims(*(r.value.dims for r in every), *(r.when.dims for r in every))) return ExpressionReference(name, cases, dims=cases.dims) diff --git a/src/mathspec/program.py b/src/mathspec/program.py index c268bef3..d2f26961 100644 --- a/src/mathspec/program.py +++ b/src/mathspec/program.py @@ -452,8 +452,8 @@ class ExpressionReference: #: [`Multiply`][] of two variable-carrying operands. A #: [`NamedExpression`][] the math never reads is held to none of them. #: No node records which tree it stands in. Every node states ``dims``, the -#: dimensions over which it is indexed, in the order ``dimensions:`` declares -#: them, so a consumer reads a node's dims rather than deriving them. An +#: dimensions over which it is indexed, sorted by name, so a consumer reads a +#: node's dims rather than deriving them. An #: [`ExpressionReference`][] states its body's dims. Expression = ( Constant diff --git a/tests/test_dimensions.py b/tests/test_dimensions.py index de861fe7..ad039784 100644 --- a/tests/test_dimensions.py +++ b/tests/test_dimensions.py @@ -808,7 +808,7 @@ def _expressions(root: object) -> Iterator[Expression]: @pytest.mark.parametrize('path', EVERY_SPEC, ids=lambda path: str(path.relative_to(Path(__file__).parent.parent))) -def test_every_expression_node_states_its_dims_in_declaration_order(path): +def test_every_expression_node_states_its_dims_sorted_by_name(path): """A consumer read a node's dims by deriving them again, as the loader did to check them and then threw away.""" spec = spec_of(path) for loaded in (spec, spec.expand()): @@ -816,9 +816,9 @@ def test_every_expression_node_states_its_dims_in_declaration_order(path): assert nodes, f'{path.name} lowers to no expression node' for node in nodes: derived = _derived(node, loaded) - assert node.dims == tuple(d for d in loaded.dimensions if d in derived), ( - f'{type(node).__name__} in {path.name} states {node.dims}, and the rules give {sorted(derived)} in ' - f'the order dimensions: declares' + assert node.dims == tuple(sorted(derived)), ( + f'{type(node).__name__} in {path.name} states {node.dims}, and the rules give {sorted(derived)}, ' + f'sorted by name' ) diff --git a/tests/test_expansion.py b/tests/test_expansion.py index 251e7f66..4d9a7330 100644 --- a/tests/test_expansion.py +++ b/tests/test_expansion.py @@ -141,7 +141,7 @@ def test_a_call_expands_to_core_ast(expressions, macros, call, want): def test_a_named_expression_arrives_under_the_node_carrying_its_name(): ns = Namespace(spec(expressions={'gen_cost': 'p * cost'})) resolved = expression_of('sum(gen_cost, over=generator)', ns, 'e') - frame = ('snapshot', 'generator') + frame = ('generator', 'snapshot') body = Multiply(VariableReference('p', dims=frame), ParameterReference('cost', dims=('generator',)), dims=frame) assert resolved == Sum( ExpressionReference('gen_cost', body, dims=frame), (Axis('generator'),), dims=('snapshot',) diff --git a/tests/test_lowering.py b/tests/test_lowering.py index 228ed140..7fc709e4 100644 --- a/tests/test_lowering.py +++ b/tests/test_lowering.py @@ -143,7 +143,7 @@ def test_program_structure(dispatch_program): assert cname == 'power_balance' assert c.dims == ('snapshot',), 'the frame is the dims, in the order the file wrote it' assert c.lhs == Sum( - VariableReference('dispatch', dims=('snapshot', 'generator')), (Axis('generator'),), dims=('snapshot',) + VariableReference('dispatch', dims=('generator', 'snapshot')), (Axis('generator'),), dims=('snapshot',) ) assert c.sense == '==', "the comparison crosses as the file's own operator, untranslated" assert c.rhs == ParameterReference('load', dims=('snapshot',)) @@ -151,9 +151,9 @@ def test_program_structure(dispatch_program): assert dispatch_program.objective.sense == 'minimize', "the program carries the language's spelling, untranslated" assert dispatch_program.objective.expression == Sum( Multiply( - VariableReference('dispatch', dims=('snapshot', 'generator')), + VariableReference('dispatch', dims=('generator', 'snapshot')), ParameterReference('cost', dims=('generator',)), - dims=('snapshot', 'generator'), + dims=('generator', 'snapshot'), ), (Axis('generator'), Axis('snapshot')), dims=(), @@ -752,7 +752,7 @@ def test_a_relation_lowers_with_the_join_each_call_names(): zonal = program.constraints['zonal'].lhs columns = JoinColumns('zone_of', declared, ('generator', 'snapshot'), ('zone', 'snapshot')) assert zonal == Sum( - Join(VariableReference('p', dims=('snapshot', 'generator')), columns, dims=('snapshot', 'zone')), + Join(VariableReference('p', dims=('generator', 'snapshot')), columns, dims=('snapshot', 'zone')), (Axis('generator', Column('zone_of', 'generator')),), dims=('snapshot', 'zone'), ), ( @@ -771,7 +771,7 @@ def test_a_relation_lowers_with_the_join_each_call_names(): ) assert program.constraints['history'].lhs == Sum( Join( - VariableReference('p', dims=('snapshot', 'generator')), + VariableReference('p', dims=('generator', 'snapshot')), JoinColumns('zone_of', declared, ('snapshot', 'generator'), ('zone', 'generator')), dims=('generator', 'zone'), ), @@ -782,7 +782,7 @@ def test_a_relation_lowers_with_the_join_each_call_names(): assert priced == Join( ParameterReference('price', dims=('snapshot', 'zone')), JoinColumns('zone_of', declared, ('zone', 'snapshot'), ('generator', 'snapshot')), - dims=('snapshot', 'generator'), + dims=('generator', 'snapshot'), ), 'and an at is the bare join, on the value column, grouped by the key columns' assert isinstance(priced, Join) assert (priced.columns.dropped_dims, priced.columns.added_dims, priced.columns.kept) == (