From cce70c6377931f9f9afd46a110358c2da584042d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 12:45:23 +0000 Subject: [PATCH] feat(language): a file says what it reads under one given key `given_variables:` and `given_constraints:` become `given: variables:` and `given: constraints:`, and the block is closed at those two kinds. `Spec.given` and `Program.given` follow the file, so the schema is the shape a reader sees. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CA5v9XJvgYViUHP7hSiKPU --- docs/about/limits.md | 8 ++-- docs/examples/library/composed.md | 2 +- docs/examples/library/generator.md | 11 ++--- docs/examples/library/load.md | 9 ++-- docs/howto/compose.md | 9 ++-- docs/reference/language/declarations.md | 28 +++++++----- docs/reference/language/file.md | 32 +++++++------- docs/reference/language/reading.md | 12 +++--- examples/library/generator.yaml | 9 ++-- examples/library/load.yaml | 9 ++-- schema/math-spec.schema.json | 45 ++++++++++++------- src/math_spec/advice.py | 6 +-- src/math_spec/composition.py | 57 ++++++++++++++----------- src/math_spec/dimensions.py | 4 +- src/math_spec/lowering.py | 11 +++-- src/math_spec/model.py | 42 ++++++++++++------ src/math_spec/program.py | 37 +++++++++++++--- src/math_spec/resolution.py | 8 ++-- src/math_spec/typesetting/symbols.py | 10 ++--- src/math_spec/typesetting/walk.py | 6 +-- tests/test_advice.py | 2 +- tests/test_composition.py | 12 ++++++ tests/test_given.py | 48 ++++++++++++--------- tests/test_library_example.py | 4 +- tools/gallery.py | 2 +- 25 files changed, 261 insertions(+), 162 deletions(-) diff --git a/docs/about/limits.md b/docs/about/limits.md index 90e02435..3d0c61be 100644 --- a/docs/about/limits.md +++ b/docs/about/limits.md @@ -29,9 +29,9 @@ costs to add. 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 + `variables:` or `given:`. 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 + typesetter prints it. `given:` entered on all three: nothing else states that a column belongs to another file, which is what lets a component file load and print on its own. @@ -196,7 +196,7 @@ framework ships and a project extends, a field at a time. both. A component file reads the coupling surface it is written against, and declares -that column under `given_variables:`. So it loads on its own, and prints as math +that column under `given: variables:`. So it 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, so a composed library carries none. @@ -205,7 +205,7 @@ A layer over a model this language never sees — one built through linopy, say has nothing to fold into. There the declaration stays, and the program carries the name and the frame for a consumer to bind, under [what a program does not build](../reference/language/reading.md#what-a-program-does-not-build). -`given_constraints:` is the same fact about a row family: `dual(balance)` prices +`given: constraints:` is the same fact about a row family: `dual(balance)` prices what the base model settles, and the file says how many duals there are and what indexes them. diff --git a/docs/examples/library/composed.md b/docs/examples/library/composed.md index 329362a4..48bb9674 100644 --- a/docs/examples/library/composed.md +++ b/docs/examples/library/composed.md @@ -17,7 +17,7 @@ spec = ms.to_spec(model) The file below is `spec.to_yaml()` — no fragment holds it, and nothing in the repository commits it. `Port_p` is one declaration here: each fragment read it -under `given_variables`, and merging folded those into the surface's own. +under `given:`, and merging folded those into the surface's own. The objective is the generator's, carried as it was written, because it is the only fragment that priced anything. A second priced fragment would have its diff --git a/docs/examples/library/generator.md b/docs/examples/library/generator.md index 4640e7f3..391defb7 100644 --- a/docs/examples/library/generator.md +++ b/docs/examples/library/generator.md @@ -8,7 +8,7 @@ SPDX-License-Identifier: CC-BY-4.0 PyPSA's `Generator`, as one fragment. It owns its dimension, its relation into `port`, its parameters, its column and its cost, and it reads `Port_p` from [the surface](surface.md) under -[`given_variables`](../../reference/language/declarations.md#given_variables). +[`given`](../../reference/language/declarations.md#given). `Generator_port` stands where PyPSA writes `Generator_bus`. The constraint is what makes the library composable: @@ -35,10 +35,11 @@ dimensions: generator: { dtype: str, description: "generating units, each on one port" } relations: Generator_port: { key: generator, value: port } -given_variables: - Port_p: - dims: [snapshot, port] - description: the surface introduces this column, and this file only writes into it +given: + variables: + Port_p: + dims: [snapshot, port] + description: the surface introduces this column, and this file only writes into it parameters: Generator_p_nom: { dims: [generator], description: nominal power } Generator_marginal_cost: { dims: [generator], description: cost of one unit of output } diff --git a/docs/examples/library/load.md b/docs/examples/library/load.md index ea5ab96e..084d5cb2 100644 --- a/docs/examples/library/load.md +++ b/docs/examples/library/load.md @@ -21,10 +21,11 @@ dimensions: load: { dtype: str, description: "demands, each on one port" } relations: Load_port: { key: load, value: port } -given_variables: - Port_p: - dims: [snapshot, port] - description: the surface introduces this column, and this file only writes into it +given: + variables: + Port_p: + dims: [snapshot, port] + description: the surface introduces this column, and this file only writes into it parameters: Load_p_set: { dims: [snapshot, load], description: "`Load-p_set` — what a load takes in a snapshot" } constraints: diff --git a/docs/howto/compose.md b/docs/howto/compose.md index ad80df4b..d28a8d91 100644 --- a/docs/howto/compose.md +++ b/docs/howto/compose.md @@ -37,7 +37,7 @@ compose: `override(merge({…}), {…})`. 2. **Write each component file against that surface.** It declares its own entities, its own math, and one relation into `port`. It names `Port_p` under - [`given_variables`](../reference/language/declarations.md#given_variables), + [`given`](../reference/language/declarations.md#given), because the surface introduces that column and this file only reads it. ```yaml title="generator.yaml" @@ -47,8 +47,9 @@ compose: `override(merge({…}), {…})`. generator: { dtype: str } relations: Generator_port: { key: generator, value: port } - given_variables: - Port_p: { dims: [snapshot, port] } + given: + variables: + Port_p: { dims: [snapshot, port] } parameters: Generator_p_nom: { dims: [generator] } Generator_marginal_cost: { dims: [generator] } @@ -78,7 +79,7 @@ compose: `override(merge({…}), {…})`. ``` `merge` folds each given declaration into the one that introduces it, so the - composed model declares `Port_p` once and carries no `given_variables`. It + composed model declares `Port_p` once and carries no `given:`. It lowers and solves like any model. 4. **Add a component type without touching the balance.** A component file pins diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index 65da623a..e43ec449 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -114,7 +114,13 @@ 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` +## `given` + +`given:` holds what this file reads and does not build: columns under +`variables:`, row families under `constraints:`. It takes those two keys and +nothing else. + +### `given: variables` A given variable is a column this file reads and another file introduces. It is what lets a fragment stand on its own: the file loads, and it prints as math, @@ -127,10 +133,11 @@ dimensions: generator: { dtype: str } relations: gen_port: { key: generator, value: port } -given_variables: - flow: - dims: [snapshot, port] - description: what a port puts into its bus +given: + variables: + flow: + dims: [snapshot, port] + description: what a port puts into its bus variables: gen_p: { dims: [snapshot, generator], bounds: { lower: 0 } } constraints: @@ -162,16 +169,17 @@ built in Python — the declaration stays, and the program carries it for a consumer to bind. See [what a program does not build](reading.md#what-a-program-does-not-build). -## `given_constraints` +### `given: constraints` A given constraint is a row family this file reads the dual of and another model builds. It is what lets a layer price something the base model settles. ```yaml -given_constraints: - balance: - dims: [snapshot, bus] - description: the host model clears each bus +given: + constraints: + balance: + dims: [snapshot, bus] + description: the host model clears each bus expressions: price: expression: dual(balance) diff --git a/docs/reference/language/file.md b/docs/reference/language/file.md index fe2ab7c7..0a7e8c93 100644 --- a/docs/reference/language/file.md +++ b/docs/reference/language/file.md @@ -5,22 +5,22 @@ SPDX-License-Identifier: CC-BY-4.0 # File shape -A model file is a YAML mapping with **twelve declaration keys**, plus -`version` and `description`. Any subset of the twelve 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 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` | what this file reads and another file builds ([given](declarations.md#given)) | +| `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/reading.md b/docs/reference/language/reading.md index 795e8b4e..cd0a09f4 100644 --- a/docs/reference/language/reading.md +++ b/docs/reference/language/reading.md @@ -118,7 +118,7 @@ classes live in `math_spec.program`. ## What a program does not build -`program.given_variables` and `program.given_constraints` name what the model +`program.given.variables` and `program.given.constraints` name what the model reads and does not build. Every other group is a build instruction — a column for each entry of `variables`, a row family for each entry of `constraints`. These two are the opposite: a name to look up in the model this one is layered @@ -128,8 +128,10 @@ onto. layer = to_program( { 'dimensions': {'snapshot': {'dtype': 'int'}, 'bus': {'dtype': 'str'}}, - 'given_variables': {'p': {'dims': ['snapshot', 'bus']}}, - 'given_constraints': {'balance': {'dims': ['snapshot', 'bus']}}, + 'given': { + 'variables': {'p': {'dims': ['snapshot', 'bus']}}, + 'constraints': {'balance': {'dims': ['snapshot', 'bus']}}, + }, 'parameters': {'rate': {'dims': ['bus']}}, 'constraints': {'cap': {'dims': [], 'expression': 'sum(p * rate) <= 100'}}, 'expressions': {'price': {'expression': 'dual(balance)'}}, @@ -137,8 +139,8 @@ layer = to_program( ) sorted(layer.variables) # [] -sorted(layer.given_variables) # ['p'] -layer.given_constraints['balance'].dims # ('snapshot', 'bus') +sorted(layer.given.variables) # ['p'] +layer.given.constraints['balance'].dims # ('snapshot', 'bus') ``` A consumer that builds a program does three things with them: diff --git a/examples/library/generator.yaml b/examples/library/generator.yaml index 95678241..beecb7e0 100644 --- a/examples/library/generator.yaml +++ b/examples/library/generator.yaml @@ -12,10 +12,11 @@ dimensions: generator: { dtype: str, description: "generating units, each on one port" } relations: Generator_port: { key: generator, value: port } -given_variables: - Port_p: - dims: [snapshot, port] - description: the surface introduces this column, and this file only writes into it +given: + variables: + Port_p: + dims: [snapshot, port] + description: the surface introduces this column, and this file only writes into it parameters: Generator_p_nom: { dims: [generator], description: nominal power } Generator_marginal_cost: { dims: [generator], description: cost of one unit of output } diff --git a/examples/library/load.yaml b/examples/library/load.yaml index 18c76ff3..94cca1fe 100644 --- a/examples/library/load.yaml +++ b/examples/library/load.yaml @@ -9,10 +9,11 @@ dimensions: load: { dtype: str, description: "demands, each on one port" } relations: Load_port: { key: load, value: port } -given_variables: - Port_p: - dims: [snapshot, port] - description: the surface introduces this column, and this file only writes into it +given: + variables: + Port_p: + dims: [snapshot, port] + description: the surface introduces this column, and this file only writes into it parameters: Load_p_set: { dims: [snapshot, load], description: "`Load-p_set` — what a load takes in a snapshot" } constraints: diff --git a/schema/math-spec.schema.json b/schema/math-spec.schema.json index dc4f1473..cd5a7c68 100644 --- a/schema/math-spec.schema.json +++ b/schema/math-spec.schema.json @@ -216,6 +216,30 @@ "title": "ExpressionCase", "type": "object" }, + "GivenBlock": { + "additionalProperties": false, + "description": "What this file reads and does not build, by kind.\n\nOne key per kind of declaration, and the section is closed at the two:\na third kind enters the day something reads one, and the schema's own\nerror names what is valid until then.", + "properties": { + "constraints": { + "additionalProperties": { + "$ref": "#/$defs/GivenConstraintBlock" + }, + "default": {}, + "title": "Constraints", + "type": "object" + }, + "variables": { + "additionalProperties": { + "$ref": "#/$defs/GivenVariableBlock" + }, + "default": {}, + "title": "Variables", + "type": "object" + } + }, + "title": "GivenBlock", + "type": "object" + }, "GivenConstraintBlock": { "additionalProperties": false, "description": "A row family this file reads the dual of and does not build.\n\nThe frame says how many duals there are and what indexes them, which is\nwhat ``dual()`` needs and all this file can answer. There is no\n``expression:``: the body is the owner's, and nothing here builds a row.", @@ -744,21 +768,12 @@ "title": "Expressions", "type": "object" }, - "given_constraints": { - "additionalProperties": { - "$ref": "#/$defs/GivenConstraintBlock" - }, - "default": {}, - "title": "Given Constraints", - "type": "object" - }, - "given_variables": { - "additionalProperties": { - "$ref": "#/$defs/GivenVariableBlock" - }, - "default": {}, - "title": "Given Variables", - "type": "object" + "given": { + "$ref": "#/$defs/GivenBlock", + "default": { + "constraints": {}, + "variables": {} + } }, "macros": { "additionalProperties": { diff --git a/src/math_spec/advice.py b/src/math_spec/advice.py index 400aec54..82fc4f4e 100644 --- a/src/math_spec/advice.py +++ b/src/math_spec/advice.py @@ -58,7 +58,7 @@ def _given(program: Program) -> list[Advice]: f'one is layered onto, and refuses where it cannot. A fragment is composed instead, and ' f'merge() folds it into the file that introduces it.', ) - for kind, group in (('variable', program.given_variables), ('row family', program.given_constraints)) + for kind, group in (('variable', program.given.variables), ('row family', program.given.constraints)) for name in group ] @@ -73,8 +73,8 @@ def _never_an_axis(program: Program) -> list[Advice]: reached: set[str] = set() for declaration in (*program.parameters.values(), *program.variables.values(), *program.constraints.values()): reached.update(declaration.dims) - reached.update(dim for given in program.given_variables.values() for dim in given.dims) - reached.update(dim for given in program.given_constraints.values() for dim in given.dims) + for group in (program.given.variables, program.given.constraints): + reached.update(dim for declaration in group.values() for dim in declaration.dims) reached |= _produced_axes(program) reached |= {dim for lk in program.relations.values() for dim in lk.dims} diff --git a/src/math_spec/composition.py b/src/math_spec/composition.py index 3268fef8..deced90e 100644 --- a/src/math_spec/composition.py +++ b/src/math_spec/composition.py @@ -70,14 +70,15 @@ #: 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', 'given_constraints') +#: What ``given:`` holds, by the key each kind sits under and what one entry +#: of it is called. The key is the owning section's name too, because what a +#: file reads is folded into the declaration of the same kind that introduces +#: it, and after that a composed library carries none. +GIVEN_KINDS = {'variables': 'given variable', 'constraints': 'given constraint'} #: 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, *GIVEN_SECTIONS) +SECTIONS = (*SHARED_SECTIONS, *OWNED_SECTIONS, 'given') #: 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 @@ -86,7 +87,6 @@ 'piecewise': 'piecewise curve', 'sos': 'special-ordered set', 'objective': 'objective', - 'given_variables': 'given variable', } @@ -125,14 +125,13 @@ def merge( merged['description'] = description for section in SHARED_SECTIONS: - if agreed := _agreed(read, section): + if agreed := _agreed(read, section, _singular(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 given := {kind: left for kind in GIVEN_KINDS if (left := _folded(read, kind, merged))}: + merged['given'] = given if (objective := _summed_objective(read)) is not None: merged['objective'] = objective return merged @@ -155,8 +154,11 @@ def _one_version(read: Mapping[str, dict[str, Any]]) -> int: 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. +def _agreed(read: Mapping[str, dict[str, Any]], section: str, label: str) -> dict[str, Any]: + """One block every fragment may declare, peers that say the same thing folded together. + + *label* is what one entry is called, because the caller knows whether the + block is the coordinate space or what a file reads. 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 @@ -169,7 +171,7 @@ def _agreed(read: Mapping[str, dict[str, Any]], section: str) -> dict[str, Any]: 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'{label} {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.' ) @@ -206,29 +208,30 @@ def _claimed(read: Mapping[str, dict[str, Any]], section: str) -> dict[str, Any] 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. +def _folded(read: Mapping[str, dict[str, Any]], kind: str, merged: Mapping[str, Any]) -> dict[str, Any]: + """One kind of given declaration, the ones a fragment introduces folded away. 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()): + asked = _agreed({name: sections.get('given') or {} for name, sections in read.items()}, kind, GIVEN_KINDS[kind]) + introduced = merged.get(kind, {}) + for key, block in list(asked.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) + reader = _author_of({name: sections.get('given') or {} for name, sections in read.items()}, kind, key) + owner = _author_of(read, kind, key) raise LanguageError( - f"fragment '{reader}' reads {_singular(section)} {key!r} as {block!r}, where '{owner}' " + f"fragment '{reader}' reads {GIVEN_KINDS[kind]} {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 + del asked[key] + return asked def _author_of(read: Mapping[str, dict[str, Any]], section: str, key: str) -> str: @@ -409,10 +412,16 @@ def _overlap_message(owner: str, claimed: tuple[str, ...], name: str, path: tupl def _lay_over(base: dict[str, Any], patch: dict[str, Any], name: str) -> dict[str, Any]: - """One patch over one base, a section at a time, the base left as it was.""" + """One patch over one base, a section at a time, the base left as it was. + + ``given:`` is laid over one kind at a time rather than whole, so a patch + that names the columns it reads does not drop the row families beside them. + """ laid = dict(base) for key, value in patch.items(): - if key in SHARED_SECTIONS: + if key == 'given': + laid[key] = {**(laid.get(key) or {}), **(value or {})} + elif key in SHARED_SECTIONS: laid[key] = _shared(laid.get(key) or {}, value or {}, key, name) elif key in OWNED_SECTIONS: laid[key] = _owned(laid.get(key) or {}, value or {}, key, name) diff --git a/src/math_spec/dimensions.py b/src/math_spec/dimensions.py index 38b95c84..53438a7d 100644 --- a/src/math_spec/dimensions.py +++ b/src/math_spec/dimensions.py @@ -90,14 +90,14 @@ def _dims( return frozenset(schema.parameters[node.name].dims) if isinstance(node, VariableNode): - return frozenset({**schema.variables, **schema.given_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.' raise AssertionError(msg) if isinstance(node, DualNode): - return frozenset({**schema.constraints, **schema.given_constraints}[node.constraint].dims) + return frozenset({**schema.constraints, **schema.given.constraints}[node.constraint].dims) if isinstance(node, FunctionCallNode): return _dims_call(node, schema, context) diff --git a/src/math_spec/lowering.py b/src/math_spec/lowering.py index 25348d8e..d2602714 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -165,10 +165,10 @@ def lower_program(expanded: _ExpandedSpec) -> program.Program: expressions[name] = program.ExpressionDeclaration( _Lowering(expanded, f"named expression '{name}'").expr(ast), in_math=name in resolved.read_by_the_math ) - given_variables = {name: program.GivenDeclaration(tuple(g.dims)) for name, g in expanded.given_variables.items()} - given_constraints = { - name: program.GivenDeclaration(tuple(g.dims)) for name, g in expanded.given_constraints.items() - } + given = program.GivenTargets( + variables={name: program.GivenDeclaration(tuple(g.dims)) for name, g in expanded.given.variables.items()}, + constraints={name: program.GivenDeclaration(tuple(g.dims)) for name, g in expanded.given.constraints.items()}, + ) return program.Program( parameters=parameters, variables=variables, @@ -178,8 +178,7 @@ def lower_program(expanded: _ExpandedSpec) -> program.Program: sos=sos, piecewise={name: declaration_of(ex) for name, ex in expanded.expanded_piecewise.items()}, named_expressions=expressions, - given_variables=given_variables, - given_constraints=given_constraints, + given=given, ) diff --git a/src/math_spec/model.py b/src/math_spec/model.py index fd32cc1c..32ead76b 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -339,6 +339,26 @@ class GivenVariableBlock(_StrictBlock): description: str | None = None +class GivenBlock(_StrictBlock): + """What this file reads and does not build, by kind. + + One key per kind of declaration, and the section is closed at the two: + a third kind enters the day something reads one, and the schema's own + error names what is valid until then. + """ + + _label: ClassVar[str] = 'a given block' + + #: Columns another file introduces (:class:`GivenVariableBlock`). + variables: dict[str, GivenVariableBlock] = {} + #: Row families another file builds (:class:`GivenConstraintBlock`). + constraints: dict[str, GivenConstraintBlock] = {} + + def __bool__(self) -> bool: + """Whether the file reads anything it does not build, so a caller can ask in one word.""" + return bool(self.variables or self.constraints) + + class ConstraintBlock(_StrictBlock): """A declared constraint: one rule, over one frame.""" @@ -755,14 +775,12 @@ 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] = {} - #: The row families this file reads the dual of and does not build - #: (:class:`GivenConstraintBlock`). Empty in a file that stands alone. - given_constraints: dict[str, GivenConstraintBlock] = {} + #: What this file reads and does not build (:class:`GivenBlock`): columns + #: under ``variables:``, row families under ``constraints:``. Empty in a + #: file that stands alone, and empty again once + #: :func:`~math_spec.composition.merge` has folded each declaration into + #: the one that introduces it. + given: GivenBlock = GivenBlock() def relations_of(self, dimension: str) -> dict[str, RelationBlock]: """The relations with a column over *dimension*, by name.""" @@ -860,7 +878,7 @@ def _given_constraint_collisions(self) -> Iterator[str]: walks — ``dual()``'s argument is the only position that reads them — so this is the one place the two constraint sections meet. """ - for name in self.given_constraints: + for name in self.given.constraints: if name in self.constraints: yield ( f"Given constraint '{name}' is also declared under 'constraints:'. A row family is " @@ -874,7 +892,7 @@ def _name_collisions(self) -> Iterator[str]: ('relation', self.relations), ('parameter', self.parameters), ('variable', self.variables), - ('given variable', self.given_variables), + ('given variable', self.given.variables), ('named expression', self.expressions), ('macro', self.macros), ] @@ -900,8 +918,8 @@ 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()), - *(('Given constraint', name, g.dims) for name, g in self.given_constraints.items()), + *(('Given variable', name, g.dims) for name, g in self.given.variables.items()), + *(('Given constraint', name, g.dims) for name, g in self.given.constraints.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/program.py b/src/math_spec/program.py index 6661ebf4..c9bdd34b 100644 --- a/src/math_spec/program.py +++ b/src/math_spec/program.py @@ -64,6 +64,7 @@ 'FirstOf', 'Footprint', 'GivenDeclaration', + 'GivenTargets', 'GroupSum', 'Increasing', 'LastOf', @@ -768,6 +769,30 @@ class GivenDeclaration: dims: tuple[str, ...] +@dataclass(frozen=True) +class GivenTargets: + """What a program reads and does not build, by kind, as the file declares it. + + Both groups are empty in a program built from one whole model. A consumer + that layers this program onto another model binds every name here before + it builds anything, and refuses what it cannot find. + """ + + #: Columns to bind, by name. + variables: Mapping[str, GivenDeclaration] = Sealed({}) + #: Row families to bind, by name, read back after the solve. + constraints: Mapping[str, GivenDeclaration] = Sealed({}) + + def __post_init__(self) -> None: + """Seal both groups, so a program handed out cannot be written to.""" + for f in fields(self): + object.__setattr__(self, f.name, Sealed(getattr(self, f.name))) + + def __bool__(self) -> bool: + """Whether the program reads anything it does not build, so a caller can ask in one word.""" + return bool(self.variables or self.constraints) + + @dataclass(frozen=True) class ConstraintDeclaration: """``lhs sense rhs`` for each coord combination of ``dims``. @@ -984,13 +1009,11 @@ class Program: #: named expression is outside the language is refused by every verb that #: reads the file rather than only by the one that reads the expression. named_expressions: Mapping[str, ExpressionDeclaration] = Sealed({}) - #: The columns this program reads and does not build. A consumer binds each - #: to a column the model it is layered onto already holds; nothing here - #: emits one, so a build reads :attr:`variables` and never this. - given_variables: Mapping[str, GivenDeclaration] = Sealed({}) - #: The row families this program reads the dual of and does not build, - #: bound the same way and read back after the solve. - given_constraints: Mapping[str, GivenDeclaration] = Sealed({}) + #: What this program reads and does not build (:class:`GivenTargets`). A + #: consumer binds each name to what the model it is layered onto already + #: holds; nothing here emits a column, so a build reads :attr:`variables` + #: and never this. + given: GivenTargets = GivenTargets() def __post_init__(self) -> None: """Seal every group, so a program handed out cannot be written to.""" diff --git a/src/math_spec/resolution.py b/src/math_spec/resolution.py index f1a6ccaa..c1dfb1f2 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.given_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,9 +142,9 @@ 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, **schema.given_variables}.items()}, + **{v: tuple(vd.dims) for v, vd in {**schema.variables, **schema.given.variables}.items()}, }, - {**schema.constraints, **schema.given_constraints}, + {**schema.constraints, **schema.given.constraints}, ) def kind(self, name: str) -> DeclarationKind | None: @@ -191,7 +191,7 @@ def unknown_constraint(self, name: str, context: str, *, formals: Iterable[str] f"{context}: dual({name}): '{name}' is not a declared constraint{also}.\n" f' Constraints: {sorted(self.constraints)}\n' f"Check for typos, or declare '{name}' — under 'constraints:' if this file builds the row, " - f"or under 'given_constraints:' if it reads the dual of one somebody else built." + f"or under 'given: constraints:' if it reads the dual of one somebody else built." ) diff --git a/src/math_spec/typesetting/symbols.py b/src/math_spec/typesetting/symbols.py index e1620939..ae0bf154 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) | frozenset(schema.given_variables) | chosen_expressions(schema) - names = (*schema.parameters, *schema.variables, *schema.given_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. @@ -129,7 +129,7 @@ def __init__(self, schema: _ExpandedSpec, fmt: Format, table: SymbolTable) -> No #: overrides it. self.constraint: dict[str, str] = { name: table.names[name] if name in table.names else _derive_name_symbol(name, declared, fmt, given=True) - for name in (*schema.constraints, *schema.given_constraints) + for name in (*schema.constraints, *schema.given.constraints) } self.index: dict[str, str] = {} @@ -242,10 +242,10 @@ def checked_against(self, schema: _ExpandedSpec) -> SymbolTable: dims | set(schema.parameters) | set(schema.variables) - | set(schema.given_variables) + | set(schema.given.variables) | set(schema.expressions) | set(schema.constraints) - | set(schema.given_constraints) + | set(schema.given.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 cd220821..033fe7ed 100644 --- a/src/math_spec/typesetting/walk.py +++ b/src/math_spec/typesetting/walk.py @@ -347,7 +347,7 @@ 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): - frames = {**self.schema.variables, **self.schema.given_variables} + 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): @@ -864,14 +864,14 @@ def glossaries(self, noticed: Noticed) -> list[Glossary]: ] 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() + for g, block in self.schema.given.variables.items() ] + [ self._entry( self.symbols.constraint[g], f'{fmt.mono(g)}{self._over(list(block.dims))}, a row family this file reads the dual of', block.description, ) - for g, block in self.schema.given_constraints.items() + for g, block in self.schema.given.constraints.items() ] definitions = [ self._entry(self.symbols.name[e], f'{fmt.mono(e)}{self._over(self.frames[e])}', block.description) diff --git a/tests/test_advice.py b/tests/test_advice.py index da6cea8f..4b28d26d 100644 --- a/tests/test_advice.py +++ b/tests/test_advice.py @@ -73,7 +73,7 @@ def test_both_kinds_of_note_come_through_the_one_door(): #: and every dimension is indexed, so neither other pass has anything to say. READS_A_COLUMN = { 'dimensions': {'g': {'dtype': 'str'}}, - 'given_variables': {'flow': {'dims': ['g']}}, + 'given': {'variables': {'flow': {'dims': ['g']}}}, 'variables': {'p': {'dims': ['g'], 'bounds': {'lower': 0, 'upper': 1}}}, 'constraints': {'tie': {'dims': ['g'], 'expression': 'p == flow'}}, } diff --git a/tests/test_composition.py b/tests/test_composition.py index 5ae51541..d8f27969 100644 --- a/tests/test_composition.py +++ b/tests/test_composition.py @@ -315,6 +315,18 @@ def test_an_objective_a_base_does_not_declare_is_refused(base, patch, says): assert says in str(raised.value) +def test_a_patch_over_one_kind_of_given_leaves_the_other_alone(): + """`given:` is laid over a kind at a time, so patching the columns cannot drop the row families.""" + base = { + 'dimensions': {'g': {'dtype': 'str'}}, + 'given': {'variables': {'p': {'dims': ['g']}}, 'constraints': {'cap': {'dims': ['g']}}}, + 'expressions': {'price': {'expression': 'dual(cap)'}}, + } + laid = override(base, {'wider': {'given': {'variables': {'p': {'dims': ['g'], 'domain': 'binary'}}}}}) + assert laid['given']['variables']['p']['domain'] == 'binary' + assert sorted(laid['given']['constraints']) == ['cap'], 'the kind the patch did not name is still there' + + def test_a_patch_is_a_path_as_readily_as_a_mapping(tmp_path): """Whatever every other verb takes, so a patch travels as a file rather than as a script.""" patch = tmp_path / 'carbon.yaml' diff --git a/tests/test_given.py b/tests/test_given.py index addb3b5f..703c33c5 100644 --- a/tests/test_given.py +++ b/tests/test_given.py @@ -27,7 +27,7 @@ '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'}}, + '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'}}, @@ -43,9 +43,16 @@ } +def test_given_holds_two_kinds_and_refuses_a_third(): + """The section is closed, so a kind nobody has admitted yet is the schema's own refusal.""" + with pytest.raises(LanguageError) as raised: + to_spec({**SUPPLY, 'given': {'parameters': {'gen_cost': {'dims': ['generator']}}}}) + assert 'Valid keys: constraints, variables' in str(raised.value), 'the refusal names what the block takes' + + 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.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' @@ -65,8 +72,8 @@ def test_a_program_carries_the_column_it_reads_apart_from_the_ones_it_builds(): """The distinction a builder needs: create this column, or bind it to one the host already holds.""" program = to_program(SUPPLY) assert sorted(program.variables) == ['gen_p'], 'a build reads this group and creates a column for each' - assert sorted(program.given_variables) == ['flow'], 'and binds each of these to a column it is given' - assert program.given_variables['flow'].dims == ('snapshot', 'port'), 'the frame is what a binder checks' + assert sorted(program.given.variables) == ['flow'], 'and binds each of these to a column it is given' + assert program.given.variables['flow'].dims == ('snapshot', 'port'), 'the frame is what a binder checks' def test_the_advice_says_which_columns_a_consumer_has_to_bind(): @@ -76,7 +83,7 @@ def test_the_advice_says_which_columns_a_consumer_has_to_bind(): 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' + assert 'given' 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" @@ -85,12 +92,12 @@ def test_merging_folds_the_given_declaration_into_the_one_that_introduces_it(): 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 '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']}}} + misread = {**SUPPLY, 'given': {'variables': {'flow': {'dims': ['snapshot', 'generator']}}}} with pytest.raises(LanguageError) as raised: merge({'surface': SURFACE, 'supply': misread}) message = str(raised.value) @@ -100,7 +107,7 @@ def test_a_given_declaration_that_disagrees_with_the_introducer_is_refused(): def test_two_fragments_must_read_one_column_the_same_way(): other = { 'dimensions': {'snapshot': {'dtype': 'int'}, 'port': {'dtype': 'str'}}, - 'given_variables': {'flow': {'dims': ['port']}}, + 'given': {'variables': {'flow': {'dims': ['port']}}}, } with pytest.raises(LanguageError, match=r'say different things about given variable'): merge({'supply': SUPPLY, 'other': other}) @@ -123,7 +130,7 @@ def test_a_name_both_introduced_and_given_in_one_file_is_refused(): ) 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}}) + to_spec({**SUPPLY, 'given': {'variables': {'flow': block}}}) assert says in str(raised.value) @@ -138,8 +145,10 @@ def test_an_expression_reads_a_given_column_as_it_reads_any_other(): LAYER = { 'description': 'A carbon cap laid over a model that already exists.', 'dimensions': {'snapshot': {'dtype': 'int'}, 'bus': {'dtype': 'str'}}, - 'given_variables': {'p': {'dims': ['snapshot', 'bus']}}, - 'given_constraints': {'balance': {'dims': ['snapshot', 'bus'], 'description': 'the host clears each bus'}}, + 'given': { + 'variables': {'p': {'dims': ['snapshot', 'bus']}}, + 'constraints': {'balance': {'dims': ['snapshot', 'bus'], 'description': 'the host clears each bus'}}, + }, 'parameters': {'rate': {'dims': ['bus']}}, 'constraints': {'cap': {'dims': [], 'expression': 'sum(p * rate) <= 100'}}, 'expressions': {'price': {'expression': 'dual(balance)'}}, @@ -148,14 +157,14 @@ def test_an_expression_reads_a_given_column_as_it_reads_any_other(): def test_a_dual_may_name_a_row_family_this_file_does_not_build(): spec = to_spec(LAYER) - assert sorted(spec.given_constraints) == ['balance'] + assert sorted(spec.given.constraints) == ['balance'] assert sorted(spec.constraints) == ['cap'], 'the row families it builds are its own, and that is not one' def test_the_program_carries_the_row_family_a_consumer_binds(): program = to_program(LAYER) - assert sorted(program.given_constraints) == ['balance'] - assert program.given_constraints['balance'].dims == ('snapshot', 'bus'), 'the frame is what a binder checks' + assert sorted(program.given.constraints) == ['balance'] + assert program.given.constraints['balance'].dims == ('snapshot', 'bus'), 'the frame is what a binder checks' def test_the_dual_takes_its_frame_from_the_given_declaration(): @@ -180,7 +189,7 @@ def test_a_dual_naming_nothing_says_where_to_declare_it(): with pytest.raises(LanguageError) as raised: to_spec(mistyped) message = str(raised.value) - assert "'constraints:'" in message and "'given_constraints:'" in message, ( + assert "'constraints:'" in message and 'given' in message, ( 'the message names both places the row family could be declared' ) @@ -194,7 +203,7 @@ def test_a_dual_naming_nothing_says_where_to_declare_it(): ) def test_a_given_row_family_is_refused_where_it_oversteps(block, says): with pytest.raises(LanguageError) as raised: - to_spec({**LAYER, 'given_constraints': {'balance': block}}) + to_spec({**LAYER, 'given': {**LAYER['given'], 'constraints': {'balance': block}}}) assert says in str(raised.value) @@ -205,10 +214,10 @@ def test_merging_folds_a_row_family_into_the_file_that_builds_it(): 'constraints': {'balance': {'dims': ['snapshot', 'bus'], 'expression': 'p >= 0'}}, } composed = merge({'builder': builder, 'layer': LAYER}) - assert 'given_constraints' not in composed and 'given_variables' not in composed + assert 'given' not in composed program = to_program(composed) assert sorted(program.constraints) == ['balance', 'cap'] - assert not program.given_constraints, 'nothing is left for a consumer to bind' + assert not program.given.constraints, 'nothing is left for a consumer to bind' def test_the_advice_names_every_declaration_a_consumer_has_to_bind(): @@ -221,8 +230,7 @@ def test_the_advice_names_every_declaration_a_consumer_has_to_bind(): #: declaration this file does not build. REACHED_ONLY_BY_A_GIVEN_FRAME = { 'dimensions': {'g': {'dtype': 'str'}, 'port': {'dtype': 'str'}, 'bus': {'dtype': 'str'}}, - 'given_variables': {'flow': {'dims': ['port']}}, - 'given_constraints': {'balance': {'dims': ['bus']}}, + 'given': {'variables': {'flow': {'dims': ['port']}}, 'constraints': {'balance': {'dims': ['bus']}}}, 'variables': {'p': {'dims': ['g'], 'bounds': {'lower': 0, 'upper': 1}}}, 'constraints': {'tie': {'dims': ['g'], 'expression': 'p >= sum(flow, over=port)'}}, 'expressions': {'price': {'expression': 'dual(balance)'}}, diff --git a/tests/test_library_example.py b/tests/test_library_example.py index 464d68b2..c3959ab9 100644 --- a/tests/test_library_example.py +++ b/tests/test_library_example.py @@ -34,7 +34,7 @@ def test_every_fragment_loads_and_prints_on_its_own(name): @pytest.mark.parametrize('name', ['generator', 'load']) def test_a_component_file_reads_the_surface_and_introduces_no_flow(name): spec = to_spec(FRAGMENTS[name]) - assert sorted(spec.given_variables) == ['Port_p'] + assert sorted(spec.given.variables) == ['Port_p'] assert 'Port_p' not in spec.variables, 'the surface introduces the column, and a component file only writes into it' @@ -42,7 +42,7 @@ def test_the_library_composes_into_one_model(): spec = to_spec(merge(FRAGMENTS)) assert sorted(spec.variables) == ['Generator_p', 'Port_p'] assert sorted(spec.constraints) == ['Bus_nodal_balance', 'Generator_injection', 'Load_withdrawal'] - assert not spec.given_variables, 'each read is folded into the declaration that introduces it' + assert not spec.given.variables, 'each read is folded into the declaration that introduces it' assert spec.objective is not None and spec.objective.expression == 'sum(Generator_p * Generator_marginal_cost)', ( "the one fragment that priced anything carries the composed model's objective, as it wrote it" ) diff --git a/tools/gallery.py b/tools/gallery.py index 56be9b1a..e75aa1cd 100644 --- a/tools/gallery.py +++ b/tools/gallery.py @@ -89,7 +89,7 @@ def symbols_for(model: Spec) -> dict[str, Any]: named = { *model.parameters, *model.variables, - *model.given_variables, + *model.given.variables, *model.expressions, *model.constraints, }