diff --git a/CHANGELOG.md b/CHANGELOG.md index f4214b45..0ce6e8cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version +- feat(composition): fragments join the properties of a subject, and a set two fragments declare differently is refused ([#3](https://github.com/fluxopt/mathspec/pull/3)) - feat(language): a call reads a property of a dimension or a set, written subject[property] ([#2](https://github.com/fluxopt/mathspec/pull/2)) - feat(language): a file may declare sets of labels and the properties of a dimension or a set ([#1](https://github.com/fluxopt/mathspec/pull/1)) - feat(language): a parameter may declare the coordinates it has with `where:`, and outside them it is absent ([#945](https://github.com/energy-models/mathspec/pull/945)) diff --git a/docs/howto/compose.md b/docs/howto/compose.md index 7e496494..cecdfc4a 100644 --- a/docs/howto/compose.md +++ b/docs/howto/compose.md @@ -200,21 +200,22 @@ which each component pins at its own port. ## 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 wording in the list is carried | -| `ordered: true` on a shared dimension | it says that the members have an order and does not change them, so the dimension is ordered if one fragment says so | -| `missing:` on a shared relation | it says what a missing row of the data means, so every fragment that declares the relation gives the same value. `missing: refused` is the same as no `missing:` | -| any other entry | 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. Its description fills the entry where the introducer wrote none | -| a given expression | the body of the definition carries no dimension that the `dims` of the reading fragment do not name | -| a given condition | the `dims` of the reading fragment name exactly the dimensions that the condition of the defining fragment reads | -| a given entry no fragment introduces | it stays under `given:` until a host model provides it | -| an expression with `adds_to:` | the sum it names is the body one fragment defines, if any, followed by every term by its name, in the order of the list. [Terms](../reference/language/parameters-variables-constraints.md#terms) gives what is refused | -| `objective` | one fragment sets it, and a second is refused. Several fragments contribute to it as terms of a sum the objective reads | -| `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=` | +| The entry | What happens | +| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| a dimension, a relation or a set | every fragment may declare it, and the ones that do say the same thing about it | +| the properties of one subject | every fragment may give the subject properties, and the subject has all of them. A property that two fragments declare says the same thing in both ([properties](../reference/language/sets.md#properties)) | +| a `description` on a shared entry | it is prose rather than a claim, and the first wording in the list is carried | +| `ordered: true` on a shared dimension | it says that the members have an order and does not change them, so the dimension is ordered if one fragment says so | +| `missing:` on a shared relation, set or property | it says what a missing row of the data means, so every fragment that declares the entry gives the same value. `missing: refused` is the same as no `missing:` | +| any other entry | 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. Its description fills the entry where the introducer wrote none | +| a given expression | the body of the definition carries no dimension that the `dims` of the reading fragment do not name | +| a given condition | the `dims` of the reading fragment name exactly the dimensions that the condition of the defining fragment reads | +| a given entry no fragment introduces | it stays under `given:` until a host model provides it | +| an expression with `adds_to:` | the sum it names is the body one fragment defines, if any, followed by every term by its name, in the order of the list. [Terms](../reference/language/parameters-variables-constraints.md#terms) gives what is refused | +| `objective` | one fragment sets it, and a second is refused. Several fragments contribute to it as terms of a sum the objective reads | +| `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=` | ## A name two fragments declare @@ -364,9 +365,11 @@ Given variable 'Generator_p' collides with the variable of the same name. Rename | `null` under an entry's name | it is removed | | `null` on a field of an entry | the field takes its default, and the rest of the entry stays | | `null` under a section's name | it is refused | -| a dimension or a relation | it is added, or restated as the base declares it | +| a dimension, a relation, a set | it is added, or restated as the base declares it | +| a property | it is added, or restated as the base declares it | | `ordered:` on a restated dimension | `true` makes the dimension ordered; `false` over an ordered one is refused | -| `missing:` on a relation | it changes whether every key combination needs a row, as on a parameter | +| `missing:` on a relation, a set | it changes whether every key combination needs a row, as on a parameter | +| `missing:` on a property | it changes whether every row of the subject has the property | | an entry under one kind of `given:` | it is edited, added or removed like any entry, and the other kinds stay | | `version`, `description` | the patch's value replaces the base's | | a field an earlier patch writes | the later patch's value replaces it | @@ -389,8 +392,8 @@ as the base spells it. ## A dimension redeclared -A patch may add a dimension or a relation, and may restate one the base -declares. The restatement says what the base says: half an entry is a +A patch may add a dimension, a relation, a set or a property, and may +restate one the base declares. The restatement says what the base says: half an entry is a second reading of the same name, and a field written at its default is the same as one left out. Changing one under the expressions already written over it is refused, and so is removing one: @@ -409,8 +412,8 @@ patch 'unordered.yaml' says the dimension 'snapshot' is not ordered, where its b ``` `missing:` is a claim about the data, not the coordinate space. A patch may -change it on a relation as on a parameter, and may name it alone: -`relations: {gen_bus: {missing: absent}}`. `null` puts the default, `refused`, +change it on a relation, a set or a property as on a parameter, and may name +it alone: `properties: {line: {bus1: {missing: absent}}}`. `null` puts the default, `refused`, back. ## A section set to `null` diff --git a/src/mathspec/_composition.py b/src/mathspec/_composition.py index 3db20585..a2b4916d 100644 --- a/src/mathspec/_composition.py +++ b/src/mathspec/_composition.py @@ -19,16 +19,18 @@ [`merge`][] writes nothing a fragment did not write, except a ``+``. It joins fragments' text in one place, a named expression that terms add to; every other entry is copied as written, refused where two fragments own it, -or held identical where it is a dimension or a relation. +or held identical where it is a dimension, a relation, a set or a property. What that means for each section: -* **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 +* **A dimension, a relation, a set or a property every fragment may + declare**, and the ones that do have to say the same thing about it. The + properties of one subject are joined, so two fragments may each give + ``generator`` a property of its own. Prose is not a claim, so two descriptions of one dimension agree, and the first one given is carried. ``ordered`` is a claim about the space, not the space, so a dimension one fragment declares ordered is ordered. - A relation's ``missing:`` is a claim about the data, and the fragments - agree on it: ``absent`` and ``refused`` change the result differently, so + The ``missing:`` of a relation, a set or a property is a claim about the + data, and the fragments agree on it: ``absent`` and ``refused`` change the result differently, so neither folds into the other. ``missing: refused`` is the same as none. * **Every other entry is owned.** A name two fragments declare is refused, both named. @@ -79,12 +81,13 @@ * **Patches are laid in order.** Each is laid on the base with every earlier patch laid on it, so a later patch wins a field an earlier one writes, and edits or removes an entry an earlier one creates. -* **A patch adjusts the math, not the coordinate space.** A ``dimensions`` or - ``relations`` entry may be added or restated as the schema reads its base, - never changed and never removed. A restated dimension may add +* **A patch adjusts the math, not the coordinate space.** A dimension, a + relation, a set or a property may be added or restated as the schema reads + its base, never changed and never removed. A restated dimension may add ``ordered: true``, and may not write it false over a base that makes it. - A relation's ``missing:`` is a claim about the data, not the space: a - patch may change it as it changes a parameter's, and may name it alone. + The ``missing:`` of a relation, a set or a property is a claim about the + data, not the space: a patch may change it as it changes a parameter's, and + may name it alone. * ``null`` **makes what it names absent.** An entry set to ``null`` is removed, and a removal of what the base does not declare is refused. A field set to ``null`` is dropped, and takes its default when the result loads: @@ -92,8 +95,6 @@ set to ``null`` is refused, because it removes nothing. * **``given:`` is laid over one kind at a time**, by the same rules as any owned section. -* **``properties:`` is laid over one subject at a time**, by the same rules, - so a patch adds, edits or removes one property and keeps the others. """ from __future__ import annotations @@ -116,12 +117,14 @@ from mathspec.program import Program -SHARED_SECTIONS = ('dimensions', 'relations') +SHARED_SECTIONS = ('dimensions', 'relations', 'sets') +#: Every section a fragment owns. ``properties:`` is shared too, one property +#: at a time, which [`_flat`][] lays out for the rules of a shared entry. OWNED_SECTIONS = tuple( name for name, field in Spec.model_fields.items() - if get_origin(field.annotation) is dict and name not in SHARED_SECTIONS + if get_origin(field.annotation) is dict and name not in (*SHARED_SECTIONS, 'properties') ) GIVEN_KINDS = { @@ -139,7 +142,6 @@ 'piecewise': 'piecewise curve', 'sos': 'special-ordered set', 'objective': 'objective', - 'properties': 'properties of', } @@ -160,7 +162,7 @@ def merge(fragments: Sequence[Source], description: str | None = None) -> Spec: Raises: LanguageError: A fragment does not load on its own; two fragments declare one name; two fragments say different things about one - dimension, relation or given entry; a fragment reads a name as + dimension, relation, set, property or given entry; a fragment reads a name as something other than what its sibling introduces, as another kind of thing, or over fewer dimensions than its body carries; a fragment adds a term to a variable, a parameter, a constraint or a @@ -181,6 +183,11 @@ def merge(fragments: Sequence[Source], description: str | None = None) -> Spec: for section in SHARED_SECTIONS: if agreed := _agreed(read, section, _singular(section), 'give one of them a name of its own'): merged[section] = agreed + flat: dict[str, dict[str, object]] = { + name: {'properties': _flat(sections.get('properties'))} for name, sections in read.items() + } + if properties := _agreed(flat, 'properties', 'property', 'give one of them a name of its own'): + merged['properties'] = _nested(properties) asked = {name: spec.given.model_dump(exclude_unset=True) for name, spec in loaded.items()} readings = { kind: _agreed(asked, kind, label, 'read it over the same dims', claims=_reading_claims) @@ -322,14 +329,41 @@ def _joined(section: str, kept: object, entry: object, claims: Callable[[object] return {**_mapping(kept), 'ordered': True} if ordered else kept -def _declared(section: str, entry: object) -> dict[str, object]: - """*entry* as the schema reads it, so a field written at its default says what leaving it out says.""" +def _declared(cls: type[BaseModel], entry: object) -> dict[str, object]: + """*entry* as the schema reads it, every field present, so a field written at its default says what leaving it out says. + + The fields themselves, not the serialised form, which writes a property + as the bare dimension it maps into. + """ try: - return _entry_class(Spec, section).model_validate(entry).model_dump() + return dict(cls.model_validate(entry)) except ValidationError as e: raise schema_error(e) from None +def _flat(properties: object) -> dict[str, object]: + """``properties:`` as one entry per property, keyed ``subject.property`` and in the long form. + + The rules for a shared entry then read each property as they read a + relation. A subject and a property are names, so neither holds a dot, and + [`_nested`][] splits the key back without doubt. + """ + return { + f'{subject}.{prop}': {'into': entry} if isinstance(entry, str) else entry + for subject, group in _mapping(properties).items() + for prop, entry in _mapping(group).items() + } + + +def _nested(flat: Mapping[str, object]) -> dict[str, object]: + """*flat* back under ``subject: {property: ...}``, as a file writes it.""" + nested: dict[str, dict[str, object]] = {} + for key, entry in flat.items(): + subject, prop = key.split('.', 1) + nested.setdefault(subject, {})[prop] = entry + return cast('dict[str, object]', nested) + + def _claimed(read: Mapping[str, dict[str, object]], section: str) -> dict[str, object]: """One section of owned entries, a name claimed twice being the refusal.""" merged: dict[str, object] = {} @@ -853,8 +887,19 @@ def _given(declared: dict[str, object], patch: dict[str, object], name: str) -> return out -def _shared(declared: dict[str, object], patch: dict[str, object], section: str, name: str) -> dict[str, object]: - """One ``dimensions`` or ``relations`` section: a patch adds one or restates one, never changes or drops it. +def _shared( + declared: dict[str, object], + patch: dict[str, object], + section: str, + name: str, + *, + label: str | None = None, + cls: type[BaseModel] | None = None, +) -> dict[str, object]: + """One shared section: a patch adds an entry or restates one, never changes or drops it. + + The section is ``dimensions``, ``relations`` or ``sets``, or the properties + as [`_flat`][] lays them out, which pass their *label* and *cls*. The restatement is compared as the schema reads both sides rather than field by field: a patch that names half an entry is as much a second @@ -862,11 +907,12 @@ def _shared(declared: dict[str, object], patch: dict[str, object], section: str, field written at its default is no change. A dimension's ``ordered`` folds by [`_joined`][], so a patch may add the claim; writing it false over a base that makes it is the one narrowing [`_joined`][] cannot see. A - relation's ``missing:`` is laid over as a field and left out of the - comparison, being a claim about the data. + ``missing:`` on anything but a dimension is laid over as a field and left + out of the comparison, being a claim about the data. """ out = dict(declared) - singular = _singular(section) + singular = label or _singular(section) + schema = cls or _entry_class(Spec, section) for key, entry in patch.items(): if entry is None: raise LanguageError( @@ -876,12 +922,12 @@ def _shared(declared: dict[str, object], patch: dict[str, object], section: str, if key not in out: out[key] = entry continue - if section == 'relations' and 'missing' in _mapping(entry): + if section != 'dimensions' and 'missing' in _mapping(entry): out[key] = _reread(out[key], _mapping(entry)) entry = _without(entry, 'missing') if not entry: continue - base, laid = _declared(section, out[key]), _declared(section, entry) + base, laid = _declared(schema, out[key]), _declared(schema, entry) if base.get('ordered') and _mapping(entry).get('ordered') is False: raise LanguageError( f"patch '{name}' says the {singular} '{key}' is not ordered, where its base declares it " @@ -929,21 +975,20 @@ def _owned( def _properties(declared: dict[str, object], patch: dict[str, object], name: str) -> dict[str, object]: - """The ``properties:`` section, one subject laid over at a time, so a patch edits one property and keeps the rest. + """The ``properties:`` section, each property laid over as a relation is: added, restated, or its ``missing:`` changed. - A property the base writes in the short form is read as ``{into: ...}`` - first, so a patch that writes only its ``missing:`` edits it rather than - replacing it. + Both sides are read in the long form first, so a patch that writes only + a property's ``missing:`` changes that field of a property the base + writes as ``bus: bus``. """ - out = dict(declared) - for subject, over in patch.items(): - if over is None: - _removed(out, subject, 'properties of', name) - continue - standing = {p: {'into': e} if isinstance(e, str) else e for p, e in _mapping(out.get(subject)).items()} - section = _section(over, f'properties: {subject}', name) - out[subject] = _owned(standing, section, 'property', PropertySpec, name) - return out + if removed := [subject for subject, over in patch.items() if over is None]: + raise LanguageError( + f"patch '{name}' removes the properties of '{removed[0]}', which a patch cannot change. Leave " + f'them out of the patch, and remove the entries written over them one at a time.' + ) + laid = {subject: _section(over, f'properties: {subject}', name) for subject, over in patch.items()} + flat = _shared(_flat(declared), _flat(laid), 'properties', name, label='property', cls=PropertySpec) + return _nested(flat) def _removed(out: dict[str, object], key: str, label: str, name: str) -> None: diff --git a/tests/test_sets.py b/tests/test_sets.py index 4e3fb2d3..b388098a 100644 --- a/tests/test_sets.py +++ b/tests/test_sets.py @@ -298,27 +298,142 @@ def test_a_missing_that_reads_a_value_is_refused(path, written, fragment): ('patch', 'properties'), [ pytest.param('properties: {generator: {zone: zone}}\n', {'bus': 'bus', 'zone': 'zone'}, id='add'), + pytest.param('properties: {generator: {bus: bus}}\n', {'bus': 'bus'}, id='restate'), pytest.param( 'properties: {generator: {bus: {missing: absent}}}\n', {'bus': {'into': 'bus', 'missing': 'absent'}}, - id='edit-a-short-form', + id='change-the-missing-of-a-short-form', ), - pytest.param('properties: {generator: {bus: null}}\n', None, id='remove-the-last'), - pytest.param('properties: {generator: null}\n', None, id='remove-the-subject'), ], ) -def test_a_patch_lays_over_one_subject_at_a_time(patch, properties): +def test_a_patch_lays_a_property_over_as_a_relation(patch, properties): laid = override(to_spec(MODEL), [patch]).to_dict()['properties'] - assert laid.get('generator') == properties + assert laid['generator'] == properties assert laid['line'] == MODEL['properties']['line'], 'a patch to one subject keeps the others' -def test_a_patch_that_creates_a_property_writes_it_whole(): - with pytest.raises(LanguageError, match=r"edits the property 'zone'.*a property needs `into`"): - override(to_spec(MODEL), ['properties: {generator: {zone: {missing: absent}}}\n']) +@pytest.mark.parametrize( + ('patch', 'fragment'), + [ + pytest.param( + 'properties: {generator: {bus: null}}\n', + "patch '#1' removes the property 'generator.bus', which a patch cannot change.", + id='remove-a-property', + ), + pytest.param( + 'properties: {generator: null}\n', + "patch '#1' removes the properties of 'generator', which a patch cannot change.", + id='remove-a-subject', + ), + pytest.param( + 'properties: {line: {bus0: zone}}\n', + "patch '#1' declares the property 'line.bus0' as {'into': 'zone'}, where its base declares " + "{'into': 'bus'}.", + id='change-what-a-property-maps-into', + ), + pytest.param( + 'properties: {generator: {zone: {missing: absent}}}\n', + 'properties.generator.zone.into: Field required', + id='create-a-property-without-into', + ), + pytest.param( + 'sets: {generator_period: {dims: [generator, zone]}}\n', + "patch '#1' declares the set 'generator_period' as {'dims': ['generator', 'zone']}, where its base " + "declares {'dims': ['generator', 'period']}.", + id='change-the-dimensions-of-a-set', + ), + pytest.param( + 'sets: {cover: null}\n', + "patch '#1' removes the set 'cover', which a patch cannot change.", + id='remove-a-set', + ), + ], +) +def test_a_patch_that_changes_a_set_or_a_property_is_refused(patch, fragment): + with pytest.raises(LanguageError) as caught: + override(to_spec(MODEL), [patch]) + assert fragment in str(caught.value) + + +def test_a_patch_changes_the_missing_of_a_set(): + laid = override(to_spec(MODEL), ['sets: {connection: {missing: refused}}\n']).to_dict()['sets'] + assert laid['connection'] == {'dims': ['entity', 'port']}, 'refused is what leaving missing out says' + + +def _fragment(properties: dict[str, Any], sets: dict[str, Any] | None = None) -> dict[str, Any]: + dimensions = {'generator': {}, 'bus': {}, 'zone': {}, 'period': {'dtype': 'int'}} + return {'dimensions': dimensions, 'properties': properties, **({'sets': sets} if sets else {})} + + +@pytest.mark.parametrize( + ('fragments', 'properties'), + [ + pytest.param( + [_fragment({'generator': {'bus': 'bus'}}), _fragment({'generator': {'zone': 'zone'}})], + {'generator': {'bus': 'bus', 'zone': 'zone'}}, + id='two-properties-of-one-subject', + ), + pytest.param( + [_fragment({'generator': {'bus': 'bus'}}), _fragment({'generator': {'bus': {'into': 'bus'}}})], + {'generator': {'bus': 'bus'}}, + id='one-property-in-both-forms', + ), + pytest.param( + [ + _fragment({'generator': {'bus': 'bus'}}), + _fragment({'generator': {'bus': {'into': 'bus', 'description': 'where it injects'}}}), + ], + {'generator': {'bus': {'into': 'bus', 'description': 'where it injects'}}}, + id='a-description-is-prose', + ), + ], +) +def test_merging_joins_the_properties_of_a_subject(fragments, properties): + assert merge(fragments).to_dict()['properties'] == properties + + +@pytest.mark.parametrize( + ('fragments', 'fragment'), + [ + pytest.param( + [_fragment({'generator': {'site': 'bus'}}), _fragment({'generator': {'site': 'zone'}})], + "say different things about the property 'generator.site': {'into': 'bus'} against " + "{'into': 'zone'}. Make the two identical, or give one of them a name of its own.", + id='a-property-into-two-dimensions', + ), + pytest.param( + [ + _fragment({'generator': {'bus': 'bus'}}), + _fragment({'generator': {'bus': {'into': 'bus', 'missing': 'absent'}}}), + ], + "say different things about the property 'generator.bus'", + id='a-property-that-reads-a-missing-row-two-ways', + ), + pytest.param( + [ + _fragment({}, {'gp': {'dims': ['generator', 'period']}}), + _fragment({}, {'gp': {'dims': ['generator', 'period'], 'missing': 'absent'}}), + ], + "say different things about the set 'gp': {'dims': ['generator', 'period']} against " + "{'dims': ['generator', 'period'], 'missing': 'absent'}.", + id='a-set-that-reads-a-missing-row-two-ways', + ), + pytest.param( + [ + _fragment({}, {'gp': {'dims': ['generator', 'period']}}), + _fragment({}, {'gp': {'dims': ['period', 'generator']}}), + ], + "say different things about the set 'gp'", + id='a-set-with-its-members-in-another-order', + ), + ], +) +def test_merging_refuses_a_set_or_a_property_two_fragments_declare_differently(fragments, fragment): + with pytest.raises(LanguageError) as caught: + merge(fragments) + assert fragment in str(caught.value) -def test_two_fragments_that_declare_one_subject_are_refused(): - fragment = {'dimensions': {'generator': {}, 'bus': {}}, 'properties': {'generator': {'bus': 'bus'}}} - with pytest.raises(LanguageError, match="both declare the properties of 'generator'"): - merge([fragment, fragment]) +def test_merging_carries_a_set_both_fragments_declare_alike(): + sets = {'gp': {'dims': ['generator', 'period']}} + assert merge([_fragment({}, sets), _fragment({}, sets)]).to_dict()['sets'] == sets