diff --git a/docs/about/limits.md b/docs/about/limits.md index 8b6b624f3..b487646b4 100644 --- a/docs/about/limits.md +++ b/docs/about/limits.md @@ -28,6 +28,12 @@ costs to add. constraints before the model is built. `piecewise:` is the only one. It costs as much as a primitive to build, but composes as freely as a macro, because the rest of the model only sees the variables and constraints it emitted. +- **A declaration section** is a block of declarations of one kind, such as + `variables:` or `given_variables:`. One enters where it says something no + section already says, where a file decides it without data, and where the + typesetter prints it. `given_variables:` entered on all three: nothing else + states that a column belongs to another file, which is what lets a template + load and print on its own. A request that is none of the three is refused, and the [table of refusals](#deliberate-non-primitives) records it with what to write @@ -182,10 +188,19 @@ a path, so a model assembled in Python is checked exactly as a file is, and `Spec.to_yaml()` writes the file a reviewer reads. A `dict` may hold only what a file may hold. There is no Python API that builds models any other way. -`override` is the verb for the layered case, where a framework ships a base -and a project extends it. It lays each patch over the base a field at a time, -so a patch says only what it changes. -[Compose a model from a base and patches](../howto/compose.md) is the recipe. +Two verbs do this, and they answer different questions. `merge` composes +peers, so a name two templates declare is a collision and the order they are +given in means nothing. `override` lays patches over a base, which is what a +framework ships and a project extends, a field at a time. +[Compose a model from several files](../howto/compose.md) is the recipe for +both. + +A template reads the coupling surface it is written against, and declares that +column under `given_variables:`. So a template loads on its own, and prints as +math on its own, which is what it could not do while a fragment was a file the +loader had to refuse. `merge` folds each given declaration into the one that +introduces it, and a program carries none of them: a build makes every column it +holds. The verb is built to collide, so every collision the caller did not ask for is refused. An entry naming some fields of a declaration the base does not have is diff --git a/docs/howto/compose.md b/docs/howto/compose.md index 716b5f48d..43ae78585 100644 --- a/docs/howto/compose.md +++ b/docs/howto/compose.md @@ -3,72 +3,106 @@ SPDX-FileCopyrightText: math-spec contributors SPDX-License-Identifier: CC-BY-4.0 --> -# Compose a model from a base and patches +# Compose a model from several files -Extend a model somebody else wrote, without copying it. The base is an ordinary -model file. A patch is a file that says only what it changes. `override` lays -the patches on the base and hands back one mapping, which -[`to_spec`](../reference/language/reading.md) loads like any file. +Build one model out of files that each say part of it. `merge` composes +**peers** — the templates of a component library, where a name two of them +declare is a collision. `override` lays **patches** over a base — what a +framework ships and a project extends. Both hand back one mapping, which +[`to_spec`](../reference/language/reading.md) loads like any file, and they +compose: `override(merge({…}), {…})`. -1. **Write the base as a model.** Nothing about it says it will be extended. +## A library of templates - ```yaml title="base.yaml" - description: Least-cost dispatch of a generator fleet against an hourly load. +1. **Write the coupling surface as a model.** One flow per port, one balance + per bus. Nothing in it knows which components exist. + + ```yaml title="surface.yaml" + dimensions: + snapshot: { dtype: int } + port: { dtype: str } + bus: { dtype: str } + relations: + port_bus: { key: port, value: bus } + variables: + flow: + dims: [snapshot, port] + description: what a port puts into its bus in a snapshot + constraints: + balance: + dims: [snapshot, bus] + expression: sum(flow, by=port_bus) == 0 + ``` + +2. **Write each component template against that surface.** It declares its own + entities, its own math, and one relation into `port`. It names `flow` under + [`given_variables`](../reference/language/declarations.md#given_variables), + because the surface introduces that column and this file only reads it. + + ```yaml title="generator.yaml" dimensions: snapshot: { dtype: int } + port: { dtype: str } generator: { dtype: str } + relations: + gen_port: { key: generator, value: port } + given_variables: + flow: { dims: [snapshot, port] } parameters: - load: { dims: [snapshot] } - cost: { dims: [generator] } - capacity: { dims: [generator] } + gen_cost: { dims: [generator] } + gen_p_max: { dims: [generator] } variables: - dispatch: - dims: [snapshot, generator] - bounds: { lower: 0, upper: capacity } + gen_p: { dims: [snapshot, generator], bounds: { lower: 0, upper: gen_p_max } } constraints: - power_balance: - dims: [snapshot] - expression: sum(dispatch, over=generator) == load + gen_injects: + dims: [snapshot, generator] + expression: at(flow, by=gen_port) == gen_p objective: sense: minimize - expression: sum(dispatch * cost) + expression: sum(gen_p * gen_cost) ``` -2. **Write each patch as the change it makes.** A declaration a patch does not - name is left as the base wrote it. A patch is not a model on its own, so - `to_spec` does not read one. + The template loads on its own, and it prints as math on its own. What it + cannot do is lower: a program builds every column it carries, and this file + says the opposite about `flow`. - ```yaml title="carbon.yaml" - parameters: - emission_rate: { dims: [generator] } - constraints: - emission_cap: - dims: [] - expression: sum(dispatch * emission_rate) <= 100 +3. **Merge the templates you need.** Each fragment is given a name, and that + name is what a refusal calls it. + + ```python + import math_spec as ms + + model = ms.merge({'surface': 'surface.yaml', 'generator': 'generator.yaml', 'demand': 'demand.yaml'}) + spec = ms.to_spec(model) ``` + `merge` folds each given declaration into the one that introduces it, so the + composed model declares `flow` once and carries no `given_variables`. It + lowers and solves like any model. + +4. **Add a component type without touching the balance.** A template pins the + flow at its own port rather than adding a term, so `balance` is written once + and stays as it is however many templates are merged. What grows is the + data: which ports exist, and which bus each one sits on. + +## A base and its patches + +1. **Write the base as a model**, and each patch as the change it makes. A + declaration a patch does not name is left as the base wrote it. + ```yaml title="operate.yaml" variables: - dispatch: { where: "capacity > 0" } + gen_p: { where: "gen_p_max > 0" } ``` - `operate.yaml` names one field. The dimensions and the bounds the base gave - `dispatch` stay. - -3. **Lay the patches on the base.** Each patch is given a name, and that name - is what a refusal calls it. +2. **Lay the patches on the base.** The patches must write different fields, so + the order they are given in cannot change the model. ```python - import math_spec as ms - model = ms.override('base.yaml', {'carbon': 'carbon.yaml', 'operate': 'operate.yaml'}) - spec = ms.to_spec(model) ``` - The patches must write different fields, so the order they are given in - cannot change the model. - -4. **Remove a declaration with `null`.** A patch that does not mention a +3. **Remove a declaration with `null`.** A patch that does not mention a declaration leaves it alone, so removal needs a marker of its own. ```yaml title="feasibility.yaml" @@ -78,15 +112,8 @@ the patches on the base and hands back one mapping, which ``` The marker is the declaration itself. Deeper down, `null` is a value the - schema already takes: `dispatch: { where: null }` gives that variable no - mask, and leaves the variable in place. - -5. **Write the composed model out where the file is what you review.** - `Spec.to_yaml()` is what every other page here diffs. - - ```python - Path('composed.yaml').write_text(ms.to_spec(model).to_yaml()) - ``` + schema already takes: `gen_p: { where: null }` gives that variable no mask, + and leaves the variable in place. ## What a patch may say @@ -98,6 +125,23 @@ the patches on the base and hands back one mapping, which | a dimension or a relation | it is added, or restated exactly as the base declares it | | `version`, `description` | the patch's value replaces the base's | +## A name two fragments declare + +Fragments own their math, so a name two of them declare is refused, both named: + +```text +fragments 'generator' and 'demand' both declare the parameter 'cost'. Two of the same kind of thing are two rows of a dimension rather than two fragments: merge the template once, and let the data carry both. Different math under one spelling is a rename — call one of them something else. +``` + +## A column read one way and introduced another + +What a template states about a column it reads has to agree with the file that +owns it: + +```text +fragment 'generator' reads given variable 'flow' as {'dims': ['snapshot', 'generator']}, where 'surface' introduces it as {'dims': ['snapshot', 'port'], 'description': 'what a port puts into its bus in a snapshot'}. A given declaration is what the file expects of a column somebody else owns, so it says the same as the declaration it is folded into, or less. +``` + ## A partial entry that lands on nothing An entry naming some fields has to land on a declaration the base has. A @@ -125,11 +169,11 @@ lays its patch on the first call's result, so the order is on the page: model = ms.override(ms.override('base.yaml', {'pathway': 'pathway.yaml'}), {'project': 'project.yaml'}) ``` -## An axis the base already declares +## An axis the other file already declares -A patch may add a dimension or a relation, and may restate one the base -declares. Changing one under the expressions already written over it is -refused: +A fragment may add a dimension or a relation, and two fragments may declare one +the same way. Saying different things about one is refused, and so is changing +one under the expressions already written over it: ```text patch 'relabelled' declares the dimension 'snapshot' as {'dtype': 'str'}, where its base declares {'dtype': 'int'}. A patch adjusts the math, not the axes the math is already written over: restate the declaration exactly, leave it out, or give the patch an axis of its own under a name of its own. diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index 9b604b550..857a01726 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -114,6 +114,56 @@ equation whether `size` is chosen or given. A pinned variable is still a variable, so `size * on` is `variable * variable`, and a pinned variable cannot stand in another variable's `bounds`. +## `given_variables` + +A given variable is a column this file reads and another file introduces. It is +what lets a template stand on its own: the file loads, and it prints as math, +without the file that owns the column. + +```yaml +dimensions: + snapshot: { dtype: int } + port: { dtype: str } + generator: { dtype: str } +relations: + gen_port: { key: generator, value: port } +given_variables: + flow: + dims: [snapshot, port] + description: what a port puts into its bus +variables: + gen_p: { dims: [snapshot, generator], bounds: { lower: 0 } } +constraints: + gen_injects: + dims: [snapshot, generator] + expression: at(flow, by=gen_port) == gen_p +``` + +| Field | | | +| ------------- | ------------------------------------------------- | -------------------- | +| `dims` | required. The dimensions the column is indexed by | | +| `domain` | `continuous`, `integer` or `binary` | default `continuous` | +| `description` | free text | default `null` | + +There is no `bounds` and no `where`. The file that introduces the column owns +both, and a second spelling here would be a second home for one fact. + +An expression reads a given variable as it reads any other, so +`at(flow, by=gen_port)` lands on the generator frame and the dim algebra +checks it at load. + +**A file with a `given_variables` block does not lower.** A program builds +every column it carries, and this file says the opposite about one of its own: + +```text +this file reads a variable it does not introduce: 'flow'. A program builds every column it carries, so compose the file with the ones that declare them first — to_program(merge({...})). The file loads and prints on its own either way. +``` + +[`merge`](../../howto/compose.md) folds each given declaration into the +declaration that introduces it, so a composed model carries none of them. The +folded declaration is the introducer's, and what the reader stated has to agree +with it. + ## `constraints` One block is one rule. The name of the block is the name of the constraint, and diff --git a/docs/reference/language/file.md b/docs/reference/language/file.md index 68b3aec9f..25b951aa1 100644 --- a/docs/reference/language/file.md +++ b/docs/reference/language/file.md @@ -5,21 +5,22 @@ SPDX-License-Identifier: CC-BY-4.0 # File shape -A model file is a YAML mapping with **ten declaration keys**, plus `version` -and `description`. Any subset of the ten is accepted. - -| Key | | -| ------------- | ------------------------------------------------------------------------------------------------------------------- | -| `dimensions` | the axes ([dimensions](dimensions.md)) | -| `relations` | named relations between dimensions ([relations](dimensions.md#relations)) | -| `parameters` | the data the model expects ([declarations](declarations.md)) | -| `variables` | what the solver decides | -| `constraints` | the rules those decisions obey | -| `objective` | what is minimised or maximised | -| `expressions` | named quantities, reusable in the math and readable after a solve ([expressions](expressions.md#named-expressions)) | -| `macros` | templates that take arguments ([macros](expressions.md#macros)) | -| `piecewise` | piecewise-linear curves ([piecewise](piecewise.md)) | -| `sos` | special-ordered sets ([sos](piecewise.md#sos)) | +A model file is a YAML mapping with **eleven declaration keys**, plus +`version` and `description`. Any subset of the eleven is accepted. + +| Key | | +| ----------------- | ------------------------------------------------------------------------------------------------------------------- | +| `dimensions` | the axes ([dimensions](dimensions.md)) | +| `relations` | named relations between dimensions ([relations](dimensions.md#relations)) | +| `parameters` | the data the model expects ([declarations](declarations.md)) | +| `variables` | what the solver decides | +| `given_variables` | columns this file reads and another introduces ([given variables](declarations.md#given_variables)) | +| `constraints` | the rules those decisions obey | +| `objective` | what is minimised or maximised | +| `expressions` | named quantities, reusable in the math and readable after a solve ([expressions](expressions.md#named-expressions)) | +| `macros` | templates that take arguments ([macros](expressions.md#macros)) | +| `piecewise` | piecewise-linear curves ([piecewise](piecewise.md)) | +| `sos` | special-ordered sets ([sos](piecewise.md#sos)) | A file with no `objective` is a **feasibility problem**: it asks whether the constraints can all be met. It loads and solves like any other model, and the diff --git a/docs/reference/language/index.md b/docs/reference/language/index.md index c081f237c..d95768036 100644 --- a/docs/reference/language/index.md +++ b/docs/reference/language/index.md @@ -46,7 +46,7 @@ message that names the fix. These ten rules are what it checks. | # | Rule | | | --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | -| 1 | A file has ten declaration keys, plus `version` and `description`. A key the schema does not know is refused, with the nearest valid key named: `boundz` → `bounds`. | [File shape](file.md) | +| 1 | A file has eleven declaration keys, plus `version` and `description`. A key the schema does not know is refused, with the nearest valid key named: `boundz` → `bounds`. | [File shape](file.md) | | 2 | Everything that can be checked without data is checked when the file loads. | [Errors](errors.md) | | 3 | Every name is declared once. A parameter and a dimension both called `snapshot` is refused, and the message names both lines. | [Names](expressions.md#name-resolution) | | 4 | Where a name may stand depends on what it is. A dimension may follow `over=` or `along=`, and may not be multiplied: `dispatch * snapshot` is refused, because `snapshot` is an axis and not a column of numbers. | [Names](expressions.md#name-resolution) | diff --git a/mkdocs.yml b/mkdocs.yml index ec602d8b6..de8b2f13d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -38,7 +38,7 @@ nav: - Check a model without data: howto/check.md - Print a model as math: howto/print.md - State a rule that differs by regime: howto/regimes.md - - Compose a model from a base and patches: howto/compose.md + - Compose a model from several files: howto/compose.md - Reference: - Language: - reference/language/index.md diff --git a/schema/math-spec.schema.json b/schema/math-spec.schema.json index a940d5750..33e309748 100644 --- a/schema/math-spec.schema.json +++ b/schema/math-spec.schema.json @@ -216,6 +216,46 @@ "title": "ExpressionCase", "type": "object" }, + "GivenVariableBlock": { + "additionalProperties": false, + "description": "A variable this file reads and does not introduce.\n\nThe frame is what every load-time pass asks of a variable, and it is all\nthis file can answer: whoever introduces the column owns its bounds and its\nmask, and a second spelling of either here would be a second home for one\nfact. :func:`~math_spec.composition.merge` folds the declaration into the\none that introduces it, so a composed model carries none of these.", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "dims": { + "items": { + "type": "string" + }, + "title": "Dims", + "type": "array" + }, + "domain": { + "default": "continuous", + "enum": [ + "continuous", + "integer", + "binary" + ], + "title": "Domain", + "type": "string" + } + }, + "required": [ + "dims" + ], + "title": "GivenVariableBlock", + "type": "object" + }, "MacroBlock": { "additionalProperties": false, "description": "A parameterised expression template, defined in the YAML itself.\n\nLanguage, not code: formals (``args`` positional, ``kwargs`` keyword)\nshadow model names inside the template, and every call site expands into\ncore AST before either backend sees the expression.", @@ -636,7 +676,7 @@ }, "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, - "description": "The declared math \u2014 one YAML file, or one dict, validated. Nothing here has seen data.\n\nA ``Spec`` that exists has passed the whole language: constructing one by\nany route \u2014 ``to_spec``, :meth:`model_validate`, the constructor \u2014 runs\nevery load-time check, expansion and expression pass included, and raises\n:class:`~math_spec.errors.LanguageError` on a model the language refuses.\nHolding one is the proof, so nothing downstream checks it again.\n\nThe API is the ten declaration sections plus ``version`` and\n``description``, and two ways back out: :meth:`to_dict` for the model as\ndata, :meth:`to_yaml` for the file a reviewer reads. Everything else on\nthis class is pydantic's, not a contract this package keeps.", + "description": "The declared math \u2014 one YAML file, or one dict, validated. Nothing here has seen data.\n\nA ``Spec`` that exists has passed the whole language: constructing one by\nany route \u2014 ``to_spec``, :meth:`model_validate`, the constructor \u2014 runs\nevery load-time check, expansion and expression pass included, and raises\n:class:`~math_spec.errors.LanguageError` on a model the language refuses.\nHolding one is the proof, so nothing downstream checks it again.\n\nThe API is the eleven declaration sections plus ``version`` and\n``description``, and two ways back out: :meth:`to_dict` for the model as\ndata, :meth:`to_yaml` for the file a reviewer reads. Everything else on\nthis class is pydantic's, not a contract this package keeps.", "properties": { "constraints": { "additionalProperties": { @@ -674,6 +714,14 @@ "title": "Expressions", "type": "object" }, + "given_variables": { + "additionalProperties": { + "$ref": "#/$defs/GivenVariableBlock" + }, + "default": {}, + "title": "Given Variables", + "type": "object" + }, "macros": { "additionalProperties": { "$ref": "#/$defs/MacroBlock" diff --git a/src/math_spec/__init__.py b/src/math_spec/__init__.py index f281b21a7..a20ec3933 100644 --- a/src/math_spec/__init__.py +++ b/src/math_spec/__init__.py @@ -12,7 +12,7 @@ from math_spec import program from math_spec.advice import advice -from math_spec.composition import override +from math_spec.composition import merge, override from math_spec.errors import ( ADVICE_KINDS, Advice, @@ -75,6 +75,7 @@ 'call_shape_error', 'did_you_mean', 'edge_error', + 'merge', 'override', 'program', 'schema_error', diff --git a/src/math_spec/composition.py b/src/math_spec/composition.py index 0fadce941..018bd415c 100644 --- a/src/math_spec/composition.py +++ b/src/math_spec/composition.py @@ -2,10 +2,18 @@ # # SPDX-License-Identifier: MIT -"""A base and its patches into one model, before any of them is validated. +"""Several files into one model, before any of them is validated. -What a framework ships and a project extends. The patch says only what it -changes, because declarations are laid over a field at a time:: +Two verbs, and they answer different questions. :func:`merge` composes +**peers**: templates that each own part of the math, where a name two of them +declare is a collision and the order they are given in means nothing. +:func:`override` layers a **base and its patches**: what a framework ships and a +project extends, where a name the patch declares is the point. Neither is a +mode of the other, and they compose — +``override(merge({...}), {...})`` builds the model and then configures the run. + +A patch says only what it changes, because declarations are laid over a field +at a time:: constraints: ramp: {dims: [snapshot, generator, investment_period]} @@ -62,14 +70,194 @@ #: The declarations a patch edits, creates or removes. OWNED_SECTIONS = ('parameters', 'variables', 'constraints', 'expressions', 'macros', 'piecewise', 'sos') +#: The declarations a file reads and does not introduce. Peers must agree +#: about one, and :func:`merge` folds it into the declaration that introduces +#: it, so a composed library carries none. +GIVEN_SECTIONS = ('given_variables',) + #: Every section keyed by declaration name. ``objective`` is one declaration #: rather than a mapping of them, and is laid over field by field beside these. -SECTIONS = (*SHARED_SECTIONS, *OWNED_SECTIONS) +SECTIONS = (*SHARED_SECTIONS, *OWNED_SECTIONS, *GIVEN_SECTIONS) #: What one entry is called where dropping the key's last letter does not say #: it: two sections that are not plurals, and the objective, which is one #: declaration rather than a mapping of them. -IRREGULAR = {'piecewise': 'piecewise curve', 'sos': 'special-ordered set', 'objective': 'objective'} +IRREGULAR = { + 'piecewise': 'piecewise curve', + 'sos': 'special-ordered set', + 'objective': 'objective', + 'given_variables': 'given variable', +} + + +def merge( + fragments: Mapping[str, str | Path | dict[str, Any] | Spec], description: str | None = None +) -> dict[str, Any]: + """*fragments* composed as peers, each owning the math it declares. + + A component library is a set of templates that agree on a coupling + surface — one flow per port, one balance per bus — and wiring a system is + rows in a table rather than generated YAML. This is what takes the + templates and hands back one model. + + Args: + fragments: What each fragment is called, to the fragment. The name is + what an error calls it, so it is the template's name rather than a + path. The order they are given in does not reach the result. + description: What the *composed* model is. A fragment's own + ``description`` is about the fragment, so it is neither carried nor + joined. + + Returns: + One mapping, ready for :func:`~math_spec.validation.to_spec`. Nothing + in it has been resolved, name-checked or lowered. + + Raises: + LanguageError: Two fragments declare one name; two fragments say + different things about one dimension or relation; two fragments + pin different language versions; or their objectives run opposite + ways. + FileNotFoundError: A ``str`` with no newline that names no file. + """ + read = {name: _declarations(fragment) for name, fragment in fragments.items()} + merged: dict[str, Any] = {'version': _one_version(read)} + if description is not None: + merged['description'] = description + + for section in SHARED_SECTIONS: + if agreed := _agreed(read, section): + merged[section] = agreed + for section in OWNED_SECTIONS: + if claimed := _claimed(read, section): + merged[section] = claimed + for section in GIVEN_SECTIONS: + if unintroduced := _folded(read, section, merged): + merged[section] = unintroduced + if (objective := _summed_objective(read)) is not None: + merged['objective'] = objective + return merged + + +def _one_version(read: Mapping[str, dict[str, Any]]) -> int: + """The language version every fragment is written against. + + A fragment saying nothing is version 0 like any file, so a library pinning + one and a template pinning none is the disagreement it looks like rather + than a default quietly winning. + """ + declared = {name: sections.get('version', 0) for name, sections in read.items()} + if len(set(declared.values())) > 1: + spelled = ', '.join(f"'{name}' says {version}" for name, version in sorted(declared.items())) + raise LanguageError( + f'the fragments are written against different language versions: {spelled}. One model has ' + f'one version, so write the same one in each — a fragment that declares none is version 0.' + ) + return next(iter(declared.values()), 0) + + +def _agreed(read: Mapping[str, dict[str, Any]], section: str) -> dict[str, Any]: + """One ``dimensions`` or ``relations`` block, peers that say the same thing folded together. + + Equality rather than :func:`_agrees`: between peers neither declaration is + the one being restated, so a field only one of them writes is a difference + nothing settles. + """ + merged: dict[str, Any] = {} + author: dict[str, str] = {} + for name, sections in read.items(): + for key, block in (sections.get(section) or {}).items(): + if key in merged and _claims(merged[key]) != _claims(block): + raise LanguageError( + f"fragments '{author[key]}' and '{name}' say different things about " + f'{_singular(section)} {key!r}: {merged[key]!r} against {block!r}. A declaration two ' + f'fragments share is one both of them say the same thing about — make the two ' + f'identical, or give one of them a name of its own.' + ) + merged.setdefault(key, block) + author.setdefault(key, name) + return merged + + +def _claims(block: Any) -> Any: + """*block* without its prose, which is what the declaration says rather than a claim about it. + + Two fragments describing one shared declaration in their own words agree + about the declaration, so the first one's wording is carried and neither is + the disagreement this refuses. + """ + 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, Any]], section: str) -> dict[str, Any]: + """One block of owned declarations, a name claimed twice being the error.""" + merged: dict[str, Any] = {} + author: dict[str, str] = {} + for name, sections in read.items(): + for key, block in (sections.get(section) or {}).items(): + if key in merged: + raise LanguageError( + f"fragments '{author[key]}' and '{name}' both declare the {_singular(section)} " + f'{key!r}. Two of the same kind of thing are two rows of a dimension rather than ' + f'two fragments: merge the template once, and let the data carry both. Different ' + f'math under one spelling is a rename — call one of them something else.' + ) + merged[key] = block + author[key] = name + return merged + + +def _folded(read: Mapping[str, dict[str, Any]], section: str, merged: Mapping[str, Any]) -> dict[str, Any]: + """The given declarations no fragment introduces, the rest folded into the ones that do. + + A fragment's given declaration is what it expects of a column a sibling + owns, so where the sibling is in the composition the expectation is checked + and then spent: the composed model declares the column once, and a name + that is both given and introduced would otherwise read as a collision. + """ + given = _agreed(read, section) + introduced = merged.get(section.removeprefix('given_'), {}) + for key, block in list(given.items()): + if key not in introduced: + continue + if not _agrees(_claims(introduced[key]), _claims(block)): + reader, owner = _author_of(read, section, key), _author_of(read, section.removeprefix('given_'), key) + raise LanguageError( + f"fragment '{reader}' reads {_singular(section)} {key!r} as {block!r}, where '{owner}' " + f'introduces it as {introduced[key]!r}. A given declaration is what the file expects of ' + f'a column somebody else owns, so it says the same as the declaration it is folded ' + f'into, or less.' + ) + del given[key] + return given + + +def _author_of(read: Mapping[str, dict[str, Any]], section: str, key: str) -> str: + """The first fragment declaring *key* in *section*, for a message that names both sides.""" + return next(name for name, sections in read.items() if key in (sections.get(section) or {})) + + +def _summed_objective(read: Mapping[str, dict[str, Any]]) -> dict[str, Any] | None: + """Every fragment's objective, summed, or ``None`` where none declares one. + + Summing is what composing costs: each template prices what it owns, and the + system pays for all of it. The senses must agree, because a sum of two + objectives has one sense and nothing in the files says which — negating the + minority would be this function deciding what a model means. + """ + declared = {name: sections['objective'] for name, sections in read.items() if sections.get('objective')} + if not declared: + return None + senses = {name: objective.get('sense', 'minimize') for name, objective in declared.items()} + if len(set(senses.values())) > 1: + spelled = ', '.join(f"'{name}' {sense}s" for name, sense in sorted(senses.items())) + raise LanguageError( + f'the fragments disagree about which way the objective runs: {spelled}. A composed model ' + f'has one objective and one sense, so write every fragment against the same one — negate ' + f'the terms of the odd one out rather than its sense.' + ) + terms = [objective['expression'] for objective in declared.values()] + joined = terms[0] if len(terms) == 1 else ' + '.join(f'({term})' for term in terms) + return {'sense': next(iter(senses.values())), 'expression': joined} def override( diff --git a/src/math_spec/dimensions.py b/src/math_spec/dimensions.py index dbb5e8a88..d7e773f88 100644 --- a/src/math_spec/dimensions.py +++ b/src/math_spec/dimensions.py @@ -90,7 +90,7 @@ def _dims( return frozenset(schema.parameters[node.name].dims) if isinstance(node, VariableNode): - return frozenset(schema.variables[node.name].dims) + return frozenset({**schema.variables, **schema.given_variables}[node.name].dims) if isinstance(node, UnresolvedNode | KwargNode): msg = f'{type(node).__name__} reached the dim checker; resolve the expression first.' diff --git a/src/math_spec/lowering.py b/src/math_spec/lowering.py index a78fab203..58d0c0ddc 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -34,11 +34,12 @@ VariableNode, ) from math_spec.dimensions import dims_of +from math_spec.errors import LanguageError from math_spec.piecewise import declaration_of, derivations_of, expand_piecewise from math_spec.validation import to_spec if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Mapping from pathlib import Path from typing import Any @@ -77,11 +78,32 @@ def to_program(spec: str | Path | dict[str, Any] | Spec | program.Program) -> pr Raises: SchemaError: The file is not a valid model. LanguageError: A construct outside the language, named with its - rewrite. + rewrite, or a file that reads variables it does not introduce. """ if isinstance(spec, program.Program): return spec - return lower_program(expand_piecewise(to_spec(spec))) + schema = to_spec(spec) + if schema.given_variables: + raise LanguageError(_unintroduced_message(schema.given_variables)) + return lower_program(expand_piecewise(schema)) + + +def _unintroduced_message(given: Mapping[str, Any]) -> str: + """The refusal for lowering a fragment, which is a model no build can finish. + + A program is what a consumer builds and solves, so every column in one is a + column something introduces. A fragment states the opposite about some of + its own, which is why it is composed before it is lowered — and why it + still loads and still prints, both of which read the model rather than + build it. + """ + named = ', '.join(f"'{name}'" for name in sorted(given)) + noun = 'a variable' if len(given) == 1 else 'variables' + return ( + f'this file reads {noun} it does not introduce: {named}. A program builds every column it ' + f'carries, so compose the file with the ones that declare them first — ' + f'to_program(merge({{...}})). The file loads and prints on its own either way.' + ) def lower_program(expanded: _ExpandedSpec) -> program.Program: diff --git a/src/math_spec/model.py b/src/math_spec/model.py index 135b34572..2c3557650 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -308,6 +308,23 @@ def _absence_needs_a_mask(self) -> VariableBlock: return self +class GivenVariableBlock(_StrictBlock): + """A variable this file reads and does not introduce. + + The frame is what every load-time pass asks of a variable, and it is all + this file can answer: whoever introduces the column owns its bounds and its + mask, and a second spelling of either here would be a second home for one + fact. :func:`~math_spec.composition.merge` folds the declaration into the + one that introduces it, so a composed model carries none of these. + """ + + _label: ClassVar[str] = 'a given variable declaration' + + dims: list[str] + domain: VariableDomain = 'continuous' + description: str | None = None + + class ConstraintBlock(_StrictBlock): """A declared constraint: one rule, over one frame.""" @@ -693,7 +710,7 @@ class Spec(_StrictBlock): :class:`~math_spec.errors.LanguageError` on a model the language refuses. Holding one is the proof, so nothing downstream checks it again. - The API is the ten declaration sections plus ``version`` and + The API is the eleven declaration sections plus ``version`` and ``description``, and two ways back out: :meth:`to_dict` for the model as data, :meth:`to_yaml` for the file a reviewer reads. Everything else on this class is pydantic's, not a contract this package keeps. @@ -724,6 +741,11 @@ class Spec(_StrictBlock): macros: dict[str, MacroBlock] = {} piecewise: dict[str, PiecewiseBlock] = {} sos: dict[str, SosBlock] = {} + #: The variables this file reads and does not introduce + #: (:class:`GivenVariableBlock`). Empty in a file that stands alone, and + #: empty again once :func:`~math_spec.composition.merge` has folded each one + #: into the declaration that introduces it. + given_variables: dict[str, GivenVariableBlock] = {} def relations_of(self, dimension: str) -> dict[str, RelationBlock]: """The relations with a column over *dimension*, by name.""" @@ -820,6 +842,7 @@ def _name_collisions(self) -> Iterator[str]: ('relation', self.relations), ('parameter', self.parameters), ('variable', self.variables), + ('given variable', self.given_variables), ('named expression', self.expressions), ('macro', self.macros), ] @@ -845,6 +868,7 @@ def _frame_dimensions(self) -> Iterator[str]: frames = [ *(('Parameter', name, p.dims) for name, p in self.parameters.items()), *(('Variable', name, v.dims) for name, v in self.variables.items()), + *(('Given variable', name, g.dims) for name, g in self.given_variables.items()), *(('Constraint', name, c.dims) for name, c in self.constraints.items()), *(('Named expression', name, e.dims or []) for name, e in self.expressions.items()), ] diff --git a/src/math_spec/resolution.py b/src/math_spec/resolution.py index 2bbd07939..59ab79bca 100644 --- a/src/math_spec/resolution.py +++ b/src/math_spec/resolution.py @@ -132,7 +132,7 @@ def __init__( def of(cls, schema: Spec) -> Namespace: """Build the namespace of *schema*, the whole of what a file may name.""" return cls( - schema.variables, + {**schema.variables, **schema.given_variables}, schema.parameters, schema.dimensions, {n: RelationDeclaration(n, lk.pairs, lk.keys) for n, lk in schema.relations.items()}, @@ -142,7 +142,7 @@ def of(cls, schema: Spec) -> Namespace: }, { **{p: tuple(pd.dims) for p, pd in schema.parameters.items()}, - **{v: tuple(vd.dims) for v, vd in schema.variables.items()}, + **{v: tuple(vd.dims) for v, vd in {**schema.variables, **schema.given_variables}.items()}, }, schema.constraints, ) diff --git a/src/math_spec/typesetting/symbols.py b/src/math_spec/typesetting/symbols.py index c6fb99d96..24125212f 100644 --- a/src/math_spec/typesetting/symbols.py +++ b/src/math_spec/typesetting/symbols.py @@ -108,8 +108,8 @@ def __init__(self, schema: _ExpandedSpec, fmt: Format, table: SymbolTable) -> No f'and nothing translates between notations — write a {fmt.notation} table.' ) raise SchemaError(msg) - chosen = frozenset(schema.variables) | chosen_expressions(schema) - names = (*schema.parameters, *schema.variables, *schema.expressions) + chosen = frozenset(schema.variables) | frozenset(schema.given_variables) | chosen_expressions(schema) + names = (*schema.parameters, *schema.variables, *schema.given_variables, *schema.expressions) declared = frozenset(names) #: Names the table spelled; the convention note quotes only derived symbols. @@ -239,7 +239,12 @@ def checked_against(self, schema: _ExpandedSpec) -> SymbolTable: """Reject entries naming nothing in *schema*, with the near miss.""" dims = set(schema.dimensions) everything = ( - dims | set(schema.parameters) | set(schema.variables) | set(schema.expressions) | set(schema.constraints) + dims + | set(schema.parameters) + | set(schema.variables) + | set(schema.given_variables) + | set(schema.expressions) + | set(schema.constraints) ) errors = [ *(_unknown_entry(d, 'dimensions', dims) for d in {*self.indices, *self.sets} - dims), diff --git a/src/math_spec/typesetting/walk.py b/src/math_spec/typesetting/walk.py index 936309d50..bc7142c36 100644 --- a/src/math_spec/typesetting/walk.py +++ b/src/math_spec/typesetting/walk.py @@ -347,7 +347,8 @@ def _arithmetic(self, node: ArithmeticNode, ctx: _Context) -> tuple[str, int]: return ctx.indexed(self.symbols.name[node.name], list(self.schema.parameters[node.name].dims)), _ATOM if isinstance(node, VariableNode): - return ctx.indexed(self.symbols.name[node.name], list(self.schema.variables[node.name].dims)), _ATOM + frames = {**self.schema.variables, **self.schema.given_variables} + return ctx.indexed(self.symbols.name[node.name], list(frames[node.name].dims)), _ATOM if isinstance(node, UnaryOperatorNode): if node.op == '+': @@ -861,6 +862,10 @@ def glossaries(self, noticed: Noticed) -> list[Glossary]: self._entry(self.symbols.name[v], f'{fmt.mono(v)}{self._over(list(block.dims))}', block.description) for v, block in self.schema.variables.items() ] + given = [ + self._entry(self.symbols.name[g], f'{fmt.mono(g)}{self._over(list(block.dims))}', block.description) + for g, block in self.schema.given_variables.items() + ] definitions = [ self._entry(self.symbols.name[e], f'{fmt.mono(e)}{self._over(self.frames[e])}', block.description) for e, block in self.schema.expressions.items() @@ -870,6 +875,7 @@ def glossaries(self, noticed: Noticed) -> list[Glossary]: Glossary('Sets', sets), Glossary('Parameters', parameters), Glossary('Variables', variables), + Glossary('Given', given), Glossary('Definitions', definitions), ) return [group for group in groups if group.entries] diff --git a/tests/test_composition.py b/tests/test_composition.py index 2d7e33283..5ae51541a 100644 --- a/tests/test_composition.py +++ b/tests/test_composition.py @@ -2,14 +2,15 @@ # # SPDX-License-Identifier: MIT -"""A base and its patches, and the three places the verb refuses to guess. - -`override` exists to collide — a name the patch declares is the point — so what -is pinned here is the opposite: every collision the caller did not ask for is -an error naming both sides. A patch that lands on nothing, two patches writing -one field, and an axis redeclared under the expressions written over it are the -three, and each one is a model that would otherwise load and mean something -nobody wrote. +"""Two verbs, and what each one refuses. + +`merge` composes peers, so a name two fragments declare is a collision and the +order they are given in means nothing. `override` layers a base and its +patches, so a name the patch declares is the point — and what is pinned for it +is the opposite: every collision the caller did not ask for is an error naming +both sides. A patch that lands on nothing, two patches writing one field, and +an axis redeclared under the expressions written over it are the three, and +each one is a model that would otherwise load and mean something nobody wrote. """ from __future__ import annotations @@ -18,9 +19,116 @@ import pytest -from math_spec import LanguageError, override, to_markdown, to_spec +from math_spec import LanguageError, merge, override, to_markdown, to_spec from tests.fixtures import DISPATCH_MODEL +#: The coupling surface a component library agrees on: one flow per port, and +#: one balance per bus. The two fragments below name `flow` and declare none of +#: it, which is what makes each of them a load error on its own. +SURFACE = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'port': {'dtype': 'str'}, 'bus': {'dtype': 'str'}}, + 'relations': {'port_bus': {'key': 'port', 'value': 'bus'}}, + 'variables': {'flow': {'dims': ['snapshot', 'port']}}, + 'constraints': {'balance': {'dims': ['snapshot', 'bus'], 'expression': 'sum(flow, by=port_bus) == 0'}}, +} + +SUPPLY = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'port': {'dtype': 'str'}, 'generator': {'dtype': 'str'}}, + 'relations': {'gen_port': {'key': 'generator', 'value': 'port'}}, + 'parameters': {'gen_cost': {'dims': ['generator']}, 'gen_p_max': {'dims': ['generator']}}, + 'variables': {'gen_p': {'dims': ['snapshot', 'generator'], 'bounds': {'lower': 0, 'upper': 'gen_p_max'}}}, + 'constraints': {'gen_injects': {'dims': ['snapshot', 'generator'], 'expression': 'at(flow, by=gen_port) == gen_p'}}, + 'objective': {'sense': 'minimize', 'expression': 'sum(gen_p * gen_cost)'}, +} + +DEMAND = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'port': {'dtype': 'str'}, 'demand': {'dtype': 'str'}}, + 'relations': {'dem_port': {'key': 'demand', 'value': 'port'}}, + 'parameters': {'dem_load': {'dims': ['snapshot', 'demand']}}, + 'constraints': { + 'dem_withdraws': {'dims': ['snapshot', 'demand'], 'expression': 'at(flow, by=dem_port) == -dem_load'} + }, +} + +LIBRARY = {'surface': SURFACE, 'supply': SUPPLY, 'demand': DEMAND} + + +def test_a_fragment_names_what_a_sibling_declares(): + """The whole reason merging happens before validation: `supply` reads `flow` and declares none of it.""" + with pytest.raises(LanguageError, match=r'flow'): + to_spec(SUPPLY) + spec = to_spec(merge(LIBRARY)) + assert sorted(spec.variables) == ['flow', 'gen_p'], "both fragments' columns are in the one model" + assert to_markdown(spec), 'a composed library prints as math' + + +def test_the_balance_does_not_grow_when_a_component_type_is_added(): + """What the port convention buys: a component pins its own flow rather than adding a term.""" + three = to_spec(merge(LIBRARY)).constraints['balance'].expression + storage = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'port': {'dtype': 'str'}, 'store': {'dtype': 'str'}}, + 'relations': {'st_port': {'key': 'store', 'value': 'port'}}, + 'parameters': {'st_capacity': {'dims': ['store']}}, + 'variables': {'st_p': {'dims': ['snapshot', 'store'], 'bounds': {'lower': 0, 'upper': 'st_capacity'}}}, + 'constraints': {'st_injects': {'dims': ['snapshot', 'store'], 'expression': 'at(flow, by=st_port) == st_p'}}, + } + four = to_spec(merge({**LIBRARY, 'storage': storage})).constraints['balance'].expression + assert three == four, 'the balance is written once, whatever is plugged into it' + + +def test_merging_is_order_independent(): + assert merge(LIBRARY) == merge(dict(reversed(list(LIBRARY.items())))) + + +def test_a_name_two_fragments_declare_is_refused(): + twin = {**DEMAND, 'parameters': {'gen_cost': {'dims': ['demand']}}} + with pytest.raises(LanguageError) as raised: + merge({'supply': SUPPLY, 'twin': twin}) + message = str(raised.value) + assert "'supply'" in message and "'twin'" in message, 'a collision names both fragments' + assert 'two rows of a dimension' in message, 'the message names the rewrite it usually is' + + +def test_an_axis_two_fragments_describe_differently_is_refused(): + relabelled = {**DEMAND, 'dimensions': {**DEMAND['dimensions'], 'snapshot': {'dtype': 'str'}}} + with pytest.raises(LanguageError, match=r'say different things about dimension'): + merge({'supply': SUPPLY, 'demand': relabelled}) + + +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'}} + composed = merge({'surface': SURFACE, 'supply': SUPPLY, 'demand': priced}) + assert composed['objective']['expression'] == '(sum(gen_p * gen_cost)) + (sum(dem_load) * 2)' + + +def test_one_fragment_s_objective_is_carried_as_it_was_written(): + assert merge(LIBRARY)['objective']['expression'] == SUPPLY['objective']['expression'] + + +def test_objectives_that_run_opposite_ways_are_refused(): + maximised = {**DEMAND, 'objective': {'sense': 'maximize', 'expression': 'sum(dem_load)'}} + with pytest.raises(LanguageError, match=r'which way the objective runs'): + merge({'supply': SUPPLY, 'demand': maximised}) + + +def test_fragments_pinning_different_versions_are_refused(): + with pytest.raises(LanguageError, match=r'different language versions'): + merge({'supply': {**SUPPLY, 'version': 0}, 'demand': {**DEMAND, 'version': 1}}) + + +def test_the_description_belongs_to_the_composition(): + described = merge(LIBRARY, description='a fleet against a load') + assert described['description'] == 'a fleet against a load' + assert 'description' not in merge(LIBRARY), "no fragment's own description is carried" + + +def test_merge_composes_the_model_and_override_configures_the_run(): + """The two verbs meet by taking and returning what the other does.""" + run = override(merge(LIBRARY), {'project': {'variables': {'gen_p': {'where': 'gen_p_max > 0'}}}}) + assert to_spec(run).variables['gen_p'].where == 'gen_p_max > 0' + + #: A patch that adds what it needs and a constraint that reads it, so the #: composed model is one `to_spec` accepts rather than only one that merges. CARBON = { diff --git a/tests/test_given_variables.py b/tests/test_given_variables.py new file mode 100644 index 000000000..35ec45a77 --- /dev/null +++ b/tests/test_given_variables.py @@ -0,0 +1,127 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""A file that reads a column it does not introduce, and still stands on its own. + +The point of the section is what a template could not do before it: load, and +print as math, without the file that owns the column. What it may not do is +lower — a program builds every column it carries — so the two doors this pins +open and shut in the same breath: `to_spec` and the typesetter take a fragment, +`to_program` refuses one and names the composition that fixes it. +""" + +from __future__ import annotations + +import pytest + +from math_spec import FORMATS, LanguageError, merge, to_markdown, to_program, to_spec, typeset + +#: One component template: it pins the flow at its own port, and the column it +#: pins belongs to the surface fragment below. +SUPPLY = { + 'description': 'A fleet of generators, each on one port.', + 'dimensions': {'snapshot': {'dtype': 'int'}, 'port': {'dtype': 'str'}, 'generator': {'dtype': 'str'}}, + 'relations': {'gen_port': {'key': 'generator', 'value': 'port'}}, + 'given_variables': {'flow': {'dims': ['snapshot', 'port'], 'description': 'what a port puts into its bus'}}, + 'parameters': {'gen_cost': {'dims': ['generator']}, 'gen_p_max': {'dims': ['generator']}}, + 'variables': {'gen_p': {'dims': ['snapshot', 'generator'], 'bounds': {'lower': 0, 'upper': 'gen_p_max'}}}, + 'constraints': {'gen_injects': {'dims': ['snapshot', 'generator'], 'expression': 'at(flow, by=gen_port) == gen_p'}}, + 'objective': {'sense': 'minimize', 'expression': 'sum(gen_p * gen_cost)'}, +} + +#: The fragment that owns `flow`, with the bounds and the balance that go with it. +SURFACE = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'port': {'dtype': 'str'}, 'bus': {'dtype': 'str'}}, + 'relations': {'port_bus': {'key': 'port', 'value': 'bus'}}, + 'variables': {'flow': {'dims': ['snapshot', 'port'], 'bounds': {'lower': -1000, 'upper': 1000}}}, + 'constraints': {'balance': {'dims': ['snapshot', 'bus'], 'expression': 'sum(flow, by=port_bus) == 0'}}, +} + + +def test_a_fragment_that_says_what_it_reads_loads_on_its_own(): + spec = to_spec(SUPPLY) + assert sorted(spec.given_variables) == ['flow'], 'the column it reads is a declaration like any other' + assert sorted(spec.variables) == ['gen_p'], 'and it is not one of the columns this file introduces' + + +@pytest.mark.parametrize('fmt', sorted(FORMATS)) +def test_a_fragment_prints_as_math_in_every_format(fmt): + """The improvement the section exists for: the unit you share is the unit you can read.""" + assert typeset(SUPPLY, fmt), f'{fmt} rendered nothing' + + +def test_the_given_column_prints_under_its_own_heading(): + printed = to_markdown(SUPPLY) + assert '#### Given' in printed, 'the legend says which symbols the file does not introduce' + assert '`flow`' in printed.split('#### Given')[1] + + +def test_a_program_refuses_a_file_that_reads_what_it_does_not_introduce(): + with pytest.raises(LanguageError, match=r"reads a variable it does not introduce: 'flow'"): + to_program(SUPPLY) + + +def test_the_refusal_names_the_composition_that_fixes_it(): + with pytest.raises(LanguageError) as raised: + to_program(SUPPLY) + assert 'merge(' in str(raised.value), 'a message names the rewrite, and here the rewrite is a verb' + + +def test_merging_folds_the_given_declaration_into_the_one_that_introduces_it(): + composed = merge({'surface': SURFACE, 'supply': SUPPLY}) + assert 'given_variables' not in composed, 'the expectation is spent once the column is in the composition' + spec = to_spec(composed) + assert sorted(spec.variables) == ['flow', 'gen_p'] + assert spec.variables['flow'].bounds.lower == -1000, "the introducer's declaration is the one that survives" + assert sorted(to_program(spec).variables) == ['flow', 'gen_p'], 'a composed library lowers like any model' + + +def test_a_given_declaration_may_say_less_than_the_introducer(): + """Bounds are the owner's, so the reader states the frame and stops.""" + assert 'bounds' not in SUPPLY['given_variables']['flow'] + assert to_spec(merge({'surface': SURFACE, 'supply': SUPPLY})).variables['flow'].bounds.upper == 1000 + + +def test_a_given_declaration_that_disagrees_with_the_introducer_is_refused(): + misread = {**SUPPLY, 'given_variables': {'flow': {'dims': ['snapshot', 'generator']}}} + with pytest.raises(LanguageError) as raised: + merge({'surface': SURFACE, 'supply': misread}) + message = str(raised.value) + assert "'supply'" in message and "'surface'" in message, 'both sides of a disagreement are named' + + +def test_two_fragments_must_read_one_column_the_same_way(): + other = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'port': {'dtype': 'str'}}, + 'given_variables': {'flow': {'dims': ['port']}}, + } + with pytest.raises(LanguageError, match=r'say different things about given variable'): + merge({'supply': SUPPLY, 'other': other}) + + +def test_a_name_both_introduced_and_given_in_one_file_is_refused(): + both = {**SUPPLY, 'variables': {**SUPPLY['variables'], 'flow': {'dims': ['snapshot', 'port']}}} + with pytest.raises(LanguageError, match=r"'flow'"): + to_spec(both) + + +@pytest.mark.parametrize( + ('block', 'says'), + [ + pytest.param({'dims': ['snapshot', 'nowhere']}, 'nowhere', id='a-frame-over-an-undeclared-dimension'), + pytest.param({'dims': ['snapshot', 'snapshot']}, 'twice', id='a-frame-naming-one-dimension-twice'), + pytest.param({'dims': ['snapshot'], 'bounds': {'lower': 0}}, 'bounds', id='bounds-the-owner-holds'), + pytest.param({'dims': ['snapshot'], 'where': 'gen_cost > 0'}, 'where', id='a-mask-the-owner-holds'), + ], +) +def test_a_given_declaration_is_refused_where_it_oversteps(block, says): + with pytest.raises(LanguageError) as raised: + to_spec({**SUPPLY, 'given_variables': {'flow': block}}) + assert says in str(raised.value) + + +def test_an_expression_reads_a_given_column_as_it_reads_any_other(): + """Resolution and the dim algebra see one namespace, so `at(flow, by=gen_port)` lands on the generator frame.""" + spec = to_spec(SUPPLY) + assert spec.constraints['gen_injects'].dims == ['snapshot', 'generator'] diff --git a/tests/test_public_surface.py b/tests/test_public_surface.py index e3b948c39..4ee5715cb 100644 --- a/tests/test_public_surface.py +++ b/tests/test_public_surface.py @@ -25,8 +25,8 @@ { # the two public states, and the conversion to each 'Spec', 'to_spec', 'program', 'to_program', - # the file-level verb that makes one model out of several - 'override', + # the file-level verbs that make one model out of several + 'merge', 'override', # the error tree 'MathSpecError', 'LanguageError', 'SchemaError', 'DimensionError', 'PiecewiseExpansionError', 'did_you_mean', 'schema_error',