Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp
- docs: the site follows the reader's light or dark setting, and a page shows where it sits in the navigation ([#727](https://github.com/energy-models/mathspec/pull/727))
- feat(language): a spec is composed from files that each state part of it, and patched with files that each change part of it ([#732](https://github.com/energy-models/mathspec/pull/732))
- feat(language): two files that state the same spec write one text, and `canonical --check` fails a file that is not in it ([#731](https://github.com/energy-models/mathspec/pull/731))
- fix(language): a merged spec's descriptions do not depend on the order the fragments are passed in, and a reader's fills one its owner left out ([#739](https://github.com/energy-models/mathspec/pull/739))

## 0.2.0 (2026-09-25)

Expand Down
6 changes: 3 additions & 3 deletions docs/howto/compose.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,13 @@ compose as `override(merge({…}), {…})`.
| 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 |
| a `description` on a shared dimension or relation | it is prose rather than a claim, and the first wording in fragment-name order is carried, whatever order the fragments are passed in |
| 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 |
| an entry under `given:` | it is checked against the fragment that introduces the name, then folded into it. Its description fills the declaration where the introducer wrote none |
| a given expression | the definition's body carries no dimension the reader's `dims` do not name |
| a given entry no fragment introduces | it stays under `given:` until a host model provides it |
| a given expression with a `term` | the name is defined as the definition one fragment writes, if any, plus every term by its name, in fragment-name order. Each term stays a named expression. A term that lands on a name no fragment defines, reads or uses is refused |
| `objective` | the terms are summed in fragment-name order, each in parentheses, and the senses agree. The first description is carried |
| `objective` | the terms are summed in fragment-name order, each in parentheses, and the senses agree. The first description in fragment-name order is carried |
| `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 spec's as `description=` |

Expand Down
38 changes: 32 additions & 6 deletions src/mathspec/composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@

* **A dimension or a relation every fragment may declare**, and the ones that
do have to say the same thing about it. Prose is not a claim, so two
descriptions of one dimension agree, and the first fragment's is carried.
descriptions of one dimension agree, and the first in the fragments' name
order is carried: the order they are passed in reaches no description.
* **Every other declaration is owned.** A name two fragments declare is refused,
both named.
* **The objectives are summed**, each term in parentheses, in the fragments'
Expand All @@ -37,7 +38,8 @@
* **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,
and a name read as one kind and introduced as another is refused.
and a name read as one kind and introduced as another is refused. A
reader's description fills a declaration its owner left undescribed.
Two fragments that both read a name have to read it over one frame. What no
fragment introduces stays under ``given:`` until a host model provides it.

Expand Down Expand Up @@ -209,7 +211,8 @@ def _agreed(read: Mapping[str, dict[str, object]], section: str, label: str) ->

Equality of the claims rather than "the same or less": between peers
neither declaration is the one being restated, so a field only one of them
writes is a difference nothing settles.
writes is a difference nothing settles. Prose is no claim: the first
description in the fragments' name order is carried.
"""
merged: dict[str, object] = {}
for name, sections in read.items():
Expand All @@ -222,9 +225,24 @@ def _agreed(read: Mapping[str, dict[str, object]], section: str, label: str) ->
f'them a name of its own.'
)
merged.setdefault(key, block)
for key, block in merged.items():
if said := _said(read, section, key):
merged[key] = {**_mapping(block), 'description': said}
return merged


def _said(read: Mapping[str, dict[str, object]], section: str, key: str) -> object:
"""The first description of *key* under *section* in the fragments' name order, which no argument order changes."""
return next(
(
said
for name in sorted(read)
if (said := _mapping(_mapping(read[name].get(section)).get(key)).get('description'))
),
None,
)


def _claims(block: object) -> object:
"""*block* without its prose, which is what the declaration says rather than a remark about it."""
return {key: value for key, value in block.items() if key != 'description'} if isinstance(block, dict) else block
Expand Down Expand Up @@ -270,7 +288,8 @@ def _agreed_readings(asked: Mapping[str, dict[str, object]]) -> dict[str, dict[s

Two readings of one name agree on the frame, compared as a set. A term is
the fragment's own and is summed by [`_summed`][], so it is no claim about
the name; prose is not one either, so the first description is carried.
the name; prose is not one either, so the first description in the
fragments' name order is carried.
"""
agreed: dict[str, dict[str, object]] = {}
for name, given in asked.items():
Expand All @@ -286,7 +305,8 @@ def _agreed_readings(asked: Mapping[str, dict[str, object]]) -> dict[str, dict[s
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['description'] = held.get('description') or entry.get('description')
for key, entry in agreed.items():
entry['description'] = _said(asked, 'expressions', key)
return agreed


Expand Down Expand Up @@ -411,7 +431,9 @@ def _folded(
Where the sibling is in the composition the expectation is checked and
then dropped, so the composed spec declares the name once. A given
expression is checked against the frame of the composed body: the body
carries no dimension the reader does not state.
carries no dimension the reader does not state. A reader's description
fills a declaration its owner left undescribed, and yields to one the
owner wrote.
"""
asked = {name: _mapping(sections.get('given')) for name, sections in read.items()}
left: dict[str, object] = {}
Expand All @@ -429,6 +451,10 @@ 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 key in introduced and (said := _mapping(block).get('description')):
owned = _as_mapping(introduced[key])
if not owned.get('description'):
introduced[key] = {**owned, 'description': said}
kept = {key: block for key, block in agreed.items() if key not in introduced}
if kept:
left[kind] = kept
Expand Down
56 changes: 49 additions & 7 deletions tests/test_composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,16 +159,58 @@ def test_a_disagreement_between_fragments_is_refused(fragments, says):
assert all(f"'{name}'" in message for name in fragments), 'a disagreement names both fragments'


def test_two_descriptions_of_one_dimension_agree_and_the_first_is_carried():
"""Prose is not a claim, so two fragments describing one dimension in their own words agree about it."""
first = {**SUPPLY, 'dimensions': {**SUPPLY['dimensions'], 'snapshot': {'dtype': 'int', 'description': 'an hour'}}}
second = {**DEMAND, 'dimensions': {**DEMAND['dimensions'], 'snapshot': {'dtype': 'int', 'description': 'a step'}}}
composed = merge({'supply': first, 'demand': second})
assert composed.dimensions['snapshot'].description == 'an hour', (
"the claim is carried whole, under the first fragment's wording of the prose"
def _said(fragment: dict[str, object], section: str, name: str, words: str) -> dict[str, object]:
"""*fragment* with the declaration *name* under *section* described as *words*."""
block = copy.deepcopy(fragment)
entries = block[section] if section != 'given' else block['given']['variables']
entries[name] = {**entries[name], 'description': words}
return block


#: Two fragments that word one declaration differently, and the wording the
#: first fragment in name order gives: `demand` sorts before `supply`.
WORDED = [
pytest.param('dimensions', 'snapshot', id='a-shared-dimension'),
pytest.param('given', 'flow', id='a-reading-nothing-introduces'),
]


@pytest.mark.parametrize(('section', 'name'), WORDED)
def test_prose_two_fragments_word_apart_is_the_first_in_name_order(section, name):
"""Prose is not a claim, so two wordings agree; which one is carried is decided by name, not by argument order.

Merging once passed the wording of whichever fragment came first in the call.
"""
supply, demand = _said(SUPPLY, section, name, 'an hour'), _said(DEMAND, section, name, 'a step')
one = merge({'supply': supply, 'demand': demand})
other = merge({'demand': demand, 'supply': supply})
assert one == other, 'the order the fragments are passed in reaches no field, prose included'
carried = one.dimensions[name] if section == 'dimensions' else one.given.variables[name]
assert carried.description == 'a step', "the claim is carried whole, under the wording of 'demand'"


def test_a_peer_s_description_is_carried_where_the_first_in_name_order_has_none():
supply = _said(SUPPLY, 'dimensions', 'snapshot', 'an hour')
assert merge({'demand': DEMAND, 'supply': supply}).dimensions['snapshot'].description == 'an hour', (
"'demand' sorts first and says nothing, so the wording of 'supply' is carried"
)


@pytest.mark.parametrize(
('owner', 'carried'),
[
pytest.param(None, 'what a port puts into its bus', id='the-owner-says-nothing'),
pytest.param('a flow', 'a flow', id='the-owner-s-own-wins'),
],
)
def test_a_reader_s_description_fills_a_declaration_that_has_none(owner, carried):
"""A reader's words about a name were dropped when the name was folded, even where the owner wrote none."""
surface = _said(SURFACE, 'variables', 'flow', owner) if owner else SURFACE
supply = _said(SUPPLY, 'given', 'flow', 'what a port puts into its bus')
composed = merge({'surface': surface, 'supply': supply, 'demand': DEMAND})
assert composed.variables['flow'].description == carried


def test_the_objectives_are_summed_each_term_parenthesised():
"""`a + b * k` reassociates, so an unparenthesised join composes a different objective."""
priced = {**DEMAND, 'objective': {'sense': 'minimize', 'expression': 'sum(dem_load) * 2'}}
Expand Down
11 changes: 11 additions & 0 deletions tests/test_terms.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,17 @@ def test_the_sum_takes_the_readers_description():
assert composed.program.expressions['injection'].description == INJECTION


def test_two_readers_that_word_the_sum_apart_give_it_the_first_wording_in_name_order():
"""The sum once took the wording of whichever reader was passed first."""
capped = {**BALANCE, 'given': {'expressions': {'injection': {'dims': FRAME, 'description': 'a cap'}}}}
capped = {**capped, 'constraints': {'capped': {'dims': FRAME, 'expression': 'injection <= 10'}}}
for fragments in (
{'capped': capped, 'balance': BALANCE, 'fleet': FLEET},
{'balance': BALANCE, 'capped': capped, 'fleet': FLEET},
):
assert merge(fragments).expressions['injection'].description == INJECTION, "the wording of 'balance'"


def test_a_composed_spec_takes_more_terms_in_a_second_merge():
"""A composed definition is one a fragment wrote, so a later term adds to it like any other."""
shipped = merge({'balance': BALANCE, 'demand': DEMAND, 'fleet': FLEET})
Expand Down
Loading