From 475b9c5889a3dc4dedbed4637ba8cd2aaa7c705f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 20:18:15 +0000 Subject: [PATCH 1/2] Choose a merged description by fragment name, and let a reader's fill a gap A shared dimension or relation, a reading several fragments share, and a sum built from terms took the description of whichever fragment was passed first. Each now takes the first description in the fragments' name order, as the objective already did, so the order of the arguments reaches no field. The order of the declarations still follows the order passed in, which is presentation. A folded given declaration dropped its reader's description even where the declaration it folds into had none; it now fills that gap, and yields to the owner's own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1 --- docs/howto/compose.md | 6 ++-- src/mathspec/composition.py | 38 +++++++++++++++++++++---- tests/test_composition.py | 56 ++++++++++++++++++++++++++++++++----- tests/test_terms.py | 11 ++++++++ 4 files changed, 95 insertions(+), 16 deletions(-) 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}) From b5c947249aa0ab65d1f65651f5fc8e8e47436062 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 20:18:44 +0000 Subject: [PATCH 2/2] Add the changelog line for #739 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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)