From 840219847fb885df5732779d55eb3ca388736c5a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 13:03:28 +0000 Subject: [PATCH 1/3] feat(language): fragments add terms to one named expression, and merge sums them An expressions: entry marked additive: true is one share of a sum. merge sums the shares of every fragment in fragment-name order and keeps the flag, so a later merge adds to the sum. It refuses a share beside a whole definition, and a fragment that adds a share and reads the name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1 --- docs/howto/compose.md | 23 ++-- docs/reference/language/named.md | 36 +++++++ schema/math-spec.schema.json | 5 + src/math_spec/composition.py | 68 +++++++++++- src/math_spec/lowering.py | 1 + src/math_spec/model.py | 19 +++- src/math_spec/program.py | 2 + src/math_spec/typesetting/legend.py | 7 +- tests/test_additive.py | 160 ++++++++++++++++++++++++++++ 9 files changed, 302 insertions(+), 19 deletions(-) create mode 100644 tests/test_additive.py diff --git a/docs/howto/compose.md b/docs/howto/compose.md index 6a01bfbd..2b7da99c 100644 --- a/docs/howto/compose.md +++ b/docs/howto/compose.md @@ -107,17 +107,18 @@ compose as `override(merge({…}), {…})`. ## What a fragment may share -| The entry | What happens | -| ------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| a dimension or a relation | every fragment may declare it, and the ones that do say the same thing about it | -| a `description` on a shared dimension or relation | it is prose rather than a claim, and the first fragment's wording is carried | -| any other declaration | one fragment declares it, and a second is refused | -| an entry under `given:` | it is checked against the fragment that introduces the name, then folded into it | -| a given expression | its frame is checked against the frame the definition's body carries | -| a given entry no fragment introduces | it stays under `given:` until a host model provides it | -| `objective` | the terms are summed in fragment-name order, each in parentheses, and the senses agree | -| `version` | every fragment is written against the same one | -| `description` at the top of a fragment | it is about the fragment and is not carried. Pass the composed model's as `description=` | +| The entry | What happens | +| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | +| a dimension or a relation | every fragment may declare it, and the ones that do say the same thing about it | +| a `description` on a shared dimension or relation | it is prose rather than a claim, and the first fragment's wording is carried | +| any other declaration | one fragment declares it, and a second is refused | +| an entry under `given:` | it is checked against the fragment that introduces the name, then folded into it | +| a given expression | its frame is checked against the frame the definition's body carries | +| a given entry no fragment introduces | it stays under `given:` until a host model provides it | +| `objective` | the terms are summed in fragment-name order, each in parentheses, and the senses agree | +| an expression each fragment that defines it marks `additive` | the bodies are summed in fragment-name order, each in parentheses | +| `version` | every fragment is written against the same one | +| `description` at the top of a fragment | it is about the fragment and is not carried. Pass the composed model's as `description=` | ## A name two fragments declare diff --git a/docs/reference/language/named.md b/docs/reference/language/named.md index 96894d29..dc9b0983 100644 --- a/docs/reference/language/named.md +++ b/docs/reference/language/named.md @@ -109,6 +109,42 @@ masked variable. `cases:` is not accepted inside a `macros:` template. +## `additive` + +`additive: true` marks a named expression as one share of a sum. Each fragment +that adds to the sum defines its own share under the same name. + +```yaml +dimensions: + snapshot: { dtype: int } + bus: { dtype: str } + generator: { dtype: str } +relations: + gen_bus: { key: generator, values: bus } +variables: + gen_p: { dims: [snapshot, generator], bounds: { lower: 0 } } +expressions: + injection: + additive: true + expression: sum(gen_p, by=gen_bus, over=generator, into=bus) + description: what the components put into a bus +``` + +| Field | | | +| ---------- | ----------------------------------------------- | --------------- | +| `additive` | `true` where other files add terms to this name | default `false` | + +In one file, the expression is its body. `additive: true` takes a plain +`expression:`, and is refused with `cases:`. +[`merge`](../../howto/compose.md#a-library-of-components) sums the shares of +every fragment, each in parentheses, in fragment-name order, and keeps +`additive: true` on the sum. It refuses a name one fragment adds to and another +defines without the flag, and a fragment that adds a share and also reads the +name. A fragment reads the sum under +[`given: expressions`](declarations.md#given-expressions). The typeset legend +lists an additive expression under _Definitions_, as a sum other files add +terms to. + ## Reported expressions A named expression is either **in the math** or **reported**. The objective diff --git a/schema/math-spec.schema.json b/schema/math-spec.schema.json index 3095efeb..cad66787 100644 --- a/schema/math-spec.schema.json +++ b/schema/math-spec.schema.json @@ -168,6 +168,11 @@ "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 :meth:`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\nA quantity whose value varies by region is written as ``cases:`` over a\ndeclared ``dims:``, with an ``otherwise:`` for the rest \u2014 see the\nlanguage reference.", "properties": { + "additive": { + "default": false, + "title": "Additive", + "type": "boolean" + }, "cases": { "additionalProperties": { "$ref": "#/$defs/ExpressionCase" diff --git a/src/math_spec/composition.py b/src/math_spec/composition.py index 93a3c79c..9746816d 100644 --- a/src/math_spec/composition.py +++ b/src/math_spec/composition.py @@ -21,6 +21,10 @@ both named. * **The objectives are summed**, each term in parentheses, in the fragments' name order, and the senses have to agree. +* **An additive expression is summed the same way.** A named expression every + fragment that defines it marks ``additive: true`` is the sum of their + bodies. A fragment that defines one share and reads the name is refused: on + its own it reads its share, and composed it would read the sum. * **A given declaration is folded** into the declaration that introduces the name, once the reader is checked to say the same as the introducer or less. A given expression is checked against the frame its definition's body @@ -150,6 +154,8 @@ def merge(fragments: Mapping[str, str | Path | Mapping[str, object] | Spec], des for section in OWNED_SECTIONS: if claimed := _claimed(read, section): merged[section] = claimed + if summed := _summed_shares(read, loaded): + merged['expressions'] = {**_mapping(merged.get('expressions')), **summed} if given := _folded(read, merged, _frames(loaded)): merged['given'] = given if (objective := _summed_objective(read)) is not None: @@ -213,10 +219,15 @@ def _claims(block: object) -> object: def _claimed(read: Mapping[str, dict[str, object]], section: str) -> dict[str, object]: - """One block of owned declarations, a name claimed twice being the refusal.""" + """One block of owned declarations, a name claimed twice being the refusal. + + An additive expression is left to :func:`_summed_shares`. + """ merged: dict[str, object] = {} for name, sections in read.items(): for key, block in _mapping(sections.get(section)).items(): + if section == 'expressions' and _is_share(block): + continue if key in merged: raise LanguageError( f"fragments '{_author_of(read, section, key)}' and '{name}' both declare the " @@ -228,16 +239,63 @@ def _claimed(read: Mapping[str, dict[str, object]], section: str) -> dict[str, o return merged +def _is_share(block: object) -> bool: + """Whether an ``expressions:`` entry, as ``to_dict`` wrote it, is one share of an additive sum.""" + return isinstance(block, dict) and bool(block.get('additive')) + + +def _summed_shares(read: Mapping[str, dict[str, object]], loaded: Mapping[str, Spec]) -> dict[str, object]: + """Every additive expression, its shares summed in the fragments' name order, each in parentheses. + + One share is carried as written. A name one fragment adds to and another + defines whole is refused, and so is a fragment that adds a share and reads + the name, since on its own that fragment reads its share and not the sum. + """ + shares: dict[str, dict[str, dict[str, object]]] = {} + for name, sections in sorted(read.items()): + for key, block in _mapping(sections.get('expressions')).items(): + if _is_share(block): + shares.setdefault(key, {})[name] = cast('dict[str, object]', block) + summed: dict[str, object] = {} + for key, by_fragment in shares.items(): + for name, sections in read.items(): + if name not in by_fragment and key in _mapping(sections.get('expressions')): + raise LanguageError( + f"fragment '{name}' defines {key!r} whole, where '{next(iter(by_fragment))}' adds a share " + f'to it. An additive expression is a sum every fragment adds to: mark the definition in ' + f"'{name}' `additive: true`, or give one of the two a name of its own." + ) + if len(by_fragment) > 1: + for name in by_fragment: + if loaded[name].program.expressions[key].in_math: + raise LanguageError( + f"fragment '{name}' adds a share to {key!r} and reads it. On its own the fragment reads " + f'its share, and composed it would read the sum of every share: read the sum in a ' + f"fragment that adds nothing to it, under 'given: expressions:'." + ) + blocks = list(by_fragment.values()) + bodies = [cast('str', block['expression']) for block in blocks] + summed[key] = { + **blocks[0], + 'expression': bodies[0] if len(bodies) == 1 else ' + '.join(f'({body})' for body in bodies), + 'description': next((block['description'] for block in blocks if block.get('description')), None), + } + return summed + + def _frames(loaded: Mapping[str, Spec]) -> dict[str, tuple[str, ...]]: """The frame of every named expression the fragments define, as each fragment's own program reads it. A definition writes no frame: its body carries one. The body is the same text in the composition, so the frame the fragment reads is the one a - given declaration is checked against. + given declaration is checked against. The shares of an additive expression + broadcast into their sum, so its frame is the union of theirs. """ - return { - name: declaration.dims for spec in loaded.values() for name, declaration in spec.program.expressions.items() - } + frames: dict[str, tuple[str, ...]] = {} + for spec in loaded.values(): + for name, declaration in spec.program.expressions.items(): + frames[name] = tuple(dict.fromkeys((*frames.get(name, ()), *declaration.dims))) + return frames def _folded( diff --git a/src/math_spec/lowering.py b/src/math_spec/lowering.py index 347d8222..6239d24c 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -221,6 +221,7 @@ def lower(schema: Spec) -> Program: _frame_of(name, entry, schema), in_math=name in in_math, description=schema.expressions[name].description, + additive=schema.expressions[name].additive, ) for name, entry in entries.items() }, diff --git a/src/math_spec/model.py b/src/math_spec/model.py index 3d34f1f3..72cfb77b 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -463,6 +463,9 @@ class ExpressionBlock(_StrictBlock): cases: Annotated[dict[str, ExpressionCase], Field(min_length=1)] = {} #: The value wherever no case's ``when`` holds, printed as the last row. otherwise: Expression | None = None + #: One share of a sum: :func:`~math_spec.composition.merge` adds the shares + #: every fragment defines under this name. + additive: bool = False description: str | None = None @model_validator(mode='before') @@ -505,6 +508,13 @@ def _one_form_or_the_other(self) -> Self: 'are none here. A value that holds everywhere is a plain `expression:`.' ) raise ValueError(msg) + if self.additive and self.cases: + msg = ( + '`additive: true` takes one `expression:`, and this has `cases:`. The shares are summed ' + 'as written, and a region belongs inside one share: write the share as its own ' + 'expression with a `cases:` entry, and add that name.' + ) + raise ValueError(msg) return self @classmethod @@ -523,9 +533,14 @@ def _as_written(self) -> str | dict[str, object]: written['otherwise'] = self.otherwise return written assert self.expression is not None - if self.description is None: + if self.description is None and not self.additive: return self.expression - return {'expression': self.expression, 'description': self.description} + written = {'expression': self.expression} + if self.additive: + written['additive'] = True + if self.description is not None: + written['description'] = self.description + return written class AssumptionBlock(_StrictBlock): diff --git a/src/math_spec/program.py b/src/math_spec/program.py index 88278419..485c2d42 100644 --- a/src/math_spec/program.py +++ b/src/math_spec/program.py @@ -713,6 +713,8 @@ class ExpressionDeclaration: dims: tuple[str, ...] in_math: bool description: str | None = None + #: One share of a sum other files add terms to, as the file marks it. + additive: bool = False @dataclass(frozen=True) diff --git a/src/math_spec/typesetting/legend.py b/src/math_spec/typesetting/legend.py index 1a8d3ff2..5214b449 100644 --- a/src/math_spec/typesetting/legend.py +++ b/src/math_spec/typesetting/legend.py @@ -187,7 +187,12 @@ def glossaries(self, noticed: Noticed, defined: Iterable[str]) -> list[tuple[str ] shown = set(defined) definitions = [ - self._entry(self.symbols.name[e], f'{fmt.mono(e)}{self._over(list(block.dims))}', block.description) + self._entry( + self.symbols.name[e], + f'{fmt.mono(e)}{self._over(list(block.dims))}' + + (', a sum other files add terms to' if block.additive else ''), + block.description, + ) for e, block in program.expressions.items() if e in shown ] diff --git a/tests/test_additive.py b/tests/test_additive.py new file mode 100644 index 00000000..b1fe555b --- /dev/null +++ b/tests/test_additive.py @@ -0,0 +1,160 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""A named expression several fragments add terms to. + +A balance reads what every component puts into a bus, and a component file +says what it puts there. With `additive: true` each component file defines its +own share under one name, and `merge` sums the shares, so a new component is a +new file and the balance does not change. +""" + +from __future__ import annotations + +import pytest + +from math_spec import FORMATS, LanguageError, merge, override, to_markdown, to_spec, typeset + +DIMS = {'snapshot': {'dtype': 'int'}, 'bus': {'dtype': 'str'}} + +#: The balance: it reads the total and defines none of it. +BALANCE = { + 'dimensions': DIMS, + 'given': {'expressions': {'injection': {'dims': ['snapshot', 'bus']}}}, + 'constraints': {'balance': {'dims': ['snapshot', 'bus'], 'expression': 'injection == 0'}}, +} + +#: A generator fleet, adding what it produces to the injection. +FLEET = { + 'dimensions': {**DIMS, 'generator': {'dtype': 'str'}}, + 'relations': {'gen_bus': {'key': 'generator', 'values': 'bus'}}, + 'variables': {'gen_p': {'dims': ['snapshot', 'generator'], 'bounds': {'lower': 0}}}, + 'expressions': { + 'injection': { + 'additive': True, + 'expression': 'sum(gen_p, by=gen_bus, over=generator, into=bus)', + 'description': 'what the components put into a bus', + } + }, + 'objective': {'sense': 'minimize', 'expression': 'sum(gen_p)'}, +} + +#: A demand, subtracting what it draws from the injection. +DEMAND = { + 'dimensions': DIMS, + 'parameters': {'load': {'dims': ['snapshot', 'bus']}}, + 'expressions': {'injection': {'additive': True, 'expression': '-load'}}, +} + + +def test_a_share_loads_on_its_own_as_the_expression_it_writes(): + spec = to_spec(FLEET) + assert spec.expressions['injection'].additive + assert spec.program.expressions['injection'].additive, 'the program carries the flag the legend prints' + + +def test_merging_sums_the_shares_in_fragment_name_order(): + composed = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) + assert ( + composed.expressions['injection'].expression == '(-load) + (sum(gen_p, by=gen_bus, over=generator, into=bus))' + ) + assert composed.expressions['injection'].additive, 'the sum stays open, so a later merge can add to it' + assert not composed.given, 'the balance reads the sum, which the composition defines' + + +def test_the_order_the_fragments_are_given_in_does_not_reach_the_sum(): + one = merge({'fleet': FLEET, 'demand': DEMAND}) + other = merge({'demand': DEMAND, 'fleet': FLEET}) + assert one == other + + +def test_a_merged_sum_takes_more_shares_in_a_second_merge(): + """The flag survives the merge, so composing in two steps gives the same model as in one.""" + storage = { + 'dimensions': {**DIMS, 'store': {'dtype': 'str'}}, + 'relations': {'store_bus': {'key': 'store', 'values': 'bus'}}, + 'variables': {'store_p': {'dims': ['snapshot', 'store']}}, + 'expressions': { + 'injection': {'additive': True, 'expression': 'sum(store_p, by=store_bus, over=store, into=bus)'} + }, + } + stepwise = merge({'core': merge({'demand': DEMAND, 'fleet': FLEET}), 'storage': storage}) + assert 'store_p' in stepwise.expressions['injection'].expression + assert 'gen_p' in stepwise.expressions['injection'].expression + + +def test_the_first_description_is_carried(): + composed = merge({'fleet': FLEET, 'demand': DEMAND}) + assert composed.expressions['injection'].description == 'what the components put into a bus' + + +def test_a_given_expression_is_checked_against_every_share(): + """A share over a narrower frame broadcasts into the sum, so the sum's frame is the union.""" + over_bus_only = {**DEMAND, 'parameters': {'load': {'dims': ['bus']}}} + composed = merge({'fleet': FLEET, 'demand': over_bus_only, 'balance': BALANCE}) + assert composed.program.expressions['injection'].dims == ('snapshot', 'bus') + + +def test_a_given_expression_over_a_frame_no_share_sums_to_is_refused(): + narrow = { + 'dimensions': DIMS, + 'given': {'expressions': {'injection': {'dims': ['bus']}}}, + 'constraints': {'balance': {'dims': ['bus'], 'expression': 'injection == 0'}}, + } + with pytest.raises(LanguageError, match=r"reads the given expression 'injection' over \['bus'\]"): + merge({'fleet': FLEET, 'demand': DEMAND, 'balance': narrow}) + + +def test_an_expression_one_fragment_adds_to_and_another_owns_is_refused(): + owned = {**DEMAND, 'expressions': {'injection': {'expression': '-load'}}} + with pytest.raises(LanguageError, match=r"'demand' defines 'injection' whole, where 'fleet' adds a share"): + merge({'fleet': FLEET, 'demand': owned}) + + +def test_a_fragment_that_reads_the_sum_it_adds_to_is_refused(): + """Alone it reads its own share; composed it would read the total, so the file would change meaning.""" + reads_itself = { + **FLEET, + 'constraints': {'capped': {'dims': ['snapshot', 'bus'], 'expression': 'injection <= 10'}}, + } + with pytest.raises(LanguageError, match=r"'fleet' adds a share to 'injection' and reads it"): + merge({'fleet': reads_itself, 'demand': DEMAND}) + + +def test_one_share_alone_is_carried_as_written(): + composed = merge({'fleet': FLEET, 'balance': BALANCE}) + assert composed.expressions['injection'].expression == 'sum(gen_p, by=gen_bus, over=generator, into=bus)' + + +def test_an_additive_expression_is_one_expression_and_not_cases(): + cased = { + **DEMAND, + 'expressions': { + 'injection': { + 'additive': True, + 'dims': ['snapshot', 'bus'], + 'cases': {'peak': {'when': 'load > 5', 'expression': '-load'}}, + 'otherwise': '0', + } + }, + } + with pytest.raises(LanguageError, match=r'`additive: true` takes one `expression:`'): + to_spec(cased) + + +def test_a_patch_replaces_a_share_rather_than_adding_one(): + """`override` edits what is there; a new share is a new fragment for `merge`.""" + laid = override(DEMAND, {'double': {'expressions': {'injection': {'expression': '-2 * load'}}}}) + assert laid.expressions['injection'].expression == '-2 * load' + assert laid.expressions['injection'].additive, 'the patch names only the field it changes' + + +def test_the_legend_says_other_files_add_to_it(): + definitions = to_markdown(FLEET).split('#### Definitions')[1] + assert 'a sum other files add terms to' in definitions + + +@pytest.mark.parametrize('fmt', sorted(FORMATS)) +def test_a_share_prints_in_every_format(fmt): + assert typeset(merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}), fmt), f'{fmt} rendered nothing' From 6cb02e969545c3a63d0e65610f7bff0c2bbc203a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 13:06:55 +0000 Subject: [PATCH 2/3] feat(language): a composed objective keeps the first description a fragment gives it Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1 --- docs/howto/compose.md | 24 ++++++++++++------------ src/math_spec/composition.py | 11 ++++++++--- tests/test_composition.py | 9 +++++++++ 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/docs/howto/compose.md b/docs/howto/compose.md index 2b7da99c..edc859ed 100644 --- a/docs/howto/compose.md +++ b/docs/howto/compose.md @@ -107,18 +107,18 @@ compose as `override(merge({…}), {…})`. ## What a fragment may share -| The entry | What happens | -| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | -| a dimension or a relation | every fragment may declare it, and the ones that do say the same thing about it | -| a `description` on a shared dimension or relation | it is prose rather than a claim, and the first fragment's wording is carried | -| any other declaration | one fragment declares it, and a second is refused | -| an entry under `given:` | it is checked against the fragment that introduces the name, then folded into it | -| a given expression | its frame is checked against the frame the definition's body carries | -| a given entry no fragment introduces | it stays under `given:` until a host model provides it | -| `objective` | the terms are summed in fragment-name order, each in parentheses, and the senses agree | -| an expression each fragment that defines it marks `additive` | the bodies are summed in fragment-name order, each in parentheses | -| `version` | every fragment is written against the same one | -| `description` at the top of a fragment | it is about the fragment and is not carried. Pass the composed model's as `description=` | +| The entry | What happens | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | +| a dimension or a relation | every fragment may declare it, and the ones that do say the same thing about it | +| a `description` on a shared dimension or relation | it is prose rather than a claim, and the first fragment's wording is carried | +| any other declaration | one fragment declares it, and a second is refused | +| an entry under `given:` | it is checked against the fragment that introduces the name, then folded into it | +| a given expression | its frame is checked against the frame the definition's body carries | +| a given entry no fragment introduces | it stays under `given:` until a host model provides it | +| `objective` | the terms are summed in fragment-name order, each in parentheses, and the senses agree. The first description is carried | +| an expression each fragment that defines it marks `additive` | the bodies are summed in fragment-name order, each in parentheses | +| `version` | every fragment is written against the same one | +| `description` at the top of a fragment | it is about the fragment and is not carried. Pass the composed model's as `description=` | ## A name two fragments declare diff --git a/src/math_spec/composition.py b/src/math_spec/composition.py index 9746816d..9b37f131 100644 --- a/src/math_spec/composition.py +++ b/src/math_spec/composition.py @@ -388,7 +388,8 @@ def _summed_objective(read: Mapping[str, dict[str, object]]) -> dict[str, object """Every fragment's objective summed, each term in parentheses, or ``None`` where none declares one. The terms are summed in the fragments' name order, so the order they were - passed in does not reach the expression. The senses have to agree: a sum has + passed in does not reach the expression. The first description in that + order is carried, as a shared dimension's is. The senses have to agree: a sum has one sense, and negating the odd one out would be this function deciding what a model means. """ @@ -403,9 +404,13 @@ def _summed_objective(read: Mapping[str, dict[str, object]]) -> dict[str, object f'one objective and one sense, so write every fragment against the same one: negate the terms ' f'of the odd one out rather than its sense.' ) - terms = [objective['expression'] for _, objective in sorted(declared.items())] + ordered = [objective for _, objective in sorted(declared.items())] + terms = [objective['expression'] for objective in ordered] joined = terms[0] if len(terms) == 1 else ' + '.join(f'({term})' for term in terms) - return {'sense': next(iter(senses.values())), 'expression': joined} + summed: dict[str, object] = {'sense': next(iter(senses.values())), 'expression': joined} + if description := next((o['description'] for o in ordered if o.get('description')), None): + summed['description'] = description + return summed def override( diff --git a/tests/test_composition.py b/tests/test_composition.py index c8f3d098..f94884d0 100644 --- a/tests/test_composition.py +++ b/tests/test_composition.py @@ -178,6 +178,15 @@ def test_the_objectives_are_summed_each_term_parenthesised(): ) +def test_a_composed_objective_keeps_the_first_description_a_fragment_gives_it(): + """Prose, as on a shared dimension: the first fragment's wording is carried, and none is lost.""" + said = {**SUPPLY, 'objective': {**SUPPLY['objective'], 'description': 'what running the fleet costs'}} + priced = {**DEMAND, 'objective': {'sense': 'minimize', 'expression': 'sum(dem_load) * 2'}} + composed = merge({'surface': SURFACE, 'supply': said, 'demand': priced}) + assert composed.objective is not None + assert composed.objective.description == 'what running the fleet costs' + + def test_one_fragment_s_objective_is_carried_as_it_was_written(): objective = merge(LIBRARY).objective assert objective is not None From a98ad6ce81c06107a25de24843e383a6ecebbf45 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:05:47 +0000 Subject: [PATCH 3/3] feat(language): the file that reads a sum marks it additive, and each contributor declares an ordinary term additive moves from a named expression to its given: entry. One reader says the name is a sum over a frame; every file that adds to it declares a plain named expression under the name. merge sums the terms of a marked name and keeps the marked entry, so a later merge adds more. A file may carry both, and then reads the sum so far; its term's frame and form are checked at load. merge refuses a cased term, a term over a dimension the sum does not state, and a contributor that reads the name without the entry. Two terms of an unmarked name collide, and the message names the fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1 --- docs/reference/language/declarations.md | 70 ++++++- docs/reference/language/named.md | 36 ---- docs/reference/reading.md | 3 + schema/math-spec.schema.json | 12 +- src/math_spec/composition.py | 193 ++++++++++++------ src/math_spec/dimensions.py | 10 + src/math_spec/lowering.py | 11 +- src/math_spec/model.py | 35 ++-- src/math_spec/program.py | 5 +- src/math_spec/resolution.py | 5 +- src/math_spec/typesetting/legend.py | 3 +- src/math_spec/validation.py | 16 +- tests/test_additive.py | 256 ++++++++++++++++-------- 13 files changed, 437 insertions(+), 218 deletions(-) diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index fdfaf45d..7bb66543 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -219,16 +219,17 @@ constraints: expression: injection == 0 ``` -| Field | | | -| ------------- | ------------------------------------------------- | -------------- | -| `dims` | required. The dimensions the expression runs over | | -| `description` | free text | default `null` | +| Field | | | +| ------------- | -------------------------------------------------------------------------------------------- | --------------- | +| `dims` | required. The dimensions the expression runs over | | +| `additive` | `true` where the name is a sum other files add terms to ([a sum](#a-sum-other-files-add-to)) | default `false` | +| `description` | free text | default `null` | There is no body. This file reads the name as it reads a given variable: a quantity over the frame, of degree one. A `where` does not read it, because a mask is built before any variable exists. A name declared under both -`expressions:` and `given: expressions:` is refused. The typeset legend lists a -given expression under _Given_. +`expressions:` and `given: expressions:` is refused, unless the entry is +marked `additive`. The typeset legend lists a given expression under _Given_. [`merge`](../../howto/compose.md#a-library-of-components) folds a given expression into the definition of another fragment. The `dims` are an upper @@ -239,6 +240,63 @@ term carries it. The composed model holds the body to the rules of every place this file reads it: a square of a given expression that is quadratic is refused once folded. +#### A sum other files add to + +`additive: true` says the name is a sum other files add terms to. One file +says it, on its `given:` entry, with the frame. Each file that adds a term +declares it as an ordinary named expression under that name. + +```yaml +# balance.yaml reads the sum +dimensions: + snapshot: { dtype: int } + bus: { dtype: str } +given: + expressions: + injection: + dims: [snapshot, bus] + additive: true + description: what the components put into a bus +variables: + slack: { dims: [snapshot, bus] } +constraints: + balance: + dims: [snapshot, bus] + expression: injection + slack == 0 +``` + +```yaml +# fleet.yaml adds a term +dimensions: + snapshot: { dtype: int } + bus: { dtype: str } + generator: { dtype: str } +relations: + gen_bus: { key: generator, values: bus } +variables: + gen_p: { dims: [snapshot, generator], bounds: { lower: 0 } } +expressions: + injection: sum(gen_p, by=gen_bus, over=generator, into=bus) +``` + +Each file loads alone: the reader over a sum it does not build, and the +contributor over its own term. Other readers state the frame and nothing more. + +A file may carry the marked entry and declare a term too. Then it reads the +sum so far, which alone is its own term. The term is one `expression:`, and +carries no dimension the entry does not state; both are checked at load. The +entry then folds into the definition, and the typeset legend lists it under +_Definitions_, as a sum other files add terms to. + +[`merge`](../../howto/compose.md#a-library-of-components) sums every term of a +marked name, each in parentheses, in fragment-name order, and keeps the marked +entry, so a later merge adds more. The entry's description is the sum's. It +refuses a term written as `cases:`, a term over a dimension the entry does not +state, and a file that declares a term and reads the name without carrying the +marked entry: on its own that file reads its term, and composed it would read +the sum. Two terms of a name no entry marks are refused as a collision. A +marked name no file adds to stays under `given:`. + ## `constraints` One block is one rule. The name of the block is the name of the constraint. diff --git a/docs/reference/language/named.md b/docs/reference/language/named.md index dc9b0983..96894d29 100644 --- a/docs/reference/language/named.md +++ b/docs/reference/language/named.md @@ -109,42 +109,6 @@ masked variable. `cases:` is not accepted inside a `macros:` template. -## `additive` - -`additive: true` marks a named expression as one share of a sum. Each fragment -that adds to the sum defines its own share under the same name. - -```yaml -dimensions: - snapshot: { dtype: int } - bus: { dtype: str } - generator: { dtype: str } -relations: - gen_bus: { key: generator, values: bus } -variables: - gen_p: { dims: [snapshot, generator], bounds: { lower: 0 } } -expressions: - injection: - additive: true - expression: sum(gen_p, by=gen_bus, over=generator, into=bus) - description: what the components put into a bus -``` - -| Field | | | -| ---------- | ----------------------------------------------- | --------------- | -| `additive` | `true` where other files add terms to this name | default `false` | - -In one file, the expression is its body. `additive: true` takes a plain -`expression:`, and is refused with `cases:`. -[`merge`](../../howto/compose.md#a-library-of-components) sums the shares of -every fragment, each in parentheses, in fragment-name order, and keeps -`additive: true` on the sum. It refuses a name one fragment adds to and another -defines without the flag, and a fragment that adds a share and also reads the -name. A fragment reads the sum under -[`given: expressions`](declarations.md#given-expressions). The typeset legend -lists an additive expression under _Definitions_, as a sum other files add -terms to. - ## Reported expressions A named expression is either **in the math** or **reported**. The objective diff --git a/docs/reference/reading.md b/docs/reference/reading.md index f3ef47d3..05ef4b14 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -169,6 +169,9 @@ reads and does not build ([given](language/declarations.md#given)). Every other group is a build instruction. These four are names to look up in the model this one is layered onto. An expression reads a given expression as a `Variable` of that name, over the frame under `program.given.expressions`. +A given expression with `additive` set is a sum other files add terms to. +Where the file also declares a term of it, the name is a definition with +`additive` set under `program.expressions`, and not a given name. ```python layer = to_spec( diff --git a/schema/math-spec.schema.json b/schema/math-spec.schema.json index cad66787..519ac1e7 100644 --- a/schema/math-spec.schema.json +++ b/schema/math-spec.schema.json @@ -168,11 +168,6 @@ "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 :meth:`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\nA quantity whose value varies by region is written as ``cases:`` over a\ndeclared ``dims:``, with an ``otherwise:`` for the rest \u2014 see the\nlanguage reference.", "properties": { - "additive": { - "default": false, - "title": "Additive", - "type": "boolean" - }, "cases": { "additionalProperties": { "$ref": "#/$defs/ExpressionCase" @@ -347,8 +342,13 @@ }, "GivenExpressionBlock": { "additionalProperties": false, - "description": "A named expression this file reads and another file defines.\n\nThe frame is all this file states. This file reads the name as a quantity\nover that frame, affine in the columns, as it reads a given variable: the\nbody is the definer's, and the composed model holds the body to the rules\nof every place this file reads it.", + "description": "A named expression this file reads and another file defines.\n\nThe frame is all this file states. This file reads the name as a quantity\nover that frame, affine in the columns, as it reads a given variable: the\nbody is the definer's, and the composed model holds the body to the rules\nof every place this file reads it.\n\n``additive: true`` says the name is a sum other files add terms to. Each\nof them declares its term as an ordinary named expression under the name,\nand :func:`~math_spec.composition.merge` sums the terms. A file that marks\nthe name may declare a term of its own too, and then reads the sum so far.", "properties": { + "additive": { + "default": false, + "title": "Additive", + "type": "boolean" + }, "description": { "anyOf": [ { diff --git a/src/math_spec/composition.py b/src/math_spec/composition.py index 22965242..1dcac3db 100644 --- a/src/math_spec/composition.py +++ b/src/math_spec/composition.py @@ -21,10 +21,14 @@ both named. * **The objectives are summed**, each term in parentheses, in the fragments' name order, and the senses have to agree. -* **An additive expression is summed the same way.** A named expression every - fragment that defines it marks ``additive: true`` is the sum of their - bodies. A fragment that defines one share and reads the name is refused: on - its own it reads its share, and composed it would read the sum. +* **A sum is summed the same way.** A ``given: expressions:`` entry marked + ``additive: true`` says the name is a sum other files add terms to. Every + fragment's declaration of it is a term, and the composed model declares their + sum and keeps the marked entry, so a later merge adds more. A term may carry + no dimension the entry does not state, and is one ``expression:``. A + fragment that declares a term and reads the name without carrying the marked + entry is refused: on its own it reads its term, and composed it would read + the sum. * **A given declaration is folded** into the declaration that introduces the name, once the reader is checked to say the same as the introducer or less. A given expression's body may carry no dimension its reader does not state, @@ -77,6 +81,7 @@ from math_spec._yaml import read_model from math_spec.errors import LanguageError, did_you_mean, schema_error from math_spec.model import GivenBlock, Spec +from math_spec.program import Named, walk from math_spec.validation import to_spec if TYPE_CHECKING: @@ -151,12 +156,15 @@ def merge(fragments: Mapping[str, str | Path | Mapping[str, object] | Spec], des for section in SHARED_SECTIONS: if agreed := _agreed(read, section, _singular(section)): merged[section] = agreed + asked = {name: _mapping(sections.get('given')) for name, sections in read.items()} + readings = _agreed_readings(asked) + sums = {key: entry for key, entry in readings.items() if entry['additive']} for section in OWNED_SECTIONS: - if claimed := _claimed(read, section): + if claimed := _claimed(read, section, sums if section == 'expressions' else {}): merged[section] = claimed - if summed := _summed_shares(read, loaded): + if summed := _summed_terms(read, loaded, sums): merged['expressions'] = {**_mapping(merged.get('expressions')), **summed} - if given := _folded(read, merged, _frames(loaded)): + if given := _folded(read, merged, loaded, readings): merged['given'] = given if (objective := _summed_objective(read)) is not None: merged['objective'] = objective @@ -218,88 +226,132 @@ def _claims(block: object) -> object: return {key: value for key, value in block.items() if key != 'description'} if isinstance(block, dict) else block -def _claimed(read: Mapping[str, dict[str, object]], section: str) -> dict[str, object]: +def _claimed(read: Mapping[str, dict[str, object]], section: str, sums: Mapping[str, object]) -> dict[str, object]: """One block of owned declarations, a name claimed twice being the refusal. - An additive expression is left to :func:`_summed_shares`. + The terms of a sum are left to :func:`_summed_terms`. """ merged: dict[str, object] = {} for name, sections in read.items(): for key, block in _mapping(sections.get(section)).items(): - if section == 'expressions' and _is_share(block): + if key in sums: continue if key in merged: + hint = ( + ' If each fragment adds a term to one sum, mark the name `additive: true` on a ' + '`given: expressions:` entry in the file that reads it.' + if section == 'expressions' + else '' + ) raise LanguageError( f"fragments '{_author_of(read, section, key)}' and '{name}' both declare the " f'{_singular(section)} {key!r}. Two of the same kind of thing are two rows of a dimension ' f'rather than two fragments: merge the fragment once, and let the data carry both. ' - f'Different math under one spelling is a rename: call one of them something else.' + f'Different math under one spelling is a rename: call one of them something else.{hint}' ) merged[key] = block return merged -def _is_share(block: object) -> bool: - """Whether an ``expressions:`` entry, as ``to_dict`` wrote it, is one share of an additive sum.""" - return isinstance(block, dict) and bool(block.get('additive')) +def _agreed_readings(asked: Mapping[str, dict[str, object]]) -> dict[str, dict[str, object]]: + """Every ``given: expressions:`` entry, the ones two fragments share folded together. + + Two readings of one name agree on the frame, compared as a set. Prose is + not a claim, and the flag is said once for every reader, so the first + description and any reader's flag are carried. + """ + agreed: dict[str, dict[str, object]] = {} + for name, given in asked.items(): + for key, block in _mapping(given.get('expressions')).items(): + entry = cast('dict[str, object]', block) + if key not in agreed: + agreed[key] = dict(entry) + continue + held = agreed[key] + if set(cast('list[str]', held['dims'])) != set(cast('list[str]', entry['dims'])): + raise LanguageError( + f"fragments '{_author_of(asked, 'expressions', key)}' and '{name}' say different things " + f'about the given expression {key!r}: over {held["dims"]} against over {entry["dims"]}. Two ' + f'files read one name over one frame: make the two identical.' + ) + held['additive'] = bool(held.get('additive') or entry.get('additive')) + held['description'] = held.get('description') or entry.get('description') + return agreed -def _summed_shares(read: Mapping[str, dict[str, object]], loaded: Mapping[str, Spec]) -> dict[str, object]: - """Every additive expression, its shares summed in the fragments' name order, each in parentheses. +def _summed_terms( + read: Mapping[str, dict[str, object]], loaded: Mapping[str, Spec], sums: Mapping[str, dict[str, object]] +) -> dict[str, object]: + """Every marked sum, its terms summed in the fragments' name order, each in parentheses. - One share is carried as written. A name one fragment adds to and another - defines whole is refused, and so is a fragment that adds a share and reads - the name, since on its own that fragment reads its share and not the sum. + One term is carried as written. A term written as ``cases:``, a term over a + dimension the marked entry does not state, and a fragment that declares a + term and reads the name without carrying the marked entry are refused. """ - shares: dict[str, dict[str, dict[str, object]]] = {} - for name, sections in sorted(read.items()): - for key, block in _mapping(sections.get('expressions')).items(): - if _is_share(block): - shares.setdefault(key, {})[name] = cast('dict[str, object]', block) summed: dict[str, object] = {} - for key, by_fragment in shares.items(): - for name, sections in read.items(): - if name not in by_fragment and key in _mapping(sections.get('expressions')): - raise LanguageError( - f"fragment '{name}' defines {key!r} whole, where '{next(iter(by_fragment))}' adds a share " - f'to it. An additive expression is a sum every fragment adds to: mark the definition in ' - f"'{name}' `additive: true`, or give one of the two a name of its own." - ) - if len(by_fragment) > 1: - for name in by_fragment: - if loaded[name].program.expressions[key].in_math: - raise LanguageError( - f"fragment '{name}' adds a share to {key!r} and reads it. On its own the fragment reads " - f'its share, and composed it would read the sum of every share: read the sum in a ' - f"fragment that adds nothing to it, under 'given: expressions:'." - ) - blocks = list(by_fragment.values()) - bodies = [cast('str', block['expression']) for block in blocks] - summed[key] = { - **blocks[0], - 'expression': bodies[0] if len(bodies) == 1 else ' + '.join(f'({body})' for body in bodies), - 'description': next((block['description'] for block in blocks if block.get('description')), None), + for key, entry in sums.items(): + terms = [ + (name, _as_mapping(_mapping(sections.get('expressions'))[key])) + for name, sections in sorted(read.items()) + if key in _mapping(sections.get('expressions')) + ] + for name, block in terms: + _one_term(key, entry, name, block, loaded[name]) + bodies = [cast('str', block['expression']) for _, block in terms] + if not bodies: + continue + term: dict[str, object] = { + 'expression': bodies[0] if len(bodies) == 1 else ' + '.join(f'({body})' for body in bodies) } + said = next((b['description'] for _, b in terms if b.get('description')), None) + if said is not None and not entry.get('description'): + term['description'] = said + summed[key] = term return summed -def _frames(loaded: Mapping[str, Spec]) -> dict[str, tuple[str, ...]]: - """The frame of every named expression the fragments define, as each fragment's own program reads it. +def _as_mapping(block: object) -> dict[str, object]: + """A named expression as ``to_dict`` wrote it, the one-line form read as its mapping.""" + return cast('dict[str, object]', block) if isinstance(block, dict) else {'expression': block} - A definition writes no frame: its body carries one. The body is the same - text in the composition, so the frame the fragment reads is the one a - given declaration is checked against. The shares of an additive expression - broadcast into their sum, so its frame is the union of theirs. - """ - frames: dict[str, tuple[str, ...]] = {} - for spec in loaded.values(): - for name, declaration in spec.program.expressions.items(): - frames[name] = tuple(dict.fromkeys((*frames.get(name, ()), *declaration.dims))) - return frames + +def _one_term(key: str, entry: Mapping[str, object], name: str, block: Mapping[str, object], spec: Spec) -> None: + """Refuse a term of the sum *key* that cannot be summed as written, or that its own file misreads.""" + if block.get('cases'): + raise LanguageError( + f"fragment '{name}' adds a term to {key!r} written as `cases:`. The terms of a sum are summed as " + f'written, and a set of cases is no one body: name the cased term as its own expression in ' + f"'{name}', and write that name as the term." + ) + stated = cast('list[str]', entry['dims']) + frame = spec.program.expressions[key].dims + if extra := sorted(set(frame) - set(stated)): + raise LanguageError( + f"fragment '{name}' adds a term to {key!r} over {sorted(frame)}, where the sum is over " + f'{sorted(stated)}. A term carries no dimension its sum does not state: add {extra} to the ' + f'dims of the marked `given:` entry, or leave them out of the term.' + ) + marks = key in spec.given.expressions and spec.given.expressions[key].additive + if not marks and _reads(spec, key): + raise LanguageError( + f"fragment '{name}' adds a term to {key!r} and reads it. On its own the fragment reads its term, " + f'and composed it would read the sum of every term: mark {key!r} `additive: true` under ' + f"'given: expressions:' in '{name}' to read the sum, or read it in another file." + ) + + +def _reads(spec: Spec, key: str) -> bool: + """Whether the math or another named expression of *spec* names *key*.""" + program = spec.program + others = [entry.expression for name, entry in program.expressions.items() if name != key] + return any(isinstance(node, Named) and node.name == key for node in walk(*program.roots, *others)) def _folded( - read: Mapping[str, dict[str, object]], merged: Mapping[str, object], frames: Mapping[str, tuple[str, ...]] + read: Mapping[str, dict[str, object]], + merged: Mapping[str, object], + loaded: Mapping[str, Spec], + readings: Mapping[str, dict[str, object]], ) -> dict[str, object]: """The ``given:`` block the composition still carries, once every reading a sibling introduces is spent. @@ -307,17 +359,20 @@ def _folded( Where the sibling is in the composition the expectation is checked and then dropped, so the composed model declares the name once. A given expression is checked against the frame of the definition's body: the body - carries no dimension the reader does not state. + carries no dimension the reader does not state. A marked entry is kept + beside the sum, so the composed model still says the name is one. """ asked = {name: _mapping(sections.get('given')) for name, sections in read.items()} left: dict[str, object] = {} for kind, label in GIVEN_KINDS.items(): introduced = _mapping(merged.get(kind)) - agreed = _agreed(asked, kind, label) + agreed = readings if kind == 'expressions' else _agreed(asked, kind, label) for key, block in agreed.items(): _same_kind(asked, read, merged, kind, key) + if kind == 'expressions' and _mapping(block).get('additive'): + continue if key in introduced and kind == 'expressions': - _within_frame(asked, read, key, block, frames[key]) + _within_frame(asked, read, key, block, _definer_frame(loaded, key)) elif key in introduced and not _says_less(block, introduced[key]): raise LanguageError( f"fragment '{_author_of(asked, kind, key)}' reads the {label} {key!r} as {block!r}, where " @@ -325,11 +380,21 @@ def _folded( f'says the same as the declaration it is folded into, or less: restate the frame as the ' f'introducer declares it, or leave the field out.' ) - if remaining := {key: block for key, block in agreed.items() if key not in introduced}: - left[kind] = remaining + kept = { + key: block + for key, block in agreed.items() + if key not in introduced or (kind == 'expressions' and _mapping(block).get('additive')) + } + if kept: + left[kind] = kept return left +def _definer_frame(loaded: Mapping[str, Spec], key: str) -> tuple[str, ...]: + """The frame of the one definition of *key*, as the fragment that writes it reads it.""" + return next(spec.program.expressions[key].dims for spec in loaded.values() if key in spec.program.expressions) + + #: The given kinds whose names share the flat namespace an expression reads. #: A row family is named only in ``dual()``, apart from it. READ_KINDS = ('parameters', 'variables', 'expressions') diff --git a/src/math_spec/dimensions.py b/src/math_spec/dimensions.py index 7dcef60b..3240b726 100644 --- a/src/math_spec/dimensions.py +++ b/src/math_spec/dimensions.py @@ -292,6 +292,16 @@ def check_schema(schema: Spec, program: Program) -> None: _check_where_dims(region.when, frame, context) _check_value_dims(region.value, schema, frame, context) + for ename, sum_entry in schema.given.expressions.items(): + if not (sum_entry.additive and ename in program.expressions): + continue + if extra := sorted(set(program.expressions[ename].dims) - set(sum_entry.dims)): + raise DimensionError( + f"Named expression '{ename}' carries {extra}, which the sum it adds a term to does not: " + f"its `given:` entry states {sum_entry.dims}. Add {extra} to the entry's dims, or leave " + f'them out of the term.' + ) + for cname, constraint in program.constraints.items(): frame = frozenset(constraint.dims) context = f"Constraint '{cname}'" diff --git a/src/math_spec/lowering.py b/src/math_spec/lowering.py index 6239d24c..89e9f379 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -20,6 +20,7 @@ from math_spec.dimensions import check_schema, dims_of from math_spec.errors import SchemaError, prefixed from math_spec.expansion import expand, parse_template +from math_spec.model import defined_sums from math_spec.piecewise import assumptions_of, curve_frame, lp_domain_refusal, resolve_links from math_spec.program import ( Assumption, @@ -197,6 +198,7 @@ def lower(schema: Spec) -> Program: assert assumption is not None and not errors, 'what a method assumes is stated in the language' assumptions[aname] = assumption + sums = defined_sums(schema) program = Program( parameters={ name: ParameterDeclaration(tuple(pdef.dims), pdef.dtype, pdef.description) @@ -220,8 +222,9 @@ def lower(schema: Spec) -> Program: entry.body, _frame_of(name, entry, schema), in_math=name in in_math, - description=schema.expressions[name].description, - additive=schema.expressions[name].additive, + description=schema.expressions[name].description + or (schema.given.expressions[name].description if name in sums else None), + additive=name in sums, ) for name, entry in entries.items() }, @@ -237,7 +240,9 @@ def lower(schema: Spec) -> Program: name: GivenDeclaration(tuple(g.dims), g.description) for name, g in schema.given.constraints.items() }, expressions={ - name: GivenDeclaration(tuple(g.dims), g.description) for name, g in schema.given.expressions.items() + name: GivenDeclaration(tuple(g.dims), g.description, additive=g.additive) + for name, g in schema.given.expressions.items() + if name not in sums }, ), description=schema.description, diff --git a/src/math_spec/model.py b/src/math_spec/model.py index 72cfb77b..77979c89 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -333,11 +333,18 @@ class GivenExpressionBlock(_StrictBlock): over that frame, affine in the columns, as it reads a given variable: the body is the definer's, and the composed model holds the body to the rules of every place this file reads it. + + ``additive: true`` says the name is a sum other files add terms to. Each + of them declares its term as an ordinary named expression under the name, + and :func:`~math_spec.composition.merge` sums the terms. A file that marks + the name may declare a term of its own too, and then reads the sum so far. """ _label: ClassVar[str] = 'a given expression declaration' dims: list[str] + #: Whether the name is a sum other files add terms to. + additive: bool = False description: str | None = None @@ -463,9 +470,6 @@ class ExpressionBlock(_StrictBlock): cases: Annotated[dict[str, ExpressionCase], Field(min_length=1)] = {} #: The value wherever no case's ``when`` holds, printed as the last row. otherwise: Expression | None = None - #: One share of a sum: :func:`~math_spec.composition.merge` adds the shares - #: every fragment defines under this name. - additive: bool = False description: str | None = None @model_validator(mode='before') @@ -508,13 +512,6 @@ def _one_form_or_the_other(self) -> Self: 'are none here. A value that holds everywhere is a plain `expression:`.' ) raise ValueError(msg) - if self.additive and self.cases: - msg = ( - '`additive: true` takes one `expression:`, and this has `cases:`. The shares are summed ' - 'as written, and a region belongs inside one share: write the share as its own ' - 'expression with a `cases:` entry, and add that name.' - ) - raise ValueError(msg) return self @classmethod @@ -533,14 +530,9 @@ def _as_written(self) -> str | dict[str, object]: written['otherwise'] = self.otherwise return written assert self.expression is not None - if self.description is None and not self.additive: + if self.description is None: return self.expression - written = {'expression': self.expression} - if self.additive: - written['additive'] = True - if self.description is not None: - written['description'] = self.description - return written + return {'expression': self.expression, 'description': self.description} class AssumptionBlock(_StrictBlock): @@ -992,6 +984,15 @@ def _lower(self) -> Spec: return self +def defined_sums(schema: Spec) -> frozenset[str]: + """The names *schema* marks ``additive`` under ``given: expressions:`` and declares a term of itself. + + Such a name is defined in the file, and reads as the sum so far: it is not + a name the file reads from elsewhere, so it joins no given group. + """ + return frozenset(name for name, g in schema.given.expressions.items() if g.additive and name in schema.expressions) + + def _formulations(asked: tuple[str, ...]) -> tuple[Formulation, ...]: """What *asked* names, in :data:`FORMULATIONS` order — all of them where it names none. diff --git a/src/math_spec/program.py b/src/math_spec/program.py index 485c2d42..f136e893 100644 --- a/src/math_spec/program.py +++ b/src/math_spec/program.py @@ -622,6 +622,8 @@ class GivenDeclaration: dims: tuple[str, ...] description: str | None = None + #: A given expression marked as a sum other files add terms to. + additive: bool = False @dataclass(frozen=True) @@ -713,7 +715,8 @@ class ExpressionDeclaration: dims: tuple[str, ...] in_math: bool description: str | None = None - #: One share of a sum other files add terms to, as the file marks it. + #: A sum other files add terms to: the file marks the name so under + #: ``given: expressions:``, and this body is the sum so far. additive: bool = False diff --git a/src/math_spec/resolution.py b/src/math_spec/resolution.py index 7943c48b..40ea113e 100644 --- a/src/math_spec/resolution.py +++ b/src/math_spec/resolution.py @@ -35,6 +35,7 @@ from math_spec.errors import LanguageError, SchemaError, case_context, prefixed from math_spec.exclusivity import overlapping from math_spec.expansion import expand, parse_and_expand +from math_spec.model import defined_sums from math_spec.program import ( BooleanLiteral, Cases, @@ -86,7 +87,9 @@ def __init__(self, schema: Spec) -> None: #: dim-checked against, since macros, named expressions and the dim #: rules read declarations the flat listing below does not carry. self.schema = schema - variables = {**schema.variables, **schema.given.variables, **schema.given.expressions} + defined = defined_sums(schema) + given = {name: g for name, g in schema.given.expressions.items() if name not in defined} + variables = {**schema.variables, **schema.given.variables, **given} parameters = {**schema.parameters, **schema.given.parameters} #: Every name an expression reads as a column: the variables, and the #: given expressions, whose bodies another file holds. diff --git a/src/math_spec/typesetting/legend.py b/src/math_spec/typesetting/legend.py index 5214b449..cfe38035 100644 --- a/src/math_spec/typesetting/legend.py +++ b/src/math_spec/typesetting/legend.py @@ -171,7 +171,8 @@ def glossaries(self, noticed: Noticed, defined: Iterable[str]) -> list[tuple[str *( self._entry( self.symbols.name[g], - f'{fmt.mono(g)}{self._over(list(block.dims))}, an expression another file defines', + f'{fmt.mono(g)}{self._over(list(block.dims))}, ' + + ('a sum other files add terms to' if block.additive else 'an expression another file defines'), block.description, ) for g, block in program.given.expressions.items() diff --git a/src/math_spec/validation.py b/src/math_spec/validation.py index 5b7079c0..b052f59a 100644 --- a/src/math_spec/validation.py +++ b/src/math_spec/validation.py @@ -24,7 +24,7 @@ from math_spec._yaml import read_model from math_spec.errors import SchemaError -from math_spec.model import NUMERIC_DTYPES, Spec, side_columns +from math_spec.model import NUMERIC_DTYPES, Spec, defined_sums, side_columns from math_spec.operators import BUILTIN_NAMES from math_spec.piecewise import Emitted as EmittedCurve from math_spec.sos import Emitted as EmittedSet @@ -89,6 +89,7 @@ def reference_errors(schema: Spec) -> list[str]: *_sos_bounds(schema), *_piecewise_references(schema), *_given_constraint_collisions(schema), + *_cased_terms(schema), ] @@ -107,7 +108,7 @@ def _flat_namespace(schema: Spec) -> list[tuple[str, Iterable[str]]]: ('variable', schema.variables), ('given variable', schema.given.variables), ('named expression', schema.expressions), - ('given expression', schema.given.expressions), + ('given expression', {n: g for n, g in schema.given.expressions.items() if n not in defined_sums(schema)}), ('macro', schema.macros), ] @@ -146,6 +147,17 @@ def _given_constraint_collisions(schema: Spec) -> Iterator[str]: ) +def _cased_terms(schema: Spec) -> Iterator[str]: + """A term of a sum is one expression, since the terms are summed as written.""" + for name in sorted(defined_sums(schema)): + if schema.expressions[name].cases: + yield ( + f"Named expression '{name}': a term of a sum is one `expression:`, and this has `cases:`. " + f'The terms are summed as written: name the cased term as its own expression, and write ' + f'that name as the term.' + ) + + def _frame_dimensions(schema: Spec) -> Iterator[str]: """Every frame is a product of distinct, declared dimensions.""" frames = [ diff --git a/tests/test_additive.py b/tests/test_additive.py index b1fe555b..d41c85ca 100644 --- a/tests/test_additive.py +++ b/tests/test_additive.py @@ -5,9 +5,11 @@ """A named expression several fragments add terms to. A balance reads what every component puts into a bus, and a component file -says what it puts there. With `additive: true` each component file defines its -own share under one name, and `merge` sums the shares, so a new component is a -new file and the balance does not change. +says what it puts there. The file that reads the total marks it +`additive: true` on its `given:` entry, and states its frame once. Each +component declares its term as an ordinary named expression under that name, +and `merge` sums the terms. The marked entry stays in the composed model, so a +later merge adds more. """ from __future__ import annotations @@ -17,144 +19,236 @@ from math_spec import FORMATS, LanguageError, merge, override, to_markdown, to_spec, typeset DIMS = {'snapshot': {'dtype': 'int'}, 'bus': {'dtype': 'str'}} +INJECTION = 'what the components put into a bus' -#: The balance: it reads the total and defines none of it. +#: The balance: it says `injection` is a sum over its frame, and adds nothing to it. BALANCE = { 'dimensions': DIMS, - 'given': {'expressions': {'injection': {'dims': ['snapshot', 'bus']}}}, + 'given': {'expressions': {'injection': {'dims': ['snapshot', 'bus'], 'additive': True, 'description': INJECTION}}}, 'constraints': {'balance': {'dims': ['snapshot', 'bus'], 'expression': 'injection == 0'}}, } -#: A generator fleet, adding what it produces to the injection. +#: A generator fleet: its term is an ordinary named expression. FLEET = { 'dimensions': {**DIMS, 'generator': {'dtype': 'str'}}, 'relations': {'gen_bus': {'key': 'generator', 'values': 'bus'}}, 'variables': {'gen_p': {'dims': ['snapshot', 'generator'], 'bounds': {'lower': 0}}}, - 'expressions': { - 'injection': { - 'additive': True, - 'expression': 'sum(gen_p, by=gen_bus, over=generator, into=bus)', - 'description': 'what the components put into a bus', - } - }, + 'expressions': {'injection': 'sum(gen_p, by=gen_bus, over=generator, into=bus)'}, 'objective': {'sense': 'minimize', 'expression': 'sum(gen_p)'}, } -#: A demand, subtracting what it draws from the injection. +#: A demand, subtracting what it draws. DEMAND = { 'dimensions': DIMS, 'parameters': {'load': {'dims': ['snapshot', 'bus']}}, - 'expressions': {'injection': {'additive': True, 'expression': '-load'}}, + 'expressions': {'injection': '-load'}, +} + +#: A store, added in a second merge. +STORAGE = { + 'dimensions': {**DIMS, 'store': {'dtype': 'str'}}, + 'relations': {'store_bus': {'key': 'store', 'values': 'bus'}}, + 'variables': {'store_p': {'dims': ['snapshot', 'store']}}, + 'expressions': {'injection': 'sum(store_p, by=store_bus, over=store, into=bus)'}, +} + +#: A reader that states the frame and does not say the name is a sum. +CAPPED = { + 'dimensions': DIMS, + 'given': {'expressions': {'injection': {'dims': ['snapshot', 'bus']}}}, + 'constraints': {'capped': {'dims': ['snapshot', 'bus'], 'expression': 'injection <= 10'}}, } -def test_a_share_loads_on_its_own_as_the_expression_it_writes(): - spec = to_spec(FLEET) - assert spec.expressions['injection'].additive - assert spec.program.expressions['injection'].additive, 'the program carries the flag the legend prints' +# --------------------------------------------------------------------------- +# one file +# --------------------------------------------------------------------------- + + +def test_a_reader_marks_the_sum_and_loads_on_its_own(): + program = to_spec(BALANCE).program + assert program.given.expressions['injection'].additive + assert program.given.expressions['injection'].dims == ('snapshot', 'bus') + + +def test_a_term_is_an_ordinary_named_expression(): + program = to_spec(FLEET).program + assert not program.expressions['injection'].additive, 'nothing in a plain contributor says it is a term' + + +#: A file that adds a term of its own and reads the sum: it carries both. +HUB = { + **FLEET, + 'given': {'expressions': {'injection': {'dims': ['snapshot', 'bus'], 'additive': True}}}, + 'constraints': {'capped': {'dims': ['snapshot', 'bus'], 'expression': 'injection <= 10'}}, +} -def test_merging_sums_the_shares_in_fragment_name_order(): +def test_a_file_may_add_a_term_and_read_the_sum_when_it_marks_it(): + """The marked entry says the file reads the sum so far, which alone is its own term.""" + program = to_spec(HUB).program + assert program.expressions['injection'].additive, 'the entry folds into the definition' + assert not program.given, 'a name this file defines is not one it reads from elsewhere' + + +def test_a_term_over_a_dimension_the_sum_does_not_state_is_refused_at_load(): + narrow = {**HUB, 'given': {'expressions': {'injection': {'dims': ['bus'], 'additive': True}}}} + narrow = { + **narrow, + 'constraints': {'capped': {'dims': ['bus'], 'expression': 'sum(injection, over=snapshot) <= 10'}}, + } + with pytest.raises(LanguageError, match=r"Named expression 'injection' carries \['snapshot'\]"): + to_spec(narrow) + + +def test_a_cased_term_beside_the_marked_entry_is_refused_at_load(): + cased = { + **DEMAND, + 'given': {'expressions': {'injection': {'dims': ['snapshot', 'bus'], 'additive': True}}}, + 'expressions': { + 'injection': { + 'dims': ['snapshot', 'bus'], + 'cases': {'peak': {'when': 'load > 5', 'expression': '-load'}}, + 'otherwise': '0', + } + }, + } + with pytest.raises(LanguageError, match=r'a term of a sum is one `expression:`'): + to_spec(cased) + + +# --------------------------------------------------------------------------- +# merge +# --------------------------------------------------------------------------- + + +def test_merging_sums_every_term_of_a_marked_name_in_fragment_name_order(): composed = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) - assert ( - composed.expressions['injection'].expression == '(-load) + (sum(gen_p, by=gen_bus, over=generator, into=bus))' + assert composed.expressions['injection'].expression == ( + '(-load) + (sum(gen_p, by=gen_bus, over=generator, into=bus))' ) - assert composed.expressions['injection'].additive, 'the sum stays open, so a later merge can add to it' - assert not composed.given, 'the balance reads the sum, which the composition defines' + assert composed.given.expressions['injection'].additive, 'the marked entry is kept' + assert not composed.program.given, 'and it folds into the definition, so the composed model reads nothing' + assert composed.program.expressions['injection'].additive def test_the_order_the_fragments_are_given_in_does_not_reach_the_sum(): - one = merge({'fleet': FLEET, 'demand': DEMAND}) - other = merge({'demand': DEMAND, 'fleet': FLEET}) + one = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) + other = merge({'balance': BALANCE, 'demand': DEMAND, 'fleet': FLEET}) assert one == other -def test_a_merged_sum_takes_more_shares_in_a_second_merge(): - """The flag survives the merge, so composing in two steps gives the same model as in one.""" - storage = { - 'dimensions': {**DIMS, 'store': {'dtype': 'str'}}, - 'relations': {'store_bus': {'key': 'store', 'values': 'bus'}}, - 'variables': {'store_p': {'dims': ['snapshot', 'store']}}, - 'expressions': { - 'injection': {'additive': True, 'expression': 'sum(store_p, by=store_bus, over=store, into=bus)'} - }, - } - stepwise = merge({'core': merge({'demand': DEMAND, 'fleet': FLEET}), 'storage': storage}) - assert 'store_p' in stepwise.expressions['injection'].expression - assert 'gen_p' in stepwise.expressions['injection'].expression +def test_a_composed_model_takes_more_terms_in_a_second_merge(): + """The kept entry is what lets a framework ship a composed model that a project adds to.""" + shipped = merge({'balance': BALANCE, 'demand': DEMAND, 'fleet': FLEET}) + extended = merge({'shipped': shipped, 'storage': STORAGE}) + assert 'store_p' in extended.expressions['injection'].expression + assert 'gen_p' in extended.expressions['injection'].expression -def test_the_first_description_is_carried(): - composed = merge({'fleet': FLEET, 'demand': DEMAND}) - assert composed.expressions['injection'].description == 'what the components put into a bus' +def test_the_sum_takes_the_readers_description(): + composed = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) + assert composed.program.expressions['injection'].description == INJECTION -def test_a_given_expression_is_checked_against_every_share(): - """A share over a narrower frame broadcasts into the sum, so the sum's frame is the union.""" - over_bus_only = {**DEMAND, 'parameters': {'load': {'dims': ['bus']}}} - composed = merge({'fleet': FLEET, 'demand': over_bus_only, 'balance': BALANCE}) - assert composed.program.expressions['injection'].dims == ('snapshot', 'bus') +def test_two_terms_and_no_marked_reader_collide_and_the_message_names_the_fix(): + with pytest.raises(LanguageError) as raised: + merge({'fleet': FLEET, 'demand': DEMAND}) + message = str(raised.value) + assert "both declare the expression 'injection'" in message + assert '`additive: true` on a `given: expressions:` entry' in message, 'the collision says how to make it a sum' -def test_a_given_expression_over_a_frame_no_share_sums_to_is_refused(): - narrow = { - 'dimensions': DIMS, - 'given': {'expressions': {'injection': {'dims': ['bus']}}}, - 'constraints': {'balance': {'dims': ['bus'], 'expression': 'injection == 0'}}, - } - with pytest.raises(LanguageError, match=r"reads the given expression 'injection' over \['bus'\]"): - merge({'fleet': FLEET, 'demand': DEMAND, 'balance': narrow}) +def test_one_term_and_an_unmarked_reader_is_an_ordinary_fold(): + composed = merge({'fleet': FLEET, 'capped': CAPPED}) + assert not composed.given + assert not composed.program.expressions['injection'].additive + + +def test_one_reader_marks_the_sum_and_another_states_the_frame(): + composed = merge({'balance': BALANCE, 'capped': CAPPED, 'demand': DEMAND, 'fleet': FLEET}) + assert composed.given.expressions['injection'].additive, 'the flag comes from either entry' + assert sorted(composed.constraints) == ['balance', 'capped'] -def test_an_expression_one_fragment_adds_to_and_another_owns_is_refused(): - owned = {**DEMAND, 'expressions': {'injection': {'expression': '-load'}}} - with pytest.raises(LanguageError, match=r"'demand' defines 'injection' whole, where 'fleet' adds a share"): - merge({'fleet': FLEET, 'demand': owned}) +def test_two_readers_that_disagree_about_the_frame_are_refused(): + narrow = {**CAPPED, 'given': {'expressions': {'injection': {'dims': ['bus']}}}} + narrow = {**narrow, 'constraints': {'capped': {'dims': ['bus'], 'expression': 'injection <= 10'}}} + with pytest.raises(LanguageError, match=r"say different things about the given expression 'injection'"): + merge({'balance': BALANCE, 'capped': narrow, 'fleet': FLEET}) -def test_a_fragment_that_reads_the_sum_it_adds_to_is_refused(): - """Alone it reads its own share; composed it would read the total, so the file would change meaning.""" - reads_itself = { - **FLEET, - 'constraints': {'capped': {'dims': ['snapshot', 'bus'], 'expression': 'injection <= 10'}}, +def test_a_term_over_a_dimension_the_sum_does_not_state_is_refused_by_merge(): + narrow = { + 'dimensions': DIMS, + 'given': {'expressions': {'injection': {'dims': ['bus'], 'additive': True}}}, + 'variables': {'slack': {'dims': ['bus']}}, + 'constraints': {'balance': {'dims': ['bus'], 'expression': 'injection + slack == 0'}}, } - with pytest.raises(LanguageError, match=r"'fleet' adds a share to 'injection' and reads it"): - merge({'fleet': reads_itself, 'demand': DEMAND}) + with pytest.raises(LanguageError, match=r"'fleet' adds a term to 'injection' over \['bus', 'snapshot'\]"): + merge({'balance': narrow, 'demand': {**DEMAND, 'parameters': {'load': {'dims': ['bus']}}}, 'fleet': FLEET}) -def test_one_share_alone_is_carried_as_written(): - composed = merge({'fleet': FLEET, 'balance': BALANCE}) - assert composed.expressions['injection'].expression == 'sum(gen_p, by=gen_bus, over=generator, into=bus)' +@pytest.mark.parametrize( + 'others', + [ + pytest.param({'demand': DEMAND}, id='beside-another-term'), + pytest.param({}, id='as-the-only-term'), + ], +) +def test_a_contributor_that_reads_the_sum_without_the_entry_is_refused(others): + """Alone it reads its own term; composed it would read the sum, so the file would mean two things.""" + reads_itself = {**FLEET, 'constraints': {'capped': {'dims': ['snapshot', 'bus'], 'expression': 'injection <= 10'}}} + with pytest.raises(LanguageError, match=r"'fleet' adds a term to 'injection' and reads it"): + merge({'balance': BALANCE, 'fleet': reads_itself, **others}) -def test_an_additive_expression_is_one_expression_and_not_cases(): +def test_a_cased_contributor_is_refused_by_merge(): cased = { **DEMAND, 'expressions': { 'injection': { - 'additive': True, 'dims': ['snapshot', 'bus'], 'cases': {'peak': {'when': 'load > 5', 'expression': '-load'}}, 'otherwise': '0', } }, } - with pytest.raises(LanguageError, match=r'`additive: true` takes one `expression:`'): - to_spec(cased) + assert to_spec(cased), 'nothing in the file alone says it is a term' + with pytest.raises(LanguageError, match=r"'demand' adds a term to 'injection' written as `cases:`"): + merge({'balance': BALANCE, 'demand': cased, 'fleet': FLEET}) -def test_a_patch_replaces_a_share_rather_than_adding_one(): - """`override` edits what is there; a new share is a new fragment for `merge`.""" - laid = override(DEMAND, {'double': {'expressions': {'injection': {'expression': '-2 * load'}}}}) +def test_a_marked_sum_nothing_adds_to_stays_under_given(): + """Zero would leave the balance a row with no variable, which the language refuses.""" + composed = merge({'balance': BALANCE, 'other': {'dimensions': DIMS}}) + assert composed.program.given.expressions['injection'].additive + + +def test_a_patch_replaces_a_term_rather_than_adding_one(): + """`override` edits what is there; a new term is a new fragment for `merge`.""" + laid = override(DEMAND, {'double': {'expressions': {'injection': '-2 * load'}}}) assert laid.expressions['injection'].expression == '-2 * load' - assert laid.expressions['injection'].additive, 'the patch names only the field it changes' -def test_the_legend_says_other_files_add_to_it(): - definitions = to_markdown(FLEET).split('#### Definitions')[1] +# --------------------------------------------------------------------------- +# printing +# --------------------------------------------------------------------------- + + +def test_the_reader_s_legend_says_other_files_add_to_it(): + given = to_markdown(BALANCE).split('#### Given')[1] + assert 'a sum other files add terms to' in given + + +def test_the_composed_legend_says_other_files_add_to_it(): + definitions = to_markdown(merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE})).split('#### Definitions')[ + 1 + ] assert 'a sum other files add terms to' in definitions @pytest.mark.parametrize('fmt', sorted(FORMATS)) -def test_a_share_prints_in_every_format(fmt): - assert typeset(merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}), fmt), f'{fmt} rendered nothing' +def test_a_reader_and_a_composition_print_in_every_format(fmt): + assert typeset(BALANCE, fmt), f'{fmt} rendered nothing for the reader' + assert typeset(merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}), fmt), f'{fmt}: the composition'