diff --git a/CHANGELOG.md b/CHANGELOG.md index d54c853a..1bf54364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docs/howto/compose.md b/docs/howto/compose.md index 480feee7..fc7b8fe2 100644 --- a/docs/howto/compose.md +++ b/docs/howto/compose.md @@ -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=` | diff --git a/src/mathspec/composition.py b/src/mathspec/composition.py index 84295e50..0f17f05c 100644 --- a/src/mathspec/composition.py +++ b/src/mathspec/composition.py @@ -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' @@ -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. @@ -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(): @@ -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 @@ -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(): @@ -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 @@ -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] = {} @@ -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 diff --git a/tests/test_composition.py b/tests/test_composition.py index 6d80bf46..a5e291f0 100644 --- a/tests/test_composition.py +++ b/tests/test_composition.py @@ -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'}} diff --git a/tests/test_terms.py b/tests/test_terms.py index 0728de01..21060a96 100644 --- a/tests/test_terms.py +++ b/tests/test_terms.py @@ -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})