From d929c696f6ce399d363b1614f744830aaa61b46b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 14:29:52 +0000 Subject: [PATCH 01/20] refactor(program): a program carries the trees the typesetter prints, and every curve is written out before a model becomes one Resolved is gone. Lowering builds the Program as a model loads and holds it on the Spec; to_program(spec) returns that object, and returns the expansion's for a model with a curve, so a program holds the rows a curve states and never the curve. Named joins program.Expression, so a use of an expressions: entry stands where it is read and the typesetter prints from the program beside the file. ExpressionDeclaration carries its dims, and piecewise.curve reads a block's typed links and frame off the model for the expansion and the walk. PiecewiseDeclaration, Program.piecewise, Spec.resolved, lowering.inline and the refusal of a model still carrying a curve are removed. The typesetter's output is unchanged: the golden files and the generated pages regenerate byte for byte. Docs sentences, after (before): reading.md n 75 avg 16.1 median 15 over25 10 (72, 16.4, 14, 13); piecewise.md n 70 avg 17.6 median 15 over25 13 (69, 17.8, 15, 14). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi --- docs/reference/language/piecewise.md | 9 +- docs/reference/reading.md | 38 +-- schema/math-spec.schema.json | 2 +- src/math_spec/__main__.py | 6 +- src/math_spec/advice.py | 3 +- src/math_spec/boundedness.py | 3 +- src/math_spec/dimensions.py | 20 +- src/math_spec/lowering.py | 367 ++++++++++++++++---------- src/math_spec/model.py | 38 +-- src/math_spec/piecewise.py | 74 +++++- src/math_spec/program.py | 53 ++-- src/math_spec/resolution.py | 59 ----- src/math_spec/sos.py | 4 +- src/math_spec/typesetting/README.md | 6 +- src/math_spec/typesetting/__init__.py | 7 +- src/math_spec/typesetting/symbols.py | 18 +- src/math_spec/typesetting/walk.py | 56 ++-- src/math_spec/validation.py | 211 +-------------- tests/test_expansion.py | 11 +- tests/test_lowering.py | 15 +- tests/test_piecewise.py | 27 +- tests/test_public_surface.py | 8 +- tests/test_sos.py | 2 - tests/test_validation.py | 14 +- tests/typesetting/test_cases.py | 6 +- tests/typesetting/test_golden.py | 25 +- tests/typesetting/test_walk.py | 5 +- 27 files changed, 463 insertions(+), 624 deletions(-) diff --git a/docs/reference/language/piecewise.md b/docs/reference/language/piecewise.md index db1dbe40..5f9e7089 100644 --- a/docs/reference/language/piecewise.md +++ b/docs/reference/language/piecewise.md @@ -260,7 +260,8 @@ a model before and after, as whole files. emits a parameter. A curve under `points:` sits its rows on `where:` predicates over the mask the file named, and the expansion is a file like any other: `to_yaml()` writes it, and loading it back changes nothing. -- **`to_program()` writes nothing out.** A model still carrying a curve is - refused, naming `spec.expand('piecewise')`. A program carries a set, because - a consumer with the concept takes one; a consumer without it refuses the - model and names `spec.expand()`. +- **`to_program()` writes every curve out.** A program holds the rows a curve + states, so `to_program(spec)` and `to_program(spec.expand('piecewise'))` are + the same program. A program carries a set, because a consumer with the + concept takes one; a consumer without it refuses the model and names + `spec.expand()`. diff --git a/docs/reference/reading.md b/docs/reference/reading.md index 14af2af1..d866ed1c 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -64,23 +64,24 @@ from math_spec import to_spec, to_program spec = to_spec('curve.yaml') sorted(spec.constraints) # ['target'] -program = to_program(spec.expand('piecewise')) +program = to_program(spec) sorted(program.constraints) # ['curve_convexity', 'curve_link0', 'curve_link1', 'target'] sorted(program.variables) # ['cost', 'curve_lam', 'p'] ``` `to_program` takes a path, the YAML, a mapping, a `Spec` or a `Program`. Called -on a `Program`, it returns the same object unchanged. It lowers the model as it -arrived and writes nothing out: a model still carrying a `piecewise:` block is -refused, and the refusal names `spec.expand('piecewise')`, which keeps every -`sos:` block, and `spec.expand()`, which writes the sets out too. Which one is -the caller's to say, because a consumer with the concept of a set takes one -whole and a consumer without it does not. - -| you are | take | because | -| ---------------------------------------------------------------------------- | --------- | ---------------------------------------- | -| building rows, as a solver backend or a second front end does | `Program` | Every declaration is there, and resolved | -| reading the file, for `macros:`, `description:`, or a link as it was written | `Spec` | A program keeps a curve's facts | +on a `Program`, it returns the same object unchanged. Called on a `Spec`, it +returns the program built when the model loaded, so two calls on one model +return one object. It writes every `piecewise:` block out as the rows the block +states: `to_program(spec)` and `to_program(spec.expand('piecewise'))` are the +same program. It keeps every `sos:` block, because a consumer with the concept +of a set takes one whole. A consumer without it calls `spec.expand()` first, +which writes the sets out too. + +| you are | take | because | +| ---------------------------------------------------------------------------- | --------- | --------------------------------------------- | +| building rows, as a solver backend or a second front end does | `Program` | Every declaration is there, and resolved | +| reading the file, for `macros:`, `description:`, or a link as it was written | `Spec` | A program holds a curve's rows, not the curve | ## Formulations written out @@ -100,9 +101,9 @@ carries a set for a consumer that has the concept. A consumer without one refuses the model and names `spec.expand('sos')`; what that emits is on the [piecewise page](language/piecewise.md#what-a-set-is-written-out-as). -`program.piecewise` keeps the curve: its breakpoint dimension, its method and -its values parameters. Every parameter the program declares is one the file -declared, and the engine binds each from its data. +Every parameter the program declares is one the file declared, and the engine +binds each from its data. A program does not keep the curve: the rows, the +weights and the conditions the method states are declarations like any other. ## What the data has to satisfy @@ -138,9 +139,10 @@ node's operands, and `where_children()` walks a predicate's. `walk()` yields every node under an expression, parents first. `walk_regions()` yields each node with the `cases:` regions it stands inside, outermost first. -`Named` is the one node no program carries. A `Spec.resolved` tree holds it -where an `expressions:` entry is used, and lowering inlines the entry's body -there before the program is built, so `Expression` does not name it. +A `Named` stands where an `expressions:` entry is used. Its `body` is the +entry's expression, the same object that `program.expressions[name].expression` +holds, and its value is the body's value. `children()` steps into the body, so +a walk reads through it; a renderer prints the name where the file wrote it. Every `where` arrives as a `Mask`. Its `.root` is the resolved predicate. The mask also answers four questions: diff --git a/schema/math-spec.schema.json b/schema/math-spec.schema.json index fc3a2836..ee07fa2e 100644 --- a/schema/math-spec.schema.json +++ b/schema/math-spec.schema.json @@ -670,7 +670,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 eleven declaration sections plus ``version`` and\n``description``, three ways back out \u2014 :meth:`to_dict` for the model as\ndata, :meth:`to_yaml` for the file a reviewer reads, :meth:`expand` for the\nsame math with its formulations written out \u2014 and :attr:`resolved`, the\ntyped trees every reader in this package walks. Everything else on this\nclass 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``, three ways back out \u2014 :meth:`to_dict` for the model as\ndata, :meth:`to_yaml` for the file a reviewer reads, :meth:`expand` for the\nsame math with its formulations written out. Everything else on this\nclass is pydantic's, not a contract this package keeps.", "properties": { "assumptions": { "additionalProperties": { diff --git a/src/math_spec/__main__.py b/src/math_spec/__main__.py index 165cf0bb..b090897d 100644 --- a/src/math_spec/__main__.py +++ b/src/math_spec/__main__.py @@ -52,14 +52,12 @@ def parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: """Run one verb; a refused file is its message on stderr and exit status 1. - Advice is not a refusal: ``check`` prints it and exits 0. It reads the - model with its curves written out, since a curve holds its variables - through the rows it states and nothing lowers a block left as written. + Advice is not a refusal: ``check`` prints it and exits 0. """ args = parser().parse_args(argv) if args.verb == 'check': try: - notes = advice(to_spec(args.model).expand('piecewise')) + notes = advice(args.model) except MathSpecError as e: sys.stderr.write(f'{e}\n') return 1 diff --git a/src/math_spec/advice.py b/src/math_spec/advice.py index 2d77d0b0..ee3406e5 100644 --- a/src/math_spec/advice.py +++ b/src/math_spec/advice.py @@ -30,8 +30,7 @@ def advice(model: str | Path | Mapping[str, object] | Spec | Program) -> tuple[A Args: model: A YAML path, a mapping, a loaded :class:`Spec`, or a :class:`Program`. Both passes read the program, so the four - answer alike, and a ``piecewise:`` block still in the model is - refused as :func:`~math_spec.lowering.to_program` refuses it. + answer alike. Returns: The never-an-axis advice in declaration order, then the unboundedness diff --git a/src/math_spec/boundedness.py b/src/math_spec/boundedness.py index 1102abe8..210c78c8 100644 --- a/src/math_spec/boundedness.py +++ b/src/math_spec/boundedness.py @@ -26,6 +26,7 @@ Expression, GroupSum, Multiply, + Named, Negate, Parameter, Power, @@ -162,7 +163,7 @@ def _record_signs(node: Expression, sign: Sign, signs: dict[str, Sign]) -> None: _record_signs(node.base, None, signs) _record_signs(node.exponent, None, signs) return - if isinstance(node, Sum | GroupSum | Pullback | Translate | WindowSum | Cases): + if isinstance(node, Sum | GroupSum | Pullback | Translate | WindowSum | Cases | Named): for child in children(node): _record_signs(child, sign, signs) return diff --git a/src/math_spec/dimensions.py b/src/math_spec/dimensions.py index e421f93e..9ee479e4 100644 --- a/src/math_spec/dimensions.py +++ b/src/math_spec/dimensions.py @@ -54,7 +54,7 @@ if TYPE_CHECKING: from math_spec.model import Spec - from math_spec.resolution import Resolved + from math_spec.program import Program def dims_of(node: Expression, schema: Spec, context: str) -> frozenset[str]: @@ -260,8 +260,8 @@ def _check_named_amount( # --------------------------------------------------------------------------- -def check_schema(schema: Spec, resolved: Resolved) -> None: - """Check every declaration's dim rules, on the trees *resolved* holds for *schema*. +def check_schema(schema: Spec, program: Program) -> None: + """Check every declaration's dim rules, on the trees *program* holds for *schema*. Raises: DimensionError: On the first declaration that breaks one. @@ -269,7 +269,7 @@ def check_schema(schema: Spec, resolved: Resolved) -> None: for vname, vdef in schema.variables.items(): frame = frozenset(vdef.dims) context = f"Variable '{vname}'" - _check_where_dims(resolved.variables[vname], frame, context) + _check_where_dims(program.variables[vname].where, frame, context) for side in ('lower', 'upper'): bound = getattr(vdef.bounds, side) if isinstance(bound, str): @@ -281,18 +281,18 @@ def check_schema(schema: Spec, resolved: Resolved) -> None: f'{sorted(frame)}.' ) - for ename, entry in resolved.expressions.items(): - if not isinstance(entry.body, Cases): + for ename, entry in program.expressions.items(): + if not isinstance(entry.expression, Cases): continue block = schema.expressions[ename] frame = frozenset(block.dims or []) - for region, label in zip(entry.body.regions, [*block.cases, None], strict=True): + for region, label in zip(entry.expression.regions, [*block.cases, None], strict=True): context = case_context(ename, label) if label is not None: _check_where_dims(region.when, frame, context) _check_value_dims(region.value, schema, frame, context) - for cname, constraint in resolved.constraints.items(): + for cname, constraint in program.constraints.items(): frame = frozenset(constraint.dims) context = f"Constraint '{cname}'" _check_where_dims(constraint.where, frame, context) @@ -310,9 +310,9 @@ def check_schema(schema: Spec, resolved: Resolved) -> None: ) raise DimensionError(f'{context}: the expression {detail}.') - if resolved.objective is not None: + if program.objective is not None: context = 'The objective' - got = dims_of(resolved.objective.expression, schema, context) + got = dims_of(program.objective.expression, schema, context) if got: raise DimensionError( f'{context}: the expression carries dims {sorted(got)}, and an objective is one ' diff --git a/src/math_spec/lowering.py b/src/math_spec/lowering.py index 8b213fca..c45d744e 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -2,39 +2,71 @@ # # SPDX-License-Identifier: MIT -"""Lower a validated model to a :class:`~math_spec.program.Program`. - -One lowering, on the language side: it packages the declarations a model -resolved to, with every named expression inlined where the math reads it, and -reaches no consumer. A construct with no lowering raises -:class:`~math_spec.errors.LanguageError` naming its rewrite. +"""Lower a model to a :class:`~math_spec.program.Program` — the pass that decides every expression. + +One lowering, on the language side, run when a :class:`~math_spec.model.Spec` +loads: it reads every expression and where string into the program's own +nodes, checks every rule decidable without data, and packages the +declarations. A ``piecewise:`` block states rows, so a program is lowered +from the model with its curves written out +(:meth:`~math_spec.model.Spec.expand`). """ from __future__ import annotations -from dataclasses import replace -from typing import TYPE_CHECKING, assert_never - -import math_spec.program as program -from math_spec.errors import LanguageError -from math_spec.piecewise import declaration_of +from typing import TYPE_CHECKING + +from math_spec.dimensions import check_schema, dims_of +from math_spec.errors import SchemaError, prefixed +from math_spec.expansion import expand, parse_template +from math_spec.piecewise import curve_frame, lp_domain_refusal, resolve_links +from math_spec.program import ( + Assumption, + BooleanLiteral, + Cases, + Constant, + ConstraintDeclaration, + DimensionDeclaration, + ExpressionDeclaration, + Mask, + Named, + ObjectiveDeclaration, + Parameter, + ParameterDeclaration, + Program, + SosDeclaration, + VariableDeclaration, + VariableDefined, + walk, +) +from math_spec.resolution import ( + Namespace, + mask_of, + resolve_constraint_text, + resolve_expression, + resolve_expression_text, + resolve_where_text, +) from math_spec.validation import to_spec if TYPE_CHECKING: from collections.abc import Mapping from pathlib import Path - from math_spec.model import Spec + from math_spec.model import AssumptionBlock, Spec + from math_spec.program import Expression -def to_program(spec: str | Path | Mapping[str, object] | Spec | program.Program) -> program.Program: +def to_program(spec: str | Path | Mapping[str, object] | Spec | Program) -> Program: """*spec* as a :class:`~math_spec.program.Program` — the public door. Takes whatever you have: a YAML path, the YAML itself, a mapping, a loaded model, or a program already. Idempotent, so a caller that does not know - which it holds can call this and be sure. The model is lowered as it - arrived: nothing is written out here, so a ``piecewise:`` block still in - it is refused, naming :meth:`~math_spec.model.Spec.expand`. + which it holds can call this and be sure, and one object per model: the + program was built when the model loaded, and a second ask is the same + object. Every ``piecewise:`` block is written out as the rows it states; + a ``sos:`` block lowers as itself, since a program carries a set and + :meth:`~math_spec.model.Spec.expand` is what states one as binaries. Args: spec: What to read the declarations from. @@ -46,153 +78,210 @@ def to_program(spec: str | Path | Mapping[str, object] | Spec | program.Program) Raises: SchemaError: The file is not a valid model. LanguageError: A construct outside the language, named with its - rewrite, or a ``piecewise:`` block left as written. + rewrite. """ - if isinstance(spec, program.Program): + if isinstance(spec, Program): return spec - return lower_program(to_spec(spec)) + program = to_spec(spec).expand('piecewise')._program + assert program is not None, 'a model that loaded was lowered' + return program -def curve_left_as_written_message(blocks: list[str]) -> str: - """The refusal for a model lowered with its ``piecewise:`` blocks still to be written out.""" - named = ', '.join(f"'{block}'" for block in blocks) - return ( - f'piecewise: {named} states rows rather than being one, and a program holds the rows. Pass ' - f"spec.expand('piecewise'), which writes each block out as the variables and constraints it states " - f'and keeps every sos: block for a consumer that takes a set — or spec.expand(), which writes the ' - f'sets out as binaries and linking rows too.' - ) +def lower(schema: Spec) -> Program: + """Lower *schema*'s own declarations, checking every rule decidable without data. + What is checked: -def lower_program(expanded: Spec) -> program.Program: - """Compile a model whose curves are written out into a :class:`~math_spec.program.Program`. + - the expression parses, and constraints hold exactly one comparison where + objectives hold none; + - every referenced name resolves, and every operator is a built-in whose + dimension arguments name declared dimensions; + - where strings parse *and* resolve — an unknown name there is an error, + not a silently-empty mask; + - macro formals may shadow model names but not a declared dimension, since + ``over=snapshot`` under a formal ``snapshot`` cannot say which it means; + - every dim rule (``dimensions.check_schema``), once names resolve. - A ``domain: binary`` variable lowers with fixed 0/1 bounds. A ``sos:`` - block lowers as itself — a program carries a set, and - :meth:`~math_spec.model.Spec.expand` is what states one as binaries - instead. A ``piecewise:`` block does not lower at all: it states rows, and - :meth:`~math_spec.model.Spec.expand` is what writes them, so a model still - carrying one is refused rather than written out on the caller's behalf. + A ``piecewise:`` block's links are resolved and its frame checked here, on + the link the file wrote, so the expansion writes rows the language has + already held to every rule. The rows themselves are on the expanded + model's program, which :func:`to_program` answers with. - Args: - expanded: A model with no ``piecewise:`` block left, which - :meth:`~math_spec.model.Spec.expand` returns. + Returns: + The program of what *schema* declares, a ``piecewise:`` block's rows + not among them. Raises: - LanguageError: A construct outside the language, named with its - rewrite, or a ``piecewise:`` block left as written. + SchemaError: Listing every problem found, one per line. + DimensionError: The first dim rule a declaration breaks, once every + name resolves. """ - if expanded.piecewise: - raise LanguageError(curve_left_as_written_message(sorted(expanded.piecewise))) - resolved = expanded.resolved - parameters = { - name: program.ParameterDeclaration(tuple(pdef.dims), pdef.dtype) for name, pdef in expanded.parameters.items() - } + ns = Namespace(schema) + errors: list[str] = [] + + for mname, macro in schema.macros.items(): + context = f"Macro '{mname}'" + formals = frozenset((*macro.args, *macro.kwargs)) + try: + body_ast = expand(parse_template(mname, macro, context), ns, context) + except ValueError as e: + errors.append(prefixed(context, e)) + continue + errors.extend( + f"{context}: formal '{f}' collides with declared dimension '{f}'. " + f'Rename the formal — a dimension name inside a template is ' + f'ambiguous with the dimension itself.' + for f in sorted(formals & ns.dimensions) + ) + resolve_expression(body_ast, ns, context, errors, formals=formals) + + entries: dict[str, Named] = {} + for ename in schema.expressions: + node, refusals = ns.named_entry(ename) + errors.extend(refusals) + if node is not None: + entries[ename] = node variables = {} - for vname, vdef in expanded.variables.items(): - domain = vdef.domain - if domain == 'binary': - lower, upper = program.Constant(0.0), program.Constant(1.0) + for vname, vdef in schema.variables.items(): + where = resolve_where_text(vdef.where, ns, f"Variable '{vname}'", errors, self_variable=vname) + if vdef.domain == 'binary': + lower_bound, upper_bound = Constant(0.0), Constant(1.0) else: - lower, upper = _bound_expression(vdef.bounds.lower), _bound_expression(vdef.bounds.upper) - variables[vname] = program.VariableDeclaration( + lower_bound, upper_bound = _bound(vdef.bounds.lower), _bound(vdef.bounds.upper) + variables[vname] = VariableDeclaration( tuple(vdef.dims), - where=_inlined_mask(resolved.variables[vname]), - lower=lower, - upper=upper, - domain=domain, + where=mask_of(where), + lower=lower_bound, + upper=upper_bound, + domain=vdef.domain, absence=vdef.absence, ) - constraints = { - cname: replace(c, lhs=inline(c.lhs), rhs=inline(c.rhs), where=_inlined_mask(c.where)) - for cname, c in resolved.constraints.items() - } + constraints: dict[str, ConstraintDeclaration] = {} + for cname, cdef in schema.constraints.items(): + context = f"Constraint '{cname}'" + where = resolve_where_text(cdef.where, ns, context, errors) + if (sides := resolve_constraint_text(cdef.expression, ns, context, errors)) is not None: + lhs, sense, rhs = sides + constraints[cname] = ConstraintDeclaration(tuple(cdef.dims), lhs, sense, rhs, mask_of(where)) + objective = None - if resolved.objective is not None: - objective = replace(resolved.objective, expression=inline(resolved.objective.expression)) - - dimensions = {dname: program.DimensionDeclaration(ddef.dtype) for dname, ddef in expanded.dimensions.items()} - sos = { - sname: program.SosDeclaration(sdef.variable, sdef.along, sos_type=sdef.type) - for sname, sdef in expanded.sos.items() - } - expressions = { - name: program.ExpressionDeclaration(inline(entry), in_math=name in resolved.read_by_the_math) - for name, entry in resolved.expressions.items() - } - assumptions = { - name: replace(holds, predicate=inline_mask(holds.predicate), where=_inlined_mask(holds.where)) - for name, holds in resolved.assumptions.items() - } - return program.Program( - parameters=parameters, + if schema.objective is not None: + expression = resolve_expression_text(schema.objective.expression, ns, 'The objective', errors, ceiling=2) + if expression is not None: + objective = ObjectiveDeclaration(schema.objective.sense, expression) + + assumptions: dict[str, Assumption] = {} + for aname, adef in schema.assumptions.items(): + if (assumption := _assumption(aname, adef, ns, errors)) is not None: + assumptions[aname] = assumption + + curves: dict[str, tuple[Expression, ...]] = {} + for pname, pdef in schema.piecewise.items(): + links = resolve_links(pname, pdef, ns, errors) + if links is None: + continue + if pdef.method == 'lp' and (refusal := lp_domain_refusal(pname, pdef, links)) is not None: + errors.append(refusal) + curves[pname] = links + + if errors: + raise SchemaError('\n'.join(errors)) + + roots = [side for c in constraints.values() for side in (c.lhs, c.rhs)] + if objective is not None: + roots.append(objective.expression) + roots.extend(link for links in curves.values() for link in links) + in_math = frozenset(node.name for node in walk(*roots) if isinstance(node, Named)) + + program = Program( + parameters={ + name: ParameterDeclaration(tuple(pdef.dims), pdef.dtype) for name, pdef in schema.parameters.items() + }, variables=variables, constraints=constraints, objective=objective, - dimensions=dimensions, - relations=resolved.relations, - sos=sos, - piecewise={name: declaration_of(pw) for name, pw in expanded._expanded_piecewise.items()}, + dimensions={name: DimensionDeclaration(ddef.dtype) for name, ddef in schema.dimensions.items()}, + relations=ns.relations, + sos={name: SosDeclaration(sdef.variable, sdef.along, sos_type=sdef.type) for name, sdef in schema.sos.items()}, assumptions=assumptions, - expressions=expressions, + expressions={ + name: ExpressionDeclaration(entry.body, _frame_of(name, entry, schema), in_math=name in in_math) + for name, entry in entries.items() + }, ) + check_schema(schema, program) + for pname, links in curves.items(): + curve_frame(schema, pname, schema.piecewise[pname], links) + return program -def inline(node: program.Expression | program.Named) -> program.Expression: - """*node* with every :class:`~math_spec.program.Named` replaced by its body — the tree a program carries. +def _frame_of(name: str, entry: Named, schema: Spec) -> tuple[str, ...]: + """The dims an entry is read over, in declaration order: declared for a cased entry, the body's for a plain one.""" + if isinstance(entry.body, Cases): + return tuple(schema.expressions[name].dims or ()) + carried = dims_of(entry.body, schema, f"Named expression '{name}'") + return tuple(d for d in schema.dimensions if d in carried) - A region's ``when`` is inlined with its value, since a mask may compare - expressions that name an entry. - """ - if isinstance(node, program.Named): - return inline(node.body) - if isinstance(node, program.Constant | program.Parameter | program.Variable | program.Dual): - return node - if isinstance(node, program.Negate): - return program.Negate(inline(node.operand)) - if isinstance(node, program.Add): - return program.Add(inline(node.left), inline(node.right)) - if isinstance(node, program.Multiply): - return program.Multiply(inline(node.left), inline(node.right)) - if isinstance(node, program.Power): - return program.Power(inline(node.base), inline(node.exponent)) - if isinstance(node, program.Divide): - return program.Divide(inline(node.numerator), inline(node.divisor)) - if isinstance(node, program.Sum | program.GroupSum | program.Pullback | program.Translate | program.WindowSum): - return replace(node, operand=inline(node.operand)) - if isinstance(node, program.Cases): - return program.Cases(tuple(program.Region(inline_mask(r.when), inline(r.value)) for r in node.regions)) - assert_never(node) - - -def inline_mask(mask: program.Mask) -> program.Mask: - """*mask* with every named expression its comparisons read inlined, as :func:`inline` does for a tree.""" - return program.Mask(_inline_predicate(mask.root)) - - -def _inlined_mask(mask: program.Mask | None) -> program.Mask | None: - return None if mask is None else inline_mask(mask) - - -def _inline_predicate(node: program.Predicate) -> program.Predicate: - if isinstance(node, program.ExpressionComparison): - return replace(node, left=inline(node.left), right=inline(node.right)) - if isinstance(node, program.CountComparison): - return replace(node, predicate=inline_mask(node.predicate)) - if isinstance(node, program.TranslatedPredicate | program.PulledBackPredicate): - return replace(node, operand=inline_mask(node.operand)) - if isinstance(node, program.Not): - return program.Not(_inline_predicate(node.operand)) - if isinstance(node, program.And): - return program.And(_inline_predicate(node.left), _inline_predicate(node.right)) - if isinstance(node, program.Or): - return program.Or(_inline_predicate(node.left), _inline_predicate(node.right)) - return node - - -def _bound_expression(value: float | str) -> program.Expression: + +def _bound(value: float | str) -> Constant | Parameter: if isinstance(value, str): - return program.Parameter(value) - return program.Constant(value) + return Parameter(value) + return Constant(value) + + +def _assumption(name: str, block: AssumptionBlock, ns: Namespace, errors: list[str]) -> Assumption | None: + """One ``assumptions:`` entry typed, or ``None`` once anything in it failed. + + A predicate the connectives decide is refused: one that folds to true + assumes nothing, and one that folds to false refuses every dataset. A + variable is refused too, since an assumption is about the data and a + variable is what the solver decides from it. + """ + context = f"Assumption '{name}'" + found = len(errors) + holds = resolve_where_text(block.holds, ns, context, errors) + where = resolve_where_text(block.where, ns, f'{context}, where', errors) + if isinstance(holds, BooleanLiteral): + errors.append(_decided_assumption(context, block.holds, value=holds.value)) + if isinstance(where, BooleanLiteral): + assert block.where is not None, 'a where the file did not write resolves to nothing' + errors.append(_decided_where(context, block.where, value=where.value)) + for mask, part in ((holds, 'assumes'), (where, 'is checked where')): + if mask is None or isinstance(mask, BooleanLiteral): + continue + errors.extend( + f"{context}: variable '{atom.name}' stands in what the assumption {part}, and an assumption is " + f'about the data — a variable is what the solver decides from it. Name a parameter, or state the ' + f'rule as a constraint.' + for atom in Mask(mask).atoms + if isinstance(atom, VariableDefined) + ) + if len(errors) > found: + return None + assert holds is not None, 'a where string that read to nothing appended an error' + return Assumption(Mask(holds), mask_of(where), block.description) + + +def _decided_assumption(context: str, text: str, *, value: bool) -> str: + """The refusal for a predicate the connectives already decided, whose data is never read.""" + if value: + return ( + f'{context}: the predicate {text!r} folds to true, so it assumes nothing of the data. ' + f'Delete it, or name a parameter it constrains.' + ) + return ( + f'{context}: the predicate {text!r} folds to false, so it holds on no data at all. ' + f'Delete it, or write the predicate the data can satisfy.' + ) + + +def _decided_where(context: str, text: str, *, value: bool) -> str: + """The refusal for a ``where`` the connectives already decided, which narrows nothing or everything.""" + if value: + return f'{context}: the where {text!r} folds to true, so it narrows nothing. Delete the where.' + return ( + f'{context}: the where {text!r} folds to false, so the assumption is checked on no row. ' + f'Delete the entry, or write the where the data can satisfy.' + ) diff --git a/src/math_spec/model.py b/src/math_spec/model.py index 64ab7b7c..3d95c95c 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -12,7 +12,6 @@ import math import re from collections import Counter -from functools import cached_property from typing import TYPE_CHECKING, Annotated, ClassVar, Literal, Self, cast, get_args, override from pydantic import ( @@ -41,7 +40,8 @@ from pydantic.config import ExtraValues from pydantic_core import CoreSchema - from math_spec.resolution import Resolved + # program.py imports this module at runtime, so the import is type-only + from math_spec.program import Program # noqa: TC004 class _StrictBlock(BaseModel): @@ -751,8 +751,7 @@ class Spec(_StrictBlock): The API is the eleven declaration sections plus ``version`` and ``description``, three ways back out — :meth:`to_dict` for the model as data, :meth:`to_yaml` for the file a reviewer reads, :meth:`expand` for the - same math with its formulations written out — and :attr:`resolved`, the - typed trees every reader in this package walks. Everything else on this + same math with its formulations written out. Everything else on this class is pydantic's, not a contract this package keeps. """ @@ -763,10 +762,11 @@ class is pydantic's, not a contract this package keeps. #: that expands to itself is not stored: two of them compare by their #: private state, which a model holding itself cannot answer. _expansions: dict[tuple[Formulation, ...], Spec] = PrivateAttr(default_factory=dict) - #: Each ``piecewise:`` block of the model this one expanded, as written, - #: which is what a program keeps of a curve. Empty on a model that is not - #: an expansion. Written by :func:`~math_spec.piecewise.expand_piecewise`. - _expanded_piecewise: dict[str, PiecewiseBlock] = PrivateAttr(default_factory=dict) + #: What this model's own declarations lower to, built as the model loads: + #: computing it *is* the expression pass, so a model the language refuses + #: never holds one. :func:`~math_spec.lowering.to_program` answers with + #: the expansion's, since a curve's rows are on that model. + _program: Program | None = PrivateAttr(default=None) #: Which language surface this file is written against. Absent means 0, so #: the field is additive. **0 means unstable** — the surface may change in @@ -899,19 +899,6 @@ def expand(self, *kinds: Formulation) -> Spec: self._expansions[wanted] = expanded return expanded - @cached_property - def resolved(self) -> Resolved: - """Every expression and where string this model declares, typed once — what every reader after validation walks. - - Computing it *is* the expression pass, so a model the language refuses - raises here; loading forces it, so a spec in hand already holds it. It - holds what *this* model declares: the rows a formulation states are on - :meth:`expand`'s result instead. - """ - from math_spec.validation import validate_expressions - - return validate_expressions(self) - @model_validator(mode='after') def _names_are_names(self) -> Spec: """Every declaration is keyed by something an expression could write. @@ -1189,15 +1176,16 @@ def _collisions(self, context: str, by_kind: Iterable[tuple[str, Iterable[str]]] ) @model_validator(mode='after') - def _validate_expressions(self) -> Spec: + def _lower(self) -> Spec: """Every expression and where string — this file's own, and every one a curve emits. This file's own first, so a fault in a link is named against the link - the file wrote, and the expansion reads the typed links rather than the - text again. A curve's expansion is a model in its own right, so + the file wrote. A curve's expansion is a model in its own right, so validating it is what holds the declarations it writes to the language. """ - _ = self.resolved + from math_spec.lowering import lower + + self._program = lower(self) self.expand('piecewise') return self diff --git a/src/math_spec/piecewise.py b/src/math_spec/piecewise.py index 4b81877e..e6ff0e9d 100644 --- a/src/math_spec/piecewise.py +++ b/src/math_spec/piecewise.py @@ -15,13 +15,14 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Literal, NamedTuple import math_spec.sos as sos from math_spec.dimensions import dims_of from math_spec.errors import DimensionError from math_spec.model import AssumptionBlock, Curvature, PiecewiseBlock, PiecewiseMethod, Spec -from math_spec.program import PiecewiseDeclaration +from math_spec.program import carries_variable +from math_spec.resolution import Namespace, resolve_expression_text if TYPE_CHECKING: from collections.abc import Iterable @@ -55,15 +56,63 @@ def _curvature_required(pw: PiecewiseBlock) -> Curvature | None: return 'convex' if sign == '>=' else 'concave' -def declaration_of(pw: PiecewiseBlock) -> PiecewiseDeclaration: - """The curve of one expanded block, as a program carries it.""" - return PiecewiseDeclaration( - over=pw.over, - method=pw.method, - breakpoints=tuple(link.values for link in pw.links), +def resolve_links(name: str, pw: PiecewiseBlock, ns: Namespace, errors: list[str]) -> tuple[Expression, ...] | None: + """Block *name*'s link expressions typed, in link order, or ``None`` once one failed, its refusal appended. + + A link is read affinely, so it is held to degree 1 where it is read. + """ + links = [ + resolve_expression_text(link.expression, ns, f"piecewise '{name}' link {i}", errors, ceiling=1) + for i, link in enumerate(pw.links) + ] + if any(link is None for link in links): + return None + return tuple(link for link in links if link is not None) + + +def lp_domain_refusal(name: str, pw: PiecewiseBlock, links: tuple[Expression, ...]) -> str | None: + """The refusal for a ``method: lp`` curve whose x-link carries no variable, or ``None``. + + The method bounds the curve's domain with two rows comparing the x-link + against the first and the last breakpoint, and a row with no variable + decides nothing. Decided on the link the file wrote, rather than on the + row the expansion would write under a name the file never declared. + """ + x = pw.curve[0] + i = next(i for i, link in enumerate(pw.links) if link is x) + if carries_variable(links[i]): + return None + return ( + f"piecewise '{name}' link {i}: method: lp bounds the curve's domain by rows comparing this link's expression " + f'against its first and last breakpoint, and {x.expression!r} carries no variable, so those rows decide ' + f'nothing. Name a variable in the link, or use method: convex, sos2 or adjacency, whose weights pin the ' + f'domain themselves.' ) +class Curve(NamedTuple): + """One ``piecewise:`` block as the curve it states: its links typed, and the frame it builds one curve per coordinate of.""" + + links: tuple[Expression, ...] + frame: tuple[str, ...] + + +def curve(schema: Spec, name: str) -> Curve: + """Block *name* of *schema* as the curve it states. + + Read off the model rather than kept on it: a program holds the rows a + curve states and not the curve, and the two readers of the curve itself + — the expansion writing those rows and the typesetter printing the block + — each ask here. Nothing here can fail: *schema* loaded, so its links + resolved and its frame held. + """ + ns = Namespace(schema) + errors: list[str] = [] + links = resolve_links(name, schema.piecewise[name], ns, errors) + assert links is not None and not errors, 'a loaded model resolved every link' + return Curve(links, curve_frame(schema, name, schema.piecewise[name], links)) + + def assumptions_of(block: str, pw: PiecewiseBlock) -> dict[str, AssumptionBlock]: """What *block* assumes of its numbers, by the name the document prints and a refusal quotes. @@ -262,8 +311,7 @@ def curve_frame(schema: Spec, name: str, pw: PiecewiseBlock, links: Iterable[Exp In declaration order, because iterating a set would vary the emitted ``dims`` — and every column index behind it — per process. *links* are the - block's link expressions typed, as - :attr:`~math_spec.resolution.Resolved.piecewise` holds them. + block's link expressions typed, as :func:`resolve_links` answers. Raises: DimensionError: A link or the gate carries the breakpoint dimension, or @@ -316,7 +364,7 @@ def __init__(self, schema: Spec, raw: dict[str, object], name: str, pw: Piecewis self.pw = pw self.emitted = Emitted.of(name, pw) self.mask = pw.points - self.frame = curve_frame(schema, name, pw, schema.resolved.piecewise[name]) + self.frame = curve(schema, name).frame def expand(self) -> None: """Write the block's declarations into the raw model.""" @@ -448,6 +496,4 @@ def expand_piecewise(schema: Spec) -> Spec: for name, pw in schema.piecewise.items(): if pw.method == 'adjacency': sos.emit(raw, name) - expanded = Spec.model_validate(raw) - expanded._expanded_piecewise = dict(schema.piecewise) - return expanded + return Spec.model_validate(raw) diff --git a/src/math_spec/program.py b/src/math_spec/program.py index 0a11db71..de38e5e7 100644 --- a/src/math_spec/program.py +++ b/src/math_spec/program.py @@ -5,7 +5,8 @@ """The program: what a file declares, with names resolved and shapes fixed. The second public state, and the one a consumer reads. A :class:`Program` is -every declaration a file makes and no data at all; +every declaration a file makes and no data at all, with every ``piecewise:`` +block written out as the rows it states; :func:`~math_spec.lowering.to_program` is the only thing that builds one, so nothing here re-checks a hand-built one. @@ -75,7 +76,6 @@ 'ParameterDefined', 'ParameterDtype', 'Partition', - 'PiecewiseDeclaration', 'Power', 'Predicate', 'PredicateOperator', @@ -351,12 +351,11 @@ class Cases: class Named: """A use of an ``expressions:`` entry, standing where its name was written, with the entry's body under it. - Only a :attr:`~math_spec.model.Spec.resolved` tree holds one: it is what - lets the typesetter print the symbol where the name stood and define it - once, and what ``in_math`` is read off. Lowering inlines every one, so no - :class:`Program` carries it and :data:`Expression` does not name it. Every - use of one entry holds the one node resolution built for it, and a walk - steps through it. + Its value is its body's: a consumer building rows steps through it, as + :func:`children` does. It is kept as a node rather than written in so the + typesetter can print the symbol where the name stood and define it once. + Every use of one entry holds the one node resolution built for it, which + is the :attr:`ExpressionDeclaration.expression` of that entry. """ name: str @@ -389,16 +388,16 @@ class Named: | Translate | WindowSum | Cases + | Named ) -def fan_in(expression: Expression | Named) -> FanIn: +def fan_in(expression: Expression) -> FanIn: """How *expression*'s output rows relate to its input slots. For the absence rules, both classes other than ``'one-to-one'`` sum several input slots into an output row. A :class:`Named` answers as its - body does, so a :attr:`~math_spec.model.Spec.resolved` tree is asked as a - program's is. + body does. """ if isinstance(expression, Named): return fan_in(expression.body) @@ -559,26 +558,6 @@ class DimensionDeclaration: dtype: DimensionDtype = 'str' -@dataclass(frozen=True) -class PiecewiseDeclaration: - """A ``piecewise:`` block, kept as the facts a consumer binding its data reads. - - The expansion lowered the links into constraints over the file's own - parameters, and emitted none. What the block assumes of its numbers is an - :class:`Assumption` like any other, under :attr:`Program.assumptions`; what - is left here is the curve. - - Attributes: - over: The breakpoint dimension. - method: How the weights are restricted. - breakpoints: The links' values parameters, in link order. - """ - - over: str - method: _model.PiecewiseMethod - breakpoints: tuple[str, ...] - - @dataclass(frozen=True) class Assumption: """A predicate the file states of its data, under the name it wrote in ``assumptions:``. @@ -688,6 +667,9 @@ class ExpressionDeclaration: """ expression: Expression + #: The frame the entry is read over: the ``dims:`` a cased entry + #: declares, or the dims a plain entry's body carries. + dims: tuple[str, ...] in_math: bool @@ -846,18 +828,15 @@ class Program: dimensions: Mapping[str, DimensionDeclaration] = Sealed({}) relations: Mapping[str, RelationDeclaration] = Sealed({}) sos: Mapping[str, SosDeclaration] = Sealed({}) - #: Each ``piecewise:`` block the file wrote, as facts — see - #: :class:`PiecewiseDeclaration`. - piecewise: Mapping[str, PiecewiseDeclaration] = Sealed({}) #: What the data has to satisfy for the answer to mean anything, by the #: name a refusal quotes: every ``assumptions:`` entry the file wrote, then #: what each ``piecewise:`` block's method assumes of its breakpoints. The #: language decides none of it, so the consumer binding the data checks #: each and refuses with :func:`assumption_message`. assumptions: Mapping[str, Assumption] = Sealed({}) - #: Declared ``expressions:``, lowered, each saying whether the math reads - #: it. None builds a row of its own — one the math reads is inlined where - #: it is read — but all are lowered with the program, so a file whose + #: Declared ``expressions:``, each saying whether the math reads it. None + #: builds a row of its own — one the math reads stands as a :class:`Named` + #: where it is read — but all are lowered with the program, so a file whose #: 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. expressions: Mapping[str, ExpressionDeclaration] = Sealed({}) diff --git a/src/math_spec/resolution.py b/src/math_spec/resolution.py index 16ff529d..9c167d9c 100644 --- a/src/math_spec/resolution.py +++ b/src/math_spec/resolution.py @@ -15,7 +15,6 @@ import datetime import re from dataclasses import dataclass -from functools import cached_property from typing import TYPE_CHECKING, Literal, NamedTuple, assert_never, cast import math_spec.degree as degree @@ -60,11 +59,9 @@ from math_spec.program import ( Add, And, - Assumption, BooleanLiteral, Cases, Constant, - ConstraintDeclaration, CountComparison, DimensionComparison, DimensionPosition, @@ -79,7 +76,6 @@ Named, Negate, Not, - ObjectiveDeclaration, Or, Parameter, ParameterComparison, @@ -310,61 +306,6 @@ def unknown_constraint(self, name: str, context: str, *, formals: Iterable[str] ) -@dataclass(frozen=True) -class Resolved: - """Every expression and where string of one schema, typed once at load, in the program's own vocabulary. - - :func:`~math_spec.validation.validate_expressions` builds it, and every - reader after — the dim rules, lowering, the typesetter — walks these trees - rather than parsing, expanding and resolving the text again. Each mapping - is keyed as the schema's own section is. A ``where`` the file did not - write, or one every row passes, is ``None``. What a program does not carry - is here alone: every use of an ``expressions:`` entry stands as the - :class:`~math_spec.program.Named` node resolution built for it, which - lowering inlines. - - Attributes: - expressions: Each ``expressions:`` entry as the node every use of it - holds — a plain entry's body, or a cased one's - :class:`~math_spec.program.Cases` with every region's mask typed - and the ``otherwise`` carrying the negation of the rest. - variables: Each variable's ``where``. - constraints: Each constraint, as a program declares it. - objective: The objective, ``None`` where the file declares none. - relations: Each relation's columns and key, as declared — the one - copy, which every :class:`~math_spec.program.Direction` and - :class:`~math_spec.program.Partition` in the trees holds. - assumptions: Each ``assumptions:`` entry's predicate and the mask it - is checked under. - piecewise: Each ``piecewise:`` block's link expressions, in link order. - """ - - expressions: dict[str, Named] - variables: dict[str, Mask | None] - constraints: dict[str, ConstraintDeclaration] - objective: ObjectiveDeclaration | None - relations: dict[str, RelationDeclaration] - assumptions: dict[str, Assumption] - piecewise: dict[str, tuple[Expression, ...]] - - @cached_property - def read_by_the_math(self) -> frozenset[str]: - """The named expressions the math reads: every entry the objective, a constraint or a curve reaches, transitively. - - Read off those three positions alone: a bound and a ``where`` name no - entry. The rest of the ``expressions:`` section is read back after a - solve and never fed to one - (:attr:`~math_spec.program.ExpressionDeclaration.in_math`). A curve - counts because it states rows, so the answer does not move when the - curve is written out (:meth:`~math_spec.model.Spec.expand`). - """ - roots = [side for constraint in self.constraints.values() for side in (constraint.lhs, constraint.rhs)] - if self.objective is not None: - roots.append(self.objective.expression) - roots.extend(link for links in self.piecewise.values() for link in links) - return frozenset(node.name for node in walk(*roots) if isinstance(node, Named)) - - # --------------------------------------------------------------------------- # the seam the rest of the package uses # --------------------------------------------------------------------------- diff --git a/src/math_spec/sos.py b/src/math_spec/sos.py index f49efd8f..d1ef4a69 100644 --- a/src/math_spec/sos.py +++ b/src/math_spec/sos.py @@ -92,9 +92,7 @@ def expand_sets(schema: Spec) -> Spec: raw = schema.model_dump() for name in list(schema.sos): emit(raw, name) - expanded = Model.model_validate(raw) - expanded._expanded_piecewise = dict(schema._expanded_piecewise) - return expanded + return Model.model_validate(raw) def emit(raw: dict[str, object], name: str) -> None: diff --git a/src/math_spec/typesetting/README.md b/src/math_spec/typesetting/README.md index 372b718b..87606044 100644 --- a/src/math_spec/typesetting/README.md +++ b/src/math_spec/typesetting/README.md @@ -5,9 +5,9 @@ SPDX-License-Identifier: MIT # `typesetting/` — the model, printed -This package is a consumer of the resolved core syntax tree. It builds no model -and binds no data. It walks the typed tree that `to_spec` validates, and prints -it. +This package is a consumer of the program. It builds no model and binds no +data. It walks the trees `to_program` lowers a model to, reads what the file +says about each declaration from the `Spec`, and prints both. | Module | Role | | ------------- | ------------------------------------------------------------------------------------------------------ | diff --git a/src/math_spec/typesetting/__init__.py b/src/math_spec/typesetting/__init__.py index 38d1e585..341e5f19 100644 --- a/src/math_spec/typesetting/__init__.py +++ b/src/math_spec/typesetting/__init__.py @@ -29,6 +29,7 @@ from typing import TYPE_CHECKING, Literal, TypedDict, Unpack from math_spec.errors import SchemaError, did_you_mean +from math_spec.lowering import to_program from math_spec.typesetting.latex import LatexFormat from math_spec.typesetting.markdown import MarkdownFormat from math_spec.typesetting.symbols import Symbols, SymbolTable @@ -87,13 +88,15 @@ def _walk( msg = f"'{fmt}' is not a format this package prints. Formats: {', '.join(FORMATS)}." raise ValueError(msg) schema = to_spec(model) + program = to_program(schema) format_ = FORMATS[fmt] if symbols is None: symbols = SymbolTable(format_.notation) table = symbols if isinstance(symbols, SymbolTable) else SymbolTable.load(symbols) return Walk( schema, - Symbols(schema, format_, table.checked_against(schema)), + program, + Symbols(schema, program, format_, table.checked_against(schema)), format_, inline_expressions=inline_expressions, ) @@ -203,7 +206,7 @@ def typeset_declaration( kinds = { 'named expression': schema.expressions, 'constraint': schema.constraints, - 'assumption': schema.resolved.assumptions, + 'assumption': walk.program.assumptions, 'curve': schema.piecewise, 'variable': schema.variables, } diff --git a/src/math_spec/typesetting/symbols.py b/src/math_spec/typesetting/symbols.py index b752d881..73ee93bb 100644 --- a/src/math_spec/typesetting/symbols.py +++ b/src/math_spec/typesetting/symbols.py @@ -16,13 +16,13 @@ from typing import TYPE_CHECKING, cast from math_spec._yaml import read_yaml -from math_spec.degree import calls_dual from math_spec.errors import SchemaError, did_you_mean -from math_spec.program import carries_variable +from math_spec.program import Dual, Variable, walk from math_spec.typesetting.format import NOTATIONS if TYPE_CHECKING: from math_spec.model import Spec + from math_spec.program import Program from math_spec.typesetting.format import Format, Notation __all__ = ['SymbolTable', 'Symbols'] @@ -73,20 +73,20 @@ def _derive_name_symbol(name: str, declared: frozenset[str], fmt: Format, *, giv return _word(name, fmt, given=given) -def chosen_expressions(schema: Spec) -> frozenset[str]: +def chosen_expressions(program: Program) -> frozenset[str]: """The named expressions the solver decides, rather than is handed. A ``when`` does not move one: a variable there asks whether the variable *exists*, which the model settles when it is built. Only a value reaching a - variable does — through another named expression too, since expansion - inlines those where the name stood. + variable does — through another named expression too, since a use of one + stands where the name was written. A ``dual`` moves one for the same reason a variable does: the solve settles it, and no data hands it over. """ return frozenset( name - for name, entry in schema.resolved.expressions.items() - if carries_variable(entry.body) or calls_dual(entry.body) + for name, entry in program.expressions.items() + if any(isinstance(node, Variable | Dual) for node in walk(entry.expression)) ) @@ -102,14 +102,14 @@ class Symbols: SchemaError: If *table* is written in a notation *fmt* does not read. """ - def __init__(self, schema: Spec, fmt: Format, table: SymbolTable) -> None: + def __init__(self, schema: Spec, program: Program, fmt: Format, table: SymbolTable) -> None: if table.notation != fmt.notation: msg = ( f'symbol table: written in {table.notation}, but this is a {fmt.notation} render ' f'and nothing translates between notations — write a {fmt.notation} table.' ) raise SchemaError(msg) - chosen = frozenset(schema.variables) | chosen_expressions(schema) + chosen = frozenset(schema.variables) | chosen_expressions(program) names = (*schema.parameters, *schema.variables, *schema.expressions) declared = frozenset(names) diff --git a/src/math_spec/typesetting/walk.py b/src/math_spec/typesetting/walk.py index 1f158c23..61c164a7 100644 --- a/src/math_spec/typesetting/walk.py +++ b/src/math_spec/typesetting/walk.py @@ -15,8 +15,7 @@ from dataclasses import dataclass, field, replace from typing import TYPE_CHECKING, Literal, assert_never -from math_spec.dimensions import dims_of -from math_spec.piecewise import curve_frame +from math_spec.piecewise import curve from math_spec.program import ( Add, And, @@ -65,6 +64,7 @@ from math_spec._expression_parser import BinaryOperator from math_spec.model import PiecewiseBlock, RelationBlock, SosBlock, Spec + from math_spec.program import Program from math_spec.typesetting.format import Format from math_spec.typesetting.symbols import Symbols @@ -241,27 +241,27 @@ class Walk: def __init__( self, schema: Spec, + program: Program, symbols: Symbols, fmt: Format, *, inline_expressions: bool = False, ) -> None: self.schema = schema + #: The typed trees, masks and frames of every declaration *schema* + #: makes, which the walk prints from; *schema* says how the file + #: wrote them and what it says about them. + self.program = program self.symbols = symbols self.format = fmt #: Substitute each plain named expression where it is used, rather than #: printing its symbol there and its definition once. self.inline_expressions = inline_expressions self.noticed = Noticed() - #: The dims a named expression is read over: a cased one declares them, - #: a plain one's fall out of its body. - self.frames: dict[str, list[str]] = {name: self._frame_of(name) for name in schema.expressions} def _frame_of(self, name: str) -> list[str]: - block = self.schema.expressions[name] - if block.cases: - return list(block.dims or ()) - return self._sorted(dims_of(self.schema.resolved.expressions[name].body, self.schema, f"expression '{name}'")) + """The dims named expression *name* is read over, as its declaration carries them.""" + return list(self.program.expressions[name].dims) def _op(self, name: OperatorName) -> str: return self.format.operators[name] @@ -345,7 +345,7 @@ def _arithmetic(self, node: Expression, ctx: _Context) -> tuple[str, int]: if isinstance(node, Named): if self.inline_expressions and not isinstance(node.body, Cases): return self._arithmetic(node.body, ctx) - return ctx.indexed(self.symbols.name[node.name], self.frames[node.name]), _ATOM + return ctx.indexed(self.symbols.name[node.name], self._frame_of(node.name)), _ATOM if isinstance(node, Constant): return self._number(node.value), _ATOM if node.value >= 0 else 1 @@ -700,7 +700,7 @@ def _objective(self) -> list[Line]: if block is None: return [] sense = self._op('minimize' if block.sense == 'minimize' else 'maximize') - objective = self.schema.resolved.objective + objective = self.program.objective assert objective is not None, 'validation resolves the objective the file declares' return [Line(label='', left=sense, right=self._expression(objective.expression, self._context()))] @@ -718,7 +718,7 @@ def _constraints(self) -> list[Line]: def _constraint(self, name: str) -> Line: block = self.schema.constraints[name] - constraint = self.schema.resolved.constraints[name] + constraint = self.program.constraints[name] ctx = self._context(frame=block.dims) condition = self._condition(ctx, constraint.where) return Line( @@ -748,23 +748,19 @@ def _defined(self) -> list[str]: """ if not self.inline_expressions: return list(self.schema.expressions) - read = self.schema.resolved.read_by_the_math - return [name for name, block in self.schema.expressions.items() if block.cases or name not in read] + entries = self.program.expressions + return [name for name, block in self.schema.expressions.items() if block.cases or not entries[name].in_math] def definition(self, name: str) -> Line: """The line defining one named expression, ``symbol = body`` over its frame.""" - entry = self.schema.resolved.expressions[name] - frame = self.frames[name] + body = self.program.expressions[name].expression + frame = self._frame_of(name) ctx = self._context(frame) - body = ( - self.format.cases(self._arms(entry.body, ctx)) - if isinstance(entry.body, Cases) - else self._expression(entry.body, ctx) - ) + rendered = self.format.cases(self._arms(body, ctx)) if isinstance(body, Cases) else self._expression(body, ctx) return Line( label=name, left=ctx.indexed(self.symbols.name[name], frame), - right=f'{self._op("equal")} {body}', + right=f'{self._op("equal")} {rendered}', condition=self._quantifier(frame, ''), ) @@ -781,7 +777,7 @@ def line(self, name: str) -> Line: return self.definition(name) if name in self.schema.constraints: return self._constraint(name) - if name in self.schema.resolved.assumptions: + if name in self.program.assumptions: return self._assumption(name) if name in self.schema.piecewise: return self._piecewise(name) @@ -822,7 +818,7 @@ def _variable(self, name: str) -> Line: block = self.schema.variables[name] ctx = self._context(frame=block.dims) symbol = ctx.indexed(self.symbols.name[name], list(block.dims)) - where = self.schema.resolved.variables[name] + where = self.program.variables[name].where condition = self._quantifier(list(block.dims), self._condition(ctx, where)) lower, upper = block.bounds.lower, block.bounds.upper @@ -864,11 +860,11 @@ def _assumptions(self) -> list[Line]: method states them in the same language: the reader sees every condition the data is held to, whoever stated it. """ - return [self._assumption(name) for name in self.schema.resolved.assumptions] + return [self._assumption(name) for name in self.program.assumptions] def _assumption(self, name: str) -> Line: """One assumption: the predicate over the frame both its masks name, under its ``where``.""" - assumption = self.schema.resolved.assumptions[name] + assumption = self.program.assumptions[name] holds, where = assumption.predicate, assumption.where frame = self._sorted(holds.dims | (where.dims if where is not None else frozenset())) ctx = self._context(frame) @@ -888,8 +884,8 @@ def _piecewise(self, name: str) -> Line: which side. """ block = self.schema.piecewise[name] - links = self.schema.resolved.piecewise[name] - frame = list(curve_frame(self.schema, name, block, links)) + links, stated = curve(self.schema, name) + frame = list(stated) ctx = self._context([*frame, block.over]) locus = self._locus(block, ctx) bounded = next((i for i, link in enumerate(block.links) if link.sign != '=='), None) @@ -950,7 +946,7 @@ def _gate(self, block: PiecewiseBlock, ctx: _Context) -> str: return '' gate = self.schema.variables[activity] symbol = ctx.indexed(self.symbols.name[activity], list(gate.dims)) - mask = self.schema.resolved.variables[activity] + mask = self.program.variables[activity].where if mask is None or gate.absence == 'zero': return symbol where = self._predicate(mask.root, ctx, need=_WHERE_PRECEDENCE['and']) @@ -988,7 +984,7 @@ def glossaries(self, noticed: Noticed) -> list[tuple[str, list[Entry]]]: for v, block in self.schema.variables.items() ] definitions = [ - self._entry(self.symbols.name[e], f'{fmt.mono(e)}{self._over(self.frames[e])}', block.description) + self._entry(self.symbols.name[e], f'{fmt.mono(e)}{self._over(self._frame_of(e))}', block.description) for e, block in self.schema.expressions.items() if e in self._defined() ] diff --git a/src/math_spec/validation.py b/src/math_spec/validation.py index fe79d07b..fdfa0bd3 100644 --- a/src/math_spec/validation.py +++ b/src/math_spec/validation.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -"""Load-time validation: the front door, and the pass that decides every expression.""" +"""The front door: a model definition read and validated into a :class:`~math_spec.model.Spec`.""" from __future__ import annotations @@ -10,37 +10,12 @@ from typing import TYPE_CHECKING from math_spec._yaml import read_model -from math_spec.dimensions import check_schema -from math_spec.errors import SchemaError, prefixed -from math_spec.expansion import expand, parse_template +from math_spec.errors import SchemaError from math_spec.model import Spec -from math_spec.piecewise import assumptions_of, curve_frame -from math_spec.program import ( - Assumption, - BooleanLiteral, - ConstraintDeclaration, - Mask, - ObjectiveDeclaration, - VariableDefined, - carries_variable, -) -from math_spec.resolution import ( - Namespace, - Resolved, - mask_of, - resolve_constraint_text, - resolve_expression, - resolve_expression_text, - resolve_where_text, -) if TYPE_CHECKING: - from collections.abc import Iterator from pathlib import Path - from math_spec.model import AssumptionBlock, PiecewiseBlock - from math_spec.program import Expression, Named - def to_spec(model: str | Path | Mapping[str, object] | Spec) -> Spec: """Load and validate a model definition — the language's front door. @@ -68,185 +43,3 @@ def to_spec(model: str | Path | Mapping[str, object] | Spec) -> Spec: if isinstance(model, Spec): return model return Spec.model_validate(model if isinstance(model, Mapping) else read_model(model)) - - -def validate_expressions(schema: Spec) -> Resolved: - """Validate and resolve every expression and where string in *schema*, once for every reader. - - What is checked: - - - the expression parses, and constraints hold exactly one comparison where - objectives hold none; - - every referenced name resolves, and every operator is a built-in whose - dimension arguments name declared dimensions; - - where strings parse *and* resolve — an unknown name there is an error, - not a silently-empty mask; - - macro formals may shadow model names but not a declared dimension, since - ``over=snapshot`` under a formal ``snapshot`` cannot say which it means; - - every dim rule (``dimensions.check_schema``), once names resolve. - - A ``piecewise:`` block's links are resolved here too, and its frame - checked, so the typesetter reads the curve a file states without expanding - it and the expansion reads the typed links. - - Returns: - Every declaration's typed tree — what the dim rules, lowering and the - typesetter read instead of resolving the text again. - - Raises: - SchemaError: Listing every problem found, one per line. - DimensionError: The first dim rule a declaration breaks, once every - name resolves. - """ - ns = Namespace(schema) - errors: list[str] = [] - - for mname, macro in schema.macros.items(): - context = f"Macro '{mname}'" - formals = frozenset((*macro.args, *macro.kwargs)) - try: - body_ast = expand(parse_template(mname, macro, context), ns, context) - except ValueError as e: - errors.append(prefixed(context, e)) - continue - errors.extend( - f"{context}: formal '{f}' collides with declared dimension '{f}'. " - f'Rename the formal — a dimension name inside a template is ' - f'ambiguous with the dimension itself.' - for f in sorted(formals & ns.dimensions) - ) - resolve_expression(body_ast, ns, context, errors, formals=formals) - - expressions: dict[str, Named] = {} - for ename in schema.expressions: - node, refusals = ns.named_entry(ename) - errors.extend(refusals) - if node is not None: - expressions[ename] = node - - variables = { - vname: mask_of(resolve_where_text(vdef.where, ns, f"Variable '{vname}'", errors, self_variable=vname)) - for vname, vdef in schema.variables.items() - } - - constraints: dict[str, ConstraintDeclaration] = {} - for cname, cdef in schema.constraints.items(): - context = f"Constraint '{cname}'" - where = resolve_where_text(cdef.where, ns, context, errors) - if (sides := resolve_constraint_text(cdef.expression, ns, context, errors)) is not None: - lhs, sense, rhs = sides - constraints[cname] = ConstraintDeclaration(tuple(cdef.dims), lhs, sense, rhs, mask_of(where)) - - objective = None - if schema.objective is not None: - expression = resolve_expression_text(schema.objective.expression, ns, 'The objective', errors, ceiling=2) - if expression is not None: - objective = ObjectiveDeclaration(schema.objective.sense, expression) - - assumptions: dict[str, Assumption] = {} - for aname, adef in schema.assumptions.items(): - if (assumption := _assumption(aname, adef, ns, errors)) is not None: - assumptions[aname] = assumption - - for block, pw in schema.piecewise.items(): - for aname, assumed in assumptions_of(block, pw).items(): - if (assumption := _assumption(aname, assumed, ns, errors)) is not None: - assumptions[aname] = assumption - - piecewise = {} - for pname, pdef in schema.piecewise.items(): - links = [ - resolve_expression_text(link.expression, ns, f"piecewise '{pname}' link {i}", errors, ceiling=1) - for i, link in enumerate(pdef.links) - ] - if any(link is None for link in links): - continue - typed = tuple(link for link in links if link is not None) - if pdef.method == 'lp': - errors.extend(_domain_decides_nothing(pname, pdef, typed)) - piecewise[pname] = typed - - if errors: - raise SchemaError('\n'.join(errors)) - - resolved = Resolved(expressions, variables, constraints, objective, ns.relations, assumptions, piecewise) - check_schema(schema, resolved) - for pname, pdef in schema.piecewise.items(): - curve_frame(schema, pname, pdef, resolved.piecewise[pname]) - return resolved - - -def _domain_decides_nothing(name: str, pw: PiecewiseBlock, links: tuple[Expression, ...]) -> Iterator[str]: - """The refusal for a ``method: lp`` curve whose x-link carries no variable. - - The method bounds the curve's domain with two rows comparing the x-link - against the first and the last breakpoint, and a row with no variable - decides nothing. Decided here, on the link the file wrote, rather than on - the row the expansion would write under a name the file never declared. - """ - x = pw.curve[0] - i = next(i for i, link in enumerate(pw.links) if link is x) - if carries_variable(links[i]): - return - yield ( - f"piecewise '{name}' link {i}: method: lp bounds the curve's domain by rows comparing this link's expression " - f'against its first and last breakpoint, and {x.expression!r} carries no variable, so those rows decide ' - f'nothing. Name a variable in the link, or use method: convex, sos2 or adjacency, whose weights pin the ' - f'domain themselves.' - ) - - -def _assumption(name: str, block: AssumptionBlock, ns: Namespace, errors: list[str]) -> Assumption | None: - """One ``assumptions:`` entry typed, or ``None`` once anything in it failed. - - A predicate the connectives decide is refused: one that folds to true - assumes nothing, and one that folds to false refuses every dataset. A - variable is refused too, since an assumption is about the data and a - variable is what the solver decides from it. - """ - context = f"Assumption '{name}'" - found = len(errors) - holds = resolve_where_text(block.holds, ns, context, errors) - where = resolve_where_text(block.where, ns, f'{context}, where', errors) - if isinstance(holds, BooleanLiteral): - errors.append(_decided_assumption(context, block.holds, value=holds.value)) - if isinstance(where, BooleanLiteral): - assert block.where is not None, 'a where the file did not write resolves to nothing' - errors.append(_decided_where(context, block.where, value=where.value)) - for mask, part in ((holds, 'assumes'), (where, 'is checked where')): - if mask is None or isinstance(mask, BooleanLiteral): - continue - errors.extend( - f"{context}: variable '{atom.name}' stands in what the assumption {part}, and an assumption is " - f'about the data — a variable is what the solver decides from it. Name a parameter, or state the ' - f'rule as a constraint.' - for atom in Mask(mask).atoms - if isinstance(atom, VariableDefined) - ) - if len(errors) > found: - return None - assert holds is not None, 'a where string that read to nothing appended an error' - return Assumption(Mask(holds), mask_of(where), block.description) - - -def _decided_assumption(context: str, text: str, *, value: bool) -> str: - """The refusal for a predicate the connectives already decided, whose data is never read.""" - if value: - return ( - f'{context}: the predicate {text!r} folds to true, so it assumes nothing of the data. ' - f'Delete it, or name a parameter it constrains.' - ) - return ( - f'{context}: the predicate {text!r} folds to false, so it holds on no data at all. ' - f'Delete it, or write the predicate the data can satisfy.' - ) - - -def _decided_where(context: str, text: str, *, value: bool) -> str: - """The refusal for a ``where`` the connectives already decided, which narrows nothing or everything.""" - if value: - return f'{context}: the where {text!r} folds to true, so it narrows nothing. Delete the where.' - return ( - f'{context}: the where {text!r} folds to false, so the assumption is checked on no row. ' - f'Delete the entry, or write the where the data can satisfy.' - ) diff --git a/tests/test_expansion.py b/tests/test_expansion.py index 6bc8f6ba..e18b91f4 100644 --- a/tests/test_expansion.py +++ b/tests/test_expansion.py @@ -6,13 +6,13 @@ from __future__ import annotations +from dataclasses import fields, is_dataclass, replace from functools import partial import pytest from math_spec.errors import LanguageError from math_spec.expansion import parse_and_expand -from math_spec.lowering import inline from math_spec.program import Multiply, Named, Parameter, Sum, Translate, Variable from math_spec.resolution import Namespace from tests.fixtures import DISPATCH_MODEL, SMALL_MODEL, comparison_of, expression_of, schema_of @@ -35,10 +35,13 @@ def _resolved(text, ns): def _bodies(resolved): """*resolved* with every named expression's body standing bare where its name was.""" + if isinstance(resolved, Named): + return _bodies(resolved.body) if isinstance(resolved, tuple): - left, op, right = resolved - return inline(left), op, inline(right) - return inline(resolved) + return tuple(_bodies(part) for part in resolved) + if is_dataclass(resolved) and not isinstance(resolved, type): + return replace(resolved, **{f.name: _bodies(getattr(resolved, f.name)) for f in fields(resolved) if f.init}) + return resolved @pytest.mark.parametrize( diff --git a/tests/test_lowering.py b/tests/test_lowering.py index cd2d1258..73d7f8c6 100644 --- a/tests/test_lowering.py +++ b/tests/test_lowering.py @@ -18,8 +18,6 @@ from math_spec import LanguageError, Spec, to_program from math_spec._where_parser import parse_where from math_spec.exclusivity import overlapping -from math_spec.lowering import lower_program -from math_spec.piecewise import expand_piecewise from math_spec.program import ( QUADRATIC_POSITIONS, Add, @@ -122,7 +120,7 @@ def dispatch_schema() -> Spec: @pytest.fixture def dispatch_program(dispatch_schema) -> Program: - return lower_program(expand_piecewise(dispatch_schema)) + return to_program(dispatch_schema) @pytest.fixture @@ -135,7 +133,7 @@ def shapes_schema() -> Spec: # --------------------------------------------------------------------------- -def test_lower_program_structure(dispatch_program): +def test_program_structure(dispatch_program): assert list(dispatch_program.parameters) == ['capacity', 'load', 'cost'], 'keyed by name, in declaration order' ((vname, v),) = dispatch_program.variables.items() assert vname == 'dispatch' @@ -227,10 +225,8 @@ def test_a_where_is_one_resolved_predicate_with_every_literal_folded(dispatch_sc def test_a_folded_mask_reaches_the_declaration_the_shorter_spelling_would_have(): """The fold is the program's, not a helper's: two files, one declaration.""" - written_out = lower_program( - expand_piecewise(schema_of(DISPATCH_MODEL, **{'variables.p.where': 'p_max > 0 AND True'})) - ) - plain = lower_program(expand_piecewise(schema_of(DISPATCH_MODEL, **{'variables.p.where': 'p_max > 0'}))) + written_out = to_program(schema_of(DISPATCH_MODEL, **{'variables.p.where': 'p_max > 0 AND True'})) + plain = to_program(schema_of(DISPATCH_MODEL, **{'variables.p.where': 'p_max > 0'})) assert written_out.variables['p'] == plain.variables['p'], 'the same mask, so the same declaration' @@ -846,6 +842,7 @@ def test_walk_is_the_node_column_of_walk_regions(): WindowSum(Variable('p'), 't', width=2, wrap=False): 'one-to-many', Cases((Region(Mask(ParameterDefined('c', ('g',))), Variable('p')),)): 'one-to-one', Dual('balance'): 'one-to-one', + Named('total', Sum(Variable('p'), ('g',))): 'many-to-one', } @@ -863,7 +860,7 @@ def test_a_node_answers_its_fan_in(node, expected): def test_fan_in_reads_through_a_named_expression(): - """`fan_in` on a `Spec.resolved` tree, which holds `Named`, ended in `assert_never`.""" + """`fan_in` on a tree holding a `Named` ended in `assert_never`.""" named = Named('total', Sum(Variable('p'), ('g',))) assert fan_in(named) == 'many-to-one', 'a use of an entry fans in as the entry does' diff --git a/tests/test_piecewise.py b/tests/test_piecewise.py index 002b64e1..1b98f921 100644 --- a/tests/test_piecewise.py +++ b/tests/test_piecewise.py @@ -15,7 +15,7 @@ from math_spec import CURVATURES from math_spec.errors import LanguageError, SchemaError -from math_spec.lowering import lower_program, to_program +from math_spec.lowering import to_program from math_spec.piecewise import expand_piecewise from math_spec.program import Assumption, assumption_message from tests.fixtures import DISPATCH_MODEL, expanded, override, raw_of, schema_of @@ -107,17 +107,21 @@ def test_the_file_keeps_its_curve_and_the_expansion_has_none(): assert not schema.expand('piecewise').piecewise, 'the block is spent once its declarations are emitted' -def test_lowering_refuses_a_model_that_still_owes_rows_to_a_curve(): - """A curve states rows and a program holds them, and nothing here writes them out on the caller's behalf. +def test_a_program_holds_the_rows_a_curve_states_and_not_the_curve(): + """A curve states rows and a program holds them, so lowering a model writes every block out. - Which formulations to write out is the caller's to say: a set is one thing - to a consumer that takes it and another to one that does not, so the - refusal names both spellings. + Writing a set out stays the caller's: a set is one thing to a consumer + that takes it and another to one that does not. """ - with pytest.raises(LanguageError, match="piecewise: 'cost_curve' states rows") as refusal: - to_program(schema_of(NONCONVEX_YAML)) - assert "expand('piecewise')" in str(refusal.value) and 'expand()' in str(refusal.value), ( - 'the refusal names both ways out, because they differ in what a set becomes' + schema = schema_of(NONCONVEX_YAML) + program = to_program(schema) + assert program is to_program(schema.expand('piecewise')), 'the program of a model is the program of its rows' + assert {'cost_curve_lam', 'p', 'op_cost'} <= set(program.variables), 'the weights the block states' + assert 'cost_curve_link0' in program.constraints and not hasattr(program, 'piecewise'), ( + 'the rows are declarations like any other, and nothing records the block they came from' + ) + assert to_program(schema.expand()).sos == {} and program.sos == {}, ( + 'an adjacency block writes its own set out; a caller writes the rest out with expand()' ) @@ -488,7 +492,7 @@ def test_a_masked_lp_curve_sits_its_rows_on_predicates_rather_than_on_parameters caller never supplied and a derivation in private state filled. The ``where`` language writes each of them, so the rows carry the predicate and the program declares the file's parameters and no other.""" - program = lower_program(expand_piecewise(schema_of(LP_MASKED))) + program = to_program(schema_of(LP_MASKED)) rows = {name: program.constraints[f'cost_curve_{name}'].where for name in ('chord', 'domain_lo', 'domain_hi')} assert set(program.parameters) == {'bp_x', 'bp_y', 'load'}, 'every parameter is one the file declared' @@ -548,7 +552,6 @@ def test_a_block_assumes_of_its_data_what_the_method_implies(): """Every condition a curve puts on its data stands with the file's own, carrying its own subjects.""" program = to_program(expanded(LP_MASKED, 'piecewise')) - assert program.piecewise['cost_curve'].breakpoints == ('bp_x', 'bp_y'), 'the values parameters, in link order' assert list(program.assumptions) == [ 'cost_curve_complete', 'cost_curve_increasing', diff --git a/tests/test_public_surface.py b/tests/test_public_surface.py index 28e54880..ef3eddd2 100644 --- a/tests/test_public_surface.py +++ b/tests/test_public_surface.py @@ -42,10 +42,10 @@ ) # fmt: skip #: What `Spec` promises beyond the sections a file declares: the two ways back -#: out, the verb that writes a formulation out, the relations of one dimension, -#: and the typed trees every reader in this package walks. A `model_`-prefixed -#: name is pydantic's, not a contract this project keeps. -SPEC_SURFACE = frozenset({'relations_of', 'to_dict', 'to_yaml', 'expand', 'resolved'}) +#: out, the verb that writes a formulation out, and the relations of one +#: dimension. A `model_`-prefixed name is pydantic's, not a contract this +#: project keeps. +SPEC_SURFACE = frozenset({'relations_of', 'to_dict', 'to_yaml', 'expand'}) #: The modules whose `__all__` a consumer imports from. MODULES = [ diff --git a/tests/test_sos.py b/tests/test_sos.py index efc23fec..a1fd6b00 100644 --- a/tests/test_sos.py +++ b/tests/test_sos.py @@ -110,8 +110,6 @@ def test_the_adjacency_method_is_the_sos2_curve_with_its_set_written_out(): assert sos2.variables == adjacency.variables assert sos2.constraints == adjacency.constraints assert not sos2.sos and not adjacency.sos, 'neither hands a solver a set' - assert sos2.piecewise['cost_curve'].method == 'sos2', 'the block still records the method it declared' - assert adjacency.piecewise['cost_curve'].method == 'adjacency' @pytest.mark.parametrize( diff --git a/tests/test_validation.py b/tests/test_validation.py index ec8afac9..23bd35c7 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -2060,13 +2060,13 @@ def test_a_name_may_open_with_an_underscore(): def test_each_declaration_is_resolved_once_however_many_readers(monkeypatch): """Loading, lowering and typesetting a model resolve each expression and where string once. - Every reader after validation — the dim rules, lowering, the typesetter — - used to parse, expand and resolve the declaration's text again, so one - constraint was resolved four times per load and the trees the readers - walked were built apart from the one the language checked (#401). They - read the trees validation built now. + Every reader after validation — the dim rules, the typesetter — used to + parse, expand and resolve the declaration's text again, so one constraint + was resolved four times per load and the trees the readers walked were + built apart from the one the language checked (#401). They read the + program lowering built now. """ - from math_spec import resolution, validation + from math_spec import lowering, resolution seen: list[tuple[str, str]] = [] @@ -2078,7 +2078,7 @@ def record(*args, **kwargs): return record doors = (resolution.resolve_expression, resolution.resolve_constraint_text, resolution.resolve_where_text) - for module in (validation, resolution): + for module in (lowering, resolution): for door in doors: monkeypatch.setattr(module, door.__name__, recorded(door)) diff --git a/tests/typesetting/test_cases.py b/tests/typesetting/test_cases.py index 70da82ee..ca1cf26a 100644 --- a/tests/typesetting/test_cases.py +++ b/tests/typesetting/test_cases.py @@ -11,8 +11,7 @@ import pytest -from math_spec import to_latex, to_spec, typeset -from math_spec.piecewise import expand_piecewise +from math_spec import to_latex, to_program, typeset from math_spec.typesetting.symbols import chosen_expressions from tests.fixtures import DISPATCH_MODEL as DISPATCH from tests.fixtures import override @@ -134,8 +133,7 @@ def test_a_variable_reached_through_another_cased_expression_still_prints_chosen holds a variable. A walk stopping at the inner block would print the outer one upright — a quantity the solver decides, set as one the model was handed. """ - schema = expand_piecewise(to_spec(_NESTED)) - assert chosen_expressions(schema) == {'headroom', 'opening_cost'}, ( + assert chosen_expressions(to_program(_NESTED)) == {'headroom', 'opening_cost'}, ( 'the chain is followed to its end, so both are chosen' ) diff --git a/tests/typesetting/test_golden.py b/tests/typesetting/test_golden.py index dc75b4d7..8cdc03d3 100644 --- a/tests/typesetting/test_golden.py +++ b/tests/typesetting/test_golden.py @@ -15,7 +15,9 @@ import pytest +from math_spec.lowering import to_program from math_spec.operators import BUILTIN_NAMES +from math_spec.piecewise import curve from math_spec.program import Dual, Expression, GroupSum, Named, Predicate, Pullback, Sum, Translate, WindowSum from math_spec.typesetting import FORMATS, to_latex, typeset, walk from math_spec.typesetting.format import OPERATOR_NAMES @@ -123,24 +125,27 @@ def _rendered_trees() -> Iterator[object]: curve's links are trees of its own, and the rows it stands for are not printed at all. """ - resolved = to_spec(golden.MODEL).resolved - assert resolved.objective is not None - yield resolved.objective.expression - for constraint in resolved.constraints.values(): + schema = to_spec(golden.MODEL) + program = to_program(schema) + assert program.objective is not None + yield program.objective.expression + for name in schema.constraints: + constraint = program.constraints[name] yield constraint.lhs yield constraint.rhs if constraint.where is not None: yield constraint.where.root - for mask in resolved.variables.values(): - if mask is not None: + for name in schema.variables: + if (mask := program.variables[name].where) is not None: yield mask.root - for assumption in resolved.assumptions.values(): + for assumption in program.assumptions.values(): yield assumption.predicate.root if assumption.where is not None: yield assumption.where.root - yield from resolved.expressions.values() - for links in resolved.piecewise.values(): - yield from links + for name in schema.expressions: + yield program.expressions[name].expression + for name in schema.piecewise: + yield from curve(schema, name).links #: A dataclass the walk steps *through* rather than renders: a region has no diff --git a/tests/typesetting/test_walk.py b/tests/typesetting/test_walk.py index 6c5cbd8e..31b35f3f 100644 --- a/tests/typesetting/test_walk.py +++ b/tests/typesetting/test_walk.py @@ -12,6 +12,7 @@ import pytest from math_spec.errors import LanguageError +from math_spec.lowering import to_program from math_spec.typesetting import FORMATS, SymbolTable, to_latex, to_markdown, typeset, typeset_declaration from math_spec.typesetting.format import OPERATOR_NAMES from math_spec.typesetting.symbols import Symbols, _derive_name_symbol, chosen_expressions @@ -512,14 +513,14 @@ def test_nothing_the_model_is_given_prints_italic(): """The convention as a property of the whole document, not of a fragment: a rendering path added later reaches the page through its own call.""" schema = to_spec(golden.MODEL) - computed = set(schema.variables) | chosen_expressions(schema) + computed = set(schema.variables) | chosen_expressions(to_program(schema)) italic = {m.replace(r'\_', '_') for m in re.findall(r'\\mathit\{([^}]*)\}', to_latex(golden.MODEL))} assert italic <= computed, ( f'{sorted(italic - computed)} print italic and are neither chosen by the solver nor read off its ' f'solution — upright is what the model is given, italic what it computes' ) - symbols = Symbols(schema, LATEX, SymbolTable('latex')) + symbols = Symbols(schema, to_program(schema), LATEX, SymbolTable('latex')) given = {name: symbols.name[name] for name in schema.parameters} assert all(symbol.startswith(r'\mathrm{') for symbol in given.values()), ( f'derived upright for every parameter, but got {sorted(s for s in given.values() if "mathrm" not in s)}' From f8614b96c7a5d1bd9e15cdffce48dd5a2981d08b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 15:13:44 +0000 Subject: [PATCH 02/20] refactor(program): a program mirrors the file, descriptions and curves included, and the typesetter reads it alone to_program lowers the model as it arrived: a piecewise: block still in it is a curve on the program, typed, with its links, signs, method, gate, mask and frame, and the program of spec.expand('piecewise') carries the rows instead. Every declaration carries its description, and the program the file's. The typesetter takes a Program and reads nothing else; the walk no longer re-resolves a curve's links at print time, and piecewise.curve is gone. relations_of moves from Spec to Program. advice() writes curves out itself and refuses a program still carrying one. The typeset output is unchanged: the golden files and the generated pages regenerate byte for byte. Docs sentences, after (before): reading.md n 75 avg 16.8 median 15 over25 12 (75, 16.1, 15, 10); piecewise.md n 70 avg 17.6 median 15 over25 14 (70, 17.6, 15, 13). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi --- docs/reference/language/piecewise.md | 8 +- docs/reference/reading.md | 58 ++++++----- src/math_spec/advice.py | 25 ++++- src/math_spec/lowering.py | 74 +++++++++---- src/math_spec/model.py | 4 - src/math_spec/piecewise.py | 53 ++++------ src/math_spec/program.py | 81 ++++++++++++++- src/math_spec/resolution.py | 2 +- src/math_spec/typesetting/README.md | 3 +- src/math_spec/typesetting/__init__.py | 48 ++++----- src/math_spec/typesetting/symbols.py | 41 +++++--- src/math_spec/typesetting/walk.py | 144 ++++++++++++-------------- tests/test_piecewise.py | 29 +++--- tests/test_public_surface.py | 7 +- tests/test_reading_page.py | 2 +- tests/typesetting/test_golden.py | 5 +- tests/typesetting/test_walk.py | 2 +- 17 files changed, 347 insertions(+), 239 deletions(-) diff --git a/docs/reference/language/piecewise.md b/docs/reference/language/piecewise.md index 5f9e7089..39da84a8 100644 --- a/docs/reference/language/piecewise.md +++ b/docs/reference/language/piecewise.md @@ -260,8 +260,8 @@ a model before and after, as whole files. emits a parameter. A curve under `points:` sits its rows on `where:` predicates over the mask the file named, and the expansion is a file like any other: `to_yaml()` writes it, and loading it back changes nothing. -- **`to_program()` writes every curve out.** A program holds the rows a curve - states, so `to_program(spec)` and `to_program(spec.expand('piecewise'))` are - the same program. A program carries a set, because a consumer with the - concept takes one; a consumer without it refuses the model and names +- **`to_program()` writes nothing out.** The program mirrors the model: a + curve the model still declares is under `program.piecewise`, typed, and the + program of `spec.expand('piecewise')` carries its rows instead. A consumer + building rows lowers the expansion; one that cannot take a set lowers `spec.expand()`. diff --git a/docs/reference/reading.md b/docs/reference/reading.md index d866ed1c..1385ca28 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -62,26 +62,30 @@ objective: from math_spec import to_spec, to_program spec = to_spec('curve.yaml') -sorted(spec.constraints) # ['target'] - program = to_program(spec) -sorted(program.constraints) # ['curve_convexity', 'curve_link0', 'curve_link1', 'target'] -sorted(program.variables) # ['cost', 'curve_lam', 'p'] +sorted(program.constraints) # ['target'] +sorted(program.piecewise) # ['curve'] + +rows = to_program(spec.expand('piecewise')) +sorted(rows.constraints) # ['curve_convexity', 'curve_link0', 'curve_link1', 'target'] +sorted(rows.variables) # ['cost', 'curve_lam', 'p'] ``` `to_program` takes a path, the YAML, a mapping, a `Spec` or a `Program`. Called on a `Program`, it returns the same object unchanged. Called on a `Spec`, it returns the program built when the model loaded, so two calls on one model -return one object. It writes every `piecewise:` block out as the rows the block -states: `to_program(spec)` and `to_program(spec.expand('piecewise'))` are the -same program. It keeps every `sos:` block, because a consumer with the concept -of a set takes one whole. A consumer without it calls `spec.expand()` first, -which writes the sets out too. - -| you are | take | because | -| ---------------------------------------------------------------------------- | --------- | --------------------------------------------- | -| building rows, as a solver backend or a second front end does | `Program` | Every declaration is there, and resolved | -| reading the file, for `macros:`, `description:`, or a link as it was written | `Spec` | A program holds a curve's rows, not the curve | +return one object. The program mirrors the model: a `piecewise:` block the +model still declares is a curve under `program.piecewise`, typed, and a `sos:` +block is a set under `program.sos`. `spec.expand('piecewise')` is the model +with each curve written out as rows, and `spec.expand()` writes the sets out +too. Which to lower is the caller's to say, because a consumer printing a +curve wants the curve and a consumer building rows wants the rows. + +| you are | take | because | +| ------------------------------------------------------------------- | --------- | -------------------------------------------- | +| building rows, as a solver backend or a second front end does | `Program` | Every declaration is there, and resolved | +| printing, or checking a model without data | `Program` | Every description and every curve is there | +| rewriting the file, for `macros:` or the text a link was written as | `Spec` | A program holds trees, and a file holds text | ## Formulations written out @@ -96,14 +100,16 @@ sorted(spec.expand().constraints) # ['curve_convexity', 'curve_link0', 'curve_l spec.expand() is spec.expand() # True ``` -`to_program` writes the curves out and leaves the sets, because a program -carries a set for a consumer that has the concept. A consumer without one -refuses the model and names `spec.expand('sos')`; what that emits is on the -[piecewise page](language/piecewise.md#what-a-set-is-written-out-as). +A consumer that takes a set lowers `spec.expand('piecewise')`, and one that +does not lowers `spec.expand()`; what a set is written out as is on the +[piecewise page](language/piecewise.md#what-a-set-is-written-out-as). A +consumer handed a program still carrying a curve or a set it cannot take +refuses it and names the expansion. Every parameter the program declares is one the file declared, and the engine -binds each from its data. A program does not keep the curve: the rows, the -weights and the conditions the method states are declarations like any other. +binds each from its data. The program of an expansion keeps no curve: the +rows, the weights and the conditions the method states are declarations like +any other. ## What the data has to satisfy @@ -180,9 +186,11 @@ arrives as a `Mask` too. The node classes live in `math_spec.program`. ## Asking what a program uses `program.footprint` says which of the language's constructs one model uses. +Ask it of the rows a solver takes, since a curve written out uses more of the +language than the block did: ```python -footprint = program.footprint +footprint = rows.footprint sorted(footprint.quadratic) # [] sorted(footprint.domains) # ['continuous'] @@ -203,10 +211,10 @@ inside one window along it: a storage balance that reads the previous snapshot does, and an annual emissions cap does not. ```python -program.separability['bp'].windowable # False -program.separability['generator'].linking_rows # ('target',) -program.separability['generator'].linking_columns # () -tied = program.separability['generator'].coupled["constraint 'target'"] +rows.separability['bp'].windowable # False +rows.separability['generator'].linking_rows # ('target',) +rows.separability['generator'].linking_columns # () +tied = rows.separability['generator'].coupled["constraint 'target'"] tied.partition(' — ')[0] # 'sums over generator' 'sum_back(window=n)' in tied # True ``` diff --git a/src/math_spec/advice.py b/src/math_spec/advice.py index ee3406e5..938428f1 100644 --- a/src/math_spec/advice.py +++ b/src/math_spec/advice.py @@ -12,16 +12,16 @@ from typing import TYPE_CHECKING from math_spec.boundedness import unbounded_notes -from math_spec.errors import Advice +from math_spec.errors import Advice, LanguageError from math_spec.lowering import to_program -from math_spec.program import GroupSum, Pullback, walk +from math_spec.program import GroupSum, Program, Pullback, walk +from math_spec.validation import to_spec if TYPE_CHECKING: from collections.abc import Mapping from pathlib import Path from math_spec.model import Spec - from math_spec.program import Program def advice(model: str | Path | Mapping[str, object] | Spec | Program) -> tuple[Advice, ...]: @@ -29,14 +29,29 @@ def advice(model: str | Path | Mapping[str, object] | Spec | Program) -> tuple[A Args: model: A YAML path, a mapping, a loaded :class:`Spec`, or a - :class:`Program`. Both passes read the program, so the four - answer alike. + :class:`Program`. Both passes read the rows a curve states, so a + file or a model is read with its curves written out, and a program + still carrying one is refused. Returns: The never-an-axis advice in declaration order, then the unboundedness advice; ``str()`` of each is its sentence. + + Raises: + LanguageError: A :class:`Program` with a ``piecewise:`` block still + in it, naming the expansion to pass instead. """ + if not isinstance(model, Program): + model = to_spec(model).expand('piecewise') program = to_program(model) + if program.piecewise: + named = ', '.join(f"'{name}'" for name in program.piecewise) + msg = ( + f'piecewise: {named} states rows, and advice reads the rows. Pass ' + f"to_program(spec.expand('piecewise')), which writes each block out as the variables and " + f'constraints it states.' + ) + raise LanguageError(msg) return tuple(_never_an_axis(program) + unbounded_notes(program)) diff --git a/src/math_spec/lowering.py b/src/math_spec/lowering.py index c45d744e..dab8f450 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -7,9 +7,10 @@ One lowering, on the language side, run when a :class:`~math_spec.model.Spec` loads: it reads every expression and where string into the program's own nodes, checks every rule decidable without data, and packages the -declarations. A ``piecewise:`` block states rows, so a program is lowered -from the model with its curves written out -(:meth:`~math_spec.model.Spec.expand`). +declarations, section for section. The program mirrors the model it was +lowered from: a ``piecewise:`` block the model still declares is a curve on +the program, and :meth:`~math_spec.model.Spec.expand` is what writes it out +as rows. """ from __future__ import annotations @@ -19,7 +20,7 @@ from math_spec.dimensions import check_schema, dims_of from math_spec.errors import SchemaError, prefixed from math_spec.expansion import expand, parse_template -from math_spec.piecewise import curve_frame, lp_domain_refusal, resolve_links +from math_spec.piecewise import assumptions_of, curve_frame, lp_domain_refusal, resolve_links from math_spec.program import ( Assumption, BooleanLiteral, @@ -28,11 +29,13 @@ ConstraintDeclaration, DimensionDeclaration, ExpressionDeclaration, + Link, Mask, Named, ObjectiveDeclaration, Parameter, ParameterDeclaration, + PiecewiseDeclaration, Program, SosDeclaration, VariableDeclaration, @@ -64,9 +67,10 @@ def to_program(spec: str | Path | Mapping[str, object] | Spec | Program) -> Prog model, or a program already. Idempotent, so a caller that does not know which it holds can call this and be sure, and one object per model: the program was built when the model loaded, and a second ask is the same - object. Every ``piecewise:`` block is written out as the rows it states; - a ``sos:`` block lowers as itself, since a program carries a set and - :meth:`~math_spec.model.Spec.expand` is what states one as binaries. + object. The program mirrors the model as it arrived: a ``piecewise:`` + block still in it is a curve on the program and a ``sos:`` block a set, + and :meth:`~math_spec.model.Spec.expand` is what writes either out as + rows for a consumer that takes rows alone. Args: spec: What to read the declarations from. @@ -82,7 +86,7 @@ def to_program(spec: str | Path | Mapping[str, object] | Spec | Program) -> Prog """ if isinstance(spec, Program): return spec - program = to_spec(spec).expand('piecewise')._program + program = to_spec(spec)._program assert program is not None, 'a model that loaded was lowered' return program @@ -104,12 +108,12 @@ def lower(schema: Spec) -> Program: A ``piecewise:`` block's links are resolved and its frame checked here, on the link the file wrote, so the expansion writes rows the language has - already held to every rule. The rows themselves are on the expanded - model's program, which :func:`to_program` answers with. + already held to every rule; what its method assumes of the breakpoints + stands under the program's assumptions with the file's own, so a model + states what it assumes whether or not its curves are written out. Returns: - The program of what *schema* declares, a ``piecewise:`` block's rows - not among them. + The program of what *schema* declares, section for section. Raises: SchemaError: Listing every problem found, one per line. @@ -156,6 +160,7 @@ def lower(schema: Spec) -> Program: upper=upper_bound, domain=vdef.domain, absence=vdef.absence, + description=vdef.description, ) constraints: dict[str, ConstraintDeclaration] = {} @@ -164,13 +169,15 @@ def lower(schema: Spec) -> Program: where = resolve_where_text(cdef.where, ns, context, errors) if (sides := resolve_constraint_text(cdef.expression, ns, context, errors)) is not None: lhs, sense, rhs = sides - constraints[cname] = ConstraintDeclaration(tuple(cdef.dims), lhs, sense, rhs, mask_of(where)) + constraints[cname] = ConstraintDeclaration( + tuple(cdef.dims), lhs, sense, rhs, mask_of(where), description=cdef.description + ) objective = None if schema.objective is not None: expression = resolve_expression_text(schema.objective.expression, ns, 'The objective', errors, ceiling=2) if expression is not None: - objective = ObjectiveDeclaration(schema.objective.sense, expression) + objective = ObjectiveDeclaration(schema.objective.sense, expression, schema.objective.description) assumptions: dict[str, Assumption] = {} for aname, adef in schema.assumptions.items(): @@ -179,6 +186,9 @@ def lower(schema: Spec) -> Program: curves: dict[str, tuple[Expression, ...]] = {} for pname, pdef in schema.piecewise.items(): + for aname, assumed in assumptions_of(pname, pdef).items(): + if (assumption := _assumption(aname, assumed, ns, errors)) is not None: + assumptions[aname] = assumption links = resolve_links(pname, pdef, ns, errors) if links is None: continue @@ -195,25 +205,49 @@ def lower(schema: Spec) -> Program: roots.extend(link for links in curves.values() for link in links) in_math = frozenset(node.name for node in walk(*roots) if isinstance(node, Named)) + piecewise = {} + for pname, links in curves.items(): + pdef = schema.piecewise[pname] + piecewise[pname] = PiecewiseDeclaration( + over=pdef.over, + links=tuple(Link(node, link.values, link.sign) for node, link in zip(links, pdef.links, strict=True)), + method=pdef.method, + frame=curve_frame(schema, pname, pdef, links), + activity=pdef.activity, + points=pdef.points, + description=pdef.description, + ) + program = Program( parameters={ - name: ParameterDeclaration(tuple(pdef.dims), pdef.dtype) for name, pdef in schema.parameters.items() + name: ParameterDeclaration(tuple(pdef.dims), pdef.dtype, pdef.description) + for name, pdef in schema.parameters.items() }, variables=variables, constraints=constraints, objective=objective, - dimensions={name: DimensionDeclaration(ddef.dtype) for name, ddef in schema.dimensions.items()}, + dimensions={ + name: DimensionDeclaration(ddef.dtype, ddef.description) for name, ddef in schema.dimensions.items() + }, relations=ns.relations, - sos={name: SosDeclaration(sdef.variable, sdef.along, sos_type=sdef.type) for name, sdef in schema.sos.items()}, + sos={ + name: SosDeclaration(sdef.variable, sdef.along, sos_type=sdef.type, description=sdef.description) + for name, sdef in schema.sos.items() + }, + piecewise=piecewise, assumptions=assumptions, expressions={ - name: ExpressionDeclaration(entry.body, _frame_of(name, entry, schema), in_math=name in in_math) + name: ExpressionDeclaration( + entry.body, + _frame_of(name, entry, schema), + in_math=name in in_math, + description=schema.expressions[name].description, + ) for name, entry in entries.items() }, + description=schema.description, ) check_schema(schema, program) - for pname, links in curves.items(): - curve_frame(schema, pname, schema.piecewise[pname], links) return program diff --git a/src/math_spec/model.py b/src/math_spec/model.py index 3d95c95c..50bd5916 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -788,10 +788,6 @@ class is pydantic's, not a contract this package keeps. sos: dict[str, SosBlock] = {} assumptions: dict[str, AssumptionBlock] = {} - def relations_of(self, dimension: str) -> dict[str, RelationBlock]: - """The relations with a column over *dimension*, by name.""" - return {n: lk for n, lk in self.relations.items() if dimension in lk.dims} - @classmethod @override def model_validate( diff --git a/src/math_spec/piecewise.py b/src/math_spec/piecewise.py index e6ff0e9d..fbc4a996 100644 --- a/src/math_spec/piecewise.py +++ b/src/math_spec/piecewise.py @@ -15,26 +15,31 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Literal, NamedTuple +from typing import TYPE_CHECKING, Literal import math_spec.sos as sos from math_spec.dimensions import dims_of from math_spec.errors import DimensionError from math_spec.model import AssumptionBlock, Curvature, PiecewiseBlock, PiecewiseMethod, Spec -from math_spec.program import carries_variable -from math_spec.resolution import Namespace, resolve_expression_text +from math_spec.program import PiecewiseDeclaration, carries_variable +from math_spec.resolution import resolve_expression_text if TYPE_CHECKING: from collections.abc import Iterable from math_spec.program import Expression + from math_spec.resolution import Namespace + +#: A block as the file wrote it, or as the program carries it: the two share +#: every name the rules here read, and the rules read nothing else. +type Curve = PiecewiseBlock | PiecewiseDeclaration #: The suffix on the second gate row, where the gate variable does not exist. _UNGATED = '_ungated' -def _curvature_required(pw: PiecewiseBlock) -> Curvature | None: +def _curvature_required(pw: Curve) -> Curvature | None: """The curvature *pw*'s method is only exact for, or ``None`` if any shape works. A bounded link binds from one side, and that side is the hull boundary the @@ -90,30 +95,7 @@ def lp_domain_refusal(name: str, pw: PiecewiseBlock, links: tuple[Expression, .. ) -class Curve(NamedTuple): - """One ``piecewise:`` block as the curve it states: its links typed, and the frame it builds one curve per coordinate of.""" - - links: tuple[Expression, ...] - frame: tuple[str, ...] - - -def curve(schema: Spec, name: str) -> Curve: - """Block *name* of *schema* as the curve it states. - - Read off the model rather than kept on it: a program holds the rows a - curve states and not the curve, and the two readers of the curve itself - — the expansion writing those rows and the typesetter printing the block - — each ask here. Nothing here can fail: *schema* loaded, so its links - resolved and its frame held. - """ - ns = Namespace(schema) - errors: list[str] = [] - links = resolve_links(name, schema.piecewise[name], ns, errors) - assert links is not None and not errors, 'a loaded model resolved every link' - return Curve(links, curve_frame(schema, name, schema.piecewise[name], links)) - - -def assumptions_of(block: str, pw: PiecewiseBlock) -> dict[str, AssumptionBlock]: +def assumptions_of(block: str, pw: Curve) -> dict[str, AssumptionBlock]: """What *block* assumes of its numbers, by the name the document prints and a refusal quotes. Every curve assumes its breakpoints are there: a missing parameter row is @@ -218,7 +200,7 @@ def _interior(over: str, mask: str | None) -> str: return f'{mask} AND shift({mask}, along={over}, offset=1) AND shift({mask}, along={over}, offset=-1)' -def _bends(block: str, pw: PiecewiseBlock, x: str, y: str, curvature: Curvature) -> AssumptionBlock: +def _bends(block: str, pw: Curve, x: str, y: str, curvature: Curvature) -> AssumptionBlock: """The curve bends the way *curvature* says, as a comparison of the two slopes at each breakpoint. The slopes are compared as a cross-product rather than as two quotients, @@ -269,7 +251,7 @@ class Emitted: assumptions: tuple[str, ...] @classmethod - def of(cls, name: str, pw: PiecewiseBlock) -> Emitted: + def of(cls, name: str, pw: Curve) -> Emitted: """The names block *name* writes.""" return cls( name, @@ -357,14 +339,16 @@ class _Block: *schema* loaded. """ - def __init__(self, schema: Spec, raw: dict[str, object], name: str, pw: PiecewiseBlock) -> None: + def __init__( + self, schema: Spec, raw: dict[str, object], name: str, pw: PiecewiseBlock, frame: tuple[str, ...] + ) -> None: self.schema = schema self.raw = raw self.name = name self.pw = pw self.emitted = Emitted.of(name, pw) self.mask = pw.points - self.frame = curve(schema, name).frame + self.frame = frame def expand(self) -> None: """Write the block's declarations into the raw model.""" @@ -483,15 +467,18 @@ def expand_piecewise(schema: Spec) -> Spec: ``method: sos2`` states, and then that set is written out here too: the binaries are what the method *is*, so the model that comes back carries no set of its own (:func:`math_spec.sos.emit` is where they are spelled). + Each block's frame is read off the program *schema* lowered to. """ if not schema.piecewise: return schema + from math_spec.lowering import to_program + program = to_program(schema) raw = schema.model_dump() raw.setdefault('variables', {}) raw.setdefault('constraints', {}) for name, pw in schema.piecewise.items(): - _Block(schema, raw, name, pw).expand() + _Block(schema, raw, name, pw, program.piecewise[name].frame).expand() raw['piecewise'].clear() for name, pw in schema.piecewise.items(): if pw.method == 'adjacency': diff --git a/src/math_spec/program.py b/src/math_spec/program.py index de38e5e7..5619c48b 100644 --- a/src/math_spec/program.py +++ b/src/math_spec/program.py @@ -5,10 +5,10 @@ """The program: what a file declares, with names resolved and shapes fixed. The second public state, and the one a consumer reads. A :class:`Program` is -every declaration a file makes and no data at all, with every ``piecewise:`` -block written out as the rows it states; -:func:`~math_spec.lowering.to_program` is the only thing that builds one, so -nothing here re-checks a hand-built one. +the file typed, section for section: every declaration it makes, with names +resolved, shapes fixed and every rule decidable without data checked, and no +data at all. :func:`~math_spec.lowering.to_program` is the only thing that +builds one, so nothing here re-checks a hand-built one. Node and declaration classes are matched with ``isinstance``. The rules a node's structure does not show are :func:`children` and :func:`fan_in`; the @@ -62,6 +62,7 @@ 'FanIn', 'Footprint', 'GroupSum', + 'Link', 'Mask', 'Multiply', 'Named', @@ -76,6 +77,7 @@ 'ParameterDefined', 'ParameterDtype', 'Partition', + 'PiecewiseDeclaration', 'Power', 'Predicate', 'PredicateOperator', @@ -455,6 +457,7 @@ class RelationDeclaration: columns: tuple[tuple[str, str], ...] key: tuple[str, ...] + description: str | None = None @property def roles(self) -> tuple[str, ...]: @@ -556,6 +559,7 @@ class DimensionDeclaration: #: checked against — the same claim ``ParameterDeclaration.dtype`` makes #: about a value column, one axis over. dtype: DimensionDtype = 'str' + description: str | None = None @dataclass(frozen=True) @@ -602,6 +606,7 @@ class ParameterDeclaration: dims: tuple[str, ...] dtype: ParameterDtype = 'float' + description: str | None = None @dataclass(frozen=True) @@ -612,6 +617,7 @@ class VariableDeclaration: upper: Expression = field(default_factory=lambda: Constant(float('inf'))) domain: VariableDomain = 'continuous' absence: VariableAbsence = 'undefined' + description: str | None = None @dataclass(frozen=True) @@ -628,6 +634,7 @@ class ConstraintDeclaration: sense: ConstraintSense rhs: Expression where: Mask | None = None + description: str | None = None @dataclass(frozen=True) @@ -644,6 +651,7 @@ class SosDeclaration: variable: str along: str sos_type: Literal[1, 2] + description: str | None = None @dataclass(frozen=True) @@ -652,6 +660,7 @@ class ObjectiveDeclaration: sense: ObjectiveSense expression: Expression + description: str | None = None @dataclass(frozen=True) @@ -671,6 +680,61 @@ class ExpressionDeclaration: #: declares, or the dims a plain entry's body carries. dims: tuple[str, ...] in_math: bool + description: str | None = None + + +@dataclass(frozen=True) +class Link: + """One link of a ``piecewise:`` block: an expression tied to the breakpoints a values parameter holds. + + ``sign`` is ``'=='`` where the link is pinned to the curve, and one side + of it where the link is bounded by the curve instead. + """ + + expression: Expression + values: str + sign: ConstraintSense = '==' + + +@dataclass(frozen=True) +class PiecewiseDeclaration: + """A ``piecewise:`` block as the curve it states, which :meth:`~math_spec.model.Spec.expand` writes out as rows. + + A program of a model that still declares one carries it here, typed; a + program of the expanded model carries the rows instead, under + :attr:`Program.variables` and :attr:`Program.constraints`, and what the + method assumes of the breakpoints under :attr:`Program.assumptions`. A + consumer building rows takes the expanded model. + + Attributes: + over: The breakpoint dimension. + links: The links, in the order the file wrote them. + method: How the weights are restricted. + activity: The binary the weights sum to, or ``None`` where they sum + to 1. + points: The parameter saying how far each curve runs, or ``None``. + frame: The dimensions the block builds one curve per coordinate of, + in declaration order. + """ + + over: str + links: tuple[Link, ...] + method: _model.PiecewiseMethod + frame: tuple[str, ...] + activity: str | None = None + points: str | None = None + description: str | None = None + + @property + def nominated(self) -> str | None: + """The block's own values parameter ``points:`` names, so the mask is derived from it — or ``None``.""" + return self.points if self.points in {link.values for link in self.links} else None + + @property + def curve(self) -> tuple[Link, Link]: + """The two links as ``(x, y)``, the bounded one last. Two-link blocks only.""" + x, y = self.links + return (y, x) if x.sign != '==' else (x, y) @dataclass(frozen=True) @@ -828,6 +892,9 @@ class Program: dimensions: Mapping[str, DimensionDeclaration] = Sealed({}) relations: Mapping[str, RelationDeclaration] = Sealed({}) sos: Mapping[str, SosDeclaration] = Sealed({}) + #: Each ``piecewise:`` block the model still declares, as the curve it + #: states; empty on a program of a model whose curves are written out. + piecewise: Mapping[str, PiecewiseDeclaration] = Sealed({}) #: What the data has to satisfy for the answer to mean anything, by the #: name a refusal quotes: every ``assumptions:`` entry the file wrote, then #: what each ``piecewise:`` block's method assumes of its breakpoints. The @@ -840,6 +907,8 @@ 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. expressions: Mapping[str, ExpressionDeclaration] = Sealed({}) + #: What the file as a whole is, as its ``description:`` says. + description: str | None = None def __post_init__(self) -> None: """Seal every group, so a program handed out cannot be written to.""" @@ -853,6 +922,10 @@ def _by_position(self) -> Iterator[tuple[QuadraticPosition, tuple[Expression, .. yield 'objective', (self.objective.expression,) if self.objective is not None else () yield 'constraint', tuple(side for c in self.constraints.values() for side in (c.lhs, c.rhs)) + def relations_of(self, dimension: str) -> Mapping[str, RelationDeclaration]: + """The relations with a column over *dimension*, by name.""" + return Sealed({n: lk for n, lk in self.relations.items() if dimension in lk.dims}) + @property def roots(self) -> tuple[Expression, ...]: """Every tree a row is built from — the objective and both sides of each constraint. diff --git a/src/math_spec/resolution.py b/src/math_spec/resolution.py index 9c167d9c..3b7c6ebe 100644 --- a/src/math_spec/resolution.py +++ b/src/math_spec/resolution.py @@ -166,7 +166,7 @@ def __init__(self, schema: Spec) -> None: } #: relation name -> its columns and key, as declared. self.relations: dict[str, RelationDeclaration] = { - n: RelationDeclaration(lk.pairs, lk.key_roles) for n, lk in schema.relations.items() + n: RelationDeclaration(lk.pairs, lk.key_roles, lk.description) for n, lk in schema.relations.items() } #: parameter or variable name -> the dims it is read through — #: parameters by their ``dims``, variables by their frame. Stamped onto diff --git a/src/math_spec/typesetting/README.md b/src/math_spec/typesetting/README.md index 87606044..a6e256b6 100644 --- a/src/math_spec/typesetting/README.md +++ b/src/math_spec/typesetting/README.md @@ -6,8 +6,7 @@ SPDX-License-Identifier: MIT # `typesetting/` — the model, printed This package is a consumer of the program. It builds no model and binds no -data. It walks the trees `to_program` lowers a model to, reads what the file -says about each declaration from the `Spec`, and prints both. +data. It walks the program `to_program` lowers a model to, and prints it. | Module | Role | | ------------- | ------------------------------------------------------------------------------------------------------ | diff --git a/src/math_spec/typesetting/__init__.py b/src/math_spec/typesetting/__init__.py index 341e5f19..d59c264f 100644 --- a/src/math_spec/typesetting/__init__.py +++ b/src/math_spec/typesetting/__init__.py @@ -35,13 +35,13 @@ from math_spec.typesetting.symbols import Symbols, SymbolTable from math_spec.typesetting.typst import TypstFormat from math_spec.typesetting.walk import Walk -from math_spec.validation import to_spec if TYPE_CHECKING: from collections.abc import Mapping from pathlib import Path from math_spec.model import Spec + from math_spec.program import Program from math_spec.typesetting.format import Format __all__ = [ @@ -77,7 +77,7 @@ class _Options(TypedDict, total=False): def _walk( - model: str | Path | Mapping[str, object] | Spec, + model: str | Path | Mapping[str, object] | Spec | Program, fmt: FormatName, symbols: str | Path | Mapping[str, object] | SymbolTable | None, *, @@ -87,23 +87,21 @@ def _walk( if fmt not in FORMATS: msg = f"'{fmt}' is not a format this package prints. Formats: {', '.join(FORMATS)}." raise ValueError(msg) - schema = to_spec(model) - program = to_program(schema) + program = to_program(model) format_ = FORMATS[fmt] if symbols is None: symbols = SymbolTable(format_.notation) table = symbols if isinstance(symbols, SymbolTable) else SymbolTable.load(symbols) return Walk( - schema, program, - Symbols(schema, program, format_, table.checked_against(schema)), + Symbols(program, format_, table.checked_against(program)), format_, inline_expressions=inline_expressions, ) def typeset( - model: str | Path | Mapping[str, object] | Spec, + model: str | Path | Mapping[str, object] | Spec | Program, fmt: FormatName, *, symbols: str | Path | Mapping[str, object] | SymbolTable | None = None, @@ -115,11 +113,11 @@ def typeset( """Render *model*'s math in *fmt*. Args: - model: Anything :func:`math_spec.to_spec` accepts. A - :class:`~math_spec.model.Spec` is rendered as it stands, so - printing one model in several formats reads and checks the file - once rather than once per format, and a curve prints as the curve it - states. Pass ``spec.expand()`` for the rows a solver holds + model: Anything :func:`math_spec.to_program` accepts. A + :class:`~math_spec.model.Spec` or a :class:`~math_spec.program.Program` + is rendered as it stands, so printing one model in several formats + reads and checks the file once rather than once per format, and a + curve prints as the curve it states. Pass ``spec.expand()`` for the rows a solver holds instead. fmt: What spells the math — a key of :data:`FORMATS`. symbols: How names print, as a :class:`SymbolTable`, a path or a @@ -145,14 +143,14 @@ def typeset( table written in a notation *fmt* does not read. """ walk = _walk(model, fmt, symbols, inline_expressions=inline_expressions) - schema, format_ = walk.schema, walk.format + program, format_ = walk.program, walk.format sections, noticed = walk.equations() rendered = [ format_.section(title, format_.equations(lines, numbered=numbered)) for title, lines in sections if lines ] - blocks = [format_.note(format_.escape(schema.description))] if schema.description else [] + blocks = [format_.note(format_.escape(program.description))] if program.description else [] if legend: blocks += [format_.section(title, format_.glossary(entries)) for title, entries in walk.glossaries(noticed)] blocks += [format_.note(text) for text in walk.convention_notes()] @@ -162,7 +160,7 @@ def typeset( def typeset_declaration( - model: str | Path | Mapping[str, object] | Spec, + model: str | Path | Mapping[str, object] | Spec | Program, name: str, fmt: FormatName, *, @@ -181,7 +179,7 @@ def typeset_declaration( one prints by symbol, and a second call with its name prints its block. Args: - model: Anything :func:`math_spec.to_spec` accepts. + model: Anything :func:`math_spec.to_program` accepts. name: A named expression, constraint, assumption, ``piecewise:`` block or variable the model declares. fmt: What spells the math — a key of :data:`FORMATS`. @@ -202,13 +200,13 @@ def typeset_declaration( names nothing in the model. """ walk = _walk(model, fmt, symbols, inline_expressions=inline_expressions) - schema = walk.schema + program = walk.program kinds = { - 'named expression': schema.expressions, - 'constraint': schema.constraints, - 'assumption': walk.program.assumptions, - 'curve': schema.piecewise, - 'variable': schema.variables, + 'named expression': program.expressions, + 'constraint': program.constraints, + 'assumption': program.assumptions, + 'curve': program.piecewise, + 'variable': program.variables, } found = [kind for kind, group in kinds.items() if name in group] if not found: @@ -224,16 +222,16 @@ def typeset_declaration( return walk.format.equation(walk.line(name)) -def to_latex(model: str | Path | Mapping[str, object] | Spec, **options: Unpack[_Options]) -> str: +def to_latex(model: str | Path | Mapping[str, object] | Spec | Program, **options: Unpack[_Options]) -> str: """Render *model* as LaTeX (amsmath ``align``). See :func:`typeset`.""" return typeset(model, 'latex', **options) -def to_typst(model: str | Path | Mapping[str, object] | Spec, **options: Unpack[_Options]) -> str: +def to_typst(model: str | Path | Mapping[str, object] | Spec | Program, **options: Unpack[_Options]) -> str: """Render *model* as Typst. See :func:`typeset`.""" return typeset(model, 'typst', **options) -def to_markdown(model: str | Path | Mapping[str, object] | Spec, **options: Unpack[_Options]) -> str: +def to_markdown(model: str | Path | Mapping[str, object] | Spec | Program, **options: Unpack[_Options]) -> str: """Render *model* as GitHub-flavoured Markdown. See :func:`typeset`.""" return typeset(model, 'markdown', **options) diff --git a/src/math_spec/typesetting/symbols.py b/src/math_spec/typesetting/symbols.py index 73ee93bb..2ce889a8 100644 --- a/src/math_spec/typesetting/symbols.py +++ b/src/math_spec/typesetting/symbols.py @@ -17,11 +17,12 @@ from math_spec._yaml import read_yaml from math_spec.errors import SchemaError, did_you_mean +from math_spec.piecewise import Emitted from math_spec.program import Dual, Variable, walk +from math_spec.sos import Emitted as EmittedSet from math_spec.typesetting.format import NOTATIONS if TYPE_CHECKING: - from math_spec.model import Spec from math_spec.program import Program from math_spec.typesetting.format import Format, Notation @@ -102,15 +103,15 @@ class Symbols: SchemaError: If *table* is written in a notation *fmt* does not read. """ - def __init__(self, schema: Spec, program: Program, fmt: Format, table: SymbolTable) -> None: + def __init__(self, program: Program, fmt: Format, table: SymbolTable) -> None: if table.notation != fmt.notation: msg = ( f'symbol table: written in {table.notation}, but this is a {fmt.notation} render ' f'and nothing translates between notations — write a {fmt.notation} table.' ) raise SchemaError(msg) - chosen = frozenset(schema.variables) | chosen_expressions(program) - names = (*schema.parameters, *schema.variables, *schema.expressions) + chosen = frozenset(program.variables) | chosen_expressions(program) + names = (*program.parameters, *program.variables, *program.expressions) declared = frozenset(names) #: Names the table spelled; the convention note quotes only derived symbols. @@ -130,13 +131,13 @@ def __init__(self, schema: Spec, program: Program, fmt: Format, table: SymbolTab #: 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 + for name in program.constraints } self.index: dict[str, str] = {} self.set: dict[str, str] = {} taken_index, taken_set = set(spoken_for), set() - for dim in schema.dimensions: + for dim in program.dimensions: overridden = dim in table.indices letter = table.indices[dim] if overridden else _first_free(_index_candidates(dim), taken_index) taken_index.add(letter) @@ -237,18 +238,15 @@ def load(cls, source: str | Path | Mapping[str, object]) -> SymbolTable: names={k: str(v) for k, v in _section(raw, 'names').items()}, ) - def checked_against(self, schema: Spec) -> SymbolTable: - """Reject entries naming nothing in *schema* or in what its formulations state, with the near miss. + def checked_against(self, program: Program) -> SymbolTable: + """Reject entries naming nothing in *program* or in what its formulations state, with the near miss. A name a ``piecewise:`` or ``sos:`` block emits counts as declared, so one table spells both readings of a model: the blocks as the file states - them, and the rows :meth:`~math_spec.model.Spec.expand` writes out. The - expansion is built only where an entry needs it. + them, and the rows :meth:`~math_spec.model.Spec.expand` writes out. """ - dims = set(schema.dimensions) - everything = dims | _declared(schema) - if set(self.names) - everything: - everything |= _declared(schema.expand()) + dims = set(program.dimensions) + everything = dims | _declared(program) | _emitted(program) errors = [ *(_unknown_entry(d, 'dimensions', dims) for d in {*self.indices, *self.sets} - dims), *(_unknown_entry(n, 'names', everything - dims) for n in set(self.names) - everything), @@ -258,9 +256,18 @@ def checked_against(self, schema: Spec) -> SymbolTable: return self -def _declared(schema: Spec) -> set[str]: - """Every name *schema* declares that a table entry may spell.""" - return set(schema.parameters) | set(schema.variables) | set(schema.expressions) | set(schema.constraints) +def _declared(program: Program) -> set[str]: + """Every name *program* declares that a table entry may spell.""" + return set(program.parameters) | set(program.variables) | set(program.expressions) | set(program.constraints) + + +def _emitted(program: Program) -> set[str]: + """Every name writing *program*'s curves and sets out would declare.""" + emitted = [ + *(Emitted.of(name, curve).by_kind for name, curve in program.piecewise.items()), + *(EmittedSet.of(name, block.sos_type).by_kind for name, block in program.sos.items()), + ] + return {name for by_kind in emitted for _, names in by_kind for name in names} def _section(raw: Mapping[str, object], name: str) -> Mapping[str, object]: diff --git a/src/math_spec/typesetting/walk.py b/src/math_spec/typesetting/walk.py index 61c164a7..0899f5d0 100644 --- a/src/math_spec/typesetting/walk.py +++ b/src/math_spec/typesetting/walk.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -"""The walk: resolved tree → typeset lines. Written once, for every format. +"""The walk: program → typeset lines. Written once, for every format. Everything here is a decision about the *math* — where a bracket changes the reading, which dimension a reduction binds, that a mask belongs on the ∀ rather @@ -15,7 +15,6 @@ from dataclasses import dataclass, field, replace from typing import TYPE_CHECKING, Literal, assert_never -from math_spec.piecewise import curve from math_spec.program import ( Add, And, @@ -63,8 +62,7 @@ from collections.abc import Iterable, Mapping from math_spec._expression_parser import BinaryOperator - from math_spec.model import PiecewiseBlock, RelationBlock, SosBlock, Spec - from math_spec.program import Program + from math_spec.program import PiecewiseDeclaration, Program, RelationDeclaration, SosDeclaration from math_spec.typesetting.format import Format from math_spec.typesetting.symbols import Symbols @@ -231,7 +229,7 @@ class Noticed: class Walk: - """Walks a validated schema, emitting :class:`Line`s in one format. + """Walks a program, emitting :class:`Line`s in one format. :meth:`equations` prints every section and returns what it :class:`Noticed`; the legend methods take that record, so they can only describe symbols the @@ -240,17 +238,12 @@ class Walk: def __init__( self, - schema: Spec, program: Program, symbols: Symbols, fmt: Format, *, inline_expressions: bool = False, ) -> None: - self.schema = schema - #: The typed trees, masks and frames of every declaration *schema* - #: makes, which the walk prints from; *schema* says how the file - #: wrote them and what it says about them. self.program = program self.symbols = symbols self.format = fmt @@ -289,9 +282,9 @@ def _relation_read(self, name: str, at: Mapping[str, str], read: str) -> str: named after the relation alone where the key determines one column, and after the column read otherwise. """ - lk = self.schema.relations[name] - function = name if len(lk.value_roles) == 1 else f'{name}.{read}' - return self.format.apply(self.format.upright(function), self.format.joined([at[k] for k in lk.key_roles], '')) + lk = self.program.relations[name] + function = name if len(lk.values) == 1 else f'{name}.{read}' + return self.format.apply(self.format.upright(function), self.format.joined([at[k] for k in lk.key], '')) def _relation_row(self, name: str, at: Mapping[str, str]) -> str: """That relation *name* has a row at the key *at* fixes. @@ -301,16 +294,16 @@ def _relation_row(self, name: str, at: Mapping[str, str]) -> str: column of it is a key column, so the row is written out: ``(g, b) ∈ connection``. """ - lk = self.schema.relations[name] - key = self.format.joined([at[k] for k in lk.key_roles], '') - if lk.value_roles: + lk = self.program.relations[name] + key = self.format.joined([at[k] for k in lk.key], '') + if lk.values: return f'{self.format.apply(self.format.upright(name), key)} {self.format.prose(" is defined")}' return f'{self.format.parenthesise(key)} {self._op("in")} {self.format.upright(name)}' def _frame_key(self, name: str, ctx: _Context) -> dict[str, str]: """Relation *name*'s key roles at the frame's own indices of their dimensions.""" - lk = self.schema.relations[name] - return {k: ctx.subscript(dict(lk.pairs)[k]) for k in lk.key_roles} + lk = self.program.relations[name] + return {k: ctx.subscript(lk.dim(k)) for k in lk.key} def _value_read(self, name: str, column: str, ctx: _Context) -> str: """A keyed relation's value *column* read at the frame's own indices of its key: ``period_of(t)``.""" @@ -351,10 +344,10 @@ def _arithmetic(self, node: Expression, ctx: _Context) -> tuple[str, int]: return self._number(node.value), _ATOM if node.value >= 0 else 1 if isinstance(node, Parameter): - return ctx.indexed(self.symbols.name[node.name], list(self.schema.parameters[node.name].dims)), _ATOM + return ctx.indexed(self.symbols.name[node.name], list(self.program.parameters[node.name].dims)), _ATOM if isinstance(node, Variable): - return ctx.indexed(self.symbols.name[node.name], list(self.schema.variables[node.name].dims)), _ATOM + return ctx.indexed(self.symbols.name[node.name], list(self.program.variables[node.name].dims)), _ATOM if isinstance(node, Negate): text, precedence = self._arithmetic(node.operand, ctx) @@ -389,7 +382,7 @@ def _arithmetic(self, node: Expression, ctx: _Context) -> tuple[str, int]: def _dual(self, node: Dual, ctx: _Context) -> str: """λ subscripted by the constraint's symbol, then the indices of the constraint's own frame.""" - frame = self._sorted(frozenset(self.schema.constraints[node.constraint].dims)) + frame = self._sorted(frozenset(self.program.constraints[node.constraint].dims)) return self.format.subscript( self._op('dual'), [self.symbols.constraint[node.constraint], *(ctx.subscript(d) for d in frame)] ) @@ -501,7 +494,7 @@ def _grouping(self, direction: Direction, dummies: Mapping[str, str], ctx: _Cont **{r: dummies[direction.dim(r)] for r in direction.consumed}, **{r: ctx.subscript(direction.dim(r)) for r in (*direction.joined, *direction.produced)}, } - fixed = [r for r in self.schema.relations[direction.name].value_roles if r in at] + fixed = [r for r in self.program.relations[direction.name].values if r in at] if not fixed: return [self._relation_row(direction.name, at)] return [f'{self._relation_read(direction.name, at, r)} {self._op("equal")} {at[r]}' for r in fixed] @@ -557,7 +550,7 @@ def _where(self, node: Predicate, ctx: _Context) -> tuple[str, int]: if isinstance(node, ParameterDefined): indexed = ctx.indexed(self.symbols.name[node.name], list(node.dims)) - if self.schema.parameters[node.name].dtype == 'bool': + if self.program.parameters[node.name].dtype == 'bool': return indexed, _ATOM return f'{indexed} {self.format.prose(" is defined")}', comparison @@ -696,12 +689,10 @@ def _objective(self) -> list[Line]: — so it renders like any other, and the line carries no label: the block has no name, and the section heading already says what it is. """ - block = self.schema.objective - if block is None: - return [] - sense = self._op('minimize' if block.sense == 'minimize' else 'maximize') objective = self.program.objective - assert objective is not None, 'validation resolves the objective the file declares' + if objective is None: + return [] + sense = self._op('minimize' if objective.sense == 'minimize' else 'maximize') return [Line(label='', left=sense, right=self._expression(objective.expression, self._context()))] def _constraints(self) -> list[Line]: @@ -712,20 +703,19 @@ def _constraints(self) -> list[Line]: the domains — where a set prints, being a property of one variable. """ return [ - *(self._constraint(name) for name in self.schema.constraints), - *(self._piecewise(name) for name in self.schema.piecewise), + *(self._constraint(name) for name in self.program.constraints), + *(self._piecewise(name) for name in self.program.piecewise), ] def _constraint(self, name: str) -> Line: - block = self.schema.constraints[name] constraint = self.program.constraints[name] - ctx = self._context(frame=block.dims) + ctx = self._context(frame=constraint.dims) condition = self._condition(ctx, constraint.where) return Line( label=name, left=self._expression(constraint.lhs, ctx), right=f'{self._op(_PREDICATES[constraint.sense])} {self._expression(constraint.rhs, ctx)}', - condition=self._quantifier(list(block.dims), condition), + condition=self._quantifier(list(constraint.dims), condition), ) def _definitions(self) -> list[Line]: @@ -746,10 +736,10 @@ def _defined(self) -> list[str]: it — a ``cases`` block, and an entry the objective and constraints never read, which is a quantity reported back rather than solved for. """ - if not self.inline_expressions: - return list(self.schema.expressions) entries = self.program.expressions - return [name for name, block in self.schema.expressions.items() if block.cases or not entries[name].in_math] + if not self.inline_expressions: + return list(entries) + return [name for name, entry in entries.items() if isinstance(entry.expression, Cases) or not entry.in_math] def definition(self, name: str) -> Line: """The line defining one named expression, ``symbol = body`` over its frame.""" @@ -773,13 +763,13 @@ def line(self, name: str) -> Line: method states is a line a reader can ask for before the curve is written out. """ - if name in self.schema.expressions: + if name in self.program.expressions: return self.definition(name) - if name in self.schema.constraints: + if name in self.program.constraints: return self._constraint(name) if name in self.program.assumptions: return self._assumption(name) - if name in self.schema.piecewise: + if name in self.program.piecewise: return self._piecewise(name) return self._variable(name) @@ -806,26 +796,25 @@ def _variables(self) -> list[Line]: variable it is a property of, rather than among the constraints, where it would read as a row a solver holds. """ - sets = {block.variable: (key, block) for key, block in self.schema.sos.items()} + sets = {block.variable: (key, block) for key, block in self.program.sos.items()} lines = [] - for name, block in self.schema.variables.items(): + for name, block in self.program.variables.items(): lines.append(self._variable(name)) if name in sets: lines.append(self._sos(name, *sets[name], self._context(frame=block.dims))) return lines def _variable(self, name: str) -> Line: - block = self.schema.variables[name] + block = self.program.variables[name] ctx = self._context(frame=block.dims) symbol = ctx.indexed(self.symbols.name[name], list(block.dims)) - where = self.program.variables[name].where - condition = self._quantifier(list(block.dims), self._condition(ctx, where)) - lower, upper = block.bounds.lower, block.bounds.upper + condition = self._quantifier(list(block.dims), self._condition(ctx, block.where)) + lower, upper = block.lower, block.upper if block.domain == 'binary': left, right = symbol, f'{self._op("in")} {self._op("binary_set")}' else: - below, above = lower == float('-inf'), upper == float('inf') + below, above = lower == Constant(float('-inf')), upper == Constant(float('inf')) if below and above: domain = self._op('integers' if block.domain == 'integer' else 'reals') left, right = symbol, f'{self._op("in")} {domain}' @@ -840,14 +829,14 @@ def _variable(self, name: str) -> Line: right = f'{right}, {symbol} {self._op("in")} {self._op("integers")}' return Line(label=name, left=left, right=right, condition=condition) - def _sos(self, name: str, key: str, block: SosBlock, ctx: _Context) -> Line: + def _sos(self, name: str, key: str, block: SosDeclaration, ctx: _Context) -> Line: """The variable's family along the set's dim, as one member of the SOS set, quantified over the other dims.""" - dims = self.schema.variables[name].dims + dims = self.program.variables[name].dims family = self.format.parenthesise(ctx.indexed(self.symbols.name[name], list(dims))) return Line( label=key, left=self.format.subscript(family, [self._membership(block.along)]), - right=f'{self._op("in")} {self._op("sos_set")}{block.type}', + right=f'{self._op("in")} {self._op("sos_set")}{block.sos_type}', condition=self._quantifier([d for d in dims if d != block.along], ''), ) @@ -883,9 +872,9 @@ def _piecewise(self, name: str) -> Line: function of the pinned link that it is and the link's own sign says which side. """ - block = self.schema.piecewise[name] - links, stated = curve(self.schema, name) - frame = list(stated) + block = self.program.piecewise[name] + links = [link.expression for link in block.links] + frame = list(block.frame) ctx = self._context([*frame, block.over]) locus = self._locus(block, ctx) bounded = next((i for i, link in enumerate(block.links) if link.sign != '=='), None) @@ -899,7 +888,7 @@ def _piecewise(self, name: str) -> Line: right = f'{sign} {self.format.apply(locus, self._expression(pinned, ctx))}' return Line(label=name, left=left, right=right, condition=self._quantifier(frame, '')) - def _locus(self, block: PiecewiseBlock, ctx: _Context) -> str: + def _locus(self, block: PiecewiseDeclaration, ctx: _Context) -> str: """The set the links lie on: the curve through the breakpoints, or the hull ``convex`` relaxes it onto. A gate multiplies it, which is what gating a curve does — the weights @@ -910,7 +899,7 @@ def _locus(self, block: PiecewiseBlock, ctx: _Context) -> str: through = self.format.subscript(operator, [self._breakpoints(block, ctx)]) values = self.format.joined( [ - ctx.indexed(self.symbols.name[link.values], list(self.schema.parameters[link.values].dims)) + ctx.indexed(self.symbols.name[link.values], list(self.program.parameters[link.values].dims)) for link in block.links ], '', @@ -919,7 +908,7 @@ def _locus(self, block: PiecewiseBlock, ctx: _Context) -> str: gate = self._gate(block, ctx) return f'{gate} {self._op("cdot")} {locus}' if gate else locus - def _breakpoints(self, block: PiecewiseBlock, ctx: _Context) -> str: + def _breakpoints(self, block: PiecewiseDeclaration, ctx: _Context) -> str: """Which breakpoints the curve runs through: every one of the dimension, or the ones ``points:`` admits. A ``points:`` naming a boolean parameter reads as the flag it is, and @@ -929,10 +918,10 @@ def _breakpoints(self, block: PiecewiseBlock, ctx: _Context) -> str: over = self._membership(block.over) if block.points is None: return over - admitted = ParameterDefined(block.points, tuple(self.schema.parameters[block.points].dims)) + admitted = ParameterDefined(block.points, tuple(self.program.parameters[block.points].dims)) return f'{over} {self._op("such_that")} {self._predicate(admitted, ctx)}' - def _gate(self, block: PiecewiseBlock, ctx: _Context) -> str: + def _gate(self, block: PiecewiseDeclaration, ctx: _Context) -> str: """The factor an ``activity:`` puts on the locus, or ``''`` where the block has none. Where the gate is a variable that does not exist at every coordinate @@ -944,9 +933,9 @@ def _gate(self, block: PiecewiseBlock, ctx: _Context) -> str: """ if (activity := block.activity) is None: return '' - gate = self.schema.variables[activity] + gate = self.program.variables[activity] symbol = ctx.indexed(self.symbols.name[activity], list(gate.dims)) - mask = self.program.variables[activity].where + mask = gate.where if mask is None or gate.absence == 'zero': return symbol where = self._predicate(mask.root, ctx, need=_WHERE_PRECEDENCE['and']) @@ -954,13 +943,15 @@ def _gate(self, block: PiecewiseBlock, ctx: _Context) -> str: [(symbol, f'{self.format.prose("if ")} {where}'), ('1', self.format.prose('otherwise'))] ) - def _bound(self, ctx: _Context, value: float | str) -> str: - if isinstance(value, str): - return ctx.indexed(self.symbols.name[value], list(self.schema.parameters[value].dims)) - return self._number(value) + def _bound(self, ctx: _Context, value: Expression) -> str: + """A bound as the file wrote it: a number, or a parameter indexed over its dims.""" + if isinstance(value, Parameter): + return ctx.indexed(self.symbols.name[value.name], list(self.program.parameters[value.name].dims)) + assert isinstance(value, Constant), 'a bound is a number or the name of a parameter' + return self._number(value.value) def _sorted(self, dims: frozenset[str]) -> list[str]: - order = list(self.schema.dimensions) + order = list(self.program.dimensions) return sorted(dims, key=order.index) # -- legend ------------------------------------------------------------ @@ -973,19 +964,19 @@ def glossaries(self, noticed: Noticed) -> list[tuple[str, list[Entry]]]: f'index {fmt.math(self.symbols.index[d])} {fmt.dash} {fmt.mono(d)}{self._coords(d, noticed)}', block.description, ) - for d, block in self.schema.dimensions.items() + for d, block in self.program.dimensions.items() ] parameters = [ self._entry(self.symbols.name[p], f'{fmt.mono(p)}{self._over(list(block.dims))}', block.description) - for p, block in self.schema.parameters.items() + for p, block in self.program.parameters.items() ] variables = [ 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() + for v, block in self.program.variables.items() ] definitions = [ self._entry(self.symbols.name[e], f'{fmt.mono(e)}{self._over(self._frame_of(e))}', block.description) - for e, block in self.schema.expressions.items() + for e, block in self.program.expressions.items() if e in self._defined() ] groups = (('Sets', sets), ('Parameters', parameters), ('Variables', variables), ('Definitions', definitions)) @@ -1001,17 +992,14 @@ def _over(self, dims: list[str]) -> str: product = self.format.joined([self.symbols.set[d] for d in dims], self._op('times')) return f' over {self.format.math(product)}' - def _signature(self, name: str, lk: RelationBlock) -> str: + def _signature(self, name: str, lk: RelationDeclaration) -> str: """A relation in the legend: a function from its key sets to its value sets, or a relation inside the product.""" - columns = dict(lk.pairs) def product(roles: Iterable[str]) -> str: - return self.format.joined([self.symbols.set[columns[r]] for r in roles], self._op('times')) + return self.format.joined([self.symbols.set[lk.dim(r)] for r in roles], self._op('times')) - if lk.value_roles: - return ( - f'{self.format.upright(name)}: {product(lk.key_roles)} {self._op("maps_to")} {product(lk.value_roles)}' - ) + if lk.values: + return f'{self.format.upright(name)}: {product(lk.key)} {self._op("maps_to")} {product(lk.values)}' return f'{self.format.upright(name)} {self._op("subset_of")} {product(lk.roles)}' def _coords(self, dim: str, noticed: Noticed) -> str: @@ -1021,10 +1009,10 @@ def _coords(self, dim: str, noticed: Noticed) -> str: number, the one place "position 3" and "the coordinate 3" are both readings of a line. """ - carried = self.schema.relations_of(dim) + carried = self.program.relations_of(dim) clauses = [] if dim in noticed.numeric_coordinates: - clauses.append(f' ({self.format.mono(self.schema.dimensions[dim].dtype)} coordinates)') + clauses.append(f' ({self.format.mono(self.program.dimensions[dim].dtype)} coordinates)') if carried: maps = self.format.joined([self._signature(c, lk) for c, lk in carried.items()], '') clauses.append(f' with {self.format.math(maps)}') @@ -1039,7 +1027,7 @@ def convention_notes(self) -> list[str]: """ derived = [ next((n for n in names if n not in self.symbols.overridden), None) - for names in (self.schema.parameters, self.schema.variables) + for names in (self.program.parameters, self.program.variables) ] if not all(derived): return [] diff --git a/tests/test_piecewise.py b/tests/test_piecewise.py index 1b98f921..019eb534 100644 --- a/tests/test_piecewise.py +++ b/tests/test_piecewise.py @@ -17,7 +17,7 @@ from math_spec.errors import LanguageError, SchemaError from math_spec.lowering import to_program from math_spec.piecewise import expand_piecewise -from math_spec.program import Assumption, assumption_message +from math_spec.program import Assumption, Variable, assumption_message from tests.fixtures import DISPATCH_MODEL, expanded, override, raw_of, schema_of #: Larger than a minimal probe on purpose: a curve that exercises adjacency @@ -107,20 +107,25 @@ def test_the_file_keeps_its_curve_and_the_expansion_has_none(): assert not schema.expand('piecewise').piecewise, 'the block is spent once its declarations are emitted' -def test_a_program_holds_the_rows_a_curve_states_and_not_the_curve(): - """A curve states rows and a program holds them, so lowering a model writes every block out. +def test_a_program_mirrors_the_model_it_was_lowered_from(): + """A model still declaring a curve lowers to a program carrying the curve, and its expansion to one carrying the rows. - Writing a set out stays the caller's: a set is one thing to a consumer - that takes it and another to one that does not. + Writing a formulation out is the caller's: a curve is one thing to a + consumer printing it and another to one building rows, as a set is. """ schema = schema_of(NONCONVEX_YAML) - program = to_program(schema) - assert program is to_program(schema.expand('piecewise')), 'the program of a model is the program of its rows' - assert {'cost_curve_lam', 'p', 'op_cost'} <= set(program.variables), 'the weights the block states' - assert 'cost_curve_link0' in program.constraints and not hasattr(program, 'piecewise'), ( - 'the rows are declarations like any other, and nothing records the block they came from' + program, rows = to_program(schema), to_program(schema.expand('piecewise')) + + curve = program.piecewise['cost_curve'] + assert [link.values for link in curve.links] == ['bp_x', 'bp_y'] and curve.frame == ('snapshot',), ( + 'the curve as the file states it, with its links typed and its frame decided' + ) + assert curve.links[0].expression == Variable('p'), 'a link is the tree the file wrote' + assert 'cost_curve_lam' not in program.variables, 'the rows are on the expansion' + assert not rows.piecewise and {'cost_curve_lam', 'p', 'op_cost'} <= set(rows.variables), ( + 'the expansion carries the rows and no curve' ) - assert to_program(schema.expand()).sos == {} and program.sos == {}, ( + assert to_program(schema.expand()).sos == {} and rows.sos == {}, ( 'an adjacency block writes its own set out; a caller writes the rest out with expand()' ) @@ -492,7 +497,7 @@ def test_a_masked_lp_curve_sits_its_rows_on_predicates_rather_than_on_parameters caller never supplied and a derivation in private state filled. The ``where`` language writes each of them, so the rows carry the predicate and the program declares the file's parameters and no other.""" - program = to_program(schema_of(LP_MASKED)) + program = to_program(schema_of(LP_MASKED).expand('piecewise')) rows = {name: program.constraints[f'cost_curve_{name}'].where for name in ('chord', 'domain_lo', 'domain_hi')} assert set(program.parameters) == {'bp_x', 'bp_y', 'load'}, 'every parameter is one the file declared' diff --git a/tests/test_public_surface.py b/tests/test_public_surface.py index ef3eddd2..11b040e0 100644 --- a/tests/test_public_surface.py +++ b/tests/test_public_surface.py @@ -42,10 +42,9 @@ ) # fmt: skip #: What `Spec` promises beyond the sections a file declares: the two ways back -#: out, the verb that writes a formulation out, and the relations of one -#: dimension. A `model_`-prefixed name is pydantic's, not a contract this -#: project keeps. -SPEC_SURFACE = frozenset({'relations_of', 'to_dict', 'to_yaml', 'expand'}) +#: out and the verb that writes a formulation out. A `model_`-prefixed name is +#: pydantic's, not a contract this project keeps. +SPEC_SURFACE = frozenset({'to_dict', 'to_yaml', 'expand'}) #: The modules whose `__all__` a consumer imports from. MODULES = [ diff --git a/tests/test_reading_page.py b/tests/test_reading_page.py index 7f932ecf..8cb3713c 100644 --- a/tests/test_reading_page.py +++ b/tests/test_reading_page.py @@ -55,6 +55,6 @@ def test_the_page_shows_the_declarations_the_expansion_emits(tmp_path, monkeypat exec(compile(code, str(PAGE), 'exec'), namespace) claims.extend(_claims(code)) - assert len(claims) == 19, 'every `expression # value` line on the page is checked; one without one is not' + assert len(claims) == 20, 'every `expression # value` line on the page is checked; one without one is not' for expression, claimed in claims: assert eval(expression, namespace) == claimed, f'reading.md says `{expression}` is {claimed}' diff --git a/tests/typesetting/test_golden.py b/tests/typesetting/test_golden.py index 8cdc03d3..224857b6 100644 --- a/tests/typesetting/test_golden.py +++ b/tests/typesetting/test_golden.py @@ -17,7 +17,6 @@ from math_spec.lowering import to_program from math_spec.operators import BUILTIN_NAMES -from math_spec.piecewise import curve from math_spec.program import Dual, Expression, GroupSum, Named, Predicate, Pullback, Sum, Translate, WindowSum from math_spec.typesetting import FORMATS, to_latex, typeset, walk from math_spec.typesetting.format import OPERATOR_NAMES @@ -144,8 +143,8 @@ def _rendered_trees() -> Iterator[object]: yield assumption.where.root for name in schema.expressions: yield program.expressions[name].expression - for name in schema.piecewise: - yield from curve(schema, name).links + for curve in program.piecewise.values(): + yield from (link.expression for link in curve.links) #: A dataclass the walk steps *through* rather than renders: a region has no diff --git a/tests/typesetting/test_walk.py b/tests/typesetting/test_walk.py index 31b35f3f..0ce66041 100644 --- a/tests/typesetting/test_walk.py +++ b/tests/typesetting/test_walk.py @@ -520,7 +520,7 @@ def test_nothing_the_model_is_given_prints_italic(): f'solution — upright is what the model is given, italic what it computes' ) - symbols = Symbols(schema, to_program(schema), LATEX, SymbolTable('latex')) + symbols = Symbols(to_program(schema), LATEX, SymbolTable('latex')) given = {name: symbols.name[name] for name in schema.parameters} assert all(symbol.startswith(r'\mathrm{') for symbol in given.values()), ( f'derived upright for every parameter, but got {sorted(s for s in given.values() if "mathrm" not in s)}' From 6306a74700497f57995bfe5cbd3c388d3da4f008 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 15:16:54 +0000 Subject: [PATCH 03/20] refactor(language): every rule that reads across declarations runs in lowering, before any expression is read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nine after-validators on Spec that read one declaration against the others — name collisions, frames over declared dimensions, relation targets, bound names, set shapes and bounds, curve references and the names an expansion would collide with — are functions in validation.py, collected by reference_errors and run first by lowering.lower. Spec keeps the shape rules pydantic decides per block, and no longer imports sos or the operator table; side_columns is the public name of the relation helper the rules share. Every message is the same string, and every refusal the same class. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi --- src/math_spec/lowering.py | 10 +- src/math_spec/model.py | 274 +---------------------------------- src/math_spec/resolution.py | 2 +- src/math_spec/validation.py | 282 +++++++++++++++++++++++++++++++++++- 4 files changed, 294 insertions(+), 274 deletions(-) diff --git a/src/math_spec/lowering.py b/src/math_spec/lowering.py index dab8f450..2eb8dae6 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -50,7 +50,7 @@ resolve_expression_text, resolve_where_text, ) -from math_spec.validation import to_spec +from math_spec.validation import reference_errors, to_spec if TYPE_CHECKING: from collections.abc import Mapping @@ -96,6 +96,9 @@ def lower(schema: Spec) -> Program: What is checked: + - every rule one declaration is held to against the others + (:func:`~math_spec.validation.reference_errors`), before any expression + is read, since resolution assumes each of them; - the expression parses, and constraints hold exactly one comparison where objectives hold none; - every referenced name resolves, and every operator is a built-in whose @@ -120,9 +123,10 @@ def lower(schema: Spec) -> Program: DimensionError: The first dim rule a declaration breaks, once every name resolves. """ - ns = Namespace(schema) - errors: list[str] = [] + if errors := reference_errors(schema): + raise SchemaError('\n'.join(errors)) + ns = Namespace(schema) for mname, macro in schema.macros.items(): context = f"Macro '{mname}'" formals = frozenset((*macro.args, *macro.kwargs)) diff --git a/src/math_spec/model.py b/src/math_spec/model.py index 50bd5916..6d099be1 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -11,7 +11,6 @@ import math import re -from collections import Counter from typing import TYPE_CHECKING, Annotated, ClassVar, Literal, Self, cast, get_args, override from pydantic import ( @@ -30,11 +29,9 @@ from math_spec._expression_parser import NAME, ComparisonOperator from math_spec.errors import did_you_mean, schema_error -from math_spec.operators import BUILTIN_NAMES -from math_spec.sos import Emitted, coefficients if TYPE_CHECKING: - from collections.abc import Iterable, Iterator, Mapping + from collections.abc import Mapping from pydantic import GetJsonSchemaHandler, SerializerFunctionWrapHandler from pydantic.config import ExtraValues @@ -160,7 +157,7 @@ def _also_written_as( return {'anyOf': [dict(generated), shorthand]} -def _side(written: str | list[str] | dict[str, str] | None) -> tuple[tuple[str, str], ...]: +def side_columns(written: str | list[str] | dict[str, str] | None) -> tuple[tuple[str, str], ...]: """``(role, dimension)`` per column of one side of a relation, in written order. A bare name or a list names each column after the dimension it is over; a @@ -208,7 +205,7 @@ def pairs(self) -> tuple[tuple[str, str], ...]: The program calls the same thing :attr:`~math_spec.program.RelationDeclaration.columns`; here the table has no field of its own, being what the two sides make. """ - return (*_side(self.key), *_side(self.values)) + return (*side_columns(self.key), *side_columns(self.values)) @property def roles(self) -> tuple[str, ...]: @@ -221,12 +218,12 @@ def dims(self) -> tuple[str, ...]: @property def key_roles(self) -> tuple[str, ...]: """The key roles, however ``key:`` was written.""" - return tuple(role for role, _ in _side(self.key)) + return tuple(role for role, _ in side_columns(self.key)) @property def value_roles(self) -> tuple[str, ...]: """The roles the key determines; empty for a bare relation.""" - return tuple(role for role, _ in _side(self.values)) + return tuple(role for role, _ in side_columns(self.values)) class DimensionBlock(_StrictBlock): @@ -715,11 +712,6 @@ def _check_type(cls, v: object, handler: ValidatorFunctionWrapHandler) -> SosTyp SUPPORTED_VERSIONS: tuple[int, ...] = (0,) -def undeclared_dimension(kind: str, name: str, dimension: str) -> str: - """The one wording for a declaration naming a dimension the file does not declare.""" - return f"{kind} '{name}' references undeclared dimension '{dimension}'. Declare it under 'dimensions:'." - - def _without_absence(value: object) -> object: """*value* with every absent entry stripped, recursively — see :meth:`Spec._drop_absence`.""" if not isinstance(value, dict): @@ -917,263 +909,9 @@ def _names_are_names(self) -> Spec: return self - @model_validator(mode='after') - def _validate_references(self) -> Spec: - """Every cross-declaration rule the schema can decide without data, collected rather than raised on the first.""" - errors = [ - *self._name_collisions(), - *self._frame_dimensions(), - *self._relation_targets(), - *self._bound_names(), - *self._sos_shapes(), - *self._sos_bounds(), - *self._sos_emitted_names(), - *self._piecewise_references(), - *self._piecewise_emitted_names(), - ] - if errors: - raise ValueError('\n'.join(errors)) - return self - - def _name_collisions(self) -> Iterator[str]: - """A name is declared once, and never as a built-in operator.""" - kinds: list[tuple[str, Iterable[str]]] = [ - ('dimension', self.dimensions), - ('relation', self.relations), - ('parameter', self.parameters), - ('variable', self.variables), - ('named expression', self.expressions), - ('macro', self.macros), - ] - seen: dict[str, str] = {} - for kind, group in kinds: - for name in group: - if name in BUILTIN_NAMES: - yield ( - f"{kind.capitalize()} '{name}' collides with the built-in operator " - f"'{name}'. The operator set is closed and its names are reserved; " - f'rename the {kind}.' - ) - if name in seen: - yield ( - f"{kind.capitalize()} '{name}' collides with the {seen[name]} of " - f'the same name. Names share one flat namespace — rename one of them.' - ) - else: - seen[name] = kind - - def _frame_dimensions(self) -> Iterator[str]: - """Every frame is a product of distinct, declared dimensions.""" - frames = [ - *(('Parameter', name, p.dims) for name, p in self.parameters.items()), - *(('Variable', name, v.dims) for name, v in self.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()), - ] - for kind, name, dims in frames: - yield from (undeclared_dimension(kind, name, d) for d in dims if d not in self.dimensions) - yield from ( - f"{kind} '{name}' names dimension '{d}' twice. A frame is a product of distinct dimensions." - for d, count in Counter(dims).items() - if count > 1 - ) - - def _relation_targets(self) -> Iterator[str]: - """A relation has at least two columns over declared dimensions, each named once, and a key naming some of them.""" - for lname, lk in self.relations.items(): - if len(lk.pairs) < 2: - yield ( - f"Relation '{lname}' has {len(lk.pairs)} column(s). A relation relates dimensions, so 'key:' and " - f"'values:' name at least two between them — a label on one dimension is a parameter over it." - ) - if not lk.key_roles: - yield ( - f"Relation '{lname}' names no key column. A relation is keyed by the columns a row is identified " - f"by — name them under 'key:', and leave the columns they determine to 'values:'." - ) - for side, written in (('key', lk.key), ('values', lk.values)): - yield from ( - f"Relation '{lname}' names dimension '{d}' twice under '{side}:'. Give the two columns roles: " - f'{side}: {{{d}0: {d}, {d}1: {d}}}.' - for d, count in Counter(dim for _, dim in _side(written)).items() - if count > 1 and not isinstance(written, dict) - ) - yield from ( - f"Relation '{lname}' names column '{role}' under both 'key:' and 'values:'. A relation names each " - f'column once — name the value column after what it holds: values: {{: {dict(lk.pairs)[role]}}}.' - for role in dict.fromkeys(lk.key_roles) - if role in lk.value_roles - ) - yield from ( - undeclared_dimension('Relation', lname, d) for d in dict.fromkeys(lk.dims) if d not in self.dimensions - ) - yield from ( - f"Relation '{lname}' names column '{role}' after dimension '{role}', but the column is over " - f"'{dim}'. A column named like a dimension is read as over it — name it after what it holds." - for role, dim in lk.pairs - if role in self.dimensions and role != dim - ) - if lk.value_roles: - yield from ( - f"Relation '{lname}' has two key columns over '{d}' " - f'({[k for k in lk.key_roles if dict(lk.pairs)[k] == d]}). A key that determines a value is read ' - f'its dimensions, and no frame carries a dimension twice — key the table by one column over ' - f'each, or leave one of them a value column.' - for d, count in Counter(dict(lk.pairs)[k] for k in lk.key_roles).items() - if count > 1 - ) - - def _bound_names(self) -> Iterator[str]: - """A named bound is a numeric parameter.""" - for vname, vdef in self.variables.items(): - for side in ('lower', 'upper'): - val = getattr(vdef.bounds, side) - if not isinstance(val, str): - continue - if val in self.parameters: - dtype = self.parameters[val].dtype - if dtype not in NUMERIC_DTYPES: - yield ( - f"Variable '{vname}' bounds.{side}: '{val}' is a {dtype} parameter, and a bound " - f'is a number. Declare it dtype: float or int, or bound the variable by another.' - ) - continue - detail = ( - f"'{val}' is not a declared parameter" - if val.isidentifier() - else f'bounds accept a parameter name or a number, not an expression (got {val!r}). ' - f'Precompute it as a parameter' - ) - yield (f"Variable '{vname}' bounds.{side}: {detail}.") - - def _sos_shapes(self) -> Iterator[str]: - """A set runs along one dim of one declared variable, and a variable carries one set.""" - claimed: dict[str, str] = {} - for sname, block in self.sos.items(): - context = f"Sos '{sname}'" - if block.along not in self.dimensions: - yield (undeclared_dimension('Sos', sname, block.along)) - elif block.variable not in self.variables: - yield ( - f"{context}: '{block.variable}' is not a declared variable.\n" - f' Variables: {sorted(self.variables)}\n' - f'A set is over one variable, so a parameter or an expression cannot carry one.' - ) - elif block.along not in self.variables[block.variable].dims: - yield ( - f"{context}: along '{block.along}' is not a dim of variable " - f"'{block.variable}' (dims {self.variables[block.variable].dims}). The set runs " - f"along one of the variable's own dims — one set per coordinate of the rest." - ) - elif block.variable in claimed: - yield ( - f"{context}: variable '{block.variable}' already carries the set declared by " - f"'{claimed[block.variable]}'. A variable holds one set — declare a second " - f'variable, or state the other restriction as a constraint.' - ) - else: - claimed[block.variable] = sname - - def _sos_bounds(self) -> Iterator[str]: - """A set states what the binaries it expands to state: each side of a member carries a coefficient. - - The rewrite holds an unpicked member at zero from both sides, so a side - the model leaves open leaves the member free of it. Either coefficient - may be a parameter, because a row multiplies by it rather than reading - it. Decided here rather than where the rewrite runs, so a set the - language cannot state twice is refused before any data exists. - """ - for sname, block in self.sos.items(): - if (member := self.variables.get(block.variable)) is None: - continue - context = f"Sos '{sname}'" - below, above = coefficients(member.domain, member.bounds.lower, member.bounds.upper) - if below is None: - yield ( - f"{context}: variable '{block.variable}' has no lower bound, and the set expands to rows " - f'that hold an unpicked member at zero from below as well as above. Declare bounds.lower, ' - f'as a number or a parameter.' - ) - if above is None: - yield ( - f"{context}: variable '{block.variable}' has no upper bound, and the set expands to rows " - f'that hold an unpicked member at zero from above as well as below. Declare bounds.upper, ' - f'as a number or a parameter.' - ) - - def _sos_emitted_names(self) -> Iterator[str]: - """No name a set's expansion writes is one the file already declares.""" - for sname, block in self.sos.items(): - yield from self._collisions(f"Sos '{sname}'", Emitted.of(sname, block.type).by_kind) - - def _piecewise_references(self) -> Iterator[str]: - """A curve runs along a declared dimension through numeric values parameters carrying it, gated by a binary, masked by a bool.""" - for name, pw in self.piecewise.items(): - context = f"piecewise '{name}'" - if pw.over not in self.dimensions: - yield undeclared_dimension('piecewise', name, pw.over) - continue - for i, link in enumerate(pw.links): - if link.values not in self.parameters: - yield f"{context}: link {i} values references undeclared parameter '{link.values}'" - elif (dtype := self.parameters[link.values].dtype) not in NUMERIC_DTYPES: - yield ( - f"{context}: link {i} values parameter '{link.values}' is declared dtype: {dtype}, and a " - f'breakpoint is a number. Declare it dtype: float or int.' - ) - elif pw.over not in self.parameters[link.values].dims: - yield ( - f"{context}: link {i} values parameter '{link.values}' must carry dim " - f"'{pw.over}' (has {self.parameters[link.values].dims})" - ) - if (activity := pw.activity) is not None: - if activity not in self.variables: - yield ( - f"{context}: activity '{activity}' is not a declared variable. A gate is a binary variable; " - f'declare it, or drop activity: for weights that sum to 1.' - ) - elif self.variables[activity].domain != 'binary': - yield f"{context}: activity variable '{activity}' must be binary" - if (points := pw.points) is None or pw.nominated is not None: - continue - if points not in self.parameters: - yield f"{context}: points references undeclared parameter '{points}'" - elif (dtype := self.parameters[points].dtype) != 'bool': - yield ( - f"{context}: points parameter '{points}' is {dtype}, and a mask is a bool parameter — one " - f'saying, per breakpoint, whether the curve reaches it. Declare it dtype: bool.' - ) - elif pw.over not in self.parameters[points].dims: - yield ( - f"{context}: points parameter '{points}' must carry dim '{pw.over}' — " - f'it says how far each curve runs along it (has {self.parameters[points].dims})' - ) - - def _piecewise_emitted_names(self) -> Iterator[str]: - """No name a curve's expansion writes is one the file already declares.""" - from math_spec.piecewise import Emitted as EmittedCurve - - for name, pw in self.piecewise.items(): - yield from self._collisions(f"piecewise '{name}'", EmittedCurve.of(name, pw).by_kind) - - def _collisions(self, context: str, by_kind: Iterable[tuple[str, Iterable[str]]]) -> Iterator[str]: - """The refusal for each name *context*'s expansion writes that the file already declares, by kind.""" - declared: dict[str, Iterable[str]] = { - 'variable': self.variables, - 'constraint': self.constraints, - 'sos': self.sos, - 'assumption': self.assumptions, - } - for kind, names in by_kind: - yield from ( - f"{context}: its expansion writes {kind} '{one}', which this file already declares. Rename one of them." - for one in names - if one in declared[kind] - ) - @model_validator(mode='after') def _lower(self) -> Spec: - """Every expression and where string — this file's own, and every one a curve emits. + """Every rule that reads across declarations, then every expression and where string — this file's own, and every one a curve emits. This file's own first, so a fault in a link is named against the link the file wrote. A curve's expansion is a model in its own right, so diff --git a/src/math_spec/resolution.py b/src/math_spec/resolution.py index 3b7c6ebe..ef59aa95 100644 --- a/src/math_spec/resolution.py +++ b/src/math_spec/resolution.py @@ -130,7 +130,7 @@ class Namespace: """The declared names of one schema, by kind — the whole of what a file may name, read once. - A name has one kind: model.py refuses one declared under two sections. + A name has one kind: validation.py refuses one declared under two sections. """ __slots__ = ( diff --git a/src/math_spec/validation.py b/src/math_spec/validation.py index fdfa0bd3..9e08fb7f 100644 --- a/src/math_spec/validation.py +++ b/src/math_spec/validation.py @@ -2,18 +2,32 @@ # # SPDX-License-Identifier: MIT -"""The front door: a model definition read and validated into a :class:`~math_spec.model.Spec`.""" +"""The front door, and every rule that reads across a model's declarations. + +:func:`to_spec` reads a model definition into a :class:`~math_spec.model.Spec`. +:func:`reference_errors` holds the rules one declaration is held to against +the others — a name declared once, a frame over declared dimensions, a bound +naming a numeric parameter, a set over one dim of one variable, a curve +through parameters carrying its breakpoints — which lowering runs before it +reads any expression, since resolution assumes every one of them. +""" from __future__ import annotations +from collections import Counter from collections.abc import Mapping from typing import TYPE_CHECKING from math_spec._yaml import read_model from math_spec.errors import SchemaError -from math_spec.model import Spec +from math_spec.model import NUMERIC_DTYPES, Spec, side_columns +from math_spec.operators import BUILTIN_NAMES +from math_spec.piecewise import Emitted as EmittedCurve +from math_spec.sos import Emitted as EmittedSet +from math_spec.sos import coefficients if TYPE_CHECKING: + from collections.abc import Iterable, Iterator from pathlib import Path @@ -43,3 +57,267 @@ def to_spec(model: str | Path | Mapping[str, object] | Spec) -> Spec: if isinstance(model, Spec): return model return Spec.model_validate(model if isinstance(model, Mapping) else read_model(model)) + + +def reference_errors(schema: Spec) -> list[str]: + """Every cross-declaration rule *schema* breaks, collected rather than raised on the first.""" + return [ + *_name_collisions(schema), + *_frame_dimensions(schema), + *_relation_targets(schema), + *_bound_names(schema), + *_sos_shapes(schema), + *_sos_bounds(schema), + *_sos_emitted_names(schema), + *_piecewise_references(schema), + *_piecewise_emitted_names(schema), + ] + + +def undeclared_dimension(kind: str, name: str, dimension: str) -> str: + """The one wording for a declaration naming a dimension the file does not declare.""" + return f"{kind} '{name}' references undeclared dimension '{dimension}'. Declare it under 'dimensions:'." + + +def _name_collisions(schema: Spec) -> Iterator[str]: + """A name is declared once, and never as a built-in operator.""" + kinds: list[tuple[str, Iterable[str]]] = [ + ('dimension', schema.dimensions), + ('relation', schema.relations), + ('parameter', schema.parameters), + ('variable', schema.variables), + ('named expression', schema.expressions), + ('macro', schema.macros), + ] + seen: dict[str, str] = {} + for kind, group in kinds: + for name in group: + if name in BUILTIN_NAMES: + yield ( + f"{kind.capitalize()} '{name}' collides with the built-in operator " + f"'{name}'. The operator set is closed and its names are reserved; " + f'rename the {kind}.' + ) + if name in seen: + yield ( + f"{kind.capitalize()} '{name}' collides with the {seen[name]} of " + f'the same name. Names share one flat namespace — rename one of them.' + ) + else: + seen[name] = kind + + +def _frame_dimensions(schema: Spec) -> Iterator[str]: + """Every frame is a product of distinct, declared dimensions.""" + frames = [ + *(('Parameter', name, p.dims) for name, p in schema.parameters.items()), + *(('Variable', name, v.dims) for name, v in schema.variables.items()), + *(('Constraint', name, c.dims) for name, c in schema.constraints.items()), + *(('Named expression', name, e.dims or []) for name, e in schema.expressions.items()), + ] + for kind, name, dims in frames: + yield from (undeclared_dimension(kind, name, d) for d in dims if d not in schema.dimensions) + yield from ( + f"{kind} '{name}' names dimension '{d}' twice. A frame is a product of distinct dimensions." + for d, count in Counter(dims).items() + if count > 1 + ) + + +def _relation_targets(schema: Spec) -> Iterator[str]: + """A relation has at least two columns over declared dimensions, each named once, and a key naming some of them.""" + for lname, lk in schema.relations.items(): + if len(lk.pairs) < 2: + yield ( + f"Relation '{lname}' has {len(lk.pairs)} column(s). A relation relates dimensions, so 'key:' and " + f"'values:' name at least two between them — a label on one dimension is a parameter over it." + ) + if not lk.key_roles: + yield ( + f"Relation '{lname}' names no key column. A relation is keyed by the columns a row is identified " + f"by — name them under 'key:', and leave the columns they determine to 'values:'." + ) + for side, written in (('key', lk.key), ('values', lk.values)): + yield from ( + f"Relation '{lname}' names dimension '{d}' twice under '{side}:'. Give the two columns roles: " + f'{side}: {{{d}0: {d}, {d}1: {d}}}.' + for d, count in Counter(dim for _, dim in side_columns(written)).items() + if count > 1 and not isinstance(written, dict) + ) + yield from ( + f"Relation '{lname}' names column '{role}' under both 'key:' and 'values:'. A relation names each " + f'column once — name the value column after what it holds: values: {{: {dict(lk.pairs)[role]}}}.' + for role in dict.fromkeys(lk.key_roles) + if role in lk.value_roles + ) + yield from ( + undeclared_dimension('Relation', lname, d) for d in dict.fromkeys(lk.dims) if d not in schema.dimensions + ) + yield from ( + f"Relation '{lname}' names column '{role}' after dimension '{role}', but the column is over " + f"'{dim}'. A column named like a dimension is read as over it — name it after what it holds." + for role, dim in lk.pairs + if role in schema.dimensions and role != dim + ) + if lk.value_roles: + yield from ( + f"Relation '{lname}' has two key columns over '{d}' " + f'({[k for k in lk.key_roles if dict(lk.pairs)[k] == d]}). A key that determines a value is read ' + f'its dimensions, and no frame carries a dimension twice — key the table by one column over ' + f'each, or leave one of them a value column.' + for d, count in Counter(dict(lk.pairs)[k] for k in lk.key_roles).items() + if count > 1 + ) + + +def _bound_names(schema: Spec) -> Iterator[str]: + """A named bound is a numeric parameter.""" + for vname, vdef in schema.variables.items(): + for side in ('lower', 'upper'): + val = getattr(vdef.bounds, side) + if not isinstance(val, str): + continue + if val in schema.parameters: + dtype = schema.parameters[val].dtype + if dtype not in NUMERIC_DTYPES: + yield ( + f"Variable '{vname}' bounds.{side}: '{val}' is a {dtype} parameter, and a bound " + f'is a number. Declare it dtype: float or int, or bound the variable by another.' + ) + continue + detail = ( + f"'{val}' is not a declared parameter" + if val.isidentifier() + else f'bounds accept a parameter name or a number, not an expression (got {val!r}). ' + f'Precompute it as a parameter' + ) + yield (f"Variable '{vname}' bounds.{side}: {detail}.") + + +def _sos_shapes(schema: Spec) -> Iterator[str]: + """A set runs along one dim of one declared variable, and a variable carries one set.""" + claimed: dict[str, str] = {} + for sname, block in schema.sos.items(): + context = f"Sos '{sname}'" + if block.along not in schema.dimensions: + yield (undeclared_dimension('Sos', sname, block.along)) + elif block.variable not in schema.variables: + yield ( + f"{context}: '{block.variable}' is not a declared variable.\n" + f' Variables: {sorted(schema.variables)}\n' + f'A set is over one variable, so a parameter or an expression cannot carry one.' + ) + elif block.along not in schema.variables[block.variable].dims: + yield ( + f"{context}: along '{block.along}' is not a dim of variable " + f"'{block.variable}' (dims {schema.variables[block.variable].dims}). The set runs " + f"along one of the variable's own dims — one set per coordinate of the rest." + ) + elif block.variable in claimed: + yield ( + f"{context}: variable '{block.variable}' already carries the set declared by " + f"'{claimed[block.variable]}'. A variable holds one set — declare a second " + f'variable, or state the other restriction as a constraint.' + ) + else: + claimed[block.variable] = sname + + +def _sos_bounds(schema: Spec) -> Iterator[str]: + """A set states what the binaries it expands to state: each side of a member carries a coefficient. + + The rewrite holds an unpicked member at zero from both sides, so a side + the model leaves open leaves the member free of it. Either coefficient + may be a parameter, because a row multiplies by it rather than reading + it. Decided here rather than where the rewrite runs, so a set the + language cannot state twice is refused before any data exists. + """ + for sname, block in schema.sos.items(): + if (member := schema.variables.get(block.variable)) is None: + continue + context = f"Sos '{sname}'" + below, above = coefficients(member.domain, member.bounds.lower, member.bounds.upper) + if below is None: + yield ( + f"{context}: variable '{block.variable}' has no lower bound, and the set expands to rows " + f'that hold an unpicked member at zero from below as well as above. Declare bounds.lower, ' + f'as a number or a parameter.' + ) + if above is None: + yield ( + f"{context}: variable '{block.variable}' has no upper bound, and the set expands to rows " + f'that hold an unpicked member at zero from above as well as below. Declare bounds.upper, ' + f'as a number or a parameter.' + ) + + +def _sos_emitted_names(schema: Spec) -> Iterator[str]: + """No name a set's expansion writes is one the file already declares.""" + for sname, block in schema.sos.items(): + yield from _collisions(schema, f"Sos '{sname}'", EmittedSet.of(sname, block.type).by_kind) + + +def _piecewise_references(schema: Spec) -> Iterator[str]: + """A curve runs along a declared dimension through numeric values parameters carrying it, gated by a binary, masked by a bool.""" + for name, pw in schema.piecewise.items(): + context = f"piecewise '{name}'" + if pw.over not in schema.dimensions: + yield undeclared_dimension('piecewise', name, pw.over) + continue + for i, link in enumerate(pw.links): + if link.values not in schema.parameters: + yield f"{context}: link {i} values references undeclared parameter '{link.values}'" + elif (dtype := schema.parameters[link.values].dtype) not in NUMERIC_DTYPES: + yield ( + f"{context}: link {i} values parameter '{link.values}' is declared dtype: {dtype}, and a " + f'breakpoint is a number. Declare it dtype: float or int.' + ) + elif pw.over not in schema.parameters[link.values].dims: + yield ( + f"{context}: link {i} values parameter '{link.values}' must carry dim " + f"'{pw.over}' (has {schema.parameters[link.values].dims})" + ) + if (activity := pw.activity) is not None: + if activity not in schema.variables: + yield ( + f"{context}: activity '{activity}' is not a declared variable. A gate is a binary variable; " + f'declare it, or drop activity: for weights that sum to 1.' + ) + elif schema.variables[activity].domain != 'binary': + yield f"{context}: activity variable '{activity}' must be binary" + if (points := pw.points) is None or pw.nominated is not None: + continue + if points not in schema.parameters: + yield f"{context}: points references undeclared parameter '{points}'" + elif (dtype := schema.parameters[points].dtype) != 'bool': + yield ( + f"{context}: points parameter '{points}' is {dtype}, and a mask is a bool parameter — one " + f'saying, per breakpoint, whether the curve reaches it. Declare it dtype: bool.' + ) + elif pw.over not in schema.parameters[points].dims: + yield ( + f"{context}: points parameter '{points}' must carry dim '{pw.over}' — " + f'it says how far each curve runs along it (has {schema.parameters[points].dims})' + ) + + +def _piecewise_emitted_names(schema: Spec) -> Iterator[str]: + """No name a curve's expansion writes is one the file already declares.""" + for name, pw in schema.piecewise.items(): + yield from _collisions(schema, f"piecewise '{name}'", EmittedCurve.of(name, pw).by_kind) + + +def _collisions(schema: Spec, context: str, by_kind: Iterable[tuple[str, Iterable[str]]]) -> Iterator[str]: + """The refusal for each name *context*'s expansion writes that the file already declares, by kind.""" + declared: dict[str, Iterable[str]] = { + 'variable': schema.variables, + 'constraint': schema.constraints, + 'sos': schema.sos, + 'assumption': schema.assumptions, + } + for kind, names in by_kind: + yield from ( + f"{context}: its expansion writes {kind} '{one}', which this file already declares. Rename one of them." + for one in names + if one in declared[kind] + ) From e29809d5b573c1b542e680c3a43eec2afc37c79d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 15:25:23 +0000 Subject: [PATCH 04/20] test(advice): a program still carrying a curve is refused, and a file is advised as its rows are The guard landed in the previous commit without the test that fails without it. With the guard deleted, the new test fails: a program with a block is advised on the file's own rows as if the curve stated none. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi --- tests/test_advice.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/tests/test_advice.py b/tests/test_advice.py index 37232fd1..6e55642d 100644 --- a/tests/test_advice.py +++ b/tests/test_advice.py @@ -16,7 +16,7 @@ import pytest -from math_spec import ADVICE_KINDS, advice, to_program, to_spec +from math_spec import ADVICE_KINDS, LanguageError, advice, to_program, to_spec from tests.fixtures import SMALL_MODEL, override if TYPE_CHECKING: @@ -32,6 +32,16 @@ #: The same with the relation gone, so nothing reaches ``h`` at all. UNREACHED = override(TARGET_ONLY, relations={}) +#: A curve on ``p``, so the program of the file as written carries a block and +#: the program of its expansion carries the rows. +CURVED = override( + UNREACHED, + dimensions={'g': {'dtype': 'str'}, 'h': {'dtype': 'str'}, 'bp': {'dtype': 'int'}}, + parameters={'c': {'dims': ['g']}, 'bp_x': {'dims': ['bp']}, 'bp_y': {'dims': ['bp']}}, + variables={'p': {'dims': ['g']}, 'cost': {'dims': ['g']}}, + piecewise={'curve': {'over': 'bp', 'links': [['p', 'bp_x'], ['cost', 'bp_y']]}}, +) + def test_a_dimension_nothing_reaches_is_named(): (note,) = advice(UNREACHED) @@ -91,3 +101,19 @@ def test_the_answer_does_not_turn_on_which_state_it_is_asked_of(form, tmp_path): ('never-an-axis', 'h'), ('unbounded', 'p'), ], 'one model, one answer, whichever of the four the caller happens to hold' + + +def test_a_curve_is_written_out_before_advice_reads_it(): + """Advice reads the rows a curve states, so a file is expanded first and a program still carrying one is refused. + + The refusal was added without a test; deleting the guard let a program + with a block advise on the file's own rows as if the curve stated none. + """ + from_file = advice(CURVED) + from_rows = advice(to_program(to_spec(CURVED).expand('piecewise'))) + assert [(n.kind, n.subject) for n in from_file] == [(n.kind, n.subject) for n in from_rows], ( + 'a file and the program of its expansion are advised alike' + ) + with pytest.raises(LanguageError, match="piecewise: 'curve' states rows") as refusal: + advice(to_program(CURVED)) + assert "expand('piecewise')" in str(refusal.value), 'the refusal names the expansion to pass' From 1d8607f4459cd35c89a839119436f41f66106afe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 15:29:43 +0000 Subject: [PATCH 05/20] refactor(language): a model loads without writing its curves out, and the program owns the vocabulary the file declares in Loading no longer expands every curve to validate the expansion: the rows a curve states are held to the language when expand() writes them out, since an expansion is a model like any other. A model with a curve is validated once at load rather than twice. The dtype, domain, absence, sense, set-order and method vocabulary moves from model.py into program.py, which model.py imports. program.py no longer imports model.py, so the type-only import of Program on Spec and its noqa go, and sos.py imports Spec plainly instead of inside a function. Load cost, before (after): examples/piecewise.yaml validated 2 (1) times and parsed 9 (4) expressions; examples/sos.yaml the same. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi --- schema/math-spec.schema.json | 2 +- src/math_spec/exclusivity.py | 3 +- src/math_spec/model.py | 61 +++++++++--------------------------- src/math_spec/piecewise.py | 4 +-- src/math_spec/program.py | 55 ++++++++++++++++++++++---------- src/math_spec/resolution.py | 3 +- src/math_spec/sos.py | 9 +++--- src/math_spec/validation.py | 4 +-- tests/test_expand.py | 16 ++++++---- 9 files changed, 76 insertions(+), 81 deletions(-) diff --git a/schema/math-spec.schema.json b/schema/math-spec.schema.json index ee07fa2e..2068ca2b 100644 --- a/schema/math-spec.schema.json +++ b/schema/math-spec.schema.json @@ -670,7 +670,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 eleven declaration sections plus ``version`` and\n``description``, three ways back out \u2014 :meth:`to_dict` for the model as\ndata, :meth:`to_yaml` for the file a reviewer reads, :meth:`expand` for the\nsame math with its formulations written out. Everything else on this\nclass 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, 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``, three ways back out \u2014 :meth:`to_dict` for the model as\ndata, :meth:`to_yaml` for the file a reviewer reads, :meth:`expand` for the\nsame math with its formulations written out. Everything else on this\nclass is pydantic's, not a contract this package keeps.", "properties": { "assumptions": { "additionalProperties": { diff --git a/src/math_spec/exclusivity.py b/src/math_spec/exclusivity.py index bd9cb857..95ea1537 100644 --- a/src/math_spec/exclusivity.py +++ b/src/math_spec/exclusivity.py @@ -48,8 +48,7 @@ if TYPE_CHECKING: from collections.abc import Iterable, Iterator, Mapping - from math_spec.model import DeclaredDtype - from math_spec.program import Expression, Predicate, PredicateOperator + from math_spec.program import DeclaredDtype, Expression, Predicate, PredicateOperator #: The most cells one pair may multiply out to; a pair past it is several expressions. CELL_BUDGET = 8192 diff --git a/src/math_spec/model.py b/src/math_spec/model.py index 6d099be1..3a335885 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -29,6 +29,16 @@ from math_spec._expression_parser import NAME, ComparisonOperator from math_spec.errors import did_you_mean, schema_error +from math_spec.program import ( + DimensionDtype, + ObjectiveSense, + ParameterDtype, + PiecewiseMethod, + Program, + SosType, + VariableAbsence, + VariableDomain, +) if TYPE_CHECKING: from collections.abc import Mapping @@ -37,9 +47,6 @@ from pydantic.config import ExtraValues from pydantic_core import CoreSchema - # program.py imports this module at runtime, so the import is type-only - from math_spec.program import Program # noqa: TC004 - class _StrictBlock(BaseModel): """Base for every schema model: unknown keys are an error, not a shrug. @@ -75,43 +82,6 @@ def _reject_unknown_keys(cls, data: object) -> object: return data -#: The dtype a dimension index may declare (the declaration rules), and what -#: its labels are. ``datetime`` is a dimension's alone — labels on a timeline -#: order and compare, where a *value* of that type is a moment nothing -#: computes with. -DimensionDtype = Literal['float', 'int', 'str', 'datetime'] - -#: The dtype a parameter may declare (the declaration rules), and what its bound -#: column must be. ``bool`` is a parameter's alone — a value column may be a -#: flag a mask reads, where a label set of two members is a dimension nothing -#: indexes by. -ParameterDtype = Literal['float', 'int', 'bool', 'str'] - -#: What a *name* a where comparison tests may be — a parameter's dtype or a -#: dimension's, since a relation's is its target's. The union rather than either -#: half, because a mask names all three kinds and reads the dtype the same way. -DeclaredDtype = ParameterDtype | DimensionDtype - -#: The domain a variable may declare. -VariableDomain = Literal['continuous', 'integer', 'binary'] - -#: What a masked variable's non-existence *means* where it does not exist. -#: ``undefined`` is the absence rules' default — a term carrying it takes its -#: row. ``zero`` says the quantity *is* zero there, so the term contributes -#: nothing and the row stands. -VariableAbsence = Literal['undefined', 'zero'] - -#: Which way an objective is optimised (the declaration rules). -ObjectiveSense = Literal['minimize', 'maximize'] - -#: The order of special ordered set. -SosType = Literal[1, 2] - -#: How a ``piecewise:`` block restricts its interpolation weights. Kept in step -#: with :data:`PIECEWISE_METHODS`, which says what each one emits, by -#: ``tests/test_schema.py``. -PiecewiseMethod = Literal['adjacency', 'sos2', 'convex', 'lp'] - #: A block that states rows rather than being one, which :meth:`Spec.expand` #: writes out on request. Formulation = Literal['piecewise', 'sos'] @@ -736,7 +706,7 @@ class Spec(_StrictBlock): A ``Spec`` that exists has passed the whole language: constructing one by any route — ``to_spec``, :meth:`model_validate`, the constructor — runs - every load-time check, expansion and expression pass included, and raises + every load-time check, expression pass included, and raises :class:`~math_spec.errors.LanguageError` on a model the language refuses. Holding one is the proof, so nothing downstream checks it again. @@ -911,16 +881,15 @@ def _names_are_names(self) -> Spec: @model_validator(mode='after') def _lower(self) -> Spec: - """Every rule that reads across declarations, then every expression and where string — this file's own, and every one a curve emits. + """Every rule that reads across declarations, then every expression and where string. - This file's own first, so a fault in a link is named against the link - the file wrote. A curve's expansion is a model in its own right, so - validating it is what holds the declarations it writes to the language. + A fault in a curve's link is named against the link the file wrote. The + rows a curve states are held to the language when :meth:`expand` + writes them out, since an expansion is a model like any other. """ from math_spec.lowering import lower self._program = lower(self) - self.expand('piecewise') return self diff --git a/src/math_spec/piecewise.py b/src/math_spec/piecewise.py index fbc4a996..837e1723 100644 --- a/src/math_spec/piecewise.py +++ b/src/math_spec/piecewise.py @@ -20,8 +20,8 @@ import math_spec.sos as sos from math_spec.dimensions import dims_of from math_spec.errors import DimensionError -from math_spec.model import AssumptionBlock, Curvature, PiecewiseBlock, PiecewiseMethod, Spec -from math_spec.program import PiecewiseDeclaration, carries_variable +from math_spec.model import AssumptionBlock, Curvature, PiecewiseBlock, Spec +from math_spec.program import PiecewiseDeclaration, PiecewiseMethod, carries_variable from math_spec.resolution import resolve_expression_text if TYPE_CHECKING: diff --git a/src/math_spec/program.py b/src/math_spec/program.py index 5619c48b..0d7a2bf2 100644 --- a/src/math_spec/program.py +++ b/src/math_spec/program.py @@ -26,7 +26,6 @@ from functools import cached_property from typing import TYPE_CHECKING, Literal, assert_never, get_args -import math_spec.model as _model from math_spec._expression_parser import ComparisonOperator from math_spec._sealed import Sealed from math_spec.errors import did_you_mean @@ -49,6 +48,7 @@ 'ConstraintDeclaration', 'ConstraintSense', 'CountComparison', + 'DeclaredDtype', 'DimensionComparison', 'DimensionDeclaration', 'DimensionDtype', @@ -78,6 +78,7 @@ 'ParameterDtype', 'Partition', 'PiecewiseDeclaration', + 'PiecewiseMethod', 'Power', 'Predicate', 'PredicateOperator', @@ -93,6 +94,7 @@ 'RelationPairComparison', 'Separability', 'SosDeclaration', + 'SosType', 'Sum', 'Translate', 'TranslatedPredicate', @@ -123,7 +125,6 @@ #: How a shape operator's output rows relate to its input slots, answered by #: :func:`fan_in` for every node. FanIn = Literal['one-to-one', 'many-to-one', 'one-to-many'] -ObjectiveSense = _model.ObjectiveSense #: Where a degree-2 product may stand in the math a solver sees. An objective #: and a constraint take ``variable * variable``; a bound and a ``piecewise:`` @@ -135,20 +136,42 @@ #: and hears about it when the language admits another. QUADRATIC_POSITIONS = frozenset(get_args(QuadraticPosition)) -#: What a dimension's labels are — the language's own vocabulary -#: (:data:`~math_spec.model.DimensionDtype`), under the name a consumer reads -#: it by. -DimensionDtype = _model.DimensionDtype +#: The dtype a dimension index may declare (the declaration rules), and what +#: its labels are. ``datetime`` is a dimension's alone — labels on a timeline +#: order and compare, where a *value* of that type is a moment nothing +#: computes with. +DimensionDtype = Literal['float', 'int', 'str', 'datetime'] -#: What a parameter's values are (:data:`~math_spec.model.ParameterDtype`). -ParameterDtype = _model.ParameterDtype +#: The dtype a parameter may declare (the declaration rules), and what its bound +#: column must be. ``bool`` is a parameter's alone — a value column may be a +#: flag a mask reads, where a label set of two members is a dimension nothing +#: indexes by. +ParameterDtype = Literal['float', 'int', 'bool', 'str'] -#: What a masked variable's non-existence means -#: (:data:`~math_spec.model.VariableAbsence`). -VariableAbsence = _model.VariableAbsence +#: What a *name* a where comparison tests may be — a parameter's dtype or a +#: dimension's, since a relation's is its target's. The union rather than either +#: half, because a mask names all three kinds and reads the dtype the same way. +DeclaredDtype = ParameterDtype | DimensionDtype -#: A variable's domain (:data:`~math_spec.model.VariableDomain`). -VariableDomain = _model.VariableDomain +#: The domain a variable may declare. +VariableDomain = Literal['continuous', 'integer', 'binary'] + +#: What a masked variable's non-existence *means* where it does not exist. +#: ``undefined`` is the absence rules' default — a term carrying it takes its +#: row. ``zero`` says the quantity *is* zero there, so the term contributes +#: nothing and the row stands. +VariableAbsence = Literal['undefined', 'zero'] + +#: Which way an objective is optimised (the declaration rules). +ObjectiveSense = Literal['minimize', 'maximize'] + +#: The order of special ordered set. +SosType = Literal[1, 2] + +#: How a ``piecewise:`` block restricts its interpolation weights. Kept in step +#: with :data:`~math_spec.model.PIECEWISE_METHODS`, which says what each one +#: emits, by ``tests/test_schema.py``. +PiecewiseMethod = Literal['adjacency', 'sos2', 'convex', 'lp'] # -------------------------------------------------------------------------- @@ -650,7 +673,7 @@ class SosDeclaration: variable: str along: str - sos_type: Literal[1, 2] + sos_type: SosType description: str | None = None @@ -719,7 +742,7 @@ class PiecewiseDeclaration: over: str links: tuple[Link, ...] - method: _model.PiecewiseMethod + method: PiecewiseMethod frame: tuple[str, ...] activity: str | None = None points: str | None = None @@ -754,7 +777,7 @@ class Footprint: quadratic: frozenset[QuadraticPosition] domains: frozenset[VariableDomain] - sos_types: frozenset[Literal[1, 2]] + sos_types: frozenset[SosType] kinds: frozenset[type[Expression]] diff --git a/src/math_spec/resolution.py b/src/math_spec/resolution.py index ef59aa95..db315869 100644 --- a/src/math_spec/resolution.py +++ b/src/math_spec/resolution.py @@ -108,7 +108,8 @@ from math_spec._expression_parser import ComparisonOperator, ParsedNode from math_spec._where_parser import ParsedWhere - from math_spec.model import DeclaredDtype, ExpressionBlock, Spec + from math_spec.model import ExpressionBlock, Spec + from math_spec.program import DeclaredDtype #: What a name a file may write turns out to be. Answered by diff --git a/src/math_spec/sos.py b/src/math_spec/sos.py index d1ef4a69..b24da870 100644 --- a/src/math_spec/sos.py +++ b/src/math_spec/sos.py @@ -18,9 +18,10 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -if TYPE_CHECKING: - from math_spec.model import SosType, Spec +from math_spec.model import Spec +if TYPE_CHECKING: + from math_spec.program import SosType #: One member's two linking coefficients, below and above. ``None`` on a side is #: a side the model leaves open, where no rewrite can hold the member at zero. @@ -87,12 +88,10 @@ def expand_sets(schema: Spec) -> Spec: The curves an expansion wrote out ride along, because a model whose curves are already written out is the one this is usually asked of. """ - from math_spec.model import Spec as Model - raw = schema.model_dump() for name in list(schema.sos): emit(raw, name) - return Model.model_validate(raw) + return Spec.model_validate(raw) def emit(raw: dict[str, object], name: str) -> None: diff --git a/src/math_spec/validation.py b/src/math_spec/validation.py index 9e08fb7f..6f99c99b 100644 --- a/src/math_spec/validation.py +++ b/src/math_spec/validation.py @@ -35,8 +35,8 @@ def to_spec(model: str | Path | Mapping[str, object] | Spec) -> Spec: """Load and validate a model definition — the language's front door. Everything decidable without data is decided here: schema shape, every - expression and where string, every macro template, and every declaration a - formulation emits. + rule one declaration is held to against the others, every expression and + where string, and every macro template. Args: model: A YAML path — a :class:`~pathlib.Path`, or a ``str`` with no diff --git a/tests/test_expand.py b/tests/test_expand.py index d2f6c681..ce31213f 100644 --- a/tests/test_expand.py +++ b/tests/test_expand.py @@ -92,18 +92,22 @@ def test_each_set_of_kinds_is_expanded_once(): assert schema.expand() is not schema.expand('piecewise'), 'a set left standing is a different model' -def test_writing_everything_out_reuses_the_curves_the_load_wrote_out(monkeypatch): - """`expand()` called the curve expander directly, so the model the load had already - written out and cached was built again, and validated again, on every full ask.""" +def test_the_curves_are_written_out_once_however_they_are_asked_for(monkeypatch): + """`expand()` called the curve expander directly, so the model `expand('piecewise')` had + already written out and cached was built again, and validated again, on every full ask. + + Loading writes nothing out: the expander runs on the first ask and never + again, whichever of the two asks comes first. + """ schema = schema_of(CURVE) asked: list[Spec] = [] written_out = piecewise.expand_piecewise monkeypatch.setattr(piecewise, 'expand_piecewise', lambda spec: asked.append(spec) or written_out(spec)) + assert asked == [], 'loading a model writes no curve out' assert not schema.expand().piecewise - assert [spec for spec in asked if spec.piecewise] == [], ( - 'the curves were written out at load, and that is the model the sets are written out of' - ) + assert not schema.expand('piecewise').piecewise + assert asked == [schema], 'the curves were written out once, and that model is the one the sets are written out of' def test_an_expansion_declares_exactly_the_parameters_the_file_declared(): From 4b257ac1ba57f96ec83ff7e6822edfe69ec419c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 15:51:31 +0000 Subject: [PATCH 06/20] feat(language): a model's program is an attribute of the model, and the one door to both states is to_spec to_program is gone. Spec.program is the model typed, section for section, built once as the model loads; spec.expand(...).program is its rows. A consumer building rows reads the sections it takes and refuses a curve or a set it finds, the way it already had to for a set; advice() does so for a Program handed to it, and writes curves out itself for a file or a Spec. typeset and typeset_declaration take a Program as before. Docs sentences, after (before): reading.md n 75 avg 17.1 median 15 over25 14 (76, 16.8, 15, 12); piecewise.md n 70 avg 17.7 median 15 over25 13 (70, 17.6, 15, 14); what-counts-as-public-api.md n 18 avg 16.0 median 14 over25 3 (unchanged count); limits.md n 55 avg 18.3 median 19 over25 14 (unchanged count). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi --- README.md | 4 +- docs/about/limits.md | 2 +- docs/about/what-counts-as-public-api.md | 3 +- docs/reference/language/piecewise.md | 10 +-- docs/reference/reading.md | 29 ++++--- schema/math-spec.schema.json | 2 +- src/math_spec/__init__.py | 9 +- src/math_spec/advice.py | 9 +- src/math_spec/lowering.py | 36 +------- src/math_spec/model.py | 24 ++++-- src/math_spec/piecewise.py | 4 +- src/math_spec/program.py | 4 +- src/math_spec/typesetting/README.md | 2 +- src/math_spec/typesetting/__init__.py | 12 +-- tests/fixtures.py | 2 +- tests/test_advice.py | 8 +- tests/test_boundedness.py | 3 +- tests/test_expand.py | 9 +- tests/test_lowering.py | 108 ++++++++++++------------ tests/test_piecewise.py | 31 +++---- tests/test_program_nodes.py | 4 +- tests/test_public_surface.py | 4 +- tests/test_separability.py | 14 +-- tests/test_sos.py | 7 +- tests/test_validation.py | 7 +- tests/typesetting/test_cases.py | 4 +- tests/typesetting/test_golden.py | 3 +- tests/typesetting/test_walk.py | 5 +- 28 files changed, 162 insertions(+), 197 deletions(-) diff --git a/README.md b/README.md index 26bd3090..297d6c2f 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ flowchart LR AST --> Q{"inside the
language?"} Q -->|"no"| ERR["load error
naming the construct + rewrite"] Q -->|"yes"| M["Spec
what the file says"] - M -->|"to_program"| P["Program
names, dimensions and operators resolved"] + M -->|".program"| P["Program
names, dimensions and operators resolved"] P --> ENG["an engine that builds → solver"] M --> T["to_latex / to_typst / to_markdown"] @@ -301,7 +301,7 @@ import math_spec as ms spec = ms.to_spec('dispatch.yaml') # schema, names, dimensions, degree: all checked here sorted(spec.variables) # ['dispatch'] -program = ms.to_program(spec) # curves expanded, names typed, operators resolved to nodes +program = spec.expand().program # curves expanded, names typed, operators resolved to nodes sorted(program.constraints) # ['power_balance'] ``` diff --git a/docs/about/limits.md b/docs/about/limits.md index 06055806..5e5a1878 100644 --- a/docs/about/limits.md +++ b/docs/about/limits.md @@ -65,7 +65,7 @@ sums, `sum(x, over=i) * sum(y, over=j)`, is refused, because the file does not say how many terms either sum has. `x[i] * y[j] * a[i, j]` is allowed, because the table `a` says which pairs exist. -A new primitive is finished when `to_program` lowers it, the typesetter prints +A new primitive is finished when lowering builds it, the typesetter prints it in all three formats, and an engine's build of a model that uses it matches the same model written out by hand. diff --git a/docs/about/what-counts-as-public-api.md b/docs/about/what-counts-as-public-api.md index beb54984..e8d184c1 100644 --- a/docs/about/what-counts-as-public-api.md +++ b/docs/about/what-counts-as-public-api.md @@ -35,7 +35,8 @@ file, it is one. - **A value or an error, and nothing between.** `to_spec` either returns a `Spec` or raises an error that names the rewrite. `advice()` is separate: it talks about a file the language accepts, and changes nothing. -- **Safe to call again.** `to_program(program)` returns `program` unchanged. +- **Safe to call again.** `spec.program` is one object, however often it is + asked for. ## Three things a function never decides diff --git a/docs/reference/language/piecewise.md b/docs/reference/language/piecewise.md index 39da84a8..5e85c906 100644 --- a/docs/reference/language/piecewise.md +++ b/docs/reference/language/piecewise.md @@ -260,8 +260,8 @@ a model before and after, as whole files. emits a parameter. A curve under `points:` sits its rows on `where:` predicates over the mask the file named, and the expansion is a file like any other: `to_yaml()` writes it, and loading it back changes nothing. -- **`to_program()` writes nothing out.** The program mirrors the model: a - curve the model still declares is under `program.piecewise`, typed, and the - program of `spec.expand('piecewise')` carries its rows instead. A consumer - building rows lowers the expansion; one that cannot take a set lowers - `spec.expand()`. +- **`spec.program` writes nothing out.** The program mirrors the model: a + curve the model still declares is under `program.piecewise`, typed, and + `spec.expand('piecewise').program` carries its rows instead. A consumer + building rows reads the expansion's program, and refuses a curve it finds on + a program; one that cannot take a set reads `spec.expand().program`. diff --git a/docs/reference/reading.md b/docs/reference/reading.md index 1385ca28..1e4923a3 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -7,10 +7,10 @@ SPDX-License-Identifier: CC-BY-4.0 This page is for whoever writes an engine that builds models, a renderer, or a checker. You need none of it to write a model. A tool reads the model through -two objects: +two objects, and one door: ```text -to_spec → Spec → to_program → Program +to_spec → Spec → .program → Program ``` ## `Spec` and `Program` @@ -59,27 +59,28 @@ objective: ``` ```python -from math_spec import to_spec, to_program +from math_spec import to_spec spec = to_spec('curve.yaml') -program = to_program(spec) +program = spec.program sorted(program.constraints) # ['target'] sorted(program.piecewise) # ['curve'] -rows = to_program(spec.expand('piecewise')) +rows = spec.expand('piecewise').program sorted(rows.constraints) # ['curve_convexity', 'curve_link0', 'curve_link1', 'target'] sorted(rows.variables) # ['cost', 'curve_lam', 'p'] ``` -`to_program` takes a path, the YAML, a mapping, a `Spec` or a `Program`. Called -on a `Program`, it returns the same object unchanged. Called on a `Spec`, it -returns the program built when the model loaded, so two calls on one model -return one object. The program mirrors the model: a `piecewise:` block the -model still declares is a curve under `program.piecewise`, typed, and a `sos:` -block is a set under `program.sos`. `spec.expand('piecewise')` is the model -with each curve written out as rows, and `spec.expand()` writes the sets out -too. Which to lower is the caller's to say, because a consumer printing a -curve wants the curve and a consumer building rows wants the rows. +`to_spec` takes a path, the YAML, a mapping or a `Spec`. `spec.program` is the +program built when the model loaded, so every ask on one model returns one +object. The program mirrors the model: a `piecewise:` block the model still +declares is a curve under `program.piecewise`, typed, and a `sos:` block is a +set under `program.sos`. `spec.expand('piecewise')` is the model with each +curve written out as rows, and `spec.expand()` writes the sets out too. Which +to read is the caller's to say, because a consumer printing a curve wants the +curve and a consumer building rows wants the rows. A consumer building rows +reads the sections it takes and refuses the rest: a curve or a set still on +the program is a block it did not ask to have written out. | you are | take | because | | ------------------------------------------------------------------- | --------- | -------------------------------------------- | diff --git a/schema/math-spec.schema.json b/schema/math-spec.schema.json index 2068ca2b..99300c13 100644 --- a/schema/math-spec.schema.json +++ b/schema/math-spec.schema.json @@ -670,7 +670,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, 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``, three ways back out \u2014 :meth:`to_dict` for the model as\ndata, :meth:`to_yaml` for the file a reviewer reads, :meth:`expand` for the\nsame math with its formulations written out. Everything else on this\nclass 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, 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``, three ways back out \u2014 :meth:`to_dict` for the model as\ndata, :meth:`to_yaml` for the file a reviewer reads, :meth:`expand` for the\nsame math with its formulations written out \u2014 and :attr:`program`, the\nmodel typed, which every reader after load walks. Everything else on this\nclass is pydantic's, not a contract this package keeps.", "properties": { "assumptions": { "additionalProperties": { diff --git a/src/math_spec/__init__.py b/src/math_spec/__init__.py index 37513955..d8ad8c9b 100644 --- a/src/math_spec/__init__.py +++ b/src/math_spec/__init__.py @@ -5,9 +5,10 @@ """The language: what a YAML file may say, and what it means. Two public states — a :class:`~math_spec.model.Spec` is what the file *says*, -a :class:`~math_spec.program.Program` is what it *means* — and a conversion to -each. Everything between them — both grammars and the tree they build — is -package-private, because a consumer reads a program instead. +and its :attr:`~math_spec.model.Spec.program` is what it *means* — and +:func:`to_spec`, the one door to both. Everything between them — both grammars +and the tree they build — is package-private, because a consumer reads a +program instead. """ from math_spec import program @@ -22,7 +23,6 @@ did_you_mean, schema_error, ) -from math_spec.lowering import to_program from math_spec.model import ( CURVATURES, DIMENSION_DTYPES, @@ -76,7 +76,6 @@ 'schema_error', 'to_latex', 'to_markdown', - 'to_program', 'to_spec', 'to_typst', 'typeset', diff --git a/src/math_spec/advice.py b/src/math_spec/advice.py index 938428f1..dea4afd0 100644 --- a/src/math_spec/advice.py +++ b/src/math_spec/advice.py @@ -13,7 +13,6 @@ from math_spec.boundedness import unbounded_notes from math_spec.errors import Advice, LanguageError -from math_spec.lowering import to_program from math_spec.program import GroupSum, Program, Pullback, walk from math_spec.validation import to_spec @@ -41,14 +40,12 @@ def advice(model: str | Path | Mapping[str, object] | Spec | Program) -> tuple[A LanguageError: A :class:`Program` with a ``piecewise:`` block still in it, naming the expansion to pass instead. """ - if not isinstance(model, Program): - model = to_spec(model).expand('piecewise') - program = to_program(model) + program = model if isinstance(model, Program) else to_spec(model).expand('piecewise').program if program.piecewise: named = ', '.join(f"'{name}'" for name in program.piecewise) msg = ( - f'piecewise: {named} states rows, and advice reads the rows. Pass ' - f"to_program(spec.expand('piecewise')), which writes each block out as the variables and " + f'piecewise: {named} states rows rather than being one, and advice reads the rows. Pass ' + f"spec.expand('piecewise').program, which writes each block out as the variables and " f'constraints it states.' ) raise LanguageError(msg) diff --git a/src/math_spec/lowering.py b/src/math_spec/lowering.py index 2eb8dae6..accd39ab 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -50,47 +50,13 @@ resolve_expression_text, resolve_where_text, ) -from math_spec.validation import reference_errors, to_spec +from math_spec.validation import reference_errors if TYPE_CHECKING: - from collections.abc import Mapping - from pathlib import Path - from math_spec.model import AssumptionBlock, Spec from math_spec.program import Expression -def to_program(spec: str | Path | Mapping[str, object] | Spec | Program) -> Program: - """*spec* as a :class:`~math_spec.program.Program` — the public door. - - Takes whatever you have: a YAML path, the YAML itself, a mapping, a loaded - model, or a program already. Idempotent, so a caller that does not know - which it holds can call this and be sure, and one object per model: the - program was built when the model loaded, and a second ask is the same - object. The program mirrors the model as it arrived: a ``piecewise:`` - block still in it is a curve on the program and a ``sos:`` block a set, - and :meth:`~math_spec.model.Spec.expand` is what writes either out as - rows for a consumer that takes rows alone. - - Args: - spec: What to read the declarations from. - - Returns: - Every declaration the file makes, with names resolved and shapes - fixed. - - Raises: - SchemaError: The file is not a valid model. - LanguageError: A construct outside the language, named with its - rewrite. - """ - if isinstance(spec, Program): - return spec - program = to_spec(spec)._program - assert program is not None, 'a model that loaded was lowered' - return program - - def lower(schema: Spec) -> Program: """Lower *schema*'s own declarations, checking every rule decidable without data. diff --git a/src/math_spec/model.py b/src/math_spec/model.py index 3a335885..d0444002 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -713,7 +713,8 @@ class Spec(_StrictBlock): The API is the eleven declaration sections plus ``version`` and ``description``, three ways back out — :meth:`to_dict` for the model as data, :meth:`to_yaml` for the file a reviewer reads, :meth:`expand` for the - same math with its formulations written out. Everything else on this + same math with its formulations written out — and :attr:`program`, the + model typed, which every reader after load walks. Everything else on this class is pydantic's, not a contract this package keeps. """ @@ -724,10 +725,9 @@ class is pydantic's, not a contract this package keeps. #: that expands to itself is not stored: two of them compare by their #: private state, which a model holding itself cannot answer. _expansions: dict[tuple[Formulation, ...], Spec] = PrivateAttr(default_factory=dict) - #: What this model's own declarations lower to, built as the model loads: - #: computing it *is* the expression pass, so a model the language refuses - #: never holds one. :func:`~math_spec.lowering.to_program` answers with - #: the expansion's, since a curve's rows are on that model. + #: What :attr:`program` answers with, built as the model loads: computing + #: it *is* the expression pass, so a model the language refuses never + #: holds one. _program: Program | None = PrivateAttr(default=None) #: Which language surface this file is written against. Absent means 0, so @@ -750,6 +750,20 @@ class is pydantic's, not a contract this package keeps. sos: dict[str, SosBlock] = {} assumptions: dict[str, AssumptionBlock] = {} + @property + def program(self) -> Program: + """This model typed, section for section — what every reader after load walks. + + Built once, as the model loaded, so every ask is the same object. It + mirrors the model: a ``piecewise:`` block still in it is a curve under + ``program.piecewise`` and a ``sos:`` block a set under ``program.sos``, + and :meth:`expand` is what writes either out as rows, so a consumer + building rows reads ``spec.expand(...).program`` and refuses a block + it does not take. + """ + assert self._program is not None, 'a model that loaded was lowered' + return self._program + @classmethod @override def model_validate( diff --git a/src/math_spec/piecewise.py b/src/math_spec/piecewise.py index 837e1723..6529406c 100644 --- a/src/math_spec/piecewise.py +++ b/src/math_spec/piecewise.py @@ -471,9 +471,7 @@ def expand_piecewise(schema: Spec) -> Spec: """ if not schema.piecewise: return schema - from math_spec.lowering import to_program - - program = to_program(schema) + program = schema.program raw = schema.model_dump() raw.setdefault('variables', {}) raw.setdefault('constraints', {}) diff --git a/src/math_spec/program.py b/src/math_spec/program.py index 0d7a2bf2..9ef1174a 100644 --- a/src/math_spec/program.py +++ b/src/math_spec/program.py @@ -7,8 +7,8 @@ The second public state, and the one a consumer reads. A :class:`Program` is the file typed, section for section: every declaration it makes, with names resolved, shapes fixed and every rule decidable without data checked, and no -data at all. :func:`~math_spec.lowering.to_program` is the only thing that -builds one, so nothing here re-checks a hand-built one. +data at all. Lowering, as a :class:`~math_spec.model.Spec` loads, is the only +thing that builds one, so nothing here re-checks a hand-built one. Node and declaration classes are matched with ``isinstance``. The rules a node's structure does not show are :func:`children` and :func:`fan_in`; the diff --git a/src/math_spec/typesetting/README.md b/src/math_spec/typesetting/README.md index a6e256b6..d657558e 100644 --- a/src/math_spec/typesetting/README.md +++ b/src/math_spec/typesetting/README.md @@ -6,7 +6,7 @@ SPDX-License-Identifier: MIT # `typesetting/` — the model, printed This package is a consumer of the program. It builds no model and binds no -data. It walks the program `to_program` lowers a model to, and prints it. +data. It walks the program a loaded `Spec` holds, and prints it. | Module | Role | | ------------- | ------------------------------------------------------------------------------------------------------ | diff --git a/src/math_spec/typesetting/__init__.py b/src/math_spec/typesetting/__init__.py index d59c264f..9689af6d 100644 --- a/src/math_spec/typesetting/__init__.py +++ b/src/math_spec/typesetting/__init__.py @@ -29,19 +29,19 @@ from typing import TYPE_CHECKING, Literal, TypedDict, Unpack from math_spec.errors import SchemaError, did_you_mean -from math_spec.lowering import to_program +from math_spec.program import Program from math_spec.typesetting.latex import LatexFormat from math_spec.typesetting.markdown import MarkdownFormat from math_spec.typesetting.symbols import Symbols, SymbolTable from math_spec.typesetting.typst import TypstFormat from math_spec.typesetting.walk import Walk +from math_spec.validation import to_spec if TYPE_CHECKING: from collections.abc import Mapping from pathlib import Path from math_spec.model import Spec - from math_spec.program import Program from math_spec.typesetting.format import Format __all__ = [ @@ -87,7 +87,7 @@ def _walk( if fmt not in FORMATS: msg = f"'{fmt}' is not a format this package prints. Formats: {', '.join(FORMATS)}." raise ValueError(msg) - program = to_program(model) + program = model if isinstance(model, Program) else to_spec(model).program format_ = FORMATS[fmt] if symbols is None: symbols = SymbolTable(format_.notation) @@ -113,8 +113,8 @@ def typeset( """Render *model*'s math in *fmt*. Args: - model: Anything :func:`math_spec.to_program` accepts. A - :class:`~math_spec.model.Spec` or a :class:`~math_spec.program.Program` + model: Anything :func:`math_spec.to_spec` accepts, or a + :class:`~math_spec.program.Program`. A loaded model or a program is rendered as it stands, so printing one model in several formats reads and checks the file once rather than once per format, and a curve prints as the curve it states. Pass ``spec.expand()`` for the rows a solver holds @@ -179,7 +179,7 @@ def typeset_declaration( one prints by symbol, and a second call with its name prints its block. Args: - model: Anything :func:`math_spec.to_program` accepts. + model: Anything :func:`math_spec.to_spec` accepts, or a :class:`~math_spec.program.Program`. name: A named expression, constraint, assumption, ``piecewise:`` block or variable the model declares. fmt: What spells the math — a key of :data:`FORMATS`. diff --git a/tests/fixtures.py b/tests/fixtures.py index a98a2a4f..b3f080c1 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -85,7 +85,7 @@ def schema_of(source: str | Path | dict[str, Any], **patch: Any) -> Spec: def expanded(source: str | Path | dict[str, Any] | Spec, *kinds: Any, **patch: Any) -> Spec: - """:func:`schema_of` with its formulations written out — what ``to_program`` takes from a model with a curve.""" + """:func:`schema_of` with its formulations written out — what a consumer building rows reads from a model with a curve.""" schema = source if isinstance(source, Spec) else schema_of(source, **patch) return schema.expand(*kinds) diff --git a/tests/test_advice.py b/tests/test_advice.py index 6e55642d..68a6ee89 100644 --- a/tests/test_advice.py +++ b/tests/test_advice.py @@ -16,7 +16,7 @@ import pytest -from math_spec import ADVICE_KINDS, LanguageError, advice, to_program, to_spec +from math_spec import ADVICE_KINDS, LanguageError, advice, to_spec from tests.fixtures import SMALL_MODEL, override if TYPE_CHECKING: @@ -92,7 +92,7 @@ def _written(model: dict, tmp_path: Path) -> Path: pytest.param(_written, id='a-path'), pytest.param(lambda model, _: model, id='a-mapping'), pytest.param(lambda model, _: to_spec(model), id='a-spec'), - pytest.param(lambda model, _: to_program(model), id='a-program'), + pytest.param(lambda model, _: to_spec(model).program, id='a-program'), ], ) def test_the_answer_does_not_turn_on_which_state_it_is_asked_of(form, tmp_path): @@ -110,10 +110,10 @@ def test_a_curve_is_written_out_before_advice_reads_it(): with a block advise on the file's own rows as if the curve stated none. """ from_file = advice(CURVED) - from_rows = advice(to_program(to_spec(CURVED).expand('piecewise'))) + from_rows = advice(to_spec(CURVED).expand('piecewise').program) assert [(n.kind, n.subject) for n in from_file] == [(n.kind, n.subject) for n in from_rows], ( 'a file and the program of its expansion are advised alike' ) with pytest.raises(LanguageError, match="piecewise: 'curve' states rows") as refusal: - advice(to_program(CURVED)) + advice(to_spec(CURVED).program) assert "expand('piecewise')" in str(refusal.value), 'the refusal names the expansion to pass' diff --git a/tests/test_boundedness.py b/tests/test_boundedness.py index 60cc588a..f701e17d 100644 --- a/tests/test_boundedness.py +++ b/tests/test_boundedness.py @@ -13,7 +13,6 @@ import pytest from math_spec.boundedness import unbounded_notes -from math_spec.lowering import to_program from math_spec.operators import BUILTIN_NAMES from tests.fixtures import SMALL_MODEL, override, schema_of @@ -25,7 +24,7 @@ def _advice(**patch): - return unbounded_notes(to_program(schema_of(BASE, **patch).expand('piecewise'))) + return unbounded_notes(schema_of(BASE, **patch).expand('piecewise').program) def _notes(**patch) -> list[str]: diff --git a/tests/test_expand.py b/tests/test_expand.py index ce31213f..a463c1d7 100644 --- a/tests/test_expand.py +++ b/tests/test_expand.py @@ -18,7 +18,6 @@ import pytest from math_spec import piecewise, to_spec -from math_spec.lowering import to_program from tests.fixtures import DISPATCH_MODEL, EXAMPLES, override, schema_of from tests.test_sos import CURVE from tools.render_tex import models @@ -138,8 +137,8 @@ def test_what_a_curve_assumes_of_its_numbers_rides_on_the_expansion_too(model): decides. The program carries the condition for the consumer that has the numbers, and writing the curve out must not be the way a model loses it.""" spec = schema_of(model) - stated = to_program(spec.expand('piecewise')).assumptions - written_out = to_program(spec.expand()).assumptions + stated = spec.expand('piecewise').program.assumptions + written_out = spec.expand().program.assumptions assert {'cost_curve_increasing', 'cost_curve_curvature'} <= set(stated), ( 'the breakpoints increase and the curve bends one way, both checked where the data is' @@ -153,7 +152,7 @@ def test_the_same_sources_bind_a_model_and_its_expansion(model): neither a set nor a curve emits a parameter. A consumer's `sources` argument is therefore the same either way.""" spec = schema_of(model) - supplied = set(to_program(spec.expand('piecewise')).parameters) - written_out = set(to_program(spec.expand()).parameters) + supplied = set(spec.expand('piecewise').program.parameters) + written_out = set(spec.expand().program.parameters) assert written_out == supplied, 'writing a formulation out asks for data the model it came from did not' diff --git a/tests/test_lowering.py b/tests/test_lowering.py index 73d7f8c6..9078cffb 100644 --- a/tests/test_lowering.py +++ b/tests/test_lowering.py @@ -15,7 +15,7 @@ import pytest -from math_spec import LanguageError, Spec, to_program +from math_spec import LanguageError, Spec, to_spec from math_spec._where_parser import parse_where from math_spec.exclusivity import overlapping from math_spec.program import ( @@ -120,7 +120,7 @@ def dispatch_schema() -> Spec: @pytest.fixture def dispatch_program(dispatch_schema) -> Program: - return to_program(dispatch_schema) + return dispatch_schema.program @pytest.fixture @@ -157,14 +157,14 @@ def test_program_structure(dispatch_program): @pytest.mark.parametrize('sense', [pytest.param('minimize', id='minimize'), pytest.param('maximize', id='maximize')]) def test_the_objective_sense_crosses_untranslated(sense: str): """One spelling from the file to the program, in both directions — each sink translates at its own edge.""" - program = to_program(override(TINY, objective={'sense': sense, 'expression': 'sum(p * cost, over=g)'})) + program = to_spec(override(TINY, objective={'sense': sense, 'expression': 'sum(p * cost, over=g)'})).program assert program.objective is not None assert program.objective.sense == sense, "the file's own word for the direction, unchanged" def test_a_file_with_no_objective_lowers_to_no_sense(): """A feasibility problem has no direction, and nothing downstream invents one.""" - program = to_program(TINY) + program = to_spec(TINY).program assert program.objective is None, 'no objective declared is no objective, not a minimisation of nothing' @@ -225,8 +225,8 @@ def test_a_where_is_one_resolved_predicate_with_every_literal_folded(dispatch_sc def test_a_folded_mask_reaches_the_declaration_the_shorter_spelling_would_have(): """The fold is the program's, not a helper's: two files, one declaration.""" - written_out = to_program(schema_of(DISPATCH_MODEL, **{'variables.p.where': 'p_max > 0 AND True'})) - plain = to_program(schema_of(DISPATCH_MODEL, **{'variables.p.where': 'p_max > 0'})) + written_out = schema_of(DISPATCH_MODEL, **{'variables.p.where': 'p_max > 0 AND True'}).program + plain = schema_of(DISPATCH_MODEL, **{'variables.p.where': 'p_max > 0'}).program assert written_out.variables['p'] == plain.variables['p'], 'the same mask, so the same declaration' @@ -295,7 +295,7 @@ def test_a_lowered_where_is_a_mask_that_answers_from_its_root(dispatch_program): def test_a_lowered_mask_answers_its_dims_conjuncts_and_atoms(variable, where, dims, conjuncts, atoms): """`Mask.dims` is read off the leaves, which carry their declarations' dims; `atoms` crosses the `OR` that `conjuncts` stops at.""" - mask = to_program(override(SMALL_MODEL, **{f'variables.{variable}.where': where})).variables[variable].where + mask = to_spec(override(SMALL_MODEL, **{f'variables.{variable}.where': where})).program.variables[variable].where assert mask.dims == frozenset(dims) assert len(mask.conjuncts) == conjuncts, 'an OR is one conjunct, a leaf is one conjunct' @@ -382,7 +382,7 @@ def test_a_mask_over_an_unresolved_tree_is_refused_at_construction(): def test_an_unwritten_where_lowers_to_none_not_an_empty_mask(): - lowered = to_program(DISPATCH_MODEL) + lowered = to_spec(DISPATCH_MODEL).program (v,) = lowered.variables.values() (c,) = lowered.constraints.values() @@ -391,7 +391,7 @@ def test_an_unwritten_where_lowers_to_none_not_an_empty_mask(): def test_a_constraint_where_is_a_mask_like_a_variable_s(): - lowered = to_program(override(DISPATCH_MODEL, **{'constraints.balance.where': 'load > 0'})) + lowered = to_spec(override(DISPATCH_MODEL, **{'constraints.balance.where': 'load > 0'})).program (c,) = lowered.constraints.values() assert c.where == Mask(ParameterComparison('load', '>', 0.0, ('snapshot',))) @@ -399,7 +399,7 @@ def test_a_constraint_where_is_a_mask_like_a_variable_s(): def test_a_comparison_of_expressions_lowers_to_program_expressions_on_both_sides(): """The resolved tree holds the core syntax tree; the program holds the vocabulary a consumer reads, and every mask is rebuilt so.""" - program = to_program( + program = to_spec( override( SHAPES_MODEL, **{ @@ -412,7 +412,7 @@ def test_a_comparison_of_expressions_lowers_to_program_expressions_on_both_sides }, }, ) - ) + ).program where = program.variables['p'].where assert where is not None assert where.root == ExpressionComparison(Parameter('c'), '<=', Multiply(Constant(0.5), Parameter('k')), ('g',)), ( @@ -428,12 +428,12 @@ def test_a_comparison_of_expressions_lowers_to_program_expressions_on_both_sides def test_a_predicate_a_leaf_carries_is_lowered_like_any_other_mask(): """A comparison of expressions inside a count is rebuilt too, so a program mask is program vocabulary throughout.""" - program = to_program( + program = to_spec( override( SHAPES_MODEL, **{'constraints.w': {'dims': ['g'], 'where': 'count(c <= 0.5 * k, over=g) >= 2', 'expression': 'p <= c'}}, ) - ) + ).program mask = program.constraints['w'].where assert mask is not None and isinstance(mask.root, CountComparison) assert mask.root.predicate.root == ExpressionComparison( @@ -444,7 +444,7 @@ def test_a_predicate_a_leaf_carries_is_lowered_like_any_other_mask(): def test_a_translated_predicate_keeps_what_it_reads_in_reach(): """A walk that asks a mask what it names has to see through the translation, or the column is silently dropped.""" - program = to_program( + program = to_spec( override( SHAPES_MODEL, **{ @@ -455,7 +455,7 @@ def test_a_translated_predicate_keeps_what_it_reads_in_reach(): } }, ) - ) + ).program mask = program.constraints['w'].where assert mask is not None assert mask.names_read == frozenset({'flag'}), 'the translated half reads the same column as the plain one' @@ -464,7 +464,7 @@ def test_a_translated_predicate_keeps_what_it_reads_in_reach(): def test_a_predicate_read_through_a_relation_is_lowered_and_keeps_the_relation_in_reach(): """The comparison under the read is rebuilt, and the relation is data the consumer binds as well as the operand.""" - program = to_program( + program = to_spec( override( SHAPES_MODEL, **{ @@ -476,7 +476,7 @@ def test_a_predicate_read_through_a_relation_is_lowered_and_keeps_the_relation_i }, }, ) - ) + ).program mask = program.constraints['w'].where assert mask is not None and isinstance(mask.root, PulledBackPredicate) assert mask.root.operand.root == ExpressionComparison( @@ -492,7 +492,7 @@ def test_assumptions_carry_the_file_s_entries_and_the_curves_behind_them(): The file's entries come first, in the order it wrote them; each ``piecewise:`` block's conditions follow under the name a refusal quotes. """ - program = to_program(expanded(EXAMPLES / 'piecewise_lp.yaml', 'piecewise')) + program = expanded(EXAMPLES / 'piecewise_lp.yaml', 'piecewise').program derived = [name for name in program.assumptions if name.startswith('cost_curve_')] assert all(isinstance(a, Assumption) for a in program.assumptions.values()), ( @@ -508,7 +508,7 @@ def test_assumptions_carry_the_file_s_entries_and_the_curves_behind_them(): def test_an_assumption_lowers_both_of_its_masks(): """The predicate and the ``where`` are rebuilt on program expressions, as every other mask is.""" - program = to_program(override(SHAPES_MODEL, assumptions={'sound': {'holds': 'c <= 0.5 * k', 'where': 'flag'}})) + program = to_spec(override(SHAPES_MODEL, assumptions={'sound': {'holds': 'c <= 0.5 * k', 'where': 'flag'}})).program assumption = program.assumptions['sound'] assert assumption == Assumption( @@ -528,7 +528,7 @@ def test_an_assumption_refuses_in_the_words_the_file_wrote(): the sentence quotes it where the file wrote one. """ reason = 'a shape with no room between its bounds cannot be cut' - program = to_program(override(SHAPES_MODEL, assumptions={'sound': {'holds': 'c <= k', 'description': reason}})) + program = to_spec(override(SHAPES_MODEL, assumptions={'sound': {'holds': 'c <= k', 'description': reason}})).program assumption = program.assumptions['sound'] assert assumption.description == reason, 'the program carries it, so a consumer needs no second read of the file' @@ -540,7 +540,7 @@ def test_an_assumption_refuses_in_the_words_the_file_wrote(): def test_a_cased_side_reads_the_data_its_regions_are_decided_by(): """`names_read` promised every parameter and relation the sides read, and dropped the `when:` of a cased entry: the walk descends a `Cases` by its values alone.""" - program = to_program( + program = to_spec( override( SHAPES_MODEL, **{ @@ -552,7 +552,7 @@ def test_a_cased_side_reads_the_data_its_regions_are_decided_by(): 'variables.p.where': 'e > 0', }, ) - ) + ).program where = program.variables['p'].where assert where is not None assert where.names_read == frozenset({'c', 'k', 'flag', 'lk2'}), ( @@ -653,7 +653,7 @@ def test_a_partition_keeps_its_group_when_the_relation_gains_a_value_column(): """ grouping = {} for values in ('day', ['day', 'week']): - program = to_program( + program = to_spec( { 'dimensions': {'hour': {'dtype': 'int'}, 'day': {}, 'week': {}}, 'relations': {'cal': {'key': 'hour', 'values': values}}, @@ -665,7 +665,7 @@ def test_a_partition_keeps_its_group_when_the_relation_gains_a_value_column(): } }, } - ) + ).program grouping[str(values)] = _partition_of(program.constraints['k']).group assert grouping == {'day': ('day',), "['day', 'week']": ('day',)}, ( 'the group is the columns the call named, on both calendars' @@ -681,7 +681,7 @@ def _partition_of(row): def test_a_relation_lowers_with_the_direction_each_call_names(): """Every node reading a relation carries its columns, its key and the direction, so a consumer joins on the right columns.""" - program = to_program( + program = to_spec( { 'dimensions': {'snapshot': {'dtype': 'int'}, 'generator': {}, 'zone': {}}, 'relations': {'zone_of': {'key': ['generator', 'snapshot'], 'values': 'zone'}}, @@ -708,7 +708,7 @@ def test_a_relation_lowers_with_the_direction_each_call_names(): }, }, } - ) + ).program columns = (('generator', 'generator'), ('snapshot', 'snapshot'), ('zone', 'zone')) declared = RelationDeclaration(columns, ('generator', 'snapshot')) @@ -751,9 +751,9 @@ def test_a_relation_lowers_with_the_direction_each_call_names(): def test_a_binary_variable_lowers_to_a_binary_domain(): - program = to_program( - schema_of(DISPATCH_YAML, **{'variables.dispatch.domain': 'binary', 'variables.dispatch.bounds': {}}) - ) + program = schema_of( + DISPATCH_YAML, **{'variables.dispatch.domain': 'binary', 'variables.dispatch.bounds': {}} + ).program assert program.variables['dispatch'].domain == 'binary' @@ -867,7 +867,7 @@ def test_fan_in_reads_through_a_named_expression(): def test_a_relation_is_declared_as_the_file_declares_it(): """One group keyed by name, each entry its columns and its key, and nothing nested under a dimension.""" - program = to_program( + program = to_spec( override( TINY, dimensions={'g': {}, 'bus': {}, 'season': {}}, @@ -876,7 +876,7 @@ def test_a_relation_is_declared_as_the_file_declares_it(): 'at_bus': {'key': 'g', 'values': 'bus'}, }, ) - ) + ).program assert program.relations == { 'season_of': RelationDeclaration((('g', 'g'), ('season', 'season')), ('g',)), @@ -901,13 +901,13 @@ def test_a_program_seals_its_declaration_groups(dispatch_program, group): def test_roots_are_the_trees_a_row_is_built_from(): """`expressions` is the file's own section, which builds no row at all; the row-building trees are `roots`.""" - program = to_program( + program = to_spec( override( TINY, expressions={'spend': 'sum(cost, over=g)'}, objective={'sense': 'minimize', 'expression': 'sum(p * cost, over=g)'}, ) - ) + ).program assert list(program.expressions) == ['spend'], 'the declared ones keep their own name' assert program.roots == ( @@ -922,13 +922,13 @@ def test_roots_are_the_trees_a_row_is_built_from(): def _footprint_of(constraint: str, objective: str) -> Footprint: - return to_program( + return to_spec( override( TINY, constraints={'k': {'dims': ['g'], 'expression': constraint}}, objective={'sense': 'minimize', 'expression': objective}, ) - ).footprint + ).program.footprint def test_the_footprint_says_which_position_a_quadratic_stands_in(): @@ -973,7 +973,7 @@ def test_the_footprint_is_walked_once_and_held(dispatch_program): def test_a_named_expression_is_not_in_the_footprint(): """It builds no row, so counting it would answer wrongly about what is solved.""" - program = to_program(override(TINY, expressions={'spend': 'sum(p * cost, over=g)'})) + program = to_spec(override(TINY, expressions={'spend': 'sum(p * cost, over=g)'})).program assert Parameter not in program.footprint.kinds, "the named expression's parameter reaches no row" assert Parameter in {type(n) for n in walk(program.expressions['spend'].expression)}, ( @@ -987,7 +987,7 @@ def test_a_dimension_carries_the_dtype_its_labels_are_checked_against(): A dimension is read from whatever table carries it, so nothing downstream can infer what the column should have been. """ - program = to_program(override(TINY, **{'dimensions.t': {'dtype': 'int'}})) + program = to_spec(override(TINY, **{'dimensions.t': {'dtype': 'int'}})).program assert program.dimensions['t'].dtype == 'int', 'a declared dtype reaches the plan' assert program.dimensions['g'].dtype == 'str', "and the schema's default does too, rather than nothing" @@ -1021,7 +1021,7 @@ def _cases_in(program: Program) -> Cases: def test_a_cased_expression_lowers_to_one_region_per_case(): """The regions come out in file order, values lowered like any other expression.""" - cases = _cases_in(to_program(CASED)) + cases = _cases_in(to_spec(CASED).program) assert len(cases.regions) == 3, 'one region per case, the `otherwise` among them' assert [type(r.value).__name__ for r in cases.regions] == ['Constant', 'Parameter', 'Translate'], ( @@ -1035,7 +1035,7 @@ def test_the_fallback_region_carries_the_mask_the_file_left_unwritten(): A consumer adds regions rather than working out which one is left over, so the remainder is resolved once here instead of once per consumer. """ - remainder = _cases_in(to_program(CASED)).regions[-1] + remainder = _cases_in(to_spec(CASED).program).regions[-1] assert isinstance(remainder.when.root, And), 'two stated cases, so the remainder is a conjunction of two negations' assert remainder.when.root.left == ParameterDefined('committable', ('g',)), ( @@ -1050,7 +1050,7 @@ def test_a_region_s_when_is_a_mask_with_its_own_dims(): branched on where a mask came from — the divergence the carrier exists to prevent. The synthesized remainder gets its dims like any stated case. """ - always_on, boundary, remainder = _cases_in(to_program(CASED)).regions + always_on, boundary, remainder = _cases_in(to_spec(CASED).program).regions assert all(isinstance(r.when, Mask) for r in (always_on, boundary, remainder)), ( 'every region, the synthesized remainder included, carries its predicate as a Mask' @@ -1068,7 +1068,7 @@ def test_the_lowered_regions_are_still_proved_apart(): same prover, against each stated case, and must overlap none of them. """ spec = schema_of(CASED) - regions = _cases_in(to_program(spec)).regions + regions = _cases_in(spec.program).regions named = {f'region{i}': r.when.root for i, r in enumerate(regions)} assert list(overlapping(named, Namespace(spec).dtypes)) == [], 'no two lowered regions can claim one coordinate' @@ -1076,7 +1076,7 @@ def test_the_lowered_regions_are_still_proved_apart(): def test_a_cased_expression_is_readable_by_the_name_the_file_wrote(): """`Program.expressions` carries it under its name, so a consumer reads it back whole.""" - program = to_program(CASED) + program = to_spec(CASED).program assert isinstance(program.expressions['previous'].expression, Cases), ( 'a cased expression reaches the program as the node, not as its fallback arm alone' @@ -1111,19 +1111,19 @@ def test_a_cased_expression_is_readable_by_the_name_the_file_wrote(): ) def test_an_entry_is_in_the_math_where_the_objective_or_a_constraint_inlines_it(patch, in_math): """`in_math` is usage, not shape: one affine body is in the math when a row inlines it, however indirectly, and a reported quantity when none does.""" - program = to_program(override(TINY, expressions={'spend': 'sum(p * cost, over=g)'}, **patch)) + program = to_spec(override(TINY, expressions={'spend': 'sum(p * cost, over=g)'}, **patch)).program assert program.expressions['spend'].in_math is in_math def test_an_entry_reached_only_through_another_is_in_the_math_with_it(): """The whole chain is in the math, not only the entry a row names: the constraint inlines `twice`, and `twice` inlines `spend`.""" - program = to_program( + program = to_spec( override( TINY, expressions={'spend': 'sum(p * cost, over=g)', 'twice': 'spend * 2'}, **{'constraints.c.expression': 'twice >= 1'}, ) - ) + ).program reads = {name: program.expressions[name].in_math for name in ('twice', 'spend')} assert reads == {'twice': True, 'spend': True}, ( 'the entry the row names and the one it reaches through are both in the math' @@ -1132,14 +1132,14 @@ def test_an_entry_reached_only_through_another_is_in_the_math_with_it(): def test_a_macro_formal_named_like_an_entry_keeps_the_entry_out_of_the_math(): """A formal shadows the entry inside the template, so the row inlines the argument, not the same-named entry.""" - program = to_program( + program = to_spec( override( TINY, expressions={'spend': 'sum(p * cost, over=g)'}, macros={'scaled': {'args': ['spend'], 'template': 'spend * 2'}}, **{'constraints.c.expression': 'scaled(sum(p, over=g)) >= 1'}, ) - ) + ).program assert program.expressions['spend'].in_math is False, ( 'the formal shadows the entry, so the constraint inlines the argument and the math never reads spend' ) @@ -1147,7 +1147,7 @@ def test_a_macro_formal_named_like_an_entry_keeps_the_entry_out_of_the_math(): def test_an_entry_that_reads_a_dual_is_a_reported_quantity(): """A dual is read after the solve, so an entry calling one is never in the math: it lowers to a Dual leaf and stays reported.""" - program = to_program(override(TINY, expressions={'shadow_price': 'dual(c)'})) + program = to_spec(override(TINY, expressions={'shadow_price': 'dual(c)'})).program declaration = program.expressions['shadow_price'] assert declaration.in_math is False, 'the entry reading a dual is reported, never in the math' assert isinstance(declaration.expression, Dual), 'and it lowers to a Dual leaf' @@ -1174,11 +1174,11 @@ def test_a_lowered_spec_still_pickles_and_lowers_to_the_same_program(): 'objective': {'sense': 'minimize', 'expression': 'sum(spend)'}, } ) - program = to_program(spec) + program = spec.program copy = pickle.loads(pickle.dumps(spec)) assert copy.model_dump() == spec.model_dump() - assert to_program(copy) == program, 'the copy lowers to the program the original did' + assert to_spec(copy).program == program, 'the copy lowers to the program the original did' def test_a_lowered_program_pickles_and_is_the_same_program(): @@ -1192,7 +1192,7 @@ def test_a_lowered_program_pickles_and_is_the_same_program(): """ import pickle - program = to_program( + program = to_spec( { 'dimensions': {'t': {'dtype': 'int'}, 'g': {'dtype': 'str'}}, 'parameters': {'load': {'dims': ['t']}, 'cost': {'dims': ['g']}}, @@ -1201,7 +1201,7 @@ def test_a_lowered_program_pickles_and_is_the_same_program(): 'expressions': {'spend': 'sum(p * cost, over=g)'}, 'objective': {'sense': 'minimize', 'expression': 'sum(spend)'}, } - ) + ).program assert program.separability['t'].ahead == 0 and program.footprint is not None, 'the caches are filled first' copy = pickle.loads(pickle.dumps(program)) @@ -1216,7 +1216,7 @@ def test_two_groups_of_a_program_merge_with_or_as_they_did_behind_the_proxy(): when the groups were `MappingProxyType`s — a consumer that walks every declaration this way (lpspec's parity harness does) broke on alpha.78, where the seal answered `|` with a `TypeError`.""" - program = to_program( + program = to_spec( { 'dimensions': {'t': {'dtype': 'int'}}, 'parameters': {'load': {'dims': ['t']}}, @@ -1224,7 +1224,7 @@ def test_two_groups_of_a_program_merge_with_or_as_they_did_behind_the_proxy(): 'constraints': {'meet': {'dims': ['t'], 'expression': 'p >= load'}}, 'objective': {'sense': 'minimize', 'expression': 'sum(p)'}, } - ) + ).program merged = program.constraints | program.variables assert isinstance(merged, dict), 'a merge is a plain dict, as the proxy gave' assert list(merged) == ['meet', 'p'], 'both groups, the left one first' diff --git a/tests/test_piecewise.py b/tests/test_piecewise.py index 019eb534..e1d210e8 100644 --- a/tests/test_piecewise.py +++ b/tests/test_piecewise.py @@ -15,7 +15,6 @@ from math_spec import CURVATURES from math_spec.errors import LanguageError, SchemaError -from math_spec.lowering import to_program from math_spec.piecewise import expand_piecewise from math_spec.program import Assumption, Variable, assumption_message from tests.fixtures import DISPATCH_MODEL, expanded, override, raw_of, schema_of @@ -114,7 +113,7 @@ def test_a_program_mirrors_the_model_it_was_lowered_from(): consumer printing it and another to one building rows, as a set is. """ schema = schema_of(NONCONVEX_YAML) - program, rows = to_program(schema), to_program(schema.expand('piecewise')) + program, rows = schema.program, schema.expand('piecewise').program curve = program.piecewise['cost_curve'] assert [link.values for link in curve.links] == ['bp_x', 'bp_y'] and curve.frame == ('snapshot',), ( @@ -125,7 +124,7 @@ def test_a_program_mirrors_the_model_it_was_lowered_from(): assert not rows.piecewise and {'cost_curve_lam', 'p', 'op_cost'} <= set(rows.variables), ( 'the expansion carries the rows and no curve' ) - assert to_program(schema.expand()).sos == {} and rows.sos == {}, ( + assert schema.expand().program.sos == {} and rows.sos == {}, ( 'an adjacency block writes its own set out; a caller writes the rest out with expand()' ) @@ -406,7 +405,7 @@ def test_an_entry_a_link_reads_is_in_the_math(): NONCONVEX_YAML, **{'expressions': {'twice': 'p * 2'}, 'piecewise.cost_curve.links': [['twice', 'bp_x'], ['op_cost', 'bp_y']]}, ) - assert to_program(schema.expand('piecewise')).expressions['twice'].in_math is True + assert schema.expand('piecewise').program.expressions['twice'].in_math is True def test_a_link_reading_a_dual_entry_is_refused(): @@ -471,7 +470,7 @@ def test_a_gate_that_is_not_a_variable_is_refused(activity, match): def test_a_method_names_the_curvature_it_is_exact_for(raw, expected): """The consumer holding the breakpoints checks the shape; this says what to check for.""" stated = [ - a.description for n, a in to_program(expanded(raw, 'piecewise')).assumptions.items() if n.endswith('_curvature') + a.description for n, a in expanded(raw, 'piecewise').program.assumptions.items() if n.endswith('_curvature') ] answer = next((c for c in CURVATURES if stated and f'a {c} curve' in stated[0]), 'either' if stated else None) assert answer == expected, 'the curvature the method is exact for is the shape its sentence names' @@ -497,7 +496,7 @@ def test_a_masked_lp_curve_sits_its_rows_on_predicates_rather_than_on_parameters caller never supplied and a derivation in private state filled. The ``where`` language writes each of them, so the rows carry the predicate and the program declares the file's parameters and no other.""" - program = to_program(schema_of(LP_MASKED).expand('piecewise')) + program = schema_of(LP_MASKED).expand('piecewise').program rows = {name: program.constraints[f'cost_curve_{name}'].where for name in ('chord', 'domain_lo', 'domain_hi')} assert set(program.parameters) == {'bp_x', 'bp_y', 'load'}, 'every parameter is one the file declared' @@ -510,14 +509,10 @@ def test_a_masked_lp_curve_sits_its_rows_on_predicates_rather_than_on_parameters def test_a_file_supplied_mask_is_what_the_contiguity_condition_reads(): """A ``points:`` naming a parameter the file declared is bound like any other, and the mask check names it.""" - program = to_program( - expanded( - override( - LP, **{'parameters.reach': {'dims': ['bp'], 'dtype': 'bool'}, 'piecewise.cost_curve.points': 'reach'} - ), - 'piecewise', - ) - ) + program = expanded( + override(LP, **{'parameters.reach': {'dims': ['bp'], 'dtype': 'bool'}, 'piecewise.cost_curve.points': 'reach'}), + 'piecewise', + ).program contiguous = program.assumptions['cost_curve_contiguous'] assert contiguous.predicate.names_read == frozenset({'reach'}), ( @@ -550,12 +545,12 @@ def test_a_gap_is_explained_by_the_rows_the_method_writes(method, reason): }, ) - assert reason in to_program(spec.expand()).assumptions['cost_curve_contiguous'].description + assert reason in spec.expand().program.assumptions['cost_curve_contiguous'].description def test_a_block_assumes_of_its_data_what_the_method_implies(): """Every condition a curve puts on its data stands with the file's own, carrying its own subjects.""" - program = to_program(expanded(LP_MASKED, 'piecewise')) + program = expanded(LP_MASKED, 'piecewise').program assert list(program.assumptions) == [ 'cost_curve_complete', @@ -571,7 +566,7 @@ def test_a_block_assumes_of_its_data_what_the_method_implies(): 'the x-axis is what increases, and the condition reads it and nothing else' ) - plain = to_program(expanded(NONCONVEX_YAML, 'piecewise')) + plain = expanded(NONCONVEX_YAML, 'piecewise').program assert list(plain.assumptions) == ['cost_curve_complete'], ( 'adjacency is exact for a curve of any shape, so it states nothing about the shape — but every ' 'curve states that its breakpoints are there, whatever the method' @@ -588,7 +583,7 @@ def test_a_curves_conditions_cannot_collide_with_a_written_assumption(): @pytest.mark.parametrize('suffix', ['increasing', 'curvature', 'breakpoints', 'contiguous']) def test_every_check_has_a_sentence(suffix): - assumptions = to_program(expanded(LP_MASKED, 'piecewise')).assumptions + assumptions = expanded(LP_MASKED, 'piecewise').program.assumptions name = f'cost_curve_{suffix}' assert name in assumptions, 'the fixture is the block that assumes everything' message = assumption_message(name, assumptions[name]) diff --git a/tests/test_program_nodes.py b/tests/test_program_nodes.py index 34f7f7bc..330f9567 100644 --- a/tests/test_program_nodes.py +++ b/tests/test_program_nodes.py @@ -20,7 +20,7 @@ import pytest -import math_spec as ms +from math_spec import to_spec from math_spec.program import Expression, Program, walk FIXTURE = Path(__file__).resolve().parent / 'fixtures' / 'every_program_node.yaml' @@ -44,7 +44,7 @@ def _expressions(program: Program) -> list[Expression]: @pytest.fixture(scope='module') def kinds() -> tuple[set[str], set[str]]: """The node classes the fixture lowers to, and the ones `Expression` declares.""" - program = ms.to_program(FIXTURE) + program = to_spec(FIXTURE).program reached = {type(node).__name__ for node in walk(*_expressions(program))} declared = {node.__name__ for node in get_args(Expression)} return reached, declared diff --git a/tests/test_public_surface.py b/tests/test_public_surface.py index 11b040e0..960d459a 100644 --- a/tests/test_public_surface.py +++ b/tests/test_public_surface.py @@ -24,7 +24,7 @@ SURFACE = frozenset( { # the two public states, and the conversion to each - 'Spec', 'to_spec', 'program', 'to_program', + 'Spec', 'to_spec', 'program', # the error tree 'MathSpecError', 'LanguageError', 'SchemaError', 'DimensionError', 'did_you_mean', 'schema_error', @@ -44,7 +44,7 @@ #: What `Spec` promises beyond the sections a file declares: the two ways back #: out and the verb that writes a formulation out. A `model_`-prefixed name is #: pydantic's, not a contract this project keeps. -SPEC_SURFACE = frozenset({'to_dict', 'to_yaml', 'expand'}) +SPEC_SURFACE = frozenset({'to_dict', 'to_yaml', 'expand', 'program'}) #: The modules whose `__all__` a consumer imports from. MODULES = [ diff --git a/tests/test_separability.py b/tests/test_separability.py index dc6be223..e4dd817c 100644 --- a/tests/test_separability.py +++ b/tests/test_separability.py @@ -18,7 +18,7 @@ import pytest -import math_spec as ms +from math_spec import to_spec from math_spec.program import Reach FIXTURE = Path(__file__).resolve().parent / 'fixtures' / 'every_program_node.yaml' @@ -38,7 +38,7 @@ def _verdict(dimension: str = 'h', **patch: Any): - return ms.to_program({**BASE, **patch}).separability[dimension] + return to_spec({**BASE, **patch}).program.separability[dimension] def _rows(expression: str, *, dims: list[str] | None = None, **block: Any) -> dict[str, Any]: @@ -213,12 +213,12 @@ def test_the_lookahead_is_the_widest_reach_of_any_block(): def test_a_grouping_that_consumes_the_axis_couples_it(): - program = ms.to_program( + program = to_spec( { **BASE, 'constraints': {'z': {'dims': ['h', 'zone'], 'expression': 'sum(p, by=zone_of, over=u, into=zone) <= cap'}}, } - ) + ).program verdict = program.separability['u'] assert not verdict.windowable, 'the grouping consumes u, so a window of u is a different sum' @@ -227,7 +227,7 @@ def test_every_declared_axis_has_a_verdict_and_nothing_else_does(): """The mapping is complete over the program's dimensions, so an axis nothing mentions is trivially windowable rather than missing, and a name that is not an axis is a `KeyError` rather than a verdict nobody should trust.""" - program = ms.to_program({**BASE, **_rows('p >= 0')}) + program = to_spec({**BASE, **_rows('p >= 0')}).program assert sorted(program.separability) == sorted(program.dimensions), 'every declared axis is answered for' assert program.separability['zone'].windowable, 'an axis no construct mentions is trivially windowable' with pytest.raises(KeyError): @@ -238,7 +238,7 @@ def test_every_declared_axis_has_a_verdict_and_nothing_else_does(): def test_every_node_a_program_can_carry_is_judged_without_raising(dimension): """The fixture the node fence maintains carries every construct, so this is the pass meeting each of them at least once.""" - verdict = ms.to_program(FIXTURE).separability[dimension] + verdict = to_spec(FIXTURE).program.separability[dimension] assert isinstance(verdict.ahead, int), 'a verdict comes back for every axis of the widest model there is' @@ -246,7 +246,7 @@ def test_a_reduction_over_several_axes_couples_every_one_of_them(): """`sum(p)` with no `over=` collapses every dimension its operand carries, so the verdict for each of them has to say so — a walk that read only the first would call the rest windowable.""" - program = ms.to_program({**BASE, 'constraints': {'all': {'dims': [], 'expression': 'sum(p) <= budget'}}}) + program = to_spec({**BASE, 'constraints': {'all': {'dims': [], 'expression': 'sum(p) <= budget'}}}).program assert not program.separability['h'].windowable, 'the reduction consumes h' assert not program.separability['u'].windowable, 'and u, in the same node' diff --git a/tests/test_sos.py b/tests/test_sos.py index a1fd6b00..cd1c059a 100644 --- a/tests/test_sos.py +++ b/tests/test_sos.py @@ -14,7 +14,6 @@ import pytest from math_spec.errors import SchemaError -from math_spec.lowering import to_program from tests.fixtures import SMALL_MODEL, expanded, override, schema_of #: A set over a bounded member, which is the smallest model `expand('sos')` acts on. @@ -97,15 +96,15 @@ def test_the_emitted_binary_carries_the_members_own_mask(): def test_a_set_emits_no_parameter_so_the_same_sources_bind_both(): - program, written_out = to_program(schema_of(PICKED)), to_program(schema_of(PICKED).expand('sos')) + program, written_out = schema_of(PICKED).program, schema_of(PICKED).expand('sos').program assert set(written_out.parameters) == set(program.parameters), 'a set states rows and columns, never data' def test_the_adjacency_method_is_the_sos2_curve_with_its_set_written_out(): """The one spelling of the binaries, so the two methods cannot drift apart.""" - sos2 = to_program(schema_of(CURVE).expand()) - adjacency = to_program(expanded(override(CURVE, **{'piecewise.cost_curve.method': 'adjacency'}), 'piecewise')) + sos2 = schema_of(CURVE).expand().program + adjacency = expanded(override(CURVE, **{'piecewise.cost_curve.method': 'adjacency'}), 'piecewise').program assert sos2.variables == adjacency.variables assert sos2.constraints == adjacency.constraints diff --git a/tests/test_validation.py b/tests/test_validation.py index 23bd35c7..c3ebbd17 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -14,7 +14,6 @@ from math_spec._yaml import parse_yaml from math_spec.errors import DimensionError, LanguageError, SchemaError -from math_spec.lowering import to_program from math_spec.program import DimensionPosition from math_spec.resolution import Namespace from math_spec.typesetting import to_markdown @@ -195,7 +194,7 @@ def test_an_unreferenced_nonlinear_entry_loads_and_is_reported(self): nothing consumes. """ model = override(SMALL_MODEL, expressions={'lcoe': 'c / sum(p)'}) - assert to_program(model).expressions['lcoe'].in_math is False, ( + assert to_spec(model).program.expressions['lcoe'].in_math is False, ( 'the unread nonlinear body loads rather than being refused, and nothing in the math reads it' ) assert 'lcoe' in to_markdown(model), 'and the page prints it, under its own name' @@ -2035,7 +2034,7 @@ def test_a_chain_of_named_expressions_is_held_to_the_resolved_depth_and_costs_no chain = _chain(150, deepest_first=deepest_first) constraint = {'dims': ['snapshot'], 'expression': 'sum(p, over=generator) <= e149'} spec = to_spec(override(DISPATCH_MODEL, expressions=chain, **{'constraints.c': constraint})) - to_markdown(to_program(spec) and spec) + to_markdown(spec.program and spec) with pytest.raises(LanguageError, match='nests 301 deep with every named expression it reads written in') as caught: to_spec(override(DISPATCH_MODEL, expressions=_chain(151, deepest_first=deepest_first))) @@ -2096,7 +2095,7 @@ def record(*args, **kwargs): }, ) ) - to_program(spec) + _ = spec.program to_markdown(spec) assert sorted(seen) == [ diff --git a/tests/typesetting/test_cases.py b/tests/typesetting/test_cases.py index ca1cf26a..97a3655d 100644 --- a/tests/typesetting/test_cases.py +++ b/tests/typesetting/test_cases.py @@ -11,7 +11,7 @@ import pytest -from math_spec import to_latex, to_program, typeset +from math_spec import to_latex, to_spec, typeset from math_spec.typesetting.symbols import chosen_expressions from tests.fixtures import DISPATCH_MODEL as DISPATCH from tests.fixtures import override @@ -133,7 +133,7 @@ def test_a_variable_reached_through_another_cased_expression_still_prints_chosen holds a variable. A walk stopping at the inner block would print the outer one upright — a quantity the solver decides, set as one the model was handed. """ - assert chosen_expressions(to_program(_NESTED)) == {'headroom', 'opening_cost'}, ( + assert chosen_expressions(to_spec(_NESTED).program) == {'headroom', 'opening_cost'}, ( 'the chain is followed to its end, so both are chosen' ) diff --git a/tests/typesetting/test_golden.py b/tests/typesetting/test_golden.py index 224857b6..1463937e 100644 --- a/tests/typesetting/test_golden.py +++ b/tests/typesetting/test_golden.py @@ -15,7 +15,6 @@ import pytest -from math_spec.lowering import to_program from math_spec.operators import BUILTIN_NAMES from math_spec.program import Dual, Expression, GroupSum, Named, Predicate, Pullback, Sum, Translate, WindowSum from math_spec.typesetting import FORMATS, to_latex, typeset, walk @@ -125,7 +124,7 @@ def _rendered_trees() -> Iterator[object]: printed at all. """ schema = to_spec(golden.MODEL) - program = to_program(schema) + program = schema.program assert program.objective is not None yield program.objective.expression for name in schema.constraints: diff --git a/tests/typesetting/test_walk.py b/tests/typesetting/test_walk.py index 0ce66041..583d68b3 100644 --- a/tests/typesetting/test_walk.py +++ b/tests/typesetting/test_walk.py @@ -12,7 +12,6 @@ import pytest from math_spec.errors import LanguageError -from math_spec.lowering import to_program from math_spec.typesetting import FORMATS, SymbolTable, to_latex, to_markdown, typeset, typeset_declaration from math_spec.typesetting.format import OPERATOR_NAMES from math_spec.typesetting.symbols import Symbols, _derive_name_symbol, chosen_expressions @@ -513,14 +512,14 @@ def test_nothing_the_model_is_given_prints_italic(): """The convention as a property of the whole document, not of a fragment: a rendering path added later reaches the page through its own call.""" schema = to_spec(golden.MODEL) - computed = set(schema.variables) | chosen_expressions(to_program(schema)) + computed = set(schema.variables) | chosen_expressions(schema.program) italic = {m.replace(r'\_', '_') for m in re.findall(r'\\mathit\{([^}]*)\}', to_latex(golden.MODEL))} assert italic <= computed, ( f'{sorted(italic - computed)} print italic and are neither chosen by the solver nor read off its ' f'solution — upright is what the model is given, italic what it computes' ) - symbols = Symbols(to_program(schema), LATEX, SymbolTable('latex')) + symbols = Symbols(schema.program, LATEX, SymbolTable('latex')) given = {name: symbols.name[name] for name in schema.parameters} assert all(symbol.startswith(r'\mathrm{') for symbol in given.values()), ( f'derived upright for every parameter, but got {sorted(s for s in given.values() if "mathrm" not in s)}' From 43e1970760b8a9b6483499097b293b20540224c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 15:54:49 +0000 Subject: [PATCH 07/20] chore(model): a model's program is a cached property, forced once as the model loads The private attribute lowering filled and the property asserting over it are one cached_property computing lower(self); the after-validator forces it, so a Spec in hand has still passed the whole language. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi --- src/math_spec/model.py | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/src/math_spec/model.py b/src/math_spec/model.py index d0444002..427ee4cb 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -11,6 +11,7 @@ import math import re +from functools import cached_property from typing import TYPE_CHECKING, Annotated, ClassVar, Literal, Self, cast, get_args, override from pydantic import ( @@ -725,11 +726,6 @@ class is pydantic's, not a contract this package keeps. #: that expands to itself is not stored: two of them compare by their #: private state, which a model holding itself cannot answer. _expansions: dict[tuple[Formulation, ...], Spec] = PrivateAttr(default_factory=dict) - #: What :attr:`program` answers with, built as the model loads: computing - #: it *is* the expression pass, so a model the language refuses never - #: holds one. - _program: Program | None = PrivateAttr(default=None) - #: Which language surface this file is written against. Absent means 0, so #: the field is additive. **0 means unstable** — the surface may change in #: any release — and declaring it is what lets a later reader refuse a file @@ -750,19 +746,21 @@ class is pydantic's, not a contract this package keeps. sos: dict[str, SosBlock] = {} assumptions: dict[str, AssumptionBlock] = {} - @property + @cached_property def program(self) -> Program: """This model typed, section for section — what every reader after load walks. - Built once, as the model loaded, so every ask is the same object. It - mirrors the model: a ``piecewise:`` block still in it is a curve under - ``program.piecewise`` and a ``sos:`` block a set under ``program.sos``, - and :meth:`expand` is what writes either out as rows, so a consumer - building rows reads ``spec.expand(...).program`` and refuses a block - it does not take. + Computing it *is* the expression pass, so a model the language refuses + raises here; loading forces it, so every ask on a model in hand is the + one object. It mirrors the model: a ``piecewise:`` block still in it is + a curve under ``program.piecewise`` and a ``sos:`` block a set under + ``program.sos``, and :meth:`expand` is what writes either out as rows, + so a consumer building rows reads ``spec.expand(...).program`` and + refuses a block it does not take. """ - assert self._program is not None, 'a model that loaded was lowered' - return self._program + from math_spec.lowering import lower + + return lower(self) @classmethod @override @@ -901,9 +899,7 @@ def _lower(self) -> Spec: rows a curve states are held to the language when :meth:`expand` writes them out, since an expansion is a model like any other. """ - from math_spec.lowering import lower - - self._program = lower(self) + _ = self.program return self From a2db22c0af205cff9b7e877b782dcbd1c238f370 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 16:25:26 +0000 Subject: [PATCH 08/20] chore(resolution): the expression walk and the where walk are two modules beside the namespace _Resolver, one class over both grammars, is ExpressionResolver in _expression_resolver.py and WhereResolver in _where_resolver.py, the where walk building a side that is an expression through the expression walk. resolution.py keeps the Namespace and the doors lowering calls. The three methods the where walk reads from the expression walk are public on it; names_in and the literal-number helper move to the parser module beside the other helpers over parsed nodes. Every message is the same string. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi --- src/math_spec/_expression_parser.py | 16 + src/math_spec/_expression_resolver.py | 739 +++++++++++++ src/math_spec/_where_resolver.py | 745 +++++++++++++ src/math_spec/resolution.py | 1418 +------------------------ 4 files changed, 1515 insertions(+), 1403 deletions(-) create mode 100644 src/math_spec/_expression_resolver.py create mode 100644 src/math_spec/_where_resolver.py diff --git a/src/math_spec/_expression_parser.py b/src/math_spec/_expression_parser.py index 891d2db9..8b2557fe 100644 --- a/src/math_spec/_expression_parser.py +++ b/src/math_spec/_expression_parser.py @@ -418,3 +418,19 @@ def parse_expression(text: str) -> ParsedNode: grammar's own complaint. """ return parse_text(_GRAMMAR, text, 'expression', _named_rewrite, children, _DEEP_REWRITE) + + +def names_in(value: ArithmeticNode) -> tuple[str, ...]: + """The names a relation kwarg carries: one bare, several bracketed, none otherwise.""" + if isinstance(value, NameNode): + return (value.name,) + return value.names if isinstance(value, NameListNode) else () + + +def literal_number(value: ArithmeticNode) -> NumberNode | None: + """The number a literal names, its sign folded in — ``None`` where *value* is not one.""" + if isinstance(value, NumberNode): + return value + if isinstance(value, UnaryOperatorNode) and isinstance(value.operand, NumberNode): + return NumberNode(-value.operand.value if value.op == '-' else value.operand.value) + return None diff --git a/src/math_spec/_expression_resolver.py b/src/math_spec/_expression_resolver.py new file mode 100644 index 00000000..2d0f7423 --- /dev/null +++ b/src/math_spec/_expression_resolver.py @@ -0,0 +1,739 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""The expression walk of name resolution: an arithmetic syntax tree into the program's expression nodes. + +Every operator call is read here — its shape against :data:`~math_spec.operators.BUILTINS`, +its dimension and relation arguments against the namespace — and the node it +stands for is built. :mod:`math_spec.resolution` holds the namespace and the +doors that call this. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, assert_never, cast + +from math_spec._expression_parser import ( + MAX_DEPTH, + ArithmeticNode, + BinaryOperatorNode, + FunctionCallNode, + KeywordNode, + NameListNode, + NameNode, + NumberNode, + UnaryOperatorNode, + depth, + literal_number, + names_in, + shown, +) +from math_spec.dimensions import dims_of +from math_spec.errors import DimensionError, SchemaError, did_you_mean +from math_spec.model import NUMERIC_DTYPES +from math_spec.operators import ( + AMOUNTS, + BUILTINS, + EDGE_WRAP, + call_shape_error, + edge_error, + unknown_operator_message, +) +from math_spec.program import ( + Add, + Constant, + Direction, + Divide, + Dual, + Expression, + GroupSum, + Multiply, + Negate, + Parameter, + Partition, + Power, + Pullback, + Sum, + Translate, + Variable, + WindowSum, + carries_variable, + children, +) + +if TYPE_CHECKING: + from collections.abc import Mapping + + from math_spec.resolution import Namespace + + +#: An ``edge=`` as a translation carries it: whether it wraps, and the number +#: the vacated positions contribute where it does not. +_Edge = tuple[bool, float | None] + +#: How deep a resolved tree may be with every named expression it reads +#: written in — the tree every pass after resolution recurses over. Three +#: times what one text may nest, since a text reads other texts: a chain of +#: 200 entries survived every pass on a default stack and 250 did not (#643). +MAX_RESOLVED_DEPTH = 3 * MAX_DEPTH + + +@dataclass(frozen=True) +class ExpressionResolver: + """One resolution walk over an expression, and the three things every step of it reads. + + A node that cannot be built comes back as ``None`` with its refusal + appended to ``errors``; every sibling is still read, so a declaration + with two faults reports both. ``formals`` are a macro template's formals: + a formal has no kind until a call site binds it, so a node one stands + under is ``None`` with nothing appended. + """ + + ns: Namespace + context: str + errors: list[str] + formals: frozenset[str] = frozenset() + + def _formal(self, value: ArithmeticNode) -> bool: + """Whether *value* is a formal, left for the call site to bind.""" + return isinstance(value, NameNode) and value.name in self.formals + + def build(self, node: ArithmeticNode) -> Expression | None: + """The program tree *node* stands for, held to :data:`MAX_RESOLVED_DEPTH` before anything walks it. + + The depth is measured with every named expression written in, since + that is the tree every later pass recurses over, and measured with an + explicit stack, since a recursion would be the crash it prevents. + """ + resolved = self.arith(node) + if resolved is None or (found := depth(resolved, children)) <= MAX_RESOLVED_DEPTH: + return resolved + self.errors.append( + f'{self.context}: the expression nests {found} deep with every named expression it reads written in, ' + f'past the {MAX_RESOLVED_DEPTH} levels the language admits. Reduce over a dimension with sum() rather ' + f'than writing the terms out, or precompute the deepest part as a parameter — a named expression ' + f'stands inline where it is read, so naming a part does not make the tree shallower.' + ) + return None + + def arith(self, node: ArithmeticNode) -> Expression | None: + """The program node *node* stands for, or ``None``. + + A quoted keyword or a name list in arithmetic arrives through a macro + formal bound to one. + """ + if isinstance(node, NumberNode): + return Constant(node.value) + if isinstance(node, NameNode): + return self._name(node) + if isinstance(node, UnaryOperatorNode): + operand = self.arith(node.operand) + if operand is None: + return None + return Negate(operand) if node.op == '-' else operand + if isinstance(node, BinaryOperatorNode): + return self._binary(node) + if isinstance(node, FunctionCallNode): + return self._call(node) + if isinstance(node, KeywordNode): + self.errors.append( + f'{self.context}: {node.value!r} is a quoted keyword, which is only legal as a ' + f"operator kwarg value such as shift(..., edge='wrap'). In an expression, quote " + f'nothing — names resolve and numbers are written bare.' + ) + return None + if isinstance(node, NameListNode): + self.errors.append( + f'{self.context}: {node} is a list of names, which is only legal as an operator ' + f'kwarg value such as sum(x, by=[gen_bus, gen_tech]). In an expression, write the ' + f'terms out and add them.' + ) + return None + assert_never(node) + + def _binary(self, node: BinaryOperatorNode) -> Expression | None: + """A subtraction is an addition of the negation, so a program has one additive node.""" + left, right = self.arith(node.left), self.arith(node.right) + if left is None or right is None: + return None + match node.op: + case '+': + return Add(left, right) + case '-': + return Add(left, Negate(right)) + case '*': + return Multiply(left, right) + case '/': + return Divide(left, right) + case '**': + return Power(left, right) + case _: + assert_never(node.op) + + def _name(self, node: NameNode) -> Expression | None: + """A bare name as the variable, parameter or named expression it declares; a dimension or relation is not a value. + + A named expression arrives as the one node :meth:`Namespace.named` + built for it; the cast is the one place a + :class:`~math_spec.program.Named` enters a tree typed as a program's, + which lowering makes true. + """ + if node.name in self.formals: + return None + if node.name in self.ns.schema.expressions: + try: + return cast('Expression', self.ns.named(node.name, self.context)) + except SchemaError as e: + self.errors.append(str(e)) + return None + match self.ns.kind(node.name): + case 'variable': + return Variable(node.name) + case 'parameter': + dtype = self.ns.dtypes.get(node.name) + if dtype is not None and dtype not in NUMERIC_DTYPES: + self.errors.append(not_a_number(node.name, dtype, self.context)) + return None + return Parameter(node.name) + case 'dimension': + self.errors.append( + f"{self.context}: '{node.name}' is a dimension, and a dimension is " + f'not a value in an expression. Dimensions appear in ' + f"'dims:', in operator arguments (sum(x, over={node.name})), " + f'and in where-comparisons — to use its coordinates as data, ' + f'declare a parameter over it.' + ) + return None + case 'relation': + self.errors.append( + f"{self.context}: '{node.name}' is a relation, and a relation is structure " + f'rather than data, so it is not a value in an expression. A relation ' + f'appears in a helper (sum(x, by={node.name})) and in a where — to ' + f'carry numbers along this dimension, declare a parameter over it.' + ) + return None + case _: + self.errors.append(self.ns.unknown(node.name, self.context, allow_dims=False, formals=self.formals)) + return None + + def _call(self, node: FunctionCallNode) -> Expression | None: + """An operator call as the node it is: its shape checked, and each kwarg read by the kind the operator declares for it. + + Every argument is read even after one failed, so a call with two + faults reports both. A formal anywhere under the call builds nothing + and refuses nothing. + """ + if node.name not in BUILTINS: + self.errors.append(f'{self.context}: {unknown_operator_message(node.name)}') + return None + builtin = BUILTINS[node.name] + shape_error = call_shape_error(node.name, len(node.args), node.kwargs) + if shape_error is not None: + self.errors.append(f'{self.context}: {shape_error}') + if node.name == 'dual': + return None if shape_error is not None else self._dual(node) + args = [self.arith(a) for a in node.args] + with_relation = any(k in node.kwargs for k in builtin.relation_kwargs) + roles = {k: v for k, v in node.kwargs.items() if builtin.kind_of(k, with_relation=with_relation) == 'role'} + unrelated = bool(roles) and 'by' not in node.kwargs + if unrelated: + self.errors.append( + f'{self.context}: {node.name}({", ".join(f"{k}=" for k in roles)}) names a column of a relation, ' + f'and no by= names the relation. Write {builtin.usage}' + ) + dims: dict[str, str | None] = {} + amounts: dict[str, int | str | None] = {} + edge: _Edge | None = None + for key, value in node.kwargs.items(): + match builtin.kind_of(key, with_relation=with_relation): + case 'edge': + edge = self._edge(value, node.name) + case 'dimension': + dims[key] = self._dim_ref(value, node.name, key) + case 'value': + amounts[key] = self._amount(value, node.name, key) + case 'relation' | 'role' | None: + pass + read = None + if 'by' in node.kwargs and builtin.kind_of('by') == 'relation': + read = self.relation_ref(node.kwargs['by'], node.name, 'by', roles, dims.get('along')) + unread = ( + shape_error is not None + or unrelated + or not args + or args[0] is None + or None in dims.values() + or None in amounts.values() + or ('edge' in node.kwargs and edge is None) + or ('by' in node.kwargs and read is None) + ) + if unread: + return None + return self._built(node.name, cast('Expression', args[0]), dims, amounts, edge, read) + + def _built( + self, + operator: str, + operand: Expression, + dims: Mapping[str, str | None], + amounts: Mapping[str, int | str | None], + edge: _Edge | None, + read: Direction | Partition | None, + ) -> Expression | None: + """The node *operator* builds from its read arguments, or ``None`` with the refusal appended.""" + if operator == 'sum': + if read is not None: + assert isinstance(read, Direction), 'a sum reads its relation in a direction' + return GroupSum(operand, read) + if (over := dims.get('over')) is not None: + return Sum(operand, (over,)) + return self._bare_sum(operand) + if operator == 'at': + assert isinstance(read, Direction), 'at reads its relation in a direction' + return Pullback(operand, read) + assert read is None or isinstance(read, Partition), 'a translation reads its relation as a partition' + along = dims['along'] + assert along is not None + wrap, fill = edge if edge is not None else (False, None) + if operator == 'shift': + offset = amounts['offset'] + assert offset is not None + if not self._edge_fits(operand, offset, wrap=wrap, fill=fill): + return None + return Translate(operand, along, offset, wrap=wrap, fill=fill, partition=read) + if fill is not None: + self.errors.append( + f"{self.context}: sum_back(edge=...) takes 'wrap' or nothing. A window sums the terms " + f'it reaches, so a position before the first contributes nothing rather than a ' + f'fill value; add the constant to the expression if you want one.' + ) + return None + width = amounts['window'] + assert width is not None + return WindowSum(operand, along, width, wrap=wrap, partition=read) + + def _bare_sum(self, operand: Expression) -> Expression | None: + """``sum(x)`` with no ``over=`` or ``by=`` reduces every dim the operand carries, which it has to carry some of.""" + try: + inner = dims_of(operand, self.ns.schema, self.context) + except DimensionError as e: + self.errors.append(str(e)) + return None + if not inner: + self.errors.append( + f'{self.context}: sum() with no over= or by= sums every dim the operand ' + f'carries, and this one carries none — the expression is already a ' + f'scalar. Drop the sum.' + ) + return None + return Sum(operand, tuple(sorted(inner))) + + def _edge_fits(self, operand: Expression, offset: int | str, *, wrap: bool, fill: float | None) -> bool: + """What a ``shift``'s ``edge=`` may say, and where saying nothing is an answer. + + Every rule here is decidable from the file — whether the operand + carries a variable, whether the offset is named, what the edge is + written as — so a file breaking one is refused at load rather than by + whoever lowers it. + """ + if wrap: + return True + has_var = carries_variable(operand) + if has_var and fill is not None and fill != 0: + self.errors.append( + f'{self.context}: shift(edge={fill:g}) over an expression containing a variable — only ' + f'fill=0 is representable there, since a vacated slot contributes no term. A nonzero ' + f'fill would be a constant standing where a term was; add that constant to the ' + f'expression instead.' + ) + return False + if fill is None and _vacates(offset) and not has_var: + self.errors.append(_shift_over_data_message(self.context)) + return False + if fill is None and isinstance(offset, str): + self.errors.append(f'{self.context}: {_named_offset_edge_message(offset)}') + return False + return True + + def _amount(self, value: ArithmeticNode, operator: str, key: str) -> int | str | None: + """``offset=`` or ``window=``: a whole number in the operator's range, or the name of a parameter. + + Closed so that :func:`math_spec.dimensions._check_named_amount` sees + every parameter an amount carries, and so that a program's + ``offset`` and ``width`` are the ``int | str`` they say. + """ + words = AMOUNTS[operator] + if (literal := literal_number(value)) is not None: + if not (literal.value.is_integer() and literal.value >= words.minimum): + self.errors.append(f'{self.context}: {operator}({key}=...) {words.form}') + return None + return int(literal.value) + bare = _without_sign(value) + if not isinstance(bare, NameNode): + self.errors.append( + f'{self.context}: {operator}({key}=) takes a number or the name of an integer parameter. ' + f'Precompute it as a parameter.' + ) + return None + if self.ns.kind(bare.name) != 'parameter': + if self._name(bare) is not None: + self.errors.append(f'{self.context}: {operator}({key}=...) {words.form}') + return None + if (dtype := self.ns.dtypes[bare.name]) != 'int': + self.errors.append( + f"{self.context}: {operator}({key}={bare.name}) counts positions, but '{bare.name}' is declared " + f'dtype: {dtype}. A count of positions is integral — declare it dtype: int, which binds only an ' + f'integer column, so a fractional {words.noun} has nowhere to arrive from.' + ) + return None + if isinstance(value, UnaryOperatorNode) and value.op == '-': + self.errors.append( + f'{self.context}: {operator}({key}=-{bare.name}) negates a named {words.noun}. {words.negated}' + ) + return None + return bare.name + + def _edge(self, value: ArithmeticNode, operator: str) -> _Edge | None: + """``edge=``: the closed keyword ``wrap``, or a number to contribute; a name here is a typo.""" + if self._formal(value): + return None + if isinstance(value, KeywordNode): + if value.value == EDGE_WRAP: + return True, None + self.errors.append(f'{self.context}: {edge_error(operator, repr(value.value))}') + return None + if isinstance(value, NameNode): + if value.name == EDGE_WRAP: + self.errors.append( + f'{self.context}: {operator}(edge={EDGE_WRAP}) is a bare name where a keyword belongs. ' + f"Write edge='{EDGE_WRAP}', quoted." + ) + return None + self.errors.append(f'{self.context}: {edge_error(operator, value.name)}') + return None + if (literal := literal_number(value)) is None: + self.errors.append( + f"{self.context}: {operator}(edge=) is an expression, and an edge is the keyword '{EDGE_WRAP}' " + f'or a number. Write the number itself.' + ) + return None + return False, literal.value + + def _dim_ref(self, value: ArithmeticNode, operator: str, key: str) -> str | None: + """An operator kwarg whose *value* must name a declared dimension.""" + if self._formal(value): + return None + if not isinstance(value, NameNode): + self.errors.append(f'{self.context}: {operator}({key}=...) must name a dimension.') + return None + if value.name not in self.ns.dimensions: + self.errors.append( + _undeclared_dim(self.context, operator, f'{key}={value.name}', value.name, self.ns, self.formals) + ) + return None + return value.name + + def _dual(self, node: FunctionCallNode) -> Dual | None: + """``dual(c)`` as the leaf it is, its one argument the name of a declared constraint. + + Constraints sit outside the flat namespace, so this store is consulted + only here — a bare name in arithmetic never reaches it. A dual standing + where the math is built is refused separately + (:mod:`math_spec.degree`); this pass only types the name. + """ + (value,) = node.args + if self._formal(value): + return None + if not isinstance(value, NameNode): + self.errors.append( + f'{self.context}: dual() takes the name of a declared constraint, written bare — ' + f'dual(). Name the constraint whose row dual you want.' + ) + return None + if value.name not in self.ns.constraints: + self.errors.append(self.ns.unknown_constraint(value.name, self.context, formals=self.formals)) + return None + return Dual(value.name) + + def relation_ref( + self, + value: ArithmeticNode, + operator: str, + key: str, + roles: Mapping[str, ArithmeticNode], + along: str | None, + ) -> Direction | Partition | None: + """An operator's ``by=`` as the direction or the partition the call reads its relation in. + + A relation carries its own dimensions, so the call names columns rather + than dims: ``over=`` the column consumed, ``into=`` the column + produced, every other key column joined on. A value column not named + is not read, and a bare relation's columns are all key. One call + addresses one table, so several columns of one table are a list and + several tables are not. *along* is the dimension a translation steps + along, already read, or ``None`` where it was refused. + """ + names = names_in(value) + if not names: + self.errors.append(f'{self.context}: {operator}({key}=...) must name a relation.') + return None + if len(names) > 1: + self.errors.append( + f'{self.context}: {operator}({key}={shown(names)}) names {len(names)} relations, and one call ' + f'reads one table. Declare one relation with the columns of all of them, or read them in turn, ' + f'one call each.' + ) + return None + name = names[0] + if name in self.formals: + return None + if (problem := self.not_a_relation(name, operator, key)) is not None: + self.errors.append(problem) + return None + if any(n in self.formals for v in roles.values() for n in names_in(v)): + return None + read = {k: self._role_name(v, operator, k) for k, v in roles.items()} + named = {k: r for k, r in read.items() if r is not None} + if operator in ('shift', 'sum_back'): + if 'within' not in named: + return None # refused already, by the call shape or by the role that named no column + return self.partition(name, operator, along, named['within']) + if not ({'over', 'into'} <= set(named)): + return None # refused already, by the call shape or by the role that named no column + return self._direction(name, operator, named['over'], named['into']) + + def _role_name(self, value: ArithmeticNode, operator: str, key: str) -> tuple[str, ...] | None: + """``over=`` or ``into=`` as the column names it must be — one bare name, or a bracketed list of them.""" + if names := names_in(value): + return names + self.errors.append( + f'{self.context}: {operator}({key}=...) names columns of the relation — a bare name, or a list of them.' + ) + return None + + def _direction( + self, + name: str, + operator: str, + from_roles: tuple[str, ...], + into_roles: tuple[str, ...], + ) -> Direction | None: + """Which direction ``sum`` or ``at`` reads relation *name* in, between the columns the call named. + + Both ends arrive written: the call shape refuses a call that leaves + one unsaid, so that a relation may gain a value column without + changing what this call means. ``at`` needs the read single-valued + and ``sum`` needs it not: a sum that lands on the key has one term + per coordinate and adds up nothing, which is a read, so it is + refused toward ``at``. A read lands on key columns and nothing else, + because a column outside the key is one no coordinate of the read + fixes. + """ + ns, context = self.ns, self.context + shape = ns.relations[name] + call = f'{operator}(by={name})' + if not ( + self._known_roles(name, call, from_roles, 'over') and self._known_roles(name, call, into_roles, 'into') + ): + return None + + forward = operator == 'sum' + if both := sorted(set(from_roles) & set(into_roles)): + self.errors.append( + f'{context}: {call}: over= and into= both name {both}, and a call reads between two sets of columns.' + ) + return None + for kwarg, roles in (('over', from_roles), ('into', into_roles)): + dims = [shape.dim(r) for r in roles] + if shared := sorted({d for d in dims if dims.count(d) > 1}): + self.errors.append( + f'{context}: {call}: {kwarg}={list(roles)} names two columns over {shared}, and the operand ' + f'carries each dimension once, so nothing says which column its coordinate is read at. Read ' + f'between columns over distinct dimensions.' + ) + return None + if not forward and (outside := [r for r in into_roles if r not in shape.key]): + self.errors.append( + f"{context}: {call}: into={list(into_roles)} names {outside}, which the key of '{name}' does not " + f'hold. A read lands on the key it reads at, {list(shape.key)}, and a column outside that key ' + f'arrives as a dimension the read never fixes. Land on the key, or sum toward {outside}.' + ) + return None + joined = tuple(r for r in shape.key if r not in from_roles and r not in into_roles) + single_valued = set(shape.key) <= {*into_roles, *joined} + direction = Direction(name, shape, from_roles, into_roles, joined) + if not forward and not single_valued: + self.errors.append( + f"{context}: {call}: at reads one value per coordinate, and '{name}' is not single-valued in " + f'{list(from_roles)} at the columns the call lands on ({[*into_roles, *joined]}) — its key is ' + f'{list(shape.key)}. Key the table by the columns the call lands on, or read the other way.' + ) + return None + if forward and single_valued: + self.errors.append( + f'{context}: {call}: this sum lands on the key {list(shape.key)}, so each coordinate has one ' + f"term and nothing is added up — that is a read, which is at()'s. Write " + f'at(..., by={name}, over={list(from_roles)}, into={list(into_roles)}), or sum toward ' + f'a value column.' + ) + return None + return direction + + def _known_roles(self, name: str, call: str, roles: tuple[str, ...], kwarg: str) -> bool: + """Whether every role *kwarg* names is a column of relation *name*, each once; the refusal otherwise.""" + shape = self.ns.relations[name] + for role in roles: + if role not in shape.roles: + self.errors.append( + f"{self.context}: {call}: {kwarg}={role} names no column of '{name}', whose columns are " + f'{list(shape.roles)}.' + ) + return False + if len(set(roles)) < len(roles): + self.errors.append(f'{self.context}: {call}: {kwarg}={list(roles)} names a column twice.') + return False + return True + + def partition( + self, name: str, operator: str, along_dim: str | None, within_roles: tuple[str, ...] + ) -> Partition | None: + """How a partition (``shift``, ``sum_back``, ``position``) steps along relation *name* over *along_dim*. + + It steps along the one key column over that dimension (a key has one + column per dimension), joins on the other key columns and groups by the + value columns *within_roles* names. ``None`` where the dimension is not one + (already refused), the relation has no key column over it, or + ``within=`` names a column that is not a value column. + """ + context = self.context + shape = self.ns.relations[name] + call = f'{operator}(by={name})' + if along_dim is None or not self._known_roles(name, call, within_roles, 'within'): + return None + if not shape.values: + self.errors.append( + f"{context}: {call}: '{name}' is a bare relation — every column is in its key — so it makes no " + f'groups and no coordinate is in exactly one. Move the columns the group is made of under ' + f'values:, leaving key: the column {operator} steps along.' + ) + return None + over_keys = [r for r in shape.key if shape.dim(r) == along_dim] + if not over_keys: + self.errors.append( + f"{context}: {call}: '{name}' has no key column over '{along_dim}' — its key is " + f'{list(shape.key)} — and a partition steps along a key column over the dimension it groups.' + ) + return None + if keyed := [r for r in within_roles if r in shape.key]: + self.errors.append( + f"{context}: {call}: within={keyed} names a key column of '{name}', and a partition groups by " + f'value columns — its value columns are {list(shape.values)}.' + ) + return None + (along,) = over_keys + joined = tuple(r for r in shape.key if r != along) + return Partition(name, shape, along, within_roles, joined) + + def not_a_relation(self, name: str, operator: str, key: str) -> str | None: + """Why *name* is not a relation; ``None`` where it is one.""" + ns, context = self.ns, self.context + if name in ns.relations: + return None + if name in ns.dimensions: + over_here = sorted(n for n, shape in ns.relations.items() if name in dict(shape.columns).values()) + hint = ( + f" Relations with a column over '{name}': {over_here}" + if over_here + else f" No relation has a column over '{name}'." + ) + return ( + f"{context}: {operator}({key}={name}): '{name}' is a dimension, and " + f'{key}= takes a relation — the named map out of a dimension.\n{hint}' + ) + return ( + f'{context}: {operator}({key}={name}) does not name a relation{_or_a_formal(self.formals)}. ' + f'{did_you_mean(name, ns.relations, label="Relations")}\n' + f"Declare it under 'relations:' — {name}: {{key: , " + f'values: }}.' + ) + + +def not_a_number(name: str, dtype: str, context: str) -> str: + """Why a ``str`` or ``bool`` parameter is refused where a value belongs; the rewrite is the dtype's own.""" + if dtype == 'str': + instead = ( + f'A label selects rather than scales: compare it in a where ' + f'("{name} == \'some_label\'"), and carry the numbers it picks out in a ' + f'parameter of its own.' + ) + else: + instead = ( + f'A flag masks rather than scales: name it in a where ("{name}", "NOT {name}"), ' + f'which is what a mask is — or declare it dtype: int where the 0/1 is meant to ' + f'arrive as data and be multiplied by.' + ) + return ( + f"{context}: '{name}' is declared dtype: {dtype}, and an expression is arithmetic — " + f'only dtype: float and dtype: int bind a column it can be done to. {instead}' + ) + + +def _undeclared_dim(context: str, operator: str, call: str, name: str, ns: Namespace, formals: frozenset[str]) -> str: + return ( + f'{context}: {operator}({call}) does not name a declared dimension{_or_a_formal(formals)}. ' + f'{did_you_mean(name, ns.dimensions, label="Dimensions")}\n' + f"Declare '{name}' under 'dimensions:', or fix the typo — an unknown " + f'dimension makes {operator}() a silent no-op rather than an error.' + ) + + +def _or_a_formal(formals: frozenset[str]) -> str: + """The words a refusal inside a template adds, since a formal would have stood there too.""" + return ' or a formal of this macro' if formals else '' + + +def _without_sign(value: ArithmeticNode) -> ArithmeticNode: + """*value* under its sign, if it carries one.""" + return value.operand if isinstance(value, UnaryOperatorNode) else value + + +def _vacates(offset: int | str) -> bool: + """Whether a translation leaves anything behind. + + A literal zero step reaches every coordinate from itself, so there is no + vacated position for an ``edge=`` to answer for and the refusal has + nothing to refuse. A *named* offset may be zero in the data and is not + known here, so it vacates until proved otherwise. + """ + return offset != 0 + + +def _named_offset_edge_message(name: str) -> str: + """Why a named offset must say what the vacated positions contribute. + + The absent edge propagates through a presence frame keyed by the translated + dimension alone, and a per-entity offset vacates a different slot for each + entity — which that frame cannot say. Refused rather than answered wrongly + (#850); the two edges that write their own answer are allowed. + """ + return ( + f'shift(offset={name}) leaves the vacated positions absent, which a ' + f'per-entity offset cannot say yet.\n' + f"Add edge='wrap' for a cyclic translation, or edge= for what the " + f'vacated positions contribute.' + ) + + +def _shift_over_data_message(context: str) -> str: + """The three ways out of a translation over data with no ``edge=``, the third being two things at once.""" + return ( + f'{context}: shift() over a variable-free expression leaves vacated positions with no ' + f'value, and inventing one is what silently pinned a bound to zero. Say which you mean:\n' + f" shift(x, along=d, offset=n, edge='wrap') the dimension really is cyclic\n" + f' shift(x, along=d, offset=n, edge=0) the vacated positions contribute zero\n' + f' ...and a where: excluding them the vacated rows should not exist at all\n' + f'A where: alone does not lift this — it is decided on the expression, before any mask ' + f'is read — and edge=0 alone leaves a row whose bound is that zero.' + ) diff --git a/src/math_spec/_where_resolver.py b/src/math_spec/_where_resolver.py new file mode 100644 index 00000000..22b46595 --- /dev/null +++ b/src/math_spec/_where_resolver.py @@ -0,0 +1,745 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""The where walk of name resolution: a parsed where string into the program's typed predicates. + +A bare name, a comparison, a count and a predicate read through a relation +or along a dimension are each typed here against the namespace, and a +comparison of expressions hands its sides to the expression walk. +:mod:`math_spec.resolution` holds the namespace and the doors that call this. +""" + +from __future__ import annotations + +import datetime +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING, NamedTuple, assert_never, cast + +import math_spec.degree as degree +from math_spec._expression_parser import ( + ArithmeticNode, + FunctionCallNode, + KeywordNode, + NameListNode, + NameNode, + literal_number, + names_in, + nodes, +) +from math_spec._expression_resolver import ExpressionResolver +from math_spec._where_parser import ( + ColumnNode, + UnresolvedComparisonNode, + UnresolvedCountNode, + UnresolvedPredicateCallNode, + UnresolvedWhereNode, +) +from math_spec.dimensions import dims_of, pulled_back_dims +from math_spec.errors import DimensionError, LanguageError, did_you_mean, prefixed +from math_spec.expansion import expand +from math_spec.operators import ( + PARTITION_NAMES_ITS_GROUP, +) +from math_spec.program import ( + Add, + And, + BooleanLiteral, + Constant, + CountComparison, + DimensionComparison, + DimensionPosition, + Direction, + Divide, + Expression, + ExpressionComparison, + Mask, + Multiply, + Negate, + Not, + Or, + ParameterComparison, + ParameterDefined, + Power, + Predicate, + PredicateOperator, + PulledBackPredicate, + RelationComparison, + RelationDefined, + RelationPairComparison, + TranslatedPredicate, + TypedPredicate, + VariableDefined, + carries_variable, + walk, +) + +if TYPE_CHECKING: + from collections.abc import Mapping + + from math_spec.program import DeclaredDtype + from math_spec.resolution import Namespace + + +@dataclass(frozen=True) +class WhereResolver: + """One resolution walk over a where string, and the things every step of it reads. + + A node that cannot be typed comes back unresolved with its refusal + appended to ``errors``; every sibling is still read, so a mask with two + faults reports both. ``self_variable`` is the variable whose own + ``where`` is being read, which may not ask whether it exists. A side + that is an expression is built by an :class:`ExpressionResolver` over + the same namespace. + """ + + ns: Namespace + context: str + errors: list[str] + self_variable: str | None = None + + @property + def _expressions(self) -> ExpressionResolver: + return ExpressionResolver(self.ns, self.context, self.errors) + + def where(self, node: Predicate | UnresolvedWhereNode) -> Predicate | UnresolvedWhereNode: + """One predicate node typed, or returned unresolved with its refusal appended.""" + if isinstance(node, BooleanLiteral | TypedPredicate): + return node + if isinstance(node, NameNode): + return self._where_name(node) + if isinstance(node, UnresolvedComparisonNode): + return self._comparison(node) + if isinstance(node, UnresolvedPredicateCallNode): + return self._predicate_call(node) + if isinstance(node, UnresolvedCountNode): + return self._count(node) + if isinstance(node, Not): + return Not(self._child(node.operand)) + if isinstance(node, And): + return And(self._child(node.left), self._child(node.right)) + if isinstance(node, Or): + return Or(self._child(node.left), self._child(node.right)) + assert_never(node) + + def _child(self, node: Predicate | UnresolvedWhereNode) -> Predicate: + """A connective's child, typed as resolved: an unresolved one survives only with its refusal appended.""" + return cast('Predicate', self.where(node)) + + def _where_name(self, node: NameNode) -> Predicate | UnresolvedWhereNode: + """A bare name: a parameter's or relation's definedness, or a variable's existence.""" + ns, context = self.ns, self.context + kind = ns.kind(node.name) + if kind is None: + self.errors.append(ns.unknown(node.name, context, allow_dims=True)) + return node + match kind: + case 'parameter': + return ParameterDefined(node.name, ns.leaf_dims[node.name]) + case 'dimension': + self.errors.append( + f"{context}: '{node.name}' is a dimension, and a bare dimension " + f'name is true at every coordinate — the mask has no effect. ' + f'Remove it, or compare it: where: "{node.name} > 0".' + ) + case 'relation': + shape = ns.relations[node.name] + dims = tuple(shape.dim(k) for k in shape.key) + if len(set(dims)) < len(dims): + self.errors.append( + f"{context}: '{node.name}' has two columns over one dimension ({list(shape.roles)}), so a " + f'bare name cannot say which the frame supplies. Compare a column: ' + f'{node.name}.{shape.values[0] if shape.values else shape.roles[-1]} == ....' + ) + return node + return RelationDefined(node.name, dims) + case 'variable': + if node.name == self.self_variable: + self.errors.append( + f"{context}: variable '{node.name}' asks whether it exists in its own " + f'where, which nothing can answer — the mask is what decides where it ' + f'exists. Test a parameter, or another variable declared before it.' + ) + else: + return VariableDefined(node.name, ns.leaf_dims[node.name]) + return node + + def _predicate_call(self, node: UnresolvedPredicateCallNode) -> Predicate | UnresolvedWhereNode: + """``shift(, along=, offset=)`` or ``at(, by=, over=, into=)`` — the two operators that read a predicate and answer one. + + ``count`` answers a number, so it stands on a comparison's side and + :meth:`_count` reads it there. Anything else naming a predicate is + refused here rather than resolved into arithmetic it cannot be. + + An operand that failed to resolve is handed straight back: resolution + collects problems rather than raising, and asking an unresolved + predicate for its dims asserts instead of refusing. + """ + context, found = self.context, len(self.errors) + if node.name == 'count': + self.errors.append( + f'{context}: count() answers a number, and a where is a predicate. Compare it: ' + f'count(, over=) .' + ) + return node + if node.name not in ('shift', 'at'): + self.errors.append( + f"{context}: '{node.name}()' does not read a predicate. `shift` and `at` read one and answer " + f'one, `count` reads one and answers a number, and every other operator reads arithmetic. ' + f'Compare the predicate, or name a parameter carrying it.' + ) + return node + operand = self._child(node.operand) + if len(self.errors) > found: + return node + if node.name == 'at': + return self._pulled_back(node, Mask(operand)) + if (refusal := _kwargs_error(context, 'shift', node.kwargs, required=('along', 'offset'))) is not None: + self.errors.append(refusal) + return node + along = node.kwargs['along'] + offset = literal_number(node.kwargs['offset']) + if not isinstance(along, NameNode) or self.ns.kind(along.name) != 'dimension': + self.errors.append( + f'{context}: shift(, along=) names the dimension the predicate is read back along. ' + f'Name a declared dimension.' + ) + return node + if offset is None or not offset.value.is_integer(): + self.errors.append( + f'{context}: shift(, offset=) counts whole coordinates back along ' + f"'{along.name}'. Write an integer." + ) + return node + mask = Mask(operand) + if along.name not in mask.dims: + self.errors.append( + f"{context}: shift(, along='{along.name}') reads the predicate back along a dimension " + f'it does not carry — it reads {_listed(sorted(mask.dims))}. Translate it along one of those.' + ) + return node + return TranslatedPredicate(mask, along.name, int(offset.value), tuple(sorted(mask.dims))) + + def _pulled_back(self, node: UnresolvedPredicateCallNode, mask: Mask) -> Predicate | UnresolvedWhereNode: + """``at(, by=, over=, into=)`` — the predicate read through a relation, as ``at`` reads an array. + + The relation and its two ends are read by the rules an expression's + ``at`` is, so the one refusal a file meets for a bad read is the same + in a ``where:`` and in an expression. + """ + context = self.context + if (refusal := _kwargs_error(context, 'at', node.kwargs, required=('by', 'over', 'into'))) is not None: + self.errors.append(refusal) + return node + found = len(self.errors) + roles = {key: node.kwargs[key] for key in ('over', 'into')} + by = self._expressions.relation_ref(node.kwargs['by'], 'at', 'by', roles, None) + if len(self.errors) > found or not isinstance(by, Direction): + return node + try: + dims = pulled_back_dims(by, mask.dims, context, 'the predicate') + except DimensionError as refusal: + self.errors.append(str(refusal)) + return node + return PulledBackPredicate(mask, by, tuple(sorted(dims))) + + def _count(self, node: UnresolvedCountNode) -> Predicate | UnresolvedWhereNode: + """``count(, over=) `` — how many coordinates the predicate admits. + + The reduction leaves every dim but ``over``, so the count is one + number per remaining coordinate and a claim about each group needs no + word for the group. + """ + context, found = self.context, len(self.errors) + operand = self._child(node.call.operand) + if len(self.errors) > found: + return node + if (refusal := _kwargs_error(context, 'count', node.call.kwargs, required=('over',))) is not None: + self.errors.append(refusal) + return node + over = node.call.kwargs['over'] + if not isinstance(over, NameNode) or self.ns.kind(over.name) != 'dimension': + self.errors.append( + f'{context}: count(, over=) names the dimension the coordinates are counted along. ' + f'Name a declared dimension.' + ) + return node + value = literal_number(node.value) + if value is None or not value.value.is_integer(): + self.errors.append( + f'{context}: a count is a whole number of coordinates, so it is compared against one. ' + f'Write count(…, over={over.name}) {node.op} .' + ) + return node + if (decided := _decided_count(node.op, value.value)) is not None: + self.errors.append( + f'{context}: count(…, over={over.name}) {node.op} {value} holds at {decided} coordinate, because ' + f'a count is never negative. Delete the comparison, or write the bound it means.' + ) + return node + mask = Mask(operand) + if over.name not in mask.dims: + self.errors.append( + f"{context}: count(, over='{over.name}') counts along a dimension the predicate does " + f'not carry — it reads {_listed(sorted(mask.dims))}. Count along one of those.' + ) + return node + dims = tuple(sorted(mask.dims - {over.name})) + return CountComparison(mask, over.name, node.op, value.value, dims) + + def _comparison(self, node: UnresolvedComparisonNode) -> Predicate | UnresolvedWhereNode: + """``side side``, read for what each side is. + + A ``position()`` call on the left is the position form. A name against + a literal or a second column is the plain form the dtype rules are + written for, unless a side names a parameter or an ``expressions:`` + entry against the other, which is arithmetic however plain it looks. + Everything else is a comparison of expressions. + """ + if isinstance(node.left, FunctionCallNode) and node.left.name == 'position': + return self._position(node.left, node) + plain = self._plain(node) + if plain is None: + return self._expression_comparison(node) + return self._plain_comparison(node, plain) + + def _plain(self, node: UnresolvedComparisonNode) -> _Plain | None: + """The comparison as ``name literal`` or ``name name``, or ``None`` where the language reads it as arithmetic. + + A side that is arithmetic makes it so, and so does a name that is a + value — a parameter or an ``expressions:`` entry — against another, + however plain the two look. + """ + ns = self.ns + name, right = _side_name(node.left), node.right + value: float | str | None + quoted = isinstance(right, KeywordNode) + if isinstance(right, KeywordNode): + value = right.value + elif isinstance(right, ColumnNode): + value = right.shown + elif (literal := literal_number(right)) is not None: + value = literal.value + else: + value = _side_name(right) + if value is not None and (value in ns.schema.expressions or ns.kind(value) == 'parameter'): + return None + if name is None or value is None or name in ns.schema.expressions: + return None + return _Plain(name, node.op, value, quoted) + + def _expression_comparison(self, node: UnresolvedComparisonNode) -> ExpressionComparison | UnresolvedComparisonNode: + """``expression expression``: each side expanded, typed and held to what a mask may read. + + A side is read as an expression is — macros and named expressions + expand, every operator and dim rule applies — except that it names no + variable and no dual, since a mask is built before either exists. + """ + ns, context = self.ns, self.context + found = len(self.errors) + sides: list[Expression] = [] + for side in (node.left, node.right): + if isinstance(side, ColumnNode | KeywordNode): + self.errors.append(_not_arithmetic(context, side)) + continue + if any(isinstance(n, FunctionCallNode) and n.name == 'count' for n in nodes(side)): + self.errors.append( + f'{context}: count() stands on the left of its comparison, and reads a predicate rather than ' + f'arithmetic. Write count(, over=) .' + ) + continue + try: + expanded = expand(side, ns, context) + except ValueError as e: + self.errors.append(prefixed(context, e)) + continue + if (resolved := self._expressions.build(expanded)) is not None: + sides.append(resolved) + if len(self.errors) > found: + return node + assert len(sides) == 2, 'a side of a where builds or refuses, since a where holds no formal' + dims: set[str] = set() + for side in sides: + if carries_variable(side): + self.errors.append( + f'{context}: a where compares expressions, and one side names a variable. A where mask ' + f'is built before variables exist — it may test parameters and dimension coordinates only.' + ) + elif degree.calls_dual(side): + self.errors.append( + f'{context}: a where compares expressions, and one side reads a dual, which only a solve ' + f'produces. A mask is built before it — test the data instead.' + ) + else: + try: + degree.check_expression(side, context) + dims |= dims_of(side, ns.schema, context) + except LanguageError as e: + self.errors.append(str(e)) + if len(self.errors) > found: + return node + left, right = sides + if all(_is_number(side) for side in sides): + self.errors.append( + f"{context}: '{node.left} {node.op} {node.right}' compares two numbers, so it is decided before any " + f'data arrives and admits every row or none. Name the parameter one side stands for, or drop ' + f'the comparison.' + ) + return node + return ExpressionComparison(left, node.op, right, tuple(d for d in ns.schema.dimensions if d in dims)) + + def _position( + self, call: FunctionCallNode, node: UnresolvedComparisonNode + ) -> DimensionPosition | UnresolvedComparisonNode: + """``position(dim[, by=relation, within=columns]) i``: the name a dimension, ``by=`` a relation keyed over it.""" + ns, context = self.ns, self.context + shape = _position_shape(call) + if shape is None: + self.errors.append( + f'{context}: position() is written position([, by=, within=]), and this ' + f'call is not of that shape. It takes the dimension it counts along and nothing else beside by= and within=.' + ) + return node + dimension, by, into = shape + index = None if isinstance(node.right, ColumnNode | KeywordNode) else literal_number(node.right) + if index is None or not index.value.is_integer(): + self.errors.append( + f'{context}: position({dimension}) is compared against an integer index, where 0 is first and a ' + f'negative number counts from the end. Write position({dimension}) {node.op} .' + ) + return node + position = int(index.value) + if dimension not in ns.dimensions: + self.errors.append( + f"{context}: position() counts along a dimension's coordinates, and " + f"'{dimension}' is {_declared_as(ns, dimension)}. " + f'{did_you_mean(dimension, ns.dimensions, label="Dimensions")}' + ) + return node + if by is None: + return DimensionPosition(dimension, node.op, position) + if (problem := self._expressions.not_a_relation(by, 'position', 'by')) is not None: + self.errors.append(problem) + return node + spelled = f'position({dimension}, by={by})' + if into is None: + self.errors.append( + f'{context}: {spelled} leaves within= unsaid. {PARTITION_NAMES_ITS_GROUP} Write ' + f"position({dimension}, by={by}, within=) — the value columns of '{by}' " + f'are {list(ns.relations[by].values)}.' + ) + return node + partition = self._expressions.partition(by, 'position', dimension, into) + if partition is None: + return node + return DimensionPosition(dimension, node.op, position, partition) + + def _plain_comparison(self, node: UnresolvedComparisonNode, plain: _Plain) -> Predicate | UnresolvedWhereNode: + """``name literal``, or the one structural form ``relation relation``.""" + ns, context = self.ns, self.context + value = plain.value + left_name, _, left_column = plain.name.partition('.') + if not plain.quoted and isinstance(value, str): + right_name, _, right_column = value.partition('.') + if (rhs_kind := ns.kind(right_name)) is not None: + if rhs_kind == 'relation' and ns.kind(left_name) == 'relation': + left = self._relation_column(left_name, left_column or None, plain.name, plain.op) + right = self._relation_column(right_name, right_column or None, value, plain.op) + if left is None or right is None: + return node + if (refusal := _relation_pair_error(context, plain, value, ns, left, right)) is not None: + self.errors.append(refusal) + return node + dims = tuple(ns.relations[left_name].dim(k) for k in ns.relations[left_name].key) + return RelationPairComparison(left_name, left, right_name, right, plain.op, dims) + self.errors.append(_declared_rhs_error(context, plain, value, rhs_kind)) + return node + + kind = ns.kind(left_name) + if kind is None: + self.errors.append(ns.unknown(left_name, context, allow_dims=True)) + return node + if left_column and kind != 'relation': + self.errors.append( + f"{context}: '{plain.name}' reads a column of '{left_name}', which is {_declared_as(ns, left_name)}. " + f'Only a relation has columns.' + ) + return node + column = None + dtype: DeclaredDtype | None = None + if kind == 'relation': + column = self._relation_column(left_name, left_column or None, plain.name, plain.op) + if column is None: + return node + dtype = ns.dtypes[ns.relations[left_name].dim(column)] + elif kind in ('parameter', 'dimension'): + dtype = ns.dtypes[left_name] + if dtype is not None: + typed = self._typedliteral(plain, dtype) + if typed is None: + return node + value = typed + + match kind: + case 'parameter': + assert not isinstance(value, datetime.date) + return ParameterComparison(left_name, plain.op, value, ns.leaf_dims[left_name]) + case 'dimension': + return DimensionComparison(left_name, plain.op, value) + case 'relation': + assert column is not None + shape = ns.relations[left_name] + return RelationComparison(left_name, column, plain.op, value, tuple(shape.dim(k) for k in shape.key)) + case 'variable': + self.errors.append( + f"{context}: where references variable '{left_name}'. A where " + f'mask is built before variables exist — it may test parameters ' + f'and dimension coordinates only.' + ) + return node + + def _relation_column(self, name: str, column: str | None, spelling: str, op: PredicateOperator) -> str | None: + """The value column a where-comparison on relation *name* reads, or the refusal. + + A comparison reads one value per coordinate, so the relation is keyed + and the column is one the key determines; unsaid, it is the one value + column where there is exactly one. + """ + ns, context = self.ns, self.context + shape = ns.relations[name] + if not shape.values: + self.errors.append( + f"{context}: '{spelling}' compares a column of '{name}', a bare relation — every column is in its " + f'key — so it has no one value per coordinate to compare. Declare that column under values:, or ' + f"test the bare name — '{name}' — for whether a row exists." + ) + return None + if column is None: + if len(shape.values) != 1: + self.errors.append( + f"{context}: '{spelling}': '{name}' has {len(shape.values)} value columns ({list(shape.values)}), " + f'so say which the comparison reads: {name}.{shape.values[0] if shape.values else "..."}.' + ) + return None + return shape.values[0] + if column not in shape.roles: + self.errors.append( + f"{context}: '{spelling}': '{column}' is not a column of '{name}', whose columns are {list(shape.roles)}." + ) + return None + if column in shape.key: + self.errors.append( + f"{context}: '{spelling}': '{column}' is a key column of '{name}', which the frame supplies rather " + f"than reads. Compare the frame's own coordinate — {shape.dim(column)} {op} ... — or a value column." + ) + return None + return column + + def _typedliteral(self, node: _Plain, dtype: DeclaredDtype) -> float | str | datetime.date | None: + """The comparison's literal, checked against the declared dtype. + + Getting it wrong is silent: polars reads a datetime column against an + integer as an epoch offset, so ``snapshot > 0`` drops every coordinate + before 1970 without a word (#460). Returns ``None`` once it has recorded + an error, so the caller leaves the node unresolved. + """ + context = self.context + value = node.value + text = isinstance(value, str) + + if dtype == 'datetime': + if not text: + self.errors.append( + f"{context}: '{node.name}' is a datetime dimension, so comparing it to " + f'{value!r} compares against the epoch — {node.name} > 0 means "after ' + f'1970-01-01", not what it looks like. Quote an ISO date instead: ' + f"{node.name} {node.op} '2030-01-01'." + ) + return None + try: + return ( + datetime.datetime.fromisoformat(value) + if _HAS_TIME.search(value) + else datetime.date.fromisoformat(value) + ) + except ValueError: + self.errors.append( + f"{context}: '{node.name}' is a datetime dimension and {value!r} is not an " + f"ISO date. Write '2030-01-01' or '2030-01-01T06:00'." + ) + return None + + if dtype == 'str' and not text: + self.errors.append( + f"{context}: '{node.name}' has dtype 'str', so comparing it to the number " + f'{value!r} matches no label. Quote it if it is one: {node.name} {node.op} ' + f"'{value:g}'." + ) + return None + if dtype in ('int', 'float', 'bool') and text: + self.errors.append( + f"{context}: '{node.name}' has dtype '{dtype}', so comparing it to the string " + f'{value!r} matches nothing. Drop the quotes if it is a number.' + ) + return None + return value + + +#: An ISO literal carrying a time-of-day, which decides date vs datetime. +_HAS_TIME = re.compile(r'[T ]\d') + + +def _declared_as(ns: Namespace, name: str) -> str: + kind = ns.kind(name) + return f'a {kind}' if kind else 'not declared' + + +class _Plain(NamedTuple): + """A where-comparison read as ``name literal`` or ``name name`` — the shape the dtype rules are written for. + + ``quoted`` says the right-hand side arrived in quotes, and so is a label + rather than a name to look up. + """ + + name: str + op: PredicateOperator + value: float | str + quoted: bool + + +def _side_name(side: ArithmeticNode | ColumnNode) -> str | None: + """The name a side of a where-comparison spells — bare or ``relation.column`` — or ``None`` where it is arithmetic.""" + if isinstance(side, NameNode): + return side.name + if isinstance(side, ColumnNode): + return side.shown + return None + + +def _position_shape(call: FunctionCallNode) -> tuple[str, str | None, tuple[str, ...] | None] | None: + """``(dim, by, within)`` off a ``position(...)`` call, or ``None`` where the call is not of that shape.""" + if len(call.args) != 1 or not isinstance(call.args[0], NameNode) or set(call.kwargs) - {'by', 'within'}: + return None + by, within = call.kwargs.get('by'), call.kwargs.get('within') + if by is not None and not isinstance(by, NameNode): + return None + if within is not None and not isinstance(within, NameNode | NameListNode): + return None + into = names_in(within) if within is not None else None + return call.args[0].name, by.name if by is not None else None, into + + +def _kwargs_error( + context: str, name: str, kwargs: Mapping[str, ArithmeticNode], required: tuple[str, ...] +) -> str | None: + """Why *kwargs* is not what *name* takes over a predicate, or ``None`` where it is. + + A predicate-reading call takes exactly the keywords named here. The + arithmetic forms of these operators take more — an ``edge=``, a ``by=`` — + and each is refused rather than ignored, since a predicate answers the + vacated coordinate itself and a grouped form has nobody asking for it yet. + """ + missing = [key for key in required if key not in kwargs] + if missing: + return f'{context}: {name}() needs {_listed([f"{key}=" for key in missing])}.' + if extra := sorted(set(kwargs) - set(required)): + edge = ' A predicate is false where a translation vacates, so there is no edge to state.' + return ( + f'{context}: {name}() does not take {_listed([f"{key}=" for key in extra])}. ' + f'It takes {_listed([f"{key}=" for key in required])}, and nothing else.' + f'{edge if "edge" in extra and name == "shift" else ""}' + ) + return None + + +def _decided_count(op: str, value: float) -> str | None: + """Whether comparing a count with *op* against *value* is settled by the count never being negative. + + Returns ``'every'`` where the comparison always holds, ``'no'`` where it + never does, and ``None`` where the data decides. + """ + if value < 0: + return 'every' if op in ('>', '>=', '!=') else 'no' + if value == 0 and op in ('>=', '<'): + return 'every' if op == '>=' else 'no' + return None + + +def _listed(items: list[str]) -> str: + """``'a'``, ``'a' and 'b'``, ``'a', 'b' and 'c'`` — one rule, so every message reads the same.""" + quoted = [f"'{item}'" for item in items] + if len(quoted) <= 1: + return quoted[0] if quoted else 'nothing' + return f'{", ".join(quoted[:-1])} and {quoted[-1]}' + + +def _is_number(side: Expression) -> bool: + """Whether *side* is arithmetic over literals alone — a value the language can fold, and a where may not test.""" + return all(isinstance(n, Constant | Negate | Add | Multiply | Divide | Power) for n in walk(side)) + + +def _not_arithmetic(context: str, side: ColumnNode | KeywordNode) -> str: + """Why a relation column or a quoted label may not stand on a side of a comparison of expressions.""" + if isinstance(side, ColumnNode): + return ( + f"{context}: '{side.shown}' is a column of a relation, which is compared against a literal or a " + f'second column and is not read in arithmetic. Compare it on its own, or carry the value in a ' + f'parameter and test that.' + ) + return ( + f"{context}: '{side.value}' is a quoted label, which is compared against one name. Put the name alone on " + f'the other side, or drop the quotes if it is a number.' + ) + + +def _declared_rhs_error(context: str, node: _Plain, value: str, kind: str) -> str: + """Why the right-hand side of a where-comparison may not name a variable, a relation or a dimension.""" + comparison = f"'{node.name} {node.op} {value}'" + if kind == 'variable': + return ( + f'{context}: {comparison} compares against variable {value!r}. ' + f'A where mask is built before variables exist.' + ) + if kind == 'relation': + return ( + f'{context}: {comparison} compares {node.name!r} against relation {value!r}, and a ' + f'relation is structure rather than data — a where tests values: a name against a literal, ' + f'or arithmetic over parameters. A relation stands on the right-hand side only against a ' + f'relation on the left sharing its dimension and its target.' + ) + return ( + f'{context}: {comparison} compares against dimension {value!r}, which the RHS reads ' + f'as the literal coordinate {value!r} and so masks everything out. Comparing two ' + f'dimensions is not in the language; if {value!r} is a coordinate rather than the ' + f'dimension, rename one of the two.' + ) + + +def _relation_pair_error(context: str, node: _Plain, other: str, ns: Namespace, left: str, right: str) -> str | None: + """Why two relation columns may not be compared, or ``None`` where they may. + + Both relations are read at their keys, so the keys must be over the same + dimensions or no row carries both; and the two columns must be over one + dimension, or no value of one is ever a value of the other. Both wrong + answers are silent, and a build's data library decides which one. + """ + comparison = f"'{node.name} {node.op} {other}'" + left_name, right_name = node.name.partition('.')[0], other.partition('.')[0] + ls, rs = ns.relations[left_name], ns.relations[right_name] + left_keys, right_keys = {ls.dim(k) for k in ls.key}, {rs.dim(k) for k in rs.key} + if left_keys != right_keys: + return ( + f'{context}: {comparison} compares relations keyed over different dimensions ' + f"('{left_name}' by {sorted(left_keys)}, '{right_name}' by {sorted(right_keys)}) — there is no row " + f'carrying both, so the comparison has nothing to test. Two relations may be compared only ' + f'where their keys are over the same dimensions.' + ) + if ls.dim(left) != rs.dim(right): + return ( + f"{context}: {comparison} compares '{node.name}' (a column over '{ls.dim(left)}') with " + f"'{other}' (a column over '{rs.dim(right)}'). No value of one is ever a value of the other, so " + f'the predicate can only mask everything out. Two columns may be compared only ' + f'where they are over the same dimension.' + ) + return None diff --git a/src/math_spec/resolution.py b/src/math_spec/resolution.py index db315869..d2bbe02d 100644 --- a/src/math_spec/resolution.py +++ b/src/math_spec/resolution.py @@ -4,107 +4,51 @@ """Name resolution — the pass that reads the syntax tree into the program vocabulary. -The grammars emit bare names and calls; this module builds the +The grammars emit bare names and calls; resolution builds the :mod:`math_spec.program` node each stands for, so every pass after — the dim -rules, the degree rules, the typesetter, lowering — reads one vocabulary. The -rules live in the language reference. +rules, the degree rules, the typesetter, lowering — reads one vocabulary. This +module holds the :class:`Namespace` a resolution reads names from and the doors +lowering calls, one per kind of text; the walks are +:class:`~math_spec._expression_resolver.ExpressionResolver` for arithmetic and +:class:`~math_spec._where_resolver.WhereResolver` for a where string. The rules +live in the language reference. """ from __future__ import annotations -import datetime -import re -from dataclasses import dataclass -from typing import TYPE_CHECKING, Literal, NamedTuple, assert_never, cast +from typing import TYPE_CHECKING, Literal, cast import math_spec.degree as degree from math_spec._expression_parser import ( - MAX_DEPTH, ArithmeticNode, - BinaryOperatorNode, ComparisonNode, - FunctionCallNode, - KeywordNode, - NameListNode, NameNode, - NumberNode, - UnaryOperatorNode, - depth, nodes, - shown, ) +from math_spec._expression_resolver import ExpressionResolver from math_spec._where_parser import ( - ColumnNode, - UnresolvedComparisonNode, - UnresolvedCountNode, - UnresolvedPredicateCallNode, UnresolvedWhereNode, nested, parse_where, ) -from math_spec.dimensions import dims_of, pulled_back_dims -from math_spec.errors import DimensionError, LanguageError, SchemaError, case_context, did_you_mean, prefixed +from math_spec._where_resolver import WhereResolver +from math_spec.errors import LanguageError, SchemaError, case_context, prefixed from math_spec.exclusivity import overlapping from math_spec.expansion import expand, parse_and_expand -from math_spec.model import NUMERIC_DTYPES -from math_spec.operators import ( - AMOUNTS, - BUILTINS, - EDGE_WRAP, - PARTITION_NAMES_ITS_GROUP, - call_shape_error, - edge_error, - unknown_operator_message, -) from math_spec.program import ( - Add, - And, BooleanLiteral, Cases, - Constant, - CountComparison, - DimensionComparison, - DimensionPosition, - Direction, - Divide, - Dual, Expression, - ExpressionComparison, - GroupSum, Mask, - Multiply, Named, - Negate, - Not, - Or, - Parameter, - ParameterComparison, - ParameterDefined, - Partition, - Power, Predicate, - PredicateOperator, - Pullback, - PulledBackPredicate, Region, - RelationComparison, RelationDeclaration, - RelationDefined, - RelationPairComparison, - Sum, - Translate, - TranslatedPredicate, - TypedPredicate, - Variable, - VariableDefined, - WindowSum, carries_variable, - children, - walk, ) if TYPE_CHECKING: - from collections.abc import Iterable, Mapping + from collections.abc import Iterable from math_spec._expression_parser import ComparisonOperator, ParsedNode from math_spec._where_parser import ParsedWhere @@ -117,16 +61,6 @@ #: than over the stores it would otherwise have to try in order. DeclarationKind = Literal['variable', 'parameter', 'dimension', 'relation'] -#: An ``edge=`` as a translation carries it: whether it wraps, and the number -#: the vacated positions contribute where it does not. -_Edge = tuple[bool, float | None] - -#: How deep a resolved tree may be with every named expression it reads -#: written in — the tree every pass after resolution recurses over. Three -#: times what one text may nest, since a text reads other texts: a chain of -#: 200 entries survived every pass on a default stack and 250 did not (#643). -MAX_RESOLVED_DEPTH = 3 * MAX_DEPTH - class Namespace: """The declared names of one schema, by kind — the whole of what a file may name, read once. @@ -312,13 +246,6 @@ def unknown_constraint(self, name: str, context: str, *, formals: Iterable[str] # --------------------------------------------------------------------------- -def names_in(value: ArithmeticNode) -> tuple[str, ...]: - """The names a relation kwarg carries: one bare, several bracketed, none otherwise.""" - if isinstance(value, NameNode): - return (value.name,) - return value.names if isinstance(value, NameListNode) else () - - def mask_of(node: Predicate | None) -> Mask | None: """The mask a declaration carries for a resolved where: ``None`` where there is none, or where every row passes.""" if node is None or (isinstance(node, BooleanLiteral) and node.value): @@ -364,7 +291,7 @@ def resolve_expression( """ before = len(errors) - resolved = _Resolver(ns, context, errors, formals=formals).build(node) + resolved = ExpressionResolver(ns, context, errors, formals=formals).build(node) return None if len(errors) > before else resolved @@ -383,7 +310,7 @@ def resolve_where( problems appended to *errors*. """ before = len(errors) - resolved = _Resolver(ns, context, errors, self_variable).where(node) + resolved = WhereResolver(ns, context, errors, self_variable).where(node) return None if len(errors) > before else Mask(cast('Predicate', resolved)).root @@ -455,7 +382,7 @@ def resolve_constraint_text( ) return None found = len(errors) - resolver = _Resolver(ns, context, errors) + resolver = ExpressionResolver(ns, context, errors) left, right = resolver.build(ast.left), resolver.build(ast.right) if len(errors) > found or left is None or right is None: return None @@ -546,1318 +473,3 @@ def _constant_arm(context: str, *, value: bool) -> str: f'or narrow the `when`.' ) return f'{context}: the mask admits no row, so this arm never applies. Delete the arm, or widen the `when`.' - - -@dataclass(frozen=True) -class _Resolver: - """One resolution walk, and the three things every step of it reads. - - A node that cannot be built comes back as ``None`` with its refusal - appended to ``errors``; every sibling is still read, so a declaration - with two faults reports both. ``self_variable`` is the variable whose own - ``where`` is being read, which may not ask whether it exists. ``formals`` - are a macro template's formals: a formal has no kind until a call site - binds it, so a node one stands under is ``None`` with nothing appended. - """ - - ns: Namespace - context: str - errors: list[str] - self_variable: str | None = None - formals: frozenset[str] = frozenset() - - def _formal(self, value: ArithmeticNode) -> bool: - """Whether *value* is a formal, left for the call site to bind.""" - return isinstance(value, NameNode) and value.name in self.formals - - # -- expressions ------------------------------------------------------- - - def build(self, node: ArithmeticNode) -> Expression | None: - """The program tree *node* stands for, held to :data:`MAX_RESOLVED_DEPTH` before anything walks it. - - The depth is measured with every named expression written in, since - that is the tree every later pass recurses over, and measured with an - explicit stack, since a recursion would be the crash it prevents. - """ - resolved = self.arith(node) - if resolved is None or (found := depth(resolved, children)) <= MAX_RESOLVED_DEPTH: - return resolved - self.errors.append( - f'{self.context}: the expression nests {found} deep with every named expression it reads written in, ' - f'past the {MAX_RESOLVED_DEPTH} levels the language admits. Reduce over a dimension with sum() rather ' - f'than writing the terms out, or precompute the deepest part as a parameter — a named expression ' - f'stands inline where it is read, so naming a part does not make the tree shallower.' - ) - return None - - def arith(self, node: ArithmeticNode) -> Expression | None: - """The program node *node* stands for, or ``None``. - - A quoted keyword or a name list in arithmetic arrives through a macro - formal bound to one. - """ - if isinstance(node, NumberNode): - return Constant(node.value) - if isinstance(node, NameNode): - return self._name(node) - if isinstance(node, UnaryOperatorNode): - operand = self.arith(node.operand) - if operand is None: - return None - return Negate(operand) if node.op == '-' else operand - if isinstance(node, BinaryOperatorNode): - return self._binary(node) - if isinstance(node, FunctionCallNode): - return self._call(node) - if isinstance(node, KeywordNode): - self.errors.append( - f'{self.context}: {node.value!r} is a quoted keyword, which is only legal as a ' - f"operator kwarg value such as shift(..., edge='wrap'). In an expression, quote " - f'nothing — names resolve and numbers are written bare.' - ) - return None - if isinstance(node, NameListNode): - self.errors.append( - f'{self.context}: {node} is a list of names, which is only legal as an operator ' - f'kwarg value such as sum(x, by=[gen_bus, gen_tech]). In an expression, write the ' - f'terms out and add them.' - ) - return None - assert_never(node) - - def _binary(self, node: BinaryOperatorNode) -> Expression | None: - """A subtraction is an addition of the negation, so a program has one additive node.""" - left, right = self.arith(node.left), self.arith(node.right) - if left is None or right is None: - return None - match node.op: - case '+': - return Add(left, right) - case '-': - return Add(left, Negate(right)) - case '*': - return Multiply(left, right) - case '/': - return Divide(left, right) - case '**': - return Power(left, right) - case _: - assert_never(node.op) - - def _name(self, node: NameNode) -> Expression | None: - """A bare name as the variable, parameter or named expression it declares; a dimension or relation is not a value. - - A named expression arrives as the one node :meth:`Namespace.named` - built for it; the cast is the one place a - :class:`~math_spec.program.Named` enters a tree typed as a program's, - which lowering makes true. - """ - if node.name in self.formals: - return None - if node.name in self.ns.schema.expressions: - try: - return cast('Expression', self.ns.named(node.name, self.context)) - except SchemaError as e: - self.errors.append(str(e)) - return None - match self.ns.kind(node.name): - case 'variable': - return Variable(node.name) - case 'parameter': - dtype = self.ns.dtypes.get(node.name) - if dtype is not None and dtype not in NUMERIC_DTYPES: - self.errors.append(_not_a_number(node.name, dtype, self.context)) - return None - return Parameter(node.name) - case 'dimension': - self.errors.append( - f"{self.context}: '{node.name}' is a dimension, and a dimension is " - f'not a value in an expression. Dimensions appear in ' - f"'dims:', in operator arguments (sum(x, over={node.name})), " - f'and in where-comparisons — to use its coordinates as data, ' - f'declare a parameter over it.' - ) - return None - case 'relation': - self.errors.append( - f"{self.context}: '{node.name}' is a relation, and a relation is structure " - f'rather than data, so it is not a value in an expression. A relation ' - f'appears in a helper (sum(x, by={node.name})) and in a where — to ' - f'carry numbers along this dimension, declare a parameter over it.' - ) - return None - case _: - self.errors.append(self.ns.unknown(node.name, self.context, allow_dims=False, formals=self.formals)) - return None - - def _call(self, node: FunctionCallNode) -> Expression | None: - """An operator call as the node it is: its shape checked, and each kwarg read by the kind the operator declares for it. - - Every argument is read even after one failed, so a call with two - faults reports both. A formal anywhere under the call builds nothing - and refuses nothing. - """ - if node.name not in BUILTINS: - self.errors.append(f'{self.context}: {unknown_operator_message(node.name)}') - return None - builtin = BUILTINS[node.name] - shape_error = call_shape_error(node.name, len(node.args), node.kwargs) - if shape_error is not None: - self.errors.append(f'{self.context}: {shape_error}') - if node.name == 'dual': - return None if shape_error is not None else self._dual(node) - args = [self.arith(a) for a in node.args] - with_relation = any(k in node.kwargs for k in builtin.relation_kwargs) - roles = {k: v for k, v in node.kwargs.items() if builtin.kind_of(k, with_relation=with_relation) == 'role'} - unrelated = bool(roles) and 'by' not in node.kwargs - if unrelated: - self.errors.append( - f'{self.context}: {node.name}({", ".join(f"{k}=" for k in roles)}) names a column of a relation, ' - f'and no by= names the relation. Write {builtin.usage}' - ) - dims: dict[str, str | None] = {} - amounts: dict[str, int | str | None] = {} - edge: _Edge | None = None - for key, value in node.kwargs.items(): - match builtin.kind_of(key, with_relation=with_relation): - case 'edge': - edge = self._edge(value, node.name) - case 'dimension': - dims[key] = self._dim_ref(value, node.name, key) - case 'value': - amounts[key] = self._amount(value, node.name, key) - case 'relation' | 'role' | None: - pass - read = None - if 'by' in node.kwargs and builtin.kind_of('by') == 'relation': - read = self._relation_ref(node.kwargs['by'], node.name, 'by', roles, dims.get('along')) - unread = ( - shape_error is not None - or unrelated - or not args - or args[0] is None - or None in dims.values() - or None in amounts.values() - or ('edge' in node.kwargs and edge is None) - or ('by' in node.kwargs and read is None) - ) - if unread: - return None - return self._built(node.name, cast('Expression', args[0]), dims, amounts, edge, read) - - def _built( - self, - operator: str, - operand: Expression, - dims: Mapping[str, str | None], - amounts: Mapping[str, int | str | None], - edge: _Edge | None, - read: Direction | Partition | None, - ) -> Expression | None: - """The node *operator* builds from its read arguments, or ``None`` with the refusal appended.""" - if operator == 'sum': - if read is not None: - assert isinstance(read, Direction), 'a sum reads its relation in a direction' - return GroupSum(operand, read) - if (over := dims.get('over')) is not None: - return Sum(operand, (over,)) - return self._bare_sum(operand) - if operator == 'at': - assert isinstance(read, Direction), 'at reads its relation in a direction' - return Pullback(operand, read) - assert read is None or isinstance(read, Partition), 'a translation reads its relation as a partition' - along = dims['along'] - assert along is not None - wrap, fill = edge if edge is not None else (False, None) - if operator == 'shift': - offset = amounts['offset'] - assert offset is not None - if not self._edge_fits(operand, offset, wrap=wrap, fill=fill): - return None - return Translate(operand, along, offset, wrap=wrap, fill=fill, partition=read) - if fill is not None: - self.errors.append( - f"{self.context}: sum_back(edge=...) takes 'wrap' or nothing. A window sums the terms " - f'it reaches, so a position before the first contributes nothing rather than a ' - f'fill value; add the constant to the expression if you want one.' - ) - return None - width = amounts['window'] - assert width is not None - return WindowSum(operand, along, width, wrap=wrap, partition=read) - - def _bare_sum(self, operand: Expression) -> Expression | None: - """``sum(x)`` with no ``over=`` or ``by=`` reduces every dim the operand carries, which it has to carry some of.""" - try: - inner = dims_of(operand, self.ns.schema, self.context) - except DimensionError as e: - self.errors.append(str(e)) - return None - if not inner: - self.errors.append( - f'{self.context}: sum() with no over= or by= sums every dim the operand ' - f'carries, and this one carries none — the expression is already a ' - f'scalar. Drop the sum.' - ) - return None - return Sum(operand, tuple(sorted(inner))) - - def _edge_fits(self, operand: Expression, offset: int | str, *, wrap: bool, fill: float | None) -> bool: - """What a ``shift``'s ``edge=`` may say, and where saying nothing is an answer. - - Every rule here is decidable from the file — whether the operand - carries a variable, whether the offset is named, what the edge is - written as — so a file breaking one is refused at load rather than by - whoever lowers it. - """ - if wrap: - return True - has_var = carries_variable(operand) - if has_var and fill is not None and fill != 0: - self.errors.append( - f'{self.context}: shift(edge={fill:g}) over an expression containing a variable — only ' - f'fill=0 is representable there, since a vacated slot contributes no term. A nonzero ' - f'fill would be a constant standing where a term was; add that constant to the ' - f'expression instead.' - ) - return False - if fill is None and _vacates(offset) and not has_var: - self.errors.append(_shift_over_data_message(self.context)) - return False - if fill is None and isinstance(offset, str): - self.errors.append(f'{self.context}: {_named_offset_edge_message(offset)}') - return False - return True - - def _amount(self, value: ArithmeticNode, operator: str, key: str) -> int | str | None: - """``offset=`` or ``window=``: a whole number in the operator's range, or the name of a parameter. - - Closed so that :func:`math_spec.dimensions._check_named_amount` sees - every parameter an amount carries, and so that a program's - ``offset`` and ``width`` are the ``int | str`` they say. - """ - words = AMOUNTS[operator] - if (literal := _literal(value)) is not None: - if not (literal.value.is_integer() and literal.value >= words.minimum): - self.errors.append(f'{self.context}: {operator}({key}=...) {words.form}') - return None - return int(literal.value) - bare = _without_sign(value) - if not isinstance(bare, NameNode): - self.errors.append( - f'{self.context}: {operator}({key}=) takes a number or the name of an integer parameter. ' - f'Precompute it as a parameter.' - ) - return None - if self.ns.kind(bare.name) != 'parameter': - if self._name(bare) is not None: - self.errors.append(f'{self.context}: {operator}({key}=...) {words.form}') - return None - if (dtype := self.ns.dtypes[bare.name]) != 'int': - self.errors.append( - f"{self.context}: {operator}({key}={bare.name}) counts positions, but '{bare.name}' is declared " - f'dtype: {dtype}. A count of positions is integral — declare it dtype: int, which binds only an ' - f'integer column, so a fractional {words.noun} has nowhere to arrive from.' - ) - return None - if isinstance(value, UnaryOperatorNode) and value.op == '-': - self.errors.append( - f'{self.context}: {operator}({key}=-{bare.name}) negates a named {words.noun}. {words.negated}' - ) - return None - return bare.name - - def _edge(self, value: ArithmeticNode, operator: str) -> _Edge | None: - """``edge=``: the closed keyword ``wrap``, or a number to contribute; a name here is a typo.""" - if self._formal(value): - return None - if isinstance(value, KeywordNode): - if value.value == EDGE_WRAP: - return True, None - self.errors.append(f'{self.context}: {edge_error(operator, repr(value.value))}') - return None - if isinstance(value, NameNode): - if value.name == EDGE_WRAP: - self.errors.append( - f'{self.context}: {operator}(edge={EDGE_WRAP}) is a bare name where a keyword belongs. ' - f"Write edge='{EDGE_WRAP}', quoted." - ) - return None - self.errors.append(f'{self.context}: {edge_error(operator, value.name)}') - return None - if (literal := _literal(value)) is None: - self.errors.append( - f"{self.context}: {operator}(edge=) is an expression, and an edge is the keyword '{EDGE_WRAP}' " - f'or a number. Write the number itself.' - ) - return None - return False, literal.value - - def _dim_ref(self, value: ArithmeticNode, operator: str, key: str) -> str | None: - """An operator kwarg whose *value* must name a declared dimension.""" - if self._formal(value): - return None - if not isinstance(value, NameNode): - self.errors.append(f'{self.context}: {operator}({key}=...) must name a dimension.') - return None - if value.name not in self.ns.dimensions: - self.errors.append( - _undeclared_dim(self.context, operator, f'{key}={value.name}', value.name, self.ns, self.formals) - ) - return None - return value.name - - def _dual(self, node: FunctionCallNode) -> Dual | None: - """``dual(c)`` as the leaf it is, its one argument the name of a declared constraint. - - Constraints sit outside the flat namespace, so this store is consulted - only here — a bare name in arithmetic never reaches it. A dual standing - where the math is built is refused separately - (:mod:`math_spec.degree`); this pass only types the name. - """ - (value,) = node.args - if self._formal(value): - return None - if not isinstance(value, NameNode): - self.errors.append( - f'{self.context}: dual() takes the name of a declared constraint, written bare — ' - f'dual(). Name the constraint whose row dual you want.' - ) - return None - if value.name not in self.ns.constraints: - self.errors.append(self.ns.unknown_constraint(value.name, self.context, formals=self.formals)) - return None - return Dual(value.name) - - def _relation_ref( - self, - value: ArithmeticNode, - operator: str, - key: str, - roles: Mapping[str, ArithmeticNode], - along: str | None, - ) -> Direction | Partition | None: - """An operator's ``by=`` as the direction or the partition the call reads its relation in. - - A relation carries its own dimensions, so the call names columns rather - than dims: ``over=`` the column consumed, ``into=`` the column - produced, every other key column joined on. A value column not named - is not read, and a bare relation's columns are all key. One call - addresses one table, so several columns of one table are a list and - several tables are not. *along* is the dimension a translation steps - along, already read, or ``None`` where it was refused. - """ - names = names_in(value) - if not names: - self.errors.append(f'{self.context}: {operator}({key}=...) must name a relation.') - return None - if len(names) > 1: - self.errors.append( - f'{self.context}: {operator}({key}={shown(names)}) names {len(names)} relations, and one call ' - f'reads one table. Declare one relation with the columns of all of them, or read them in turn, ' - f'one call each.' - ) - return None - name = names[0] - if name in self.formals: - return None - if (problem := self._not_a_relation(name, operator, key)) is not None: - self.errors.append(problem) - return None - if any(n in self.formals for v in roles.values() for n in names_in(v)): - return None - read = {k: self._role_name(v, operator, k) for k, v in roles.items()} - named = {k: r for k, r in read.items() if r is not None} - if operator in ('shift', 'sum_back'): - if 'within' not in named: - return None # refused already, by the call shape or by the role that named no column - return self._partition(name, operator, along, named['within']) - if not ({'over', 'into'} <= set(named)): - return None # refused already, by the call shape or by the role that named no column - return self._direction(name, operator, named['over'], named['into']) - - def _role_name(self, value: ArithmeticNode, operator: str, key: str) -> tuple[str, ...] | None: - """``over=`` or ``into=`` as the column names it must be — one bare name, or a bracketed list of them.""" - if names := names_in(value): - return names - self.errors.append( - f'{self.context}: {operator}({key}=...) names columns of the relation — a bare name, or a list of them.' - ) - return None - - def _direction( - self, - name: str, - operator: str, - from_roles: tuple[str, ...], - into_roles: tuple[str, ...], - ) -> Direction | None: - """Which direction ``sum`` or ``at`` reads relation *name* in, between the columns the call named. - - Both ends arrive written: the call shape refuses a call that leaves - one unsaid, so that a relation may gain a value column without - changing what this call means. ``at`` needs the read single-valued - and ``sum`` needs it not: a sum that lands on the key has one term - per coordinate and adds up nothing, which is a read, so it is - refused toward ``at``. A read lands on key columns and nothing else, - because a column outside the key is one no coordinate of the read - fixes. - """ - ns, context = self.ns, self.context - shape = ns.relations[name] - call = f'{operator}(by={name})' - if not ( - self._known_roles(name, call, from_roles, 'over') and self._known_roles(name, call, into_roles, 'into') - ): - return None - - forward = operator == 'sum' - if both := sorted(set(from_roles) & set(into_roles)): - self.errors.append( - f'{context}: {call}: over= and into= both name {both}, and a call reads between two sets of columns.' - ) - return None - for kwarg, roles in (('over', from_roles), ('into', into_roles)): - dims = [shape.dim(r) for r in roles] - if shared := sorted({d for d in dims if dims.count(d) > 1}): - self.errors.append( - f'{context}: {call}: {kwarg}={list(roles)} names two columns over {shared}, and the operand ' - f'carries each dimension once, so nothing says which column its coordinate is read at. Read ' - f'between columns over distinct dimensions.' - ) - return None - if not forward and (outside := [r for r in into_roles if r not in shape.key]): - self.errors.append( - f"{context}: {call}: into={list(into_roles)} names {outside}, which the key of '{name}' does not " - f'hold. A read lands on the key it reads at, {list(shape.key)}, and a column outside that key ' - f'arrives as a dimension the read never fixes. Land on the key, or sum toward {outside}.' - ) - return None - joined = tuple(r for r in shape.key if r not in from_roles and r not in into_roles) - single_valued = set(shape.key) <= {*into_roles, *joined} - direction = Direction(name, shape, from_roles, into_roles, joined) - if not forward and not single_valued: - self.errors.append( - f"{context}: {call}: at reads one value per coordinate, and '{name}' is not single-valued in " - f'{list(from_roles)} at the columns the call lands on ({[*into_roles, *joined]}) — its key is ' - f'{list(shape.key)}. Key the table by the columns the call lands on, or read the other way.' - ) - return None - if forward and single_valued: - self.errors.append( - f'{context}: {call}: this sum lands on the key {list(shape.key)}, so each coordinate has one ' - f"term and nothing is added up — that is a read, which is at()'s. Write " - f'at(..., by={name}, over={list(from_roles)}, into={list(into_roles)}), or sum toward ' - f'a value column.' - ) - return None - return direction - - def _known_roles(self, name: str, call: str, roles: tuple[str, ...], kwarg: str) -> bool: - """Whether every role *kwarg* names is a column of relation *name*, each once; the refusal otherwise.""" - shape = self.ns.relations[name] - for role in roles: - if role not in shape.roles: - self.errors.append( - f"{self.context}: {call}: {kwarg}={role} names no column of '{name}', whose columns are " - f'{list(shape.roles)}.' - ) - return False - if len(set(roles)) < len(roles): - self.errors.append(f'{self.context}: {call}: {kwarg}={list(roles)} names a column twice.') - return False - return True - - def _partition( - self, name: str, operator: str, along_dim: str | None, within_roles: tuple[str, ...] - ) -> Partition | None: - """How a partition (``shift``, ``sum_back``, ``position``) steps along relation *name* over *along_dim*. - - It steps along the one key column over that dimension (a key has one - column per dimension), joins on the other key columns and groups by the - value columns *within_roles* names. ``None`` where the dimension is not one - (already refused), the relation has no key column over it, or - ``within=`` names a column that is not a value column. - """ - context = self.context - shape = self.ns.relations[name] - call = f'{operator}(by={name})' - if along_dim is None or not self._known_roles(name, call, within_roles, 'within'): - return None - if not shape.values: - self.errors.append( - f"{context}: {call}: '{name}' is a bare relation — every column is in its key — so it makes no " - f'groups and no coordinate is in exactly one. Move the columns the group is made of under ' - f'values:, leaving key: the column {operator} steps along.' - ) - return None - over_keys = [r for r in shape.key if shape.dim(r) == along_dim] - if not over_keys: - self.errors.append( - f"{context}: {call}: '{name}' has no key column over '{along_dim}' — its key is " - f'{list(shape.key)} — and a partition steps along a key column over the dimension it groups.' - ) - return None - if keyed := [r for r in within_roles if r in shape.key]: - self.errors.append( - f"{context}: {call}: within={keyed} names a key column of '{name}', and a partition groups by " - f'value columns — its value columns are {list(shape.values)}.' - ) - return None - (along,) = over_keys - joined = tuple(r for r in shape.key if r != along) - return Partition(name, shape, along, within_roles, joined) - - def _not_a_relation(self, name: str, operator: str, key: str) -> str | None: - """Why *name* is not a relation; ``None`` where it is one.""" - ns, context = self.ns, self.context - if name in ns.relations: - return None - if name in ns.dimensions: - over_here = sorted(n for n, shape in ns.relations.items() if name in dict(shape.columns).values()) - hint = ( - f" Relations with a column over '{name}': {over_here}" - if over_here - else f" No relation has a column over '{name}'." - ) - return ( - f"{context}: {operator}({key}={name}): '{name}' is a dimension, and " - f'{key}= takes a relation — the named map out of a dimension.\n{hint}' - ) - return ( - f'{context}: {operator}({key}={name}) does not name a relation{_or_a_formal(self.formals)}. ' - f'{did_you_mean(name, ns.relations, label="Relations")}\n' - f"Declare it under 'relations:' — {name}: {{key: , " - f'values: }}.' - ) - - # -- where strings ----------------------------------------------------- - - def where(self, node: Predicate | UnresolvedWhereNode) -> Predicate | UnresolvedWhereNode: - """One predicate node typed, or returned unresolved with its refusal appended.""" - if isinstance(node, BooleanLiteral | TypedPredicate): - return node - if isinstance(node, NameNode): - return self._where_name(node) - if isinstance(node, UnresolvedComparisonNode): - return self._comparison(node) - if isinstance(node, UnresolvedPredicateCallNode): - return self._predicate_call(node) - if isinstance(node, UnresolvedCountNode): - return self._count(node) - if isinstance(node, Not): - return Not(self._child(node.operand)) - if isinstance(node, And): - return And(self._child(node.left), self._child(node.right)) - if isinstance(node, Or): - return Or(self._child(node.left), self._child(node.right)) - assert_never(node) - - def _child(self, node: Predicate | UnresolvedWhereNode) -> Predicate: - """A connective's child, typed as resolved: an unresolved one survives only with its refusal appended.""" - return cast('Predicate', self.where(node)) - - def _where_name(self, node: NameNode) -> Predicate | UnresolvedWhereNode: - """A bare name: a parameter's or relation's definedness, or a variable's existence.""" - ns, context = self.ns, self.context - kind = ns.kind(node.name) - if kind is None: - self.errors.append(ns.unknown(node.name, context, allow_dims=True)) - return node - match kind: - case 'parameter': - return ParameterDefined(node.name, ns.leaf_dims[node.name]) - case 'dimension': - self.errors.append( - f"{context}: '{node.name}' is a dimension, and a bare dimension " - f'name is true at every coordinate — the mask has no effect. ' - f'Remove it, or compare it: where: "{node.name} > 0".' - ) - case 'relation': - shape = ns.relations[node.name] - dims = tuple(shape.dim(k) for k in shape.key) - if len(set(dims)) < len(dims): - self.errors.append( - f"{context}: '{node.name}' has two columns over one dimension ({list(shape.roles)}), so a " - f'bare name cannot say which the frame supplies. Compare a column: ' - f'{node.name}.{shape.values[0] if shape.values else shape.roles[-1]} == ....' - ) - return node - return RelationDefined(node.name, dims) - case 'variable': - if node.name == self.self_variable: - self.errors.append( - f"{context}: variable '{node.name}' asks whether it exists in its own " - f'where, which nothing can answer — the mask is what decides where it ' - f'exists. Test a parameter, or another variable declared before it.' - ) - else: - return VariableDefined(node.name, ns.leaf_dims[node.name]) - return node - - def _predicate_call(self, node: UnresolvedPredicateCallNode) -> Predicate | UnresolvedWhereNode: - """``shift(, along=, offset=)`` or ``at(, by=, over=, into=)`` — the two operators that read a predicate and answer one. - - ``count`` answers a number, so it stands on a comparison's side and - :meth:`_count` reads it there. Anything else naming a predicate is - refused here rather than resolved into arithmetic it cannot be. - - An operand that failed to resolve is handed straight back: resolution - collects problems rather than raising, and asking an unresolved - predicate for its dims asserts instead of refusing. - """ - context, found = self.context, len(self.errors) - if node.name == 'count': - self.errors.append( - f'{context}: count() answers a number, and a where is a predicate. Compare it: ' - f'count(, over=) .' - ) - return node - if node.name not in ('shift', 'at'): - self.errors.append( - f"{context}: '{node.name}()' does not read a predicate. `shift` and `at` read one and answer " - f'one, `count` reads one and answers a number, and every other operator reads arithmetic. ' - f'Compare the predicate, or name a parameter carrying it.' - ) - return node - operand = self._child(node.operand) - if len(self.errors) > found: - return node - if node.name == 'at': - return self._pulled_back(node, Mask(operand)) - if (refusal := _kwargs_error(context, 'shift', node.kwargs, required=('along', 'offset'))) is not None: - self.errors.append(refusal) - return node - along = node.kwargs['along'] - offset = _literal(node.kwargs['offset']) - if not isinstance(along, NameNode) or self.ns.kind(along.name) != 'dimension': - self.errors.append( - f'{context}: shift(, along=) names the dimension the predicate is read back along. ' - f'Name a declared dimension.' - ) - return node - if offset is None or not offset.value.is_integer(): - self.errors.append( - f'{context}: shift(, offset=) counts whole coordinates back along ' - f"'{along.name}'. Write an integer." - ) - return node - mask = Mask(operand) - if along.name not in mask.dims: - self.errors.append( - f"{context}: shift(, along='{along.name}') reads the predicate back along a dimension " - f'it does not carry — it reads {_listed(sorted(mask.dims))}. Translate it along one of those.' - ) - return node - return TranslatedPredicate(mask, along.name, int(offset.value), tuple(sorted(mask.dims))) - - def _pulled_back(self, node: UnresolvedPredicateCallNode, mask: Mask) -> Predicate | UnresolvedWhereNode: - """``at(, by=, over=, into=)`` — the predicate read through a relation, as ``at`` reads an array. - - The relation and its two ends are read by the rules an expression's - ``at`` is, so the one refusal a file meets for a bad read is the same - in a ``where:`` and in an expression. - """ - context = self.context - if (refusal := _kwargs_error(context, 'at', node.kwargs, required=('by', 'over', 'into'))) is not None: - self.errors.append(refusal) - return node - found = len(self.errors) - roles = {key: node.kwargs[key] for key in ('over', 'into')} - by = self._relation_ref(node.kwargs['by'], 'at', 'by', roles, None) - if len(self.errors) > found or not isinstance(by, Direction): - return node - try: - dims = pulled_back_dims(by, mask.dims, context, 'the predicate') - except DimensionError as refusal: - self.errors.append(str(refusal)) - return node - return PulledBackPredicate(mask, by, tuple(sorted(dims))) - - def _count(self, node: UnresolvedCountNode) -> Predicate | UnresolvedWhereNode: - """``count(, over=) `` — how many coordinates the predicate admits. - - The reduction leaves every dim but ``over``, so the count is one - number per remaining coordinate and a claim about each group needs no - word for the group. - """ - context, found = self.context, len(self.errors) - operand = self._child(node.call.operand) - if len(self.errors) > found: - return node - if (refusal := _kwargs_error(context, 'count', node.call.kwargs, required=('over',))) is not None: - self.errors.append(refusal) - return node - over = node.call.kwargs['over'] - if not isinstance(over, NameNode) or self.ns.kind(over.name) != 'dimension': - self.errors.append( - f'{context}: count(, over=) names the dimension the coordinates are counted along. ' - f'Name a declared dimension.' - ) - return node - value = _literal(node.value) - if value is None or not value.value.is_integer(): - self.errors.append( - f'{context}: a count is a whole number of coordinates, so it is compared against one. ' - f'Write count(…, over={over.name}) {node.op} .' - ) - return node - if (decided := _decided_count(node.op, value.value)) is not None: - self.errors.append( - f'{context}: count(…, over={over.name}) {node.op} {value} holds at {decided} coordinate, because ' - f'a count is never negative. Delete the comparison, or write the bound it means.' - ) - return node - mask = Mask(operand) - if over.name not in mask.dims: - self.errors.append( - f"{context}: count(, over='{over.name}') counts along a dimension the predicate does " - f'not carry — it reads {_listed(sorted(mask.dims))}. Count along one of those.' - ) - return node - dims = tuple(sorted(mask.dims - {over.name})) - return CountComparison(mask, over.name, node.op, value.value, dims) - - def _comparison(self, node: UnresolvedComparisonNode) -> Predicate | UnresolvedWhereNode: - """``side side``, read for what each side is. - - A ``position()`` call on the left is the position form. A name against - a literal or a second column is the plain form the dtype rules are - written for, unless a side names a parameter or an ``expressions:`` - entry against the other, which is arithmetic however plain it looks. - Everything else is a comparison of expressions. - """ - if isinstance(node.left, FunctionCallNode) and node.left.name == 'position': - return self._position(node.left, node) - plain = self._plain(node) - if plain is None: - return self._expression_comparison(node) - return self._plain_comparison(node, plain) - - def _plain(self, node: UnresolvedComparisonNode) -> _Plain | None: - """The comparison as ``name literal`` or ``name name``, or ``None`` where the language reads it as arithmetic. - - A side that is arithmetic makes it so, and so does a name that is a - value — a parameter or an ``expressions:`` entry — against another, - however plain the two look. - """ - ns = self.ns - name, right = _side_name(node.left), node.right - value: float | str | None - quoted = isinstance(right, KeywordNode) - if isinstance(right, KeywordNode): - value = right.value - elif isinstance(right, ColumnNode): - value = right.shown - elif (literal := _literal(right)) is not None: - value = literal.value - else: - value = _side_name(right) - if value is not None and (value in ns.schema.expressions or ns.kind(value) == 'parameter'): - return None - if name is None or value is None or name in ns.schema.expressions: - return None - return _Plain(name, node.op, value, quoted) - - def _expression_comparison(self, node: UnresolvedComparisonNode) -> ExpressionComparison | UnresolvedComparisonNode: - """``expression expression``: each side expanded, typed and held to what a mask may read. - - A side is read as an expression is — macros and named expressions - expand, every operator and dim rule applies — except that it names no - variable and no dual, since a mask is built before either exists. - """ - ns, context = self.ns, self.context - found = len(self.errors) - sides: list[Expression] = [] - for side in (node.left, node.right): - if isinstance(side, ColumnNode | KeywordNode): - self.errors.append(_not_arithmetic(context, side)) - continue - if any(isinstance(n, FunctionCallNode) and n.name == 'count' for n in nodes(side)): - self.errors.append( - f'{context}: count() stands on the left of its comparison, and reads a predicate rather than ' - f'arithmetic. Write count(, over=) .' - ) - continue - try: - expanded = expand(side, ns, context) - except ValueError as e: - self.errors.append(prefixed(context, e)) - continue - if (resolved := self.build(expanded)) is not None: - sides.append(resolved) - if len(self.errors) > found: - return node - assert len(sides) == 2, 'a side of a where builds or refuses, since a where holds no formal' - dims: set[str] = set() - for side in sides: - if carries_variable(side): - self.errors.append( - f'{context}: a where compares expressions, and one side names a variable. A where mask ' - f'is built before variables exist — it may test parameters and dimension coordinates only.' - ) - elif degree.calls_dual(side): - self.errors.append( - f'{context}: a where compares expressions, and one side reads a dual, which only a solve ' - f'produces. A mask is built before it — test the data instead.' - ) - else: - try: - degree.check_expression(side, context) - dims |= dims_of(side, ns.schema, context) - except LanguageError as e: - self.errors.append(str(e)) - if len(self.errors) > found: - return node - left, right = sides - if all(_is_number(side) for side in sides): - self.errors.append( - f"{context}: '{node.left} {node.op} {node.right}' compares two numbers, so it is decided before any " - f'data arrives and admits every row or none. Name the parameter one side stands for, or drop ' - f'the comparison.' - ) - return node - return ExpressionComparison(left, node.op, right, tuple(d for d in ns.schema.dimensions if d in dims)) - - def _position( - self, call: FunctionCallNode, node: UnresolvedComparisonNode - ) -> DimensionPosition | UnresolvedComparisonNode: - """``position(dim[, by=relation, within=columns]) i``: the name a dimension, ``by=`` a relation keyed over it.""" - ns, context = self.ns, self.context - shape = _position_shape(call) - if shape is None: - self.errors.append( - f'{context}: position() is written position([, by=, within=]), and this ' - f'call is not of that shape. It takes the dimension it counts along and nothing else beside by= and within=.' - ) - return node - dimension, by, into = shape - index = None if isinstance(node.right, ColumnNode | KeywordNode) else _literal(node.right) - if index is None or not index.value.is_integer(): - self.errors.append( - f'{context}: position({dimension}) is compared against an integer index, where 0 is first and a ' - f'negative number counts from the end. Write position({dimension}) {node.op} .' - ) - return node - position = int(index.value) - if dimension not in ns.dimensions: - self.errors.append( - f"{context}: position() counts along a dimension's coordinates, and " - f"'{dimension}' is {_declared_as(ns, dimension)}. " - f'{did_you_mean(dimension, ns.dimensions, label="Dimensions")}' - ) - return node - if by is None: - return DimensionPosition(dimension, node.op, position) - if (problem := self._not_a_relation(by, 'position', 'by')) is not None: - self.errors.append(problem) - return node - spelled = f'position({dimension}, by={by})' - if into is None: - self.errors.append( - f'{context}: {spelled} leaves within= unsaid. {PARTITION_NAMES_ITS_GROUP} Write ' - f"position({dimension}, by={by}, within=) — the value columns of '{by}' " - f'are {list(ns.relations[by].values)}.' - ) - return node - partition = self._partition(by, 'position', dimension, into) - if partition is None: - return node - return DimensionPosition(dimension, node.op, position, partition) - - def _plain_comparison(self, node: UnresolvedComparisonNode, plain: _Plain) -> Predicate | UnresolvedWhereNode: - """``name literal``, or the one structural form ``relation relation``.""" - ns, context = self.ns, self.context - value = plain.value - left_name, _, left_column = plain.name.partition('.') - if not plain.quoted and isinstance(value, str): - right_name, _, right_column = value.partition('.') - if (rhs_kind := ns.kind(right_name)) is not None: - if rhs_kind == 'relation' and ns.kind(left_name) == 'relation': - left = self._relation_column(left_name, left_column or None, plain.name, plain.op) - right = self._relation_column(right_name, right_column or None, value, plain.op) - if left is None or right is None: - return node - if (refusal := _relation_pair_error(context, plain, value, ns, left, right)) is not None: - self.errors.append(refusal) - return node - dims = tuple(ns.relations[left_name].dim(k) for k in ns.relations[left_name].key) - return RelationPairComparison(left_name, left, right_name, right, plain.op, dims) - self.errors.append(_declared_rhs_error(context, plain, value, rhs_kind)) - return node - - kind = ns.kind(left_name) - if kind is None: - self.errors.append(ns.unknown(left_name, context, allow_dims=True)) - return node - if left_column and kind != 'relation': - self.errors.append( - f"{context}: '{plain.name}' reads a column of '{left_name}', which is {_declared_as(ns, left_name)}. " - f'Only a relation has columns.' - ) - return node - column = None - dtype: DeclaredDtype | None = None - if kind == 'relation': - column = self._relation_column(left_name, left_column or None, plain.name, plain.op) - if column is None: - return node - dtype = ns.dtypes[ns.relations[left_name].dim(column)] - elif kind in ('parameter', 'dimension'): - dtype = ns.dtypes[left_name] - if dtype is not None: - typed = self._typed_literal(plain, dtype) - if typed is None: - return node - value = typed - - match kind: - case 'parameter': - assert not isinstance(value, datetime.date) - return ParameterComparison(left_name, plain.op, value, ns.leaf_dims[left_name]) - case 'dimension': - return DimensionComparison(left_name, plain.op, value) - case 'relation': - assert column is not None - shape = ns.relations[left_name] - return RelationComparison(left_name, column, plain.op, value, tuple(shape.dim(k) for k in shape.key)) - case 'variable': - self.errors.append( - f"{context}: where references variable '{left_name}'. A where " - f'mask is built before variables exist — it may test parameters ' - f'and dimension coordinates only.' - ) - return node - - def _relation_column(self, name: str, column: str | None, spelling: str, op: PredicateOperator) -> str | None: - """The value column a where-comparison on relation *name* reads, or the refusal. - - A comparison reads one value per coordinate, so the relation is keyed - and the column is one the key determines; unsaid, it is the one value - column where there is exactly one. - """ - ns, context = self.ns, self.context - shape = ns.relations[name] - if not shape.values: - self.errors.append( - f"{context}: '{spelling}' compares a column of '{name}', a bare relation — every column is in its " - f'key — so it has no one value per coordinate to compare. Declare that column under values:, or ' - f"test the bare name — '{name}' — for whether a row exists." - ) - return None - if column is None: - if len(shape.values) != 1: - self.errors.append( - f"{context}: '{spelling}': '{name}' has {len(shape.values)} value columns ({list(shape.values)}), " - f'so say which the comparison reads: {name}.{shape.values[0] if shape.values else "..."}.' - ) - return None - return shape.values[0] - if column not in shape.roles: - self.errors.append( - f"{context}: '{spelling}': '{column}' is not a column of '{name}', whose columns are {list(shape.roles)}." - ) - return None - if column in shape.key: - self.errors.append( - f"{context}: '{spelling}': '{column}' is a key column of '{name}', which the frame supplies rather " - f"than reads. Compare the frame's own coordinate — {shape.dim(column)} {op} ... — or a value column." - ) - return None - return column - - def _typed_literal(self, node: _Plain, dtype: DeclaredDtype) -> float | str | datetime.date | None: - """The comparison's literal, checked against the declared dtype. - - Getting it wrong is silent: polars reads a datetime column against an - integer as an epoch offset, so ``snapshot > 0`` drops every coordinate - before 1970 without a word (#460). Returns ``None`` once it has recorded - an error, so the caller leaves the node unresolved. - """ - context = self.context - value = node.value - text = isinstance(value, str) - - if dtype == 'datetime': - if not text: - self.errors.append( - f"{context}: '{node.name}' is a datetime dimension, so comparing it to " - f'{value!r} compares against the epoch — {node.name} > 0 means "after ' - f'1970-01-01", not what it looks like. Quote an ISO date instead: ' - f"{node.name} {node.op} '2030-01-01'." - ) - return None - try: - return ( - datetime.datetime.fromisoformat(value) - if _HAS_TIME.search(value) - else datetime.date.fromisoformat(value) - ) - except ValueError: - self.errors.append( - f"{context}: '{node.name}' is a datetime dimension and {value!r} is not an " - f"ISO date. Write '2030-01-01' or '2030-01-01T06:00'." - ) - return None - - if dtype == 'str' and not text: - self.errors.append( - f"{context}: '{node.name}' has dtype 'str', so comparing it to the number " - f'{value!r} matches no label. Quote it if it is one: {node.name} {node.op} ' - f"'{value:g}'." - ) - return None - if dtype in ('int', 'float', 'bool') and text: - self.errors.append( - f"{context}: '{node.name}' has dtype '{dtype}', so comparing it to the string " - f'{value!r} matches nothing. Drop the quotes if it is a number.' - ) - return None - return value - - -#: An ISO literal carrying a time-of-day, which decides date vs datetime. -_HAS_TIME = re.compile(r'[T ]\d') - - -def _not_a_number(name: str, dtype: str, context: str) -> str: - """Why a ``str`` or ``bool`` parameter is refused where a value belongs; the rewrite is the dtype's own.""" - if dtype == 'str': - instead = ( - f'A label selects rather than scales: compare it in a where ' - f'("{name} == \'some_label\'"), and carry the numbers it picks out in a ' - f'parameter of its own.' - ) - else: - instead = ( - f'A flag masks rather than scales: name it in a where ("{name}", "NOT {name}"), ' - f'which is what a mask is — or declare it dtype: int where the 0/1 is meant to ' - f'arrive as data and be multiplied by.' - ) - return ( - f"{context}: '{name}' is declared dtype: {dtype}, and an expression is arithmetic — " - f'only dtype: float and dtype: int bind a column it can be done to. {instead}' - ) - - -def _undeclared_dim(context: str, operator: str, call: str, name: str, ns: Namespace, formals: frozenset[str]) -> str: - return ( - f'{context}: {operator}({call}) does not name a declared dimension{_or_a_formal(formals)}. ' - f'{did_you_mean(name, ns.dimensions, label="Dimensions")}\n' - f"Declare '{name}' under 'dimensions:', or fix the typo — an unknown " - f'dimension makes {operator}() a silent no-op rather than an error.' - ) - - -def _or_a_formal(formals: frozenset[str]) -> str: - """The words a refusal inside a template adds, since a formal would have stood there too.""" - return ' or a formal of this macro' if formals else '' - - -def _declared_as(ns: Namespace, name: str) -> str: - kind = ns.kind(name) - return f'a {kind}' if kind else 'not declared' - - -def _without_sign(value: ArithmeticNode) -> ArithmeticNode: - """*value* under its sign, if it carries one.""" - return value.operand if isinstance(value, UnaryOperatorNode) else value - - -class _Plain(NamedTuple): - """A where-comparison read as ``name literal`` or ``name name`` — the shape the dtype rules are written for. - - ``quoted`` says the right-hand side arrived in quotes, and so is a label - rather than a name to look up. - """ - - name: str - op: PredicateOperator - value: float | str - quoted: bool - - -def _side_name(side: ArithmeticNode | ColumnNode) -> str | None: - """The name a side of a where-comparison spells — bare or ``relation.column`` — or ``None`` where it is arithmetic.""" - if isinstance(side, NameNode): - return side.name - if isinstance(side, ColumnNode): - return side.shown - return None - - -def _position_shape(call: FunctionCallNode) -> tuple[str, str | None, tuple[str, ...] | None] | None: - """``(dim, by, within)`` off a ``position(...)`` call, or ``None`` where the call is not of that shape.""" - if len(call.args) != 1 or not isinstance(call.args[0], NameNode) or set(call.kwargs) - {'by', 'within'}: - return None - by, within = call.kwargs.get('by'), call.kwargs.get('within') - if by is not None and not isinstance(by, NameNode): - return None - if within is not None and not isinstance(within, NameNode | NameListNode): - return None - into = names_in(within) if within is not None else None - return call.args[0].name, by.name if by is not None else None, into - - -def _kwargs_error( - context: str, name: str, kwargs: Mapping[str, ArithmeticNode], required: tuple[str, ...] -) -> str | None: - """Why *kwargs* is not what *name* takes over a predicate, or ``None`` where it is. - - A predicate-reading call takes exactly the keywords named here. The - arithmetic forms of these operators take more — an ``edge=``, a ``by=`` — - and each is refused rather than ignored, since a predicate answers the - vacated coordinate itself and a grouped form has nobody asking for it yet. - """ - missing = [key for key in required if key not in kwargs] - if missing: - return f'{context}: {name}() needs {_listed([f"{key}=" for key in missing])}.' - if extra := sorted(set(kwargs) - set(required)): - edge = ' A predicate is false where a translation vacates, so there is no edge to state.' - return ( - f'{context}: {name}() does not take {_listed([f"{key}=" for key in extra])}. ' - f'It takes {_listed([f"{key}=" for key in required])}, and nothing else.' - f'{edge if "edge" in extra and name == "shift" else ""}' - ) - return None - - -def _decided_count(op: str, value: float) -> str | None: - """Whether comparing a count with *op* against *value* is settled by the count never being negative. - - Returns ``'every'`` where the comparison always holds, ``'no'`` where it - never does, and ``None`` where the data decides. - """ - if value < 0: - return 'every' if op in ('>', '>=', '!=') else 'no' - if value == 0 and op in ('>=', '<'): - return 'every' if op == '>=' else 'no' - return None - - -def _listed(items: list[str]) -> str: - """``'a'``, ``'a' and 'b'``, ``'a', 'b' and 'c'`` — one rule, so every message reads the same.""" - quoted = [f"'{item}'" for item in items] - if len(quoted) <= 1: - return quoted[0] if quoted else 'nothing' - return f'{", ".join(quoted[:-1])} and {quoted[-1]}' - - -def _is_number(side: Expression) -> bool: - """Whether *side* is arithmetic over literals alone — a value the language can fold, and a where may not test.""" - return all(isinstance(n, Constant | Negate | Add | Multiply | Divide | Power) for n in walk(side)) - - -def _literal(value: ArithmeticNode) -> NumberNode | None: - """The number a literal names, its sign folded in — ``None`` where *value* is not one.""" - if isinstance(value, NumberNode): - return value - if isinstance(value, UnaryOperatorNode) and isinstance(value.operand, NumberNode): - return NumberNode(-value.operand.value if value.op == '-' else value.operand.value) - return None - - -def _not_arithmetic(context: str, side: ColumnNode | KeywordNode) -> str: - """Why a relation column or a quoted label may not stand on a side of a comparison of expressions.""" - if isinstance(side, ColumnNode): - return ( - f"{context}: '{side.shown}' is a column of a relation, which is compared against a literal or a " - f'second column and is not read in arithmetic. Compare it on its own, or carry the value in a ' - f'parameter and test that.' - ) - return ( - f"{context}: '{side.value}' is a quoted label, which is compared against one name. Put the name alone on " - f'the other side, or drop the quotes if it is a number.' - ) - - -def _declared_rhs_error(context: str, node: _Plain, value: str, kind: str) -> str: - """Why the right-hand side of a where-comparison may not name a variable, a relation or a dimension.""" - comparison = f"'{node.name} {node.op} {value}'" - if kind == 'variable': - return ( - f'{context}: {comparison} compares against variable {value!r}. ' - f'A where mask is built before variables exist.' - ) - if kind == 'relation': - return ( - f'{context}: {comparison} compares {node.name!r} against relation {value!r}, and a ' - f'relation is structure rather than data — a where tests values: a name against a literal, ' - f'or arithmetic over parameters. A relation stands on the right-hand side only against a ' - f'relation on the left sharing its dimension and its target.' - ) - return ( - f'{context}: {comparison} compares against dimension {value!r}, which the RHS reads ' - f'as the literal coordinate {value!r} and so masks everything out. Comparing two ' - f'dimensions is not in the language; if {value!r} is a coordinate rather than the ' - f'dimension, rename one of the two.' - ) - - -def _relation_pair_error(context: str, node: _Plain, other: str, ns: Namespace, left: str, right: str) -> str | None: - """Why two relation columns may not be compared, or ``None`` where they may. - - Both relations are read at their keys, so the keys must be over the same - dimensions or no row carries both; and the two columns must be over one - dimension, or no value of one is ever a value of the other. Both wrong - answers are silent, and a build's data library decides which one. - """ - comparison = f"'{node.name} {node.op} {other}'" - left_name, right_name = node.name.partition('.')[0], other.partition('.')[0] - ls, rs = ns.relations[left_name], ns.relations[right_name] - left_keys, right_keys = {ls.dim(k) for k in ls.key}, {rs.dim(k) for k in rs.key} - if left_keys != right_keys: - return ( - f'{context}: {comparison} compares relations keyed over different dimensions ' - f"('{left_name}' by {sorted(left_keys)}, '{right_name}' by {sorted(right_keys)}) — there is no row " - f'carrying both, so the comparison has nothing to test. Two relations may be compared only ' - f'where their keys are over the same dimensions.' - ) - if ls.dim(left) != rs.dim(right): - return ( - f"{context}: {comparison} compares '{node.name}' (a column over '{ls.dim(left)}') with " - f"'{other}' (a column over '{rs.dim(right)}'). No value of one is ever a value of the other, so " - f'the predicate can only mask everything out. Two columns may be compared only ' - f'where they are over the same dimension.' - ) - return None - - -def _vacates(offset: int | str) -> bool: - """Whether a translation leaves anything behind. - - A literal zero step reaches every coordinate from itself, so there is no - vacated position for an ``edge=`` to answer for and the refusal has - nothing to refuse. A *named* offset may be zero in the data and is not - known here, so it vacates until proved otherwise. - """ - return offset != 0 - - -def _named_offset_edge_message(name: str) -> str: - """Why a named offset must say what the vacated positions contribute. - - The absent edge propagates through a presence frame keyed by the translated - dimension alone, and a per-entity offset vacates a different slot for each - entity — which that frame cannot say. Refused rather than answered wrongly - (#850); the two edges that write their own answer are allowed. - """ - return ( - f'shift(offset={name}) leaves the vacated positions absent, which a ' - f'per-entity offset cannot say yet.\n' - f"Add edge='wrap' for a cyclic translation, or edge= for what the " - f'vacated positions contribute.' - ) - - -def _shift_over_data_message(context: str) -> str: - """The three ways out of a translation over data with no ``edge=``, the third being two things at once.""" - return ( - f'{context}: shift() over a variable-free expression leaves vacated positions with no ' - f'value, and inventing one is what silently pinned a bound to zero. Say which you mean:\n' - f" shift(x, along=d, offset=n, edge='wrap') the dimension really is cyclic\n" - f' shift(x, along=d, offset=n, edge=0) the vacated positions contribute zero\n' - f' ...and a where: excluding them the vacated rows should not exist at all\n' - f'A where: alone does not lift this — it is decided on the expression, before any mask ' - f'is read — and edge=0 alone leaves a row whose bound is that zero.' - ) From f3f3321afc9d90f3a81302bfecee25d1ca2650ee Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 16:28:39 +0000 Subject: [PATCH 09/20] chore(piecewise): the rules a curve is held to read the declaration the program carries, and nothing else The Curve alias, a tacit protocol between the pydantic block and the program declaration, is gone: assumptions_of, Emitted.of and the curvature rule take a PiecewiseDeclaration. The expansion keeps the block for the link text its rows repeat and takes the declaration for the frame and the names it writes. The two emitted-name collision rules read the program rather than the file, so they run once the declarations exist; every message is the same string. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi --- src/math_spec/lowering.py | 14 ++++++++++---- src/math_spec/piecewise.py | 27 +++++++++++++-------------- src/math_spec/validation.py | 31 +++++++++++++++++-------------- 3 files changed, 40 insertions(+), 32 deletions(-) diff --git a/src/math_spec/lowering.py b/src/math_spec/lowering.py index accd39ab..d637b712 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -50,7 +50,7 @@ resolve_expression_text, resolve_where_text, ) -from math_spec.validation import reference_errors +from math_spec.validation import emitted_name_errors, reference_errors if TYPE_CHECKING: from math_spec.model import AssumptionBlock, Spec @@ -73,6 +73,9 @@ def lower(schema: Spec) -> Program: not a silently-empty mask; - macro formals may shadow model names but not a declared dimension, since ``over=snapshot`` under a formal ``snapshot`` cannot say which it means; + - no name a set or curve writes out is one the file declares + (:func:`~math_spec.validation.emitted_name_errors`), read off the + curve as lowered; - every dim rule (``dimensions.check_schema``), once names resolve. A ``piecewise:`` block's links are resolved and its frame checked here, on @@ -156,9 +159,6 @@ def lower(schema: Spec) -> Program: curves: dict[str, tuple[Expression, ...]] = {} for pname, pdef in schema.piecewise.items(): - for aname, assumed in assumptions_of(pname, pdef).items(): - if (assumption := _assumption(aname, assumed, ns, errors)) is not None: - assumptions[aname] = assumption links = resolve_links(pname, pdef, ns, errors) if links is None: continue @@ -187,6 +187,10 @@ def lower(schema: Spec) -> Program: points=pdef.points, description=pdef.description, ) + for aname, assumed in assumptions_of(pname, piecewise[pname]).items(): + assumption = _assumption(aname, assumed, ns, errors) + assert assumption is not None and not errors, 'what a method assumes is stated in the language' + assumptions[aname] = assumption program = Program( parameters={ @@ -217,6 +221,8 @@ def lower(schema: Spec) -> Program: }, description=schema.description, ) + if errors := emitted_name_errors(schema, program): + raise SchemaError('\n'.join(errors)) check_schema(schema, program) return program diff --git a/src/math_spec/piecewise.py b/src/math_spec/piecewise.py index 6529406c..7bd122b9 100644 --- a/src/math_spec/piecewise.py +++ b/src/math_spec/piecewise.py @@ -30,16 +30,12 @@ from math_spec.program import Expression from math_spec.resolution import Namespace -#: A block as the file wrote it, or as the program carries it: the two share -#: every name the rules here read, and the rules read nothing else. -type Curve = PiecewiseBlock | PiecewiseDeclaration - #: The suffix on the second gate row, where the gate variable does not exist. _UNGATED = '_ungated' -def _curvature_required(pw: Curve) -> Curvature | None: +def _curvature_required(pw: PiecewiseDeclaration) -> Curvature | None: """The curvature *pw*'s method is only exact for, or ``None`` if any shape works. A bounded link binds from one side, and that side is the hull boundary the @@ -95,7 +91,7 @@ def lp_domain_refusal(name: str, pw: PiecewiseBlock, links: tuple[Expression, .. ) -def assumptions_of(block: str, pw: Curve) -> dict[str, AssumptionBlock]: +def assumptions_of(block: str, pw: PiecewiseDeclaration) -> dict[str, AssumptionBlock]: """What *block* assumes of its numbers, by the name the document prints and a refusal quotes. Every curve assumes its breakpoints are there: a missing parameter row is @@ -200,7 +196,7 @@ def _interior(over: str, mask: str | None) -> str: return f'{mask} AND shift({mask}, along={over}, offset=1) AND shift({mask}, along={over}, offset=-1)' -def _bends(block: str, pw: Curve, x: str, y: str, curvature: Curvature) -> AssumptionBlock: +def _bends(block: str, pw: PiecewiseDeclaration, x: str, y: str, curvature: Curvature) -> AssumptionBlock: """The curve bends the way *curvature* says, as a comparison of the two slopes at each breakpoint. The slopes are compared as a cross-product rather than as two quotients, @@ -251,7 +247,7 @@ class Emitted: assumptions: tuple[str, ...] @classmethod - def of(cls, name: str, pw: Curve) -> Emitted: + def of(cls, name: str, pw: PiecewiseDeclaration) -> Emitted: """The names block *name* writes.""" return cls( name, @@ -340,15 +336,18 @@ class _Block: """ def __init__( - self, schema: Spec, raw: dict[str, object], name: str, pw: PiecewiseBlock, frame: tuple[str, ...] + self, schema: Spec, raw: dict[str, object], name: str, pw: PiecewiseBlock, curve: PiecewiseDeclaration ) -> None: self.schema = schema self.raw = raw self.name = name + #: The block as the file wrote it, for the link text the rows repeat. self.pw = pw - self.emitted = Emitted.of(name, pw) + #: The block as the program carries it, for its frame and the names it writes. + self.curve = curve + self.emitted = Emitted.of(name, curve) self.mask = pw.points - self.frame = frame + self.frame = curve.frame def expand(self) -> None: """Write the block's declarations into the raw model.""" @@ -366,7 +365,7 @@ def _assumptions(self) -> None: as something a consumer has to know to ask for. """ assumptions = sos.section(self.raw, 'assumptions') - for name, assumed in assumptions_of(self.name, self.pw).items(): + for name, assumed in assumptions_of(self.name, self.curve).items(): assumptions[name] = assumed.model_dump() # -- emitters ---------------------------------------------------------- @@ -467,7 +466,7 @@ def expand_piecewise(schema: Spec) -> Spec: ``method: sos2`` states, and then that set is written out here too: the binaries are what the method *is*, so the model that comes back carries no set of its own (:func:`math_spec.sos.emit` is where they are spelled). - Each block's frame is read off the program *schema* lowered to. + Each block's frame and names are read off the program *schema* lowered to. """ if not schema.piecewise: return schema @@ -476,7 +475,7 @@ def expand_piecewise(schema: Spec) -> Spec: raw.setdefault('variables', {}) raw.setdefault('constraints', {}) for name, pw in schema.piecewise.items(): - _Block(schema, raw, name, pw, program.piecewise[name].frame).expand() + _Block(schema, raw, name, pw, program.piecewise[name]).expand() raw['piecewise'].clear() for name, pw in schema.piecewise.items(): if pw.method == 'adjacency': diff --git a/src/math_spec/validation.py b/src/math_spec/validation.py index 6f99c99b..096c10de 100644 --- a/src/math_spec/validation.py +++ b/src/math_spec/validation.py @@ -10,6 +10,8 @@ naming a numeric parameter, a set over one dim of one variable, a curve through parameters carrying its breakpoints — which lowering runs before it reads any expression, since resolution assumes every one of them. +:func:`emitted_name_errors` is the one rule read off the program instead: what +a block's expansion writes is decided by the block as lowered. """ from __future__ import annotations @@ -30,6 +32,8 @@ from collections.abc import Iterable, Iterator from pathlib import Path + from math_spec.program import Program + def to_spec(model: str | Path | Mapping[str, object] | Spec) -> Spec: """Load and validate a model definition — the language's front door. @@ -59,6 +63,19 @@ def to_spec(model: str | Path | Mapping[str, object] | Spec) -> Spec: return Spec.model_validate(model if isinstance(model, Mapping) else read_model(model)) +def emitted_name_errors(schema: Spec, program: Program) -> list[str]: + """Every name a set or curve of *program* would write out that *schema* already declares. + + Read off the program rather than the file, since what a curve writes is + decided by the curve as lowered — its links, its method, its mask. + """ + by_block = [ + *((f"Sos '{name}'", EmittedSet.of(name, block.sos_type).by_kind) for name, block in program.sos.items()), + *((f"piecewise '{name}'", EmittedCurve.of(name, curve).by_kind) for name, curve in program.piecewise.items()), + ] + return [error for context, by_kind in by_block for error in _collisions(schema, context, by_kind)] + + def reference_errors(schema: Spec) -> list[str]: """Every cross-declaration rule *schema* breaks, collected rather than raised on the first.""" return [ @@ -68,9 +85,7 @@ def reference_errors(schema: Spec) -> list[str]: *_bound_names(schema), *_sos_shapes(schema), *_sos_bounds(schema), - *_sos_emitted_names(schema), *_piecewise_references(schema), - *_piecewise_emitted_names(schema), ] @@ -251,12 +266,6 @@ def _sos_bounds(schema: Spec) -> Iterator[str]: ) -def _sos_emitted_names(schema: Spec) -> Iterator[str]: - """No name a set's expansion writes is one the file already declares.""" - for sname, block in schema.sos.items(): - yield from _collisions(schema, f"Sos '{sname}'", EmittedSet.of(sname, block.type).by_kind) - - def _piecewise_references(schema: Spec) -> Iterator[str]: """A curve runs along a declared dimension through numeric values parameters carrying it, gated by a binary, masked by a bool.""" for name, pw in schema.piecewise.items(): @@ -301,12 +310,6 @@ def _piecewise_references(schema: Spec) -> Iterator[str]: ) -def _piecewise_emitted_names(schema: Spec) -> Iterator[str]: - """No name a curve's expansion writes is one the file already declares.""" - for name, pw in schema.piecewise.items(): - yield from _collisions(schema, f"piecewise '{name}'", EmittedCurve.of(name, pw).by_kind) - - def _collisions(schema: Spec, context: str, by_kind: Iterable[tuple[str, Iterable[str]]]) -> Iterator[str]: """The refusal for each name *context*'s expansion writes that the file already declares, by kind.""" declared: dict[str, Iterable[str]] = { From 3c4f66510c3b84bec048f245aca15083156ea978 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 16:32:23 +0000 Subject: [PATCH 10/20] chore(typeset): the legend is its own module, reads what the equations use off the program, and the walk keeps no record The glossary and the three kinds of note move from walk.py to legend.py. What they explain is read off the program before anything prints, by notice(), so the walk no longer fills a Noticed record as it prints and a subscript no longer mutates the walk through its context. Walk.line refuses a name declared as none of the five kinds or as two, so typeset_declaration is one call. Symbols is a frozen record built by symbols_for. The typeset output is unchanged: the golden files match byte for byte. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi --- src/math_spec/typesetting/__init__.py | 42 ++-- src/math_spec/typesetting/legend.py | 288 ++++++++++++++++++++++++++ src/math_spec/typesetting/symbols.py | 108 +++++----- src/math_spec/typesetting/walk.py | 246 ++++------------------ tests/typesetting/test_golden.py | 7 +- tests/typesetting/test_walk.py | 4 +- 6 files changed, 413 insertions(+), 282 deletions(-) create mode 100644 src/math_spec/typesetting/legend.py diff --git a/src/math_spec/typesetting/__init__.py b/src/math_spec/typesetting/__init__.py index 9689af6d..ef5af3aa 100644 --- a/src/math_spec/typesetting/__init__.py +++ b/src/math_spec/typesetting/__init__.py @@ -28,11 +28,11 @@ from typing import TYPE_CHECKING, Literal, TypedDict, Unpack -from math_spec.errors import SchemaError, did_you_mean from math_spec.program import Program from math_spec.typesetting.latex import LatexFormat +from math_spec.typesetting.legend import Legend, notice from math_spec.typesetting.markdown import MarkdownFormat -from math_spec.typesetting.symbols import Symbols, SymbolTable +from math_spec.typesetting.symbols import SymbolTable, symbols_for from math_spec.typesetting.typst import TypstFormat from math_spec.typesetting.walk import Walk from math_spec.validation import to_spec @@ -94,7 +94,7 @@ def _walk( table = symbols if isinstance(symbols, SymbolTable) else SymbolTable.load(symbols) return Walk( program, - Symbols(program, format_, table.checked_against(program)), + symbols_for(program, format_, table.checked_against(program)), format_, inline_expressions=inline_expressions, ) @@ -145,17 +145,22 @@ def typeset( walk = _walk(model, fmt, symbols, inline_expressions=inline_expressions) program, format_ = walk.program, walk.format - sections, noticed = walk.equations() rendered = [ - format_.section(title, format_.equations(lines, numbered=numbered)) for title, lines in sections if lines + format_.section(title, format_.equations(lines, numbered=numbered)) + for title, lines in walk.equations() + if lines ] blocks = [format_.note(format_.escape(program.description))] if program.description else [] if legend: - blocks += [format_.section(title, format_.glossary(entries)) for title, entries in walk.glossaries(noticed)] - blocks += [format_.note(text) for text in walk.convention_notes()] - blocks += [format_.note(text) for text in walk.translation_notes(noticed)] - blocks += [format_.note(text) for text in walk.position_notes(noticed)] + explained, noticed = Legend(program, walk.symbols, format_), notice(program) + blocks += [ + format_.section(title, format_.glossary(entries)) + for title, entries in explained.glossaries(noticed, walk.defined()) + ] + blocks += [format_.note(text) for text in explained.convention_notes()] + blocks += [format_.note(text) for text in explained.translation_notes(noticed)] + blocks += [format_.note(text) for text in explained.position_notes(noticed)] return format_.document([*blocks, *rendered], standalone=standalone) @@ -200,25 +205,6 @@ def typeset_declaration( names nothing in the model. """ walk = _walk(model, fmt, symbols, inline_expressions=inline_expressions) - program = walk.program - kinds = { - 'named expression': program.expressions, - 'constraint': program.constraints, - 'assumption': program.assumptions, - 'curve': program.piecewise, - 'variable': program.variables, - } - found = [kind for kind, group in kinds.items() if name in group] - if not found: - everything = {n for group in kinds.values() for n in group} - msg = ( - f"'{name}' is not a named expression, constraint, assumption, curve or variable. " - f'{did_you_mean(name, everything)}' - ) - raise SchemaError(msg) - if len(found) > 1: - msg = f"'{name}' is declared twice, as {found[0]} and as {found[1]}, and one line prints one of them — rename one." - raise SchemaError(msg) return walk.format.equation(walk.line(name)) diff --git a/src/math_spec/typesetting/legend.py b/src/math_spec/typesetting/legend.py new file mode 100644 index 00000000..c7cf34fc --- /dev/null +++ b/src/math_spec/typesetting/legend.py @@ -0,0 +1,288 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""The legend: the glossary of symbols, and a note for each notation the equations use. + +What the equations use is read off the program before anything prints +(:func:`notice`), so the legend explains every symbol the walk will print and +nothing the walk decides is asked of it twice. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal + +from math_spec.program import ( + CountComparison, + DimensionComparison, + DimensionPosition, + ExpressionComparison, + PulledBackPredicate, + Translate, + TranslatedPredicate, + WindowSum, + walk_regions, +) +from math_spec.typesetting.format import Entry + +if TYPE_CHECKING: + from collections.abc import Iterable + + from math_spec.program import Expression, Mask, Program, RelationDeclaration + from math_spec.typesetting.format import Format, OperatorName + from math_spec.typesetting.symbols import Symbols + +#: What a translation does with the row the shift vacates. Three policies get +#: three spellings because they are three different equations at the boundary. +TranslationPolicy = Literal['plain', 'wrap', 'edge'] + +#: The positional forms an equation can print, each of which the legend explains once. +PositionForm = Literal['plain', 'grouped', 'from_end'] + + +def policy_of(node: Translate | WindowSum) -> TranslationPolicy: + """Which translation symbol *node* prints with: cyclic, filled at the edge, or plain.""" + if node.wrap: + return 'wrap' + if isinstance(node, Translate) and node.fill is not None: + return 'edge' + return 'plain' + + +@dataclass(frozen=True) +class Noticed: + """What the equations use that the legend has to explain. + + Attributes: + policies: Each translation policy some ``shift`` or ``sum_back`` prints. + grouped: Whether some translation is counted inside a relation's group. + positions: Each form a ``position()`` prints in. + numeric_coordinates: Each dimension whose index is compared against a + number, where "position 3" and "the coordinate 3" are both + readings of a line. + """ + + policies: frozenset[TranslationPolicy] + grouped: bool + positions: frozenset[PositionForm] + numeric_coordinates: frozenset[str] + + +def notice(program: Program) -> Noticed: + """What printing *program* uses, read off every tree the walk prints.""" + policies: set[TranslationPolicy] = set() + grouped = False + positions: set[PositionForm] = set() + numeric: set[str] = set() + + def expressions(*roots: Expression) -> None: + nonlocal grouped + for node, regions in walk_regions(*roots): + if isinstance(node, Translate | WindowSum): + policies.add(policy_of(node)) + grouped = grouped or node.partition is not None + if isinstance(node, Translate): + continue + for region in regions: + masks(region) + + seen: set[int] = set() + + def masks(mask: Mask) -> None: + if id(mask) in seen: + return + seen.add(id(mask)) + for atom in mask.atoms: + if isinstance(atom, DimensionComparison) and isinstance(atom.value, int | float): + numeric.add(atom.name) + elif isinstance(atom, DimensionPosition): + positions.add('grouped' if atom.partition is not None else 'plain') + if atom.position < 0: + positions.add('from_end') + elif isinstance(atom, ExpressionComparison): + expressions(atom.left, atom.right) + elif isinstance(atom, CountComparison): + masks(atom.predicate) + elif isinstance(atom, TranslatedPredicate | PulledBackPredicate): + masks(atom.operand) + + expressions(*program.roots) + for entry in program.expressions.values(): + expressions(entry.expression) + for curve in program.piecewise.values(): + expressions(*(link.expression for link in curve.links)) + for declaration in (*program.constraints.values(), *program.variables.values()): + if declaration.where is not None: + masks(declaration.where) + for assumption in program.assumptions.values(): + masks(assumption.predicate) + if assumption.where is not None: + masks(assumption.where) + return Noticed(frozenset(policies), grouped, frozenset(positions), frozenset(numeric)) + + +@dataclass(frozen=True) +class Legend: + """The glossary and the notes, spelled with one program's symbols in one format.""" + + program: Program + symbols: Symbols + format: Format + + def _op(self, name: OperatorName) -> str: + return self.format.operators[name] + + def glossaries(self, noticed: Noticed, defined: Iterable[str]) -> list[tuple[str, list[Entry]]]: + """The sets, parameters, variables and definitions, each with its symbol, its dims and its description. + + *defined* names the expressions that print under their own symbol, so + a legend row stands exactly where a symbol does. + """ + fmt, program = self.format, self.program + sets = [ + self._entry( + self.symbols.set[d], + f'index {fmt.math(self.symbols.index[d])} {fmt.dash} {fmt.mono(d)}{self._coords(d, noticed)}', + block.description, + ) + for d, block in program.dimensions.items() + ] + parameters = [ + self._entry(self.symbols.name[p], f'{fmt.mono(p)}{self._over(list(block.dims))}', block.description) + for p, block in program.parameters.items() + ] + variables = [ + self._entry(self.symbols.name[v], f'{fmt.mono(v)}{self._over(list(block.dims))}', block.description) + for v, block in program.variables.items() + ] + shown = set(defined) + definitions = [ + self._entry(self.symbols.name[e], f'{fmt.mono(e)}{self._over(list(block.dims))}', block.description) + for e, block in program.expressions.items() + if e in shown + ] + groups = (('Sets', sets), ('Parameters', parameters), ('Variables', variables), ('Definitions', definitions)) + return [(title, entries) for title, entries in groups if entries] + + def _entry(self, symbol: str, what: str, description: str | None) -> Entry: + meaning = f'{what} {self.format.dash} {self.format.escape(description)}' if description else what + return Entry(symbol, meaning) + + def _over(self, dims: list[str]) -> str: + if not dims: + return ' (scalar)' + product = self.format.joined([self.symbols.set[d] for d in dims], self._op('times')) + return f' over {self.format.math(product)}' + + def _signature(self, name: str, lk: RelationDeclaration) -> str: + """A relation in the legend: a function from its key sets to its value sets, or a relation inside the product.""" + + def product(roles: Iterable[str]) -> str: + return self.format.joined([self.symbols.set[lk.dim(r)] for r in roles], self._op('times')) + + if lk.values: + return f'{self.format.upright(name)}: {product(lk.key)} {self._op("maps_to")} {product(lk.values)}' + return f'{self.format.upright(name)} {self._op("subset_of")} {product(lk.roles)}' + + def _coords(self, dim: str, noticed: Noticed) -> str: + """The dimension's carried structure: each relation with a column over it, as the map or relation it is. + + The dtype is named only where an equation compared the index against a + number, the one place "position 3" and "the coordinate 3" are both + readings of a line. + """ + carried = self.program.relations_of(dim) + clauses = [] + if dim in noticed.numeric_coordinates: + clauses.append(f' ({self.format.mono(self.program.dimensions[dim].dtype)} coordinates)') + if carried: + maps = self.format.joined([self._signature(c, lk) for c, lk in carried.items()], '') + clauses.append(f' with {self.format.math(maps)}') + return ''.join(clauses) + + def convention_notes(self) -> list[str]: + """What the two faces mean, with the model's own symbols. + + Only where the model has both, and quoting only derived symbols: a + table is the author's to write, so a symbol it supplies is not one this + note governs. + """ + derived = [ + next((n for n in names if n not in self.symbols.overridden), None) + for names in (self.program.parameters, self.program.variables) + ] + if not all(derived): + return [] + given, chosen = (self.format.math(self.symbols.name[n]) for n in derived if n is not None) + return [ + f'Upright is what the model is given {self.format.dash} a parameter such as {given}, a coordinate ' + f'map, a label {self.format.dash} and italic is what the solver chooses, such as {chosen}. ' + f'An index is italic too, being what a quantifier chooses, and a set is script.' + ] + + def translation_notes(self, noticed: Noticed) -> list[str]: + """A sentence for each translation symbol the model printed; plain ``t-k`` needs none.""" + notes = [] + if 'wrap' in noticed.policies: + cyclic = self.format.math(f't {self._op("cyclic_minus")} k') + notes.append( + f'{cyclic} denotes cyclic translation: index {self.format.math("t-k")} taken modulo the size of ' + f'the dimension ({self.format.mono("roll")}). Plain {self.format.math("t-k")} ' + f'({self.format.mono("shift")}) has no wraparound {self.format.dash} terms translated past ' + f'the edge are simply absent.' + ) + if 'edge' in noticed.policies: + filled = self.format.math(f't {self.format.subscript(self._op("edge_minus"), ["v"])} k') + notes.append( + f'{filled} denotes translation with {self.format.math("v")} standing where index ' + f'{self.format.math("t-k")} leaves the dimension ({self.format.mono("shift(edge=v)")}), so the row ' + f'at that boundary is built and carries {self.format.math("v")} rather than being dropped.' + ) + if noticed.grouped: + applied = self.format.apply(self.format.upright('relation'), 't') + counted = self.format.math(f't {self.format.superscript(self._op("cyclic_minus"), applied)} k') + note = ( + f'{counted} denotes a translation counted inside the group a relation puts {self.format.math("t")} ' + f'in ({self.format.mono("shift(by=relation)")}), so a term never crosses out of its own group.' + ) + if 'edge' in noticed.policies: + both = self.format.superscript(self.format.subscript(self._op('edge_minus'), ['v']), applied) + note += ( + f' The two modifiers take different slots {self.format.dash} the group above, the fill ' + f'below {self.format.dash} so {self.format.math(f"t {both} k")} is both at once.' + ) + notes.append(note) + return notes + + def position_notes(self, noticed: Noticed) -> list[str]: + """A sentence for each positional symbol the model printed; the first says which of ``pos(t)`` and ``t`` is the position.""" + notes = [] + if noticed.positions: + index = self.format.math('t') + place = self.format.math(self.format.apply(self._op('position'), 't')) + dash = self.format.dash + notes.append( + f"{place} denotes where index {index} sits along its dimension's own order {dash} the order " + f'{self.format.mono("shift")} steps along, not the order labels sort in {dash} counted from ' + f'{self.format.math("0")}. The index itself stays the coordinate, so {index} compares against ' + f'labels and {place} against positions.' + ) + if 'grouped' in noticed.positions: + applied = self.format.apply(self.format.upright('relation'), 't') + grouped = self.format.math(self.format.apply(self.format.subscript(self._op('position'), [applied]), 't')) + group = self.format.math(self.format.subscript(self.format.script('T'), [applied])) + notes.append( + f'{grouped} counts within the group a relation puts {self.format.math("t")} in: the subscript names ' + f'the map, {group} is the group it lands in, and that group has a first position of its own.' + ) + if 'from_end' in noticed.positions: + size = self.format.cardinality(self.format.script('T')) + last = self.format.math(f'{size} {self._op("minus")} 1') + notes.append( + f'{self.format.math(size)} denotes the size of the set being counted along, and a position ' + f'counted from the end prints against it {self.format.dash} {last} is the last position, one ' + f'less than the size because the first is {self.format.math("0")}.' + ) + return notes diff --git a/src/math_spec/typesetting/symbols.py b/src/math_spec/typesetting/symbols.py index 2ce889a8..d285124e 100644 --- a/src/math_spec/typesetting/symbols.py +++ b/src/math_spec/typesetting/symbols.py @@ -26,7 +26,7 @@ from math_spec.program import Program from math_spec.typesetting.format import Format, Notation -__all__ = ['SymbolTable', 'Symbols'] +__all__ = ['SymbolTable', 'Symbols', 'symbols_for'] #: Dimensions whose conventional index letter is not their own initial, which #: is what anything unlisted falls back to. @@ -91,60 +91,74 @@ def chosen_expressions(program: Program) -> frozenset[str]: ) +@dataclass(frozen=True) class Symbols: r"""How every declared name prints: overrides first, derivation for the rest. - Name symbols settle *before* dimension indices, so an index is kept off a - single letter a variable owns — a dimension ``plant`` beside a variable - ``p`` would otherwise render ``p_{t,p}``. A parameter is upright, so - ``\mathrm{p}`` beside an index ``p`` is not a collision. + Built by :func:`symbols_for`. Name symbols settle *before* dimension + indices, so an index is kept off a single letter a variable owns — a + dimension ``plant`` beside a variable ``p`` would otherwise render + ``p_{t,p}``. A parameter is upright, so ``\mathrm{p}`` beside an index + ``p`` is not a collision. + + Attributes: + overridden: Names the table spelled; the convention note quotes only + derived symbols. + name: Each parameter's, variable's and expression's symbol. + constraint: Each constraint's symbol, the subscript ``dual(c)`` prints + λ against. Off the flat namespace, like the constraints themselves + — a model may name a constraint after a variable, so this is its + own map rather than an entry in :attr:`name`. Given structure, so + upright unless a table overrides it. + index: Each dimension's index letter. + set: Each dimension's set symbol. + """ + + overridden: frozenset[str] + name: Mapping[str, str] + constraint: Mapping[str, str] + index: Mapping[str, str] + set: Mapping[str, str] + + +def symbols_for(program: Program, fmt: Format, table: SymbolTable) -> Symbols: + """The :class:`Symbols` *program* prints with in *fmt*, *table* overriding the derivation. Raises: SchemaError: If *table* is written in a notation *fmt* does not read. """ + if table.notation != fmt.notation: + msg = ( + f'symbol table: written in {table.notation}, but this is a {fmt.notation} render ' + f'and nothing translates between notations — write a {fmt.notation} table.' + ) + raise SchemaError(msg) + chosen = frozenset(program.variables) | chosen_expressions(program) + names = (*program.parameters, *program.variables, *program.expressions) + declared = frozenset(names) - def __init__(self, program: Program, fmt: Format, table: SymbolTable) -> None: - if table.notation != fmt.notation: - msg = ( - f'symbol table: written in {table.notation}, but this is a {fmt.notation} render ' - f'and nothing translates between notations — write a {fmt.notation} table.' - ) - raise SchemaError(msg) - chosen = frozenset(program.variables) | chosen_expressions(program) - names = (*program.parameters, *program.variables, *program.expressions) - declared = frozenset(names) - - #: Names the table spelled; the convention note quotes only derived symbols. - self.overridden = frozenset(table.names) & declared - self.name: dict[str, str] = { - name: table.names[name] - if name in table.names - else _derive_name_symbol(name, declared, fmt, given=name not in chosen) - for name in names - } - spoken_for = {s for s in self.name.values() if len(s) == 1} - - #: Each constraint's symbol, the subscript ``dual(c)`` prints λ against. - #: Off the flat namespace, like the constraints themselves — a model may - #: name a constraint after a variable, so this is its own map rather than - #: an entry in :attr:`name`. Given structure, so upright unless a table - #: 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 program.constraints - } - - self.index: dict[str, str] = {} - self.set: dict[str, str] = {} - taken_index, taken_set = set(spoken_for), set() - for dim in program.dimensions: - overridden = dim in table.indices - letter = table.indices[dim] if overridden else _first_free(_index_candidates(dim), taken_index) - taken_index.add(letter) - self.index[dim] = letter if len(letter) <= 1 or overridden else fmt.upright(letter) - upper = _first_free(_set_candidates(dim, letter), taken_set) - taken_set.add(upper) - self.set[dim] = table.sets[dim] if dim in table.sets else fmt.script(upper) + name = { + n: table.names[n] if n in table.names else _derive_name_symbol(n, declared, fmt, given=n not in chosen) + for n in names + } + spoken_for = {s for s in name.values() if len(s) == 1} + constraint = { + n: table.names[n] if n in table.names else _derive_name_symbol(n, declared, fmt, given=True) + for n in program.constraints + } + + index: dict[str, str] = {} + sets: dict[str, str] = {} + taken_index, taken_set = set(spoken_for), set() + for dim in program.dimensions: + overridden = dim in table.indices + letter = table.indices[dim] if overridden else _first_free(_index_candidates(dim), taken_index) + taken_index.add(letter) + index[dim] = letter if len(letter) <= 1 or overridden else fmt.upright(letter) + upper = _first_free(_set_candidates(dim, letter), taken_set) + taken_set.add(upper) + sets[dim] = table.sets[dim] if dim in table.sets else fmt.script(upper) + return Symbols(frozenset(table.names) & declared, name, constraint, index, sets) def _index_candidates(dim: str) -> list[str]: diff --git a/src/math_spec/typesetting/walk.py b/src/math_spec/typesetting/walk.py index 0899f5d0..1ccef519 100644 --- a/src/math_spec/typesetting/walk.py +++ b/src/math_spec/typesetting/walk.py @@ -13,8 +13,9 @@ from __future__ import annotations from dataclasses import dataclass, field, replace -from typing import TYPE_CHECKING, Literal, assert_never +from typing import TYPE_CHECKING, assert_never +from math_spec.errors import SchemaError, did_you_mean from math_spec.program import ( Add, And, @@ -55,14 +56,15 @@ VariableDefined, WindowSum, ) -from math_spec.typesetting.format import Entry, Line, OperatorName +from math_spec.typesetting.format import Line, OperatorName +from math_spec.typesetting.legend import TranslationPolicy, policy_of if TYPE_CHECKING: import datetime from collections.abc import Iterable, Mapping from math_spec._expression_parser import BinaryOperator - from math_spec.program import PiecewiseDeclaration, Program, RelationDeclaration, SosDeclaration + from math_spec.program import PiecewiseDeclaration, Program, SosDeclaration from math_spec.typesetting.format import Format from math_spec.typesetting.symbols import Symbols @@ -98,13 +100,6 @@ } -#: What a translation does with the row the shift vacates. Three policies get -#: three spellings because they are three different equations at the boundary. -TranslationPolicy = Literal['plain', 'wrap', 'edge'] - -#: The positional forms an equation can print, each of which the legend explains once. -PositionForm = Literal['plain', 'grouped', 'from_end'] - #: Edge policy -> the operator pair that renders it, backward then forward — #: the vacated row dropped, wrapped, or filled. _TRANSLATIONS: dict[TranslationPolicy, tuple[OperatorName, OperatorName]] = { @@ -218,22 +213,11 @@ def _unsigned(node: Expression) -> Expression | None: return None -@dataclass -class Noticed: - """What the equations printed that the legend has to explain.""" - - policies: set[TranslationPolicy] = field(default_factory=set) - grouped: bool = False - positions: set[PositionForm] = field(default_factory=set) - numeric_coordinates: set[str] = field(default_factory=set) - - class Walk: """Walks a program, emitting :class:`Line`s in one format. - :meth:`equations` prints every section and returns what it :class:`Noticed`; - the legend methods take that record, so they can only describe symbols the - equations printed. + :meth:`equations` prints every section; what those sections use, the + legend reads off the program (:func:`~math_spec.typesetting.legend.notice`). """ def __init__( @@ -250,7 +234,6 @@ def __init__( #: Substitute each plain named expression where it is used, rather than #: printing its symbol there and its definition once. self.inline_expressions = inline_expressions - self.noticed = Noticed() def _frame_of(self, name: str) -> list[str]: """The dims named expression *name* is read over, as its declaration carries them.""" @@ -272,7 +255,6 @@ def _translation(self, step: _Step) -> str: operator = self.format.subscript(operator, [step.fill]) if not step.within: return operator - self.noticed.grouped = True return self.format.superscript(operator, step.within) def _relation_read(self, name: str, at: Mapping[str, str], read: str) -> str: @@ -454,17 +436,13 @@ def _translate(self, node: Translate, ctx: _Context) -> tuple[str, int]: of their own; absent is the bare shift, whose vacated positions are absent. """ - policy: TranslationPolicy = 'wrap' if node.wrap else 'edge' if node.fill is not None else 'plain' fill = '' if node.fill is None else self._number(node.fill) - self.noticed.policies.add(policy) - step = _Step(node.offset, policy, fill, self._group(node.partition)) + step = _Step(node.offset, policy_of(node), fill, self._group(node.partition)) return self._arithmetic(node.operand, ctx.translated(node.along, step)) def _window_sum(self, node: WindowSum, ctx: _Context) -> tuple[str, int]: """``sum_back``: a sum over the positions behind the row, the lag written as a translation of the index.""" - policy: TranslationPolicy = 'wrap' if node.wrap else 'plain' - step = _Step(1, policy, within=self._group(node.partition)) - self.noticed.policies.add(step.policy) + step = _Step(1, policy_of(node), within=self._group(node.partition)) source, inner = ctx.reducing(node.along) lag = f'{ctx.subscript(node.along)} {self._translation(step)} {source}' domain = ( @@ -603,8 +581,6 @@ def sides(self, node: AlignedComparison, ctx: _Context) -> tuple[str, str]: elif isinstance(node, ExpressionComparison): left, right = self._expression(node.left, ctx), self._expression(node.right, ctx) elif isinstance(node, DimensionComparison): - if isinstance(node.value, int | float): - self.noticed.numeric_coordinates.add(node.name) left, right = ctx.subscript(node.name), self._literal(node.value) elif isinstance(node, DimensionPosition): grouping = ( @@ -634,7 +610,6 @@ def _literal(self, value: float | str | datetime.date) -> str: def _position(self, index: str, grouping: str | None) -> str: """``position(dim)`` applied to the row, *grouping* as a subscript — as an argument it read as a second position.""" - self.noticed.positions.add('grouped' if grouping is not None else 'plain') symbol = self._op('position') if grouping is not None: symbol = self.format.subscript(symbol, [grouping]) @@ -644,7 +619,6 @@ def _ordinal(self, dimension: str, at: int, grouping: str | None) -> str: """The position compared against; a negative one counts back from the size of the set it is a position in — the group's where grouped.""" if at >= 0: return self._number(at) - self.noticed.positions.add('from_end') size = self.symbols.set[dimension] if grouping is not None: size = self.format.subscript(size, [grouping]) @@ -671,16 +645,15 @@ def _quantifier(self, dims: list[str], condition: str) -> str: # -- declarations ------------------------------------------------------ - def equations(self) -> tuple[list[tuple[str, list[Line]]], Noticed]: - """Every titled section of equations, and what printing them noticed for the legend.""" - sections = [ + def equations(self) -> list[tuple[str, list[Line]]]: + """Every titled section of equations.""" + return [ ('Objective', self._objective()), ('Subject to', self._constraints()), ('Definitions', self._definitions()), ('Variable domains', self._variables()), ('Assumptions', self._assumptions()), ] - return sections, self.noticed def _objective(self) -> list[Line]: """The objective's line. @@ -727,9 +700,9 @@ def _definitions(self) -> list[Line]: no single body to substitute, and an entry the math never reads has nowhere to be substituted *into*, so both still print. """ - return [self.definition(name) for name in self._defined()] + return [self.definition(name) for name in self.defined()] - def _defined(self) -> list[str]: + def defined(self) -> list[str]: """The named expressions that print under their own symbol: every one, or only the unsubstitutable when inlining. Inlining leaves a name standing only where substitution cannot reach @@ -757,21 +730,35 @@ def definition(self, name: str) -> Line: def line(self, name: str) -> Line: """The one line *name* prints as: a named expression, a constraint, an assumption, a curve, or a variable's domain. - *name* is one of the five; :func:`~math_spec.typesetting.typeset_declaration` - refuses the rest, and a name declared as two of them. An assumption is - looked up where the document prints it from, so a condition a curve's - method states is a line a reader can ask for before the curve is - written out. + An assumption is looked up where the document prints it from, so a + condition a curve's method states is a line a reader can ask for + before the curve is written out. + + Raises: + SchemaError: *name* is declared as none of the five, or as two — a + constraint may share a variable's name, and one line prints + one of them. """ - if name in self.program.expressions: - return self.definition(name) - if name in self.program.constraints: - return self._constraint(name) - if name in self.program.assumptions: - return self._assumption(name) - if name in self.program.piecewise: - return self._piecewise(name) - return self._variable(name) + program = self.program + kinds = { + 'named expression': (program.expressions, self.definition), + 'constraint': (program.constraints, self._constraint), + 'assumption': (program.assumptions, self._assumption), + 'curve': (program.piecewise, self._piecewise), + 'variable': (program.variables, self._variable), + } + found = [kind for kind, (group, _) in kinds.items() if name in group] + if not found: + everything = {n for group, _ in kinds.values() for n in group} + msg = ( + f"'{name}' is not a named expression, constraint, assumption, curve or variable. " + f'{did_you_mean(name, everything)}' + ) + raise SchemaError(msg) + if len(found) > 1: + msg = f"'{name}' is declared twice, as {found[0]} and as {found[1]}, and one line prints one of them — rename one." + raise SchemaError(msg) + return kinds[found[0]][1](name) def _arms(self, node: Cases, ctx: _Context) -> list[tuple[str, str]]: """Each region as its value and the words saying where it applies. @@ -953,152 +940,3 @@ def _bound(self, ctx: _Context, value: Expression) -> str: def _sorted(self, dims: frozenset[str]) -> list[str]: order = list(self.program.dimensions) return sorted(dims, key=order.index) - - # -- legend ------------------------------------------------------------ - - def glossaries(self, noticed: Noticed) -> list[tuple[str, list[Entry]]]: - fmt = self.format - sets = [ - self._entry( - self.symbols.set[d], - f'index {fmt.math(self.symbols.index[d])} {fmt.dash} {fmt.mono(d)}{self._coords(d, noticed)}', - block.description, - ) - for d, block in self.program.dimensions.items() - ] - parameters = [ - self._entry(self.symbols.name[p], f'{fmt.mono(p)}{self._over(list(block.dims))}', block.description) - for p, block in self.program.parameters.items() - ] - variables = [ - self._entry(self.symbols.name[v], f'{fmt.mono(v)}{self._over(list(block.dims))}', block.description) - for v, block in self.program.variables.items() - ] - definitions = [ - self._entry(self.symbols.name[e], f'{fmt.mono(e)}{self._over(self._frame_of(e))}', block.description) - for e, block in self.program.expressions.items() - if e in self._defined() - ] - groups = (('Sets', sets), ('Parameters', parameters), ('Variables', variables), ('Definitions', definitions)) - return [(title, entries) for title, entries in groups if entries] - - def _entry(self, symbol: str, what: str, description: str | None) -> Entry: - meaning = f'{what} {self.format.dash} {self.format.escape(description)}' if description else what - return Entry(symbol, meaning) - - def _over(self, dims: list[str]) -> str: - if not dims: - return ' (scalar)' - product = self.format.joined([self.symbols.set[d] for d in dims], self._op('times')) - return f' over {self.format.math(product)}' - - def _signature(self, name: str, lk: RelationDeclaration) -> str: - """A relation in the legend: a function from its key sets to its value sets, or a relation inside the product.""" - - def product(roles: Iterable[str]) -> str: - return self.format.joined([self.symbols.set[lk.dim(r)] for r in roles], self._op('times')) - - if lk.values: - return f'{self.format.upright(name)}: {product(lk.key)} {self._op("maps_to")} {product(lk.values)}' - return f'{self.format.upright(name)} {self._op("subset_of")} {product(lk.roles)}' - - def _coords(self, dim: str, noticed: Noticed) -> str: - """The dimension's carried structure: each relation with a column over it, as the map or relation it is. - - The dtype is named only where an equation compared the index against a - number, the one place "position 3" and "the coordinate 3" are both - readings of a line. - """ - carried = self.program.relations_of(dim) - clauses = [] - if dim in noticed.numeric_coordinates: - clauses.append(f' ({self.format.mono(self.program.dimensions[dim].dtype)} coordinates)') - if carried: - maps = self.format.joined([self._signature(c, lk) for c, lk in carried.items()], '') - clauses.append(f' with {self.format.math(maps)}') - return ''.join(clauses) - - def convention_notes(self) -> list[str]: - """What the two faces mean, with the model's own symbols. - - Only where the model has both, and quoting only derived symbols: a - table is the author's to write, so a symbol it supplies is not one this - note governs. - """ - derived = [ - next((n for n in names if n not in self.symbols.overridden), None) - for names in (self.program.parameters, self.program.variables) - ] - if not all(derived): - return [] - given, chosen = (self.format.math(self.symbols.name[n]) for n in derived if n is not None) - return [ - f'Upright is what the model is given {self.format.dash} a parameter such as {given}, a coordinate ' - f'map, a label {self.format.dash} and italic is what the solver chooses, such as {chosen}. ' - f'An index is italic too, being what a quantifier chooses, and a set is script.' - ] - - def translation_notes(self, noticed: Noticed) -> list[str]: - """A sentence for each translation symbol the model printed; plain ``t-k`` needs none.""" - notes = [] - if 'wrap' in noticed.policies: - cyclic = self.format.math(f't {self._op("cyclic_minus")} k') - notes.append( - f'{cyclic} denotes cyclic translation: index {self.format.math("t-k")} taken modulo the size of ' - f'the dimension ({self.format.mono("roll")}). Plain {self.format.math("t-k")} ' - f'({self.format.mono("shift")}) has no wraparound {self.format.dash} terms translated past ' - f'the edge are simply absent.' - ) - if 'edge' in noticed.policies: - filled = self.format.math(f't {self.format.subscript(self._op("edge_minus"), ["v"])} k') - notes.append( - f'{filled} denotes translation with {self.format.math("v")} standing where index ' - f'{self.format.math("t-k")} leaves the dimension ({self.format.mono("shift(edge=v)")}), so the row ' - f'at that boundary is built and carries {self.format.math("v")} rather than being dropped.' - ) - if noticed.grouped: - applied = self.format.apply(self.format.upright('relation'), 't') - counted = self.format.math(f't {self.format.superscript(self._op("cyclic_minus"), applied)} k') - note = ( - f'{counted} denotes a translation counted inside the group a relation puts {self.format.math("t")} ' - f'in ({self.format.mono("shift(by=relation)")}), so a term never crosses out of its own group.' - ) - if 'edge' in noticed.policies: - both = self.format.superscript(self.format.subscript(self._op('edge_minus'), ['v']), applied) - note += ( - f' The two modifiers take different slots {self.format.dash} the group above, the fill ' - f'below {self.format.dash} so {self.format.math(f"t {both} k")} is both at once.' - ) - notes.append(note) - return notes - - def position_notes(self, noticed: Noticed) -> list[str]: - """A sentence for each positional symbol the model printed; the first says which of ``pos(t)`` and ``t`` is the position.""" - notes = [] - if noticed.positions: - index = self.format.math('t') - place = self.format.math(self.format.apply(self._op('position'), 't')) - dash = self.format.dash - notes.append( - f"{place} denotes where index {index} sits along its dimension's own order {dash} the order " - f'{self.format.mono("shift")} steps along, not the order labels sort in {dash} counted from ' - f'{self.format.math("0")}. The index itself stays the coordinate, so {index} compares against ' - f'labels and {place} against positions.' - ) - if 'grouped' in noticed.positions: - applied = self.format.apply(self.format.upright('relation'), 't') - grouped = self.format.math(self.format.apply(self.format.subscript(self._op('position'), [applied]), 't')) - group = self.format.math(self.format.subscript(self.format.script('T'), [applied])) - notes.append( - f'{grouped} counts within the group a relation puts {self.format.math("t")} in: the subscript names ' - f'the map, {group} is the group it lands in, and that group has a first position of its own.' - ) - if 'from_end' in noticed.positions: - size = self.format.cardinality(self.format.script('T')) - last = self.format.math(f'{size} {self._op("minus")} {self._number(1)}') - notes.append( - f'{self.format.math(size)} denotes the size of the set being counted along, and a position ' - f'counted from the end prints against it {self.format.dash} {last} is the last position, one ' - f'less than the size because the first is {self.format.math("0")}.' - ) - return notes diff --git a/tests/typesetting/test_golden.py b/tests/typesetting/test_golden.py index 1463937e..93639781 100644 --- a/tests/typesetting/test_golden.py +++ b/tests/typesetting/test_golden.py @@ -192,13 +192,18 @@ def test_the_golden_model_calls_every_operator_in_the_language(): #: and nowhere else, so the arm that would print one in place is the type's #: closure rather than a case. The absent objective is the arm a *different* #: model takes — a file declares at most one — and -#: `test_a_model_with_no_objective_prints_the_rest` covers it. +#: `test_a_model_with_no_objective_prints_the_rest` covers it. A refusal of +#: the name asked for renders nothing, and `test_declaration.py` pins both. UNREACHABLE = { 'return self.format.cases(self._arms(node, ctx)), _ATOM', 'assert_never(node)', 'assert_never(check)', 'if block is None:', 'return []', + 'everything = {n for group, _ in kinds.values() for n in group}', + 'msg = (', + 'raise SchemaError(msg)', + 'msg = f"\'{name}\' is declared twice, as {found[0]} and as {found[1]}, and one line prints one of them — rename one."', } diff --git a/tests/typesetting/test_walk.py b/tests/typesetting/test_walk.py index 583d68b3..a66b9c7f 100644 --- a/tests/typesetting/test_walk.py +++ b/tests/typesetting/test_walk.py @@ -14,7 +14,7 @@ from math_spec.errors import LanguageError from math_spec.typesetting import FORMATS, SymbolTable, to_latex, to_markdown, typeset, typeset_declaration from math_spec.typesetting.format import OPERATOR_NAMES -from math_spec.typesetting.symbols import Symbols, _derive_name_symbol, chosen_expressions +from math_spec.typesetting.symbols import _derive_name_symbol, chosen_expressions, symbols_for from math_spec.validation import to_spec from tests.fixtures import DISPATCH_MODEL, EXAMPLES, OPERATOR_PROBES, override from tests.typesetting import golden @@ -519,7 +519,7 @@ def test_nothing_the_model_is_given_prints_italic(): f'solution — upright is what the model is given, italic what it computes' ) - symbols = Symbols(schema.program, LATEX, SymbolTable('latex')) + symbols = symbols_for(schema.program, LATEX, SymbolTable('latex')) given = {name: symbols.name[name] for name in schema.parameters} assert all(symbol.startswith(r'\mathrm{') for symbol in given.values()), ( f'derived upright for every parameter, but got {sorted(s for s in given.values() if "mathrm" not in s)}' From 9b72b13b82084fb0e9b5680618227e596cd09fd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 16:38:11 +0000 Subject: [PATCH 11/20] refactor(language): the top-level surface is the two states, the door, the errors, the advice and the typesetter Thirteen names leave math_spec's top level: the message builders call_shape_error, edge_error, unknown_operator_message and schema_error, the tables BUILTIN_NAMES and EDGE_WRAP, the vocabulary sets ADVICE_KINDS, DIMENSION_DTYPES, PARAMETER_DTYPES, VARIABLE_DOMAINS, VARIABLE_ABSENCE and CURVATURES, and SosBlock. The message builders and tables stay in their modules for the resolver; the vocabulary sets are deleted, since the Literals they were the set form of are what a consumer pins against, and nothing in the package read them. did_you_mean stays: it is the one wording a consumer's own refusals share with the language's. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi --- src/math_spec/__init__.py | 41 ++---------------------------------- src/math_spec/errors.py | 3 +-- src/math_spec/model.py | 6 ------ tests/test_advice.py | 9 +++++--- tests/test_piecewise.py | 23 +++++++++++--------- tests/test_public_surface.py | 12 +++-------- 6 files changed, 25 insertions(+), 69 deletions(-) diff --git a/src/math_spec/__init__.py b/src/math_spec/__init__.py index d8ad8c9b..b88d8965 100644 --- a/src/math_spec/__init__.py +++ b/src/math_spec/__init__.py @@ -13,32 +13,8 @@ from math_spec import program from math_spec.advice import advice -from math_spec.errors import ( - ADVICE_KINDS, - Advice, - DimensionError, - LanguageError, - MathSpecError, - SchemaError, - did_you_mean, - schema_error, -) -from math_spec.model import ( - CURVATURES, - DIMENSION_DTYPES, - PARAMETER_DTYPES, - VARIABLE_ABSENCE, - VARIABLE_DOMAINS, - SosBlock, - Spec, -) -from math_spec.operators import ( - BUILTIN_NAMES, - EDGE_WRAP, - call_shape_error, - edge_error, - unknown_operator_message, -) +from math_spec.errors import Advice, DimensionError, LanguageError, MathSpecError, SchemaError, did_you_mean +from math_spec.model import Spec from math_spec.typesetting import ( FORMATS, SymbolTable, @@ -51,36 +27,23 @@ from math_spec.validation import to_spec __all__ = [ - 'ADVICE_KINDS', - 'BUILTIN_NAMES', - 'CURVATURES', - 'DIMENSION_DTYPES', - 'EDGE_WRAP', 'FORMATS', - 'PARAMETER_DTYPES', - 'VARIABLE_ABSENCE', - 'VARIABLE_DOMAINS', 'Advice', 'DimensionError', 'LanguageError', 'MathSpecError', 'SchemaError', - 'SosBlock', 'Spec', 'SymbolTable', 'advice', - 'call_shape_error', 'did_you_mean', - 'edge_error', 'program', - 'schema_error', 'to_latex', 'to_markdown', 'to_spec', 'to_typst', 'typeset', 'typeset_declaration', - 'unknown_operator_message', ] import warnings as _warnings diff --git a/src/math_spec/errors.py b/src/math_spec/errors.py index 97220d2b..eb27a7ed 100644 --- a/src/math_spec/errors.py +++ b/src/math_spec/errors.py @@ -8,7 +8,7 @@ import difflib from dataclasses import dataclass -from typing import TYPE_CHECKING, Literal, get_args +from typing import TYPE_CHECKING, Literal if TYPE_CHECKING: from collections.abc import Iterable @@ -19,7 +19,6 @@ #: Which pass an :class:`Advice` comes from. Closed, like the operator set: a #: consumer filtering on it can enumerate every value. AdviceKind = Literal['never-an-axis', 'unbounded'] -ADVICE_KINDS = frozenset(get_args(AdviceKind)) @dataclass(frozen=True) diff --git a/src/math_spec/model.py b/src/math_spec/model.py index 427ee4cb..efc11f3c 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -94,15 +94,9 @@ def _reject_unknown_keys(cls, data: object) -> object: #: curve fails it. Curvature = Literal['convex', 'concave', 'either'] -#: The set form of each vocabulary above, for callers that want membership. -DIMENSION_DTYPES = frozenset(get_args(DimensionDtype)) -PARAMETER_DTYPES = frozenset(get_args(ParameterDtype)) #: The parameter dtypes that stand where a number belongs — a coefficient, a #: term, a divisor, a bound. A label selects and a flag masks; neither is one. NUMERIC_DTYPES: frozenset[ParameterDtype] = frozenset({'float', 'int'}) -VARIABLE_DOMAINS = frozenset(get_args(VariableDomain)) -VARIABLE_ABSENCE = frozenset(get_args(VariableAbsence)) -CURVATURES = frozenset(get_args(Curvature)) #: Every formulation, in the order :meth:`Spec.expand` writes them out: a curve #: emits a set, and no set emits a curve. diff --git a/tests/test_advice.py b/tests/test_advice.py index 68a6ee89..a2373df8 100644 --- a/tests/test_advice.py +++ b/tests/test_advice.py @@ -12,11 +12,12 @@ from __future__ import annotations import json -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, get_args import pytest -from math_spec import ADVICE_KINDS, LanguageError, advice, to_spec +from math_spec import LanguageError, advice, to_spec +from math_spec.errors import AdviceKind from tests.fixtures import SMALL_MODEL, override if TYPE_CHECKING: @@ -76,7 +77,9 @@ def test_both_kinds_of_note_come_through_the_one_door(): assert [(n.kind, n.subject) for n in notes] == [('never-an-axis', 'h'), ('unbounded', 'p')], ( 'the never-an-axis advice comes first, then the unboundedness advice' ) - assert {n.kind for n in notes} == ADVICE_KINDS, 'every kind a consumer can pin against is one this file produces' + assert {n.kind for n in notes} == set(get_args(AdviceKind)), ( + 'every kind a consumer can pin against is one this file produces' + ) def _written(model: dict, tmp_path: Path) -> Path: diff --git a/tests/test_piecewise.py b/tests/test_piecewise.py index 6a3ad505..6f39d1e9 100644 --- a/tests/test_piecewise.py +++ b/tests/test_piecewise.py @@ -11,10 +11,12 @@ from __future__ import annotations +from typing import get_args + import pytest -from math_spec import CURVATURES from math_spec.errors import LanguageError, SchemaError +from math_spec.model import Curvature from math_spec.piecewise import expand_piecewise from math_spec.program import Assumption, Variable, assumption_message from tests.fixtures import DISPATCH_MODEL, expanded, override, raw_of, schema_of @@ -490,21 +492,22 @@ def test_a_method_names_the_curvature_it_is_exact_for(raw, expected): stated = [ a.description for n, a in expanded(raw, 'piecewise').program.assumptions.items() if n.endswith('_curvature') ] - answer = next((c for c in CURVATURES if stated and f'a {c} curve' in stated[0]), 'either' if stated else None) + curvatures = get_args(Curvature) + answer = next((c for c in curvatures if stated and f'a {c} curve' in stated[0]), 'either' if stated else None) assert answer == expected, 'the curvature the method is exact for is the shape its sentence names' - assert answer is None or answer in CURVATURES, ( - f'{answer!r} is not one of the curvatures the package publishes, so a consumer ' - f'pinning its table against CURVATURES would never match it' + assert answer is None or answer in curvatures, ( + f'{answer!r} is not one of the curvatures the language names, so a consumer ' + f'pinning its table against `Curvature` would never match it' ) -def test_every_published_curvature_is_one_a_method_can_ask_for(): - """`CURVATURES` is what a consumer pins its own table against, so a name in +def test_every_named_curvature_is_one_a_method_can_ask_for(): + """`Curvature` is what a consumer pins its own table against, so a name in it that nothing returns is a branch they write and never reach.""" answered = {case.values[1] for case in _CURVATURE_CASES} - {None} - assert answered == set(CURVATURES), ( - f'the cases above answer {sorted(answered)} but the package publishes ' - f'{sorted(CURVATURES)} — one of the two is out of date' + assert answered == set(get_args(Curvature)), ( + f'the cases above answer {sorted(answered)} but the language names ' + f'{sorted(get_args(Curvature))} — one of the two is out of date' ) diff --git a/tests/test_public_surface.py b/tests/test_public_surface.py index 960d459a..a0739008 100644 --- a/tests/test_public_surface.py +++ b/tests/test_public_surface.py @@ -23,19 +23,13 @@ #: here would make the two one list checked against itself. SURFACE = frozenset( { - # the two public states, and the conversion to each + # the two public states, and the door to both 'Spec', 'to_spec', 'program', - # the error tree + # the error tree, and the one wording a consumer's own refusals share 'MathSpecError', 'LanguageError', 'SchemaError', 'DimensionError', - 'did_you_mean', 'schema_error', + 'did_you_mean', # the verdicts a consumer asks for rather than re-deriving 'advice', 'Advice', - # the closed operator set, and the wording of its refusals - 'BUILTIN_NAMES', 'EDGE_WRAP', 'call_shape_error', 'edge_error', - 'unknown_operator_message', - # the declaration vocabularies a consumer pins its own tables against - 'DIMENSION_DTYPES', 'PARAMETER_DTYPES', 'VARIABLE_DOMAINS', 'VARIABLE_ABSENCE', 'ADVICE_KINDS', - 'CURVATURES', 'SosBlock', # typesetting 'FORMATS', 'SymbolTable', 'typeset', 'typeset_declaration', 'to_latex', 'to_typst', 'to_markdown', } From 24e56b4827b024b9594b817141440a7fc1071ab3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 16:40:41 +0000 Subject: [PATCH 12/20] refactor(language): the closed operator set stays public, as the one vocabulary with no Literal form A consumer pins its operator table against BUILTIN_NAMES, and unlike the dtype vocabularies it has no Literal on math_spec.program to pin against instead; the module it lives in is package-private. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi --- src/math_spec/__init__.py | 2 ++ tests/test_public_surface.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/math_spec/__init__.py b/src/math_spec/__init__.py index b88d8965..edfe35fc 100644 --- a/src/math_spec/__init__.py +++ b/src/math_spec/__init__.py @@ -15,6 +15,7 @@ from math_spec.advice import advice from math_spec.errors import Advice, DimensionError, LanguageError, MathSpecError, SchemaError, did_you_mean from math_spec.model import Spec +from math_spec.operators import BUILTIN_NAMES from math_spec.typesetting import ( FORMATS, SymbolTable, @@ -27,6 +28,7 @@ from math_spec.validation import to_spec __all__ = [ + 'BUILTIN_NAMES', 'FORMATS', 'Advice', 'DimensionError', diff --git a/tests/test_public_surface.py b/tests/test_public_surface.py index a0739008..188e23b7 100644 --- a/tests/test_public_surface.py +++ b/tests/test_public_surface.py @@ -30,6 +30,8 @@ 'did_you_mean', # the verdicts a consumer asks for rather than re-deriving 'advice', 'Advice', + # the closed operator set, the one vocabulary with no Literal form, which a consumer pins its table against + 'BUILTIN_NAMES', # typesetting 'FORMATS', 'SymbolTable', 'typeset', 'typeset_declaration', 'to_latex', 'to_typst', 'to_markdown', } From 1169b1f3994e86255a3f7820f58e2940c11aabec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 20:36:32 +0000 Subject: [PATCH 13/20] feat(language): a curve left as written is refused in one wording, and advice writes nothing out on the caller's behalf UnexpandedCurveError carries the one sentence every consumer building rows says of a program still carrying a piecewise: block, naming the blocks and the expansion to pass. The language does not raise it at load, since a model with a curve is printed and edited as written; advice() raises it for a file, a Spec or a Program alike, and no longer expands a file or a Spec itself. The check verb and the check how-to expand first, as a front end may. Docs sentences, after (before): reading.md n 76 avg 17.1 median 15 over25 14 (75, 17.1, 15, 14); check.md n 20 avg 12.5 median 14 over25 1 (19 sentences before, one added). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi --- docs/howto/check.md | 5 +++-- docs/reference/language/piecewise.md | 3 ++- docs/reference/reading.md | 17 ++++++++++++++- src/math_spec/__init__.py | 11 +++++++++- src/math_spec/__main__.py | 6 ++++-- src/math_spec/advice.py | 22 +++++++------------- src/math_spec/errors.py | 25 ++++++++++++++++++++++ tests/test_advice.py | 31 +++++++++++++++++----------- tests/test_public_surface.py | 2 +- tests/test_reading_page.py | 2 +- 10 files changed, 89 insertions(+), 35 deletions(-) diff --git a/docs/howto/check.md b/docs/howto/check.md index ad8c1747..add9db01 100644 --- a/docs/howto/check.md +++ b/docs/howto/check.md @@ -37,13 +37,14 @@ machine and in CI. 3. **Ask from Python** where the check is one step of a longer script. `to_spec` raises a `MathSpecError` for anything the language refuses, and - `advice` returns what it would print: + `advice` returns what it would print. Advice reads the rows a curve states, + so pass the model with its curves written out: ```python import math_spec as ms spec = ms.to_spec('model.yaml') - for note in ms.advice(spec): + for note in ms.advice(spec.expand('piecewise')): print(note) ``` diff --git a/docs/reference/language/piecewise.md b/docs/reference/language/piecewise.md index 5e85c906..507b05b2 100644 --- a/docs/reference/language/piecewise.md +++ b/docs/reference/language/piecewise.md @@ -264,4 +264,5 @@ a model before and after, as whole files. curve the model still declares is under `program.piecewise`, typed, and `spec.expand('piecewise').program` carries its rows instead. A consumer building rows reads the expansion's program, and refuses a curve it finds on - a program; one that cannot take a set reads `spec.expand().program`. + a program with `UnexpandedCurveError`; one that cannot take a set reads + `spec.expand().program`. diff --git a/docs/reference/reading.md b/docs/reference/reading.md index 1e4923a3..6f0b7fd9 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -80,7 +80,22 @@ curve written out as rows, and `spec.expand()` writes the sets out too. Which to read is the caller's to say, because a consumer printing a curve wants the curve and a consumer building rows wants the rows. A consumer building rows reads the sections it takes and refuses the rest: a curve or a set still on -the program is a block it did not ask to have written out. +the program is a block it did not ask to have written out. The refusal of a +curve has one wording, `UnexpandedCurveError`, which names the blocks and the +expansion to pass: + +```python +from math_spec import UnexpandedCurveError + + +def rows_of(program): + if program.piecewise: + raise UnexpandedCurveError(program.piecewise) + return program + + +rows_of(rows) is rows # True +``` | you are | take | because | | ------------------------------------------------------------------- | --------- | -------------------------------------------- | diff --git a/src/math_spec/__init__.py b/src/math_spec/__init__.py index edfe35fc..db066896 100644 --- a/src/math_spec/__init__.py +++ b/src/math_spec/__init__.py @@ -13,7 +13,15 @@ from math_spec import program from math_spec.advice import advice -from math_spec.errors import Advice, DimensionError, LanguageError, MathSpecError, SchemaError, did_you_mean +from math_spec.errors import ( + Advice, + DimensionError, + LanguageError, + MathSpecError, + SchemaError, + UnexpandedCurveError, + did_you_mean, +) from math_spec.model import Spec from math_spec.operators import BUILTIN_NAMES from math_spec.typesetting import ( @@ -37,6 +45,7 @@ 'SchemaError', 'Spec', 'SymbolTable', + 'UnexpandedCurveError', 'advice', 'did_you_mean', 'program', diff --git a/src/math_spec/__main__.py b/src/math_spec/__main__.py index b090897d..1dc9cd23 100644 --- a/src/math_spec/__main__.py +++ b/src/math_spec/__main__.py @@ -52,12 +52,14 @@ def parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: """Run one verb; a refused file is its message on stderr and exit status 1. - Advice is not a refusal: ``check`` prints it and exits 0. + Advice is not a refusal: ``check`` prints it and exits 0. It reads the + model with its curves written out, since advice reads the rows a curve + states and a front end may make that choice for the file. """ args = parser().parse_args(argv) if args.verb == 'check': try: - notes = advice(args.model) + notes = advice(to_spec(args.model).expand('piecewise')) except MathSpecError as e: sys.stderr.write(f'{e}\n') return 1 diff --git a/src/math_spec/advice.py b/src/math_spec/advice.py index dea4afd0..489e9340 100644 --- a/src/math_spec/advice.py +++ b/src/math_spec/advice.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING from math_spec.boundedness import unbounded_notes -from math_spec.errors import Advice, LanguageError +from math_spec.errors import Advice, UnexpandedCurveError from math_spec.program import GroupSum, Program, Pullback, walk from math_spec.validation import to_spec @@ -28,27 +28,21 @@ def advice(model: str | Path | Mapping[str, object] | Spec | Program) -> tuple[A Args: model: A YAML path, a mapping, a loaded :class:`Spec`, or a - :class:`Program`. Both passes read the rows a curve states, so a - file or a model is read with its curves written out, and a program - still carrying one is refused. + :class:`Program`, read as it arrived. Both passes read the rows a + curve states, so a model still carrying a ``piecewise:`` block is + refused the way any consumer building rows refuses one: pass + ``spec.expand('piecewise')``. Returns: The never-an-axis advice in declaration order, then the unboundedness advice; ``str()`` of each is its sentence. Raises: - LanguageError: A :class:`Program` with a ``piecewise:`` block still - in it, naming the expansion to pass instead. + UnexpandedCurveError: A ``piecewise:`` block still in the model. """ - program = model if isinstance(model, Program) else to_spec(model).expand('piecewise').program + program = model if isinstance(model, Program) else to_spec(model).program if program.piecewise: - named = ', '.join(f"'{name}'" for name in program.piecewise) - msg = ( - f'piecewise: {named} states rows rather than being one, and advice reads the rows. Pass ' - f"spec.expand('piecewise').program, which writes each block out as the variables and " - f'constraints it states.' - ) - raise LanguageError(msg) + raise UnexpandedCurveError(program.piecewise) return tuple(_never_an_axis(program) + unbounded_notes(program)) diff --git a/src/math_spec/errors.py b/src/math_spec/errors.py index eb27a7ed..21192543 100644 --- a/src/math_spec/errors.py +++ b/src/math_spec/errors.py @@ -59,6 +59,31 @@ class DimensionError(LanguageError): """A dim-set rule was violated. Raised at load time, before any data.""" +class UnexpandedCurveError(LanguageError): + """A program still carries a ``piecewise:`` block, and the consumer raising this builds rows. + + The one sentence for that refusal, so every consumer says it in the + language's words: ``if program.piecewise: raise + UnexpandedCurveError(program.piecewise)``. The language does not raise it + at load — a model with a curve is a model like any other, printed and + edited as written — because only a consumer building rows knows it wants + the rows. + """ + + def __init__(self, blocks: Iterable[str]) -> None: + self.blocks = tuple(blocks) + named = ', '.join(f"'{block}'" for block in self.blocks) + super().__init__( + f'piecewise: {named} states rows rather than being one, and a program holds the rows. Pass ' + f"spec.expand('piecewise').program, which writes each block out as the variables and constraints " + f'it states and keeps every sos: block for a consumer that takes a set — or spec.expand().program, ' + f'which writes the sets out as binaries and linking rows too.' + ) + + def __reduce__(self) -> tuple[type[UnexpandedCurveError], tuple[tuple[str, ...]]]: + return type(self), (self.blocks,) + + def did_you_mean(name: str, known: Iterable[str], *, label: str = 'Declared') -> str: """The repair clause for an unrecognised name: the near miss, or the set.""" candidates = sorted(known) diff --git a/tests/test_advice.py b/tests/test_advice.py index a2373df8..24765174 100644 --- a/tests/test_advice.py +++ b/tests/test_advice.py @@ -16,7 +16,7 @@ import pytest -from math_spec import LanguageError, advice, to_spec +from math_spec import LanguageError, UnexpandedCurveError, advice, to_spec from math_spec.errors import AdviceKind from tests.fixtures import SMALL_MODEL, override @@ -106,17 +106,24 @@ def test_the_answer_does_not_turn_on_which_state_it_is_asked_of(form, tmp_path): ], 'one model, one answer, whichever of the four the caller happens to hold' -def test_a_curve_is_written_out_before_advice_reads_it(): - """Advice reads the rows a curve states, so a file is expanded first and a program still carrying one is refused. +def test_a_curve_left_as_written_is_refused_however_the_model_arrives(): + """Advice reads the rows a curve states and writes nothing out on the caller's behalf. - The refusal was added without a test; deleting the guard let a program - with a block advise on the file's own rows as if the curve stated none. + It once expanded a file or a Spec itself, which is the choice every other + door leaves to the caller; a program with a block was let through when the + guard was deleted, advising on the file's own rows as if the curve stated + none. """ - from_file = advice(CURVED) - from_rows = advice(to_spec(CURVED).expand('piecewise').program) - assert [(n.kind, n.subject) for n in from_file] == [(n.kind, n.subject) for n in from_rows], ( - 'a file and the program of its expansion are advised alike' + rows = advice(to_spec(CURVED).expand('piecewise')) + assert [(n.kind, n.subject) for n in rows] == [(n.kind, n.subject) for n in advice(to_spec(CURVED).expand())], ( + 'the expansion is what advice reads, with or without its sets' ) - with pytest.raises(LanguageError, match="piecewise: 'curve' states rows") as refusal: - advice(to_spec(CURVED).program) - assert "expand('piecewise')" in str(refusal.value), 'the refusal names the expansion to pass' + for arrived in (CURVED, to_spec(CURVED), to_spec(CURVED).program): + with pytest.raises( + UnexpandedCurveError, match="piecewise: 'curve' states rows rather than being one" + ) as refusal: + advice(arrived) + assert refusal.value.blocks == ('curve',) and "expand('piecewise')" in str(refusal.value), ( + 'the refusal names the block and the expansion to pass' + ) + assert isinstance(refusal.value, LanguageError), "a consumer catching the language's refusals catches this one" diff --git a/tests/test_public_surface.py b/tests/test_public_surface.py index 188e23b7..a2607729 100644 --- a/tests/test_public_surface.py +++ b/tests/test_public_surface.py @@ -26,7 +26,7 @@ # the two public states, and the door to both 'Spec', 'to_spec', 'program', # the error tree, and the one wording a consumer's own refusals share - 'MathSpecError', 'LanguageError', 'SchemaError', 'DimensionError', + 'MathSpecError', 'LanguageError', 'SchemaError', 'DimensionError', 'UnexpandedCurveError', 'did_you_mean', # the verdicts a consumer asks for rather than re-deriving 'advice', 'Advice', diff --git a/tests/test_reading_page.py b/tests/test_reading_page.py index 8cb3713c..8f37704c 100644 --- a/tests/test_reading_page.py +++ b/tests/test_reading_page.py @@ -55,6 +55,6 @@ def test_the_page_shows_the_declarations_the_expansion_emits(tmp_path, monkeypat exec(compile(code, str(PAGE), 'exec'), namespace) claims.extend(_claims(code)) - assert len(claims) == 20, 'every `expression # value` line on the page is checked; one without one is not' + assert len(claims) == 21, 'every `expression # value` line on the page is checked; one without one is not' for expression, claimed in claims: assert eval(expression, namespace) == claimed, f'reading.md says `{expression}` is {claimed}' From f0dc3e37fb6eff74a9bfa342ca309c42fb3b8cd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 20:44:21 +0000 Subject: [PATCH 14/20] feat(language): nothing writes a formulation out unasked, and the check verb takes --expand like every other The check verb read a file with its curves written out, the one verb that read a file differently from the rest. Every verb now reads the file as written and takes --expand; check refuses a curve model in the one wording until asked. The premise is on the public-API page, beside the other things every function keeps, and the reading page and the check how-to say it where a consumer meets it. Docs sentences, after (before): check.md n 22 avg 13.0 median 14 over25 2 (20, 12.5, 14, 1); what-counts-as-public-api.md n 22 avg 16.4 median 14 over25 4 (18, 16.0, 14, 3); reading.md n 77 avg 17.1 median 15 over25 14 (76, 17.1, 15, 14). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi --- docs/about/what-counts-as-public-api.md | 6 ++++++ docs/howto/check.md | 4 +++- docs/reference/reading.md | 8 +++++--- src/math_spec/__main__.py | 23 +++++++++++++++-------- tests/typesetting/test_cli.py | 17 ++++++++++++++++- 5 files changed, 45 insertions(+), 13 deletions(-) diff --git a/docs/about/what-counts-as-public-api.md b/docs/about/what-counts-as-public-api.md index e8d184c1..a3fce6ab 100644 --- a/docs/about/what-counts-as-public-api.md +++ b/docs/about/what-counts-as-public-api.md @@ -37,6 +37,12 @@ file, it is one. talks about a file the language accepts, and changes nothing. - **Safe to call again.** `spec.program` is one object, however often it is asked for. +- **Nothing is written out unasked.** A `piecewise:` or `sos:` block is the + block until a caller writes it out with `spec.expand(...)`. No door, verb or + check expands a model on the caller's behalf: `spec.program` mirrors the + file, `advice` and the `check` verb refuse a curve left as written, and + `--expand` is how the shell asks. An engine that writes curves out at its + own door makes that choice for its users, not for the language. ## Three things a function never decides diff --git a/docs/howto/check.md b/docs/howto/check.md index add9db01..a6f442a4 100644 --- a/docs/howto/check.md +++ b/docs/howto/check.md @@ -21,7 +21,9 @@ machine and in CI. ``` Advice prints on stdout and exits with status 0. A model the language - accepts with nothing to advise prints nothing. + accepts with nothing to advise prints nothing. A model with a `piecewise:` + block is refused as written, because advice reads the rows a curve states + and nothing writes them out unasked: pass `--expand` to check the rows. ```text Variable 'slack' makes this model unbounded: no constraint names it, and bounds.lower is -inf, which is the direction a +slack term improves a minimize objective in. No data can change that, so the solve would answer `unbounded` and name nothing. diff --git a/docs/reference/reading.md b/docs/reference/reading.md index 6f0b7fd9..f7bb57af 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -80,9 +80,11 @@ curve written out as rows, and `spec.expand()` writes the sets out too. Which to read is the caller's to say, because a consumer printing a curve wants the curve and a consumer building rows wants the rows. A consumer building rows reads the sections it takes and refuses the rest: a curve or a set still on -the program is a block it did not ask to have written out. The refusal of a -curve has one wording, `UnexpandedCurveError`, which names the blocks and the -expansion to pass: +the program is a block it did not ask to have written out. Nothing in the +package writes a block out unasked, so a consumer that wants the rows calls +`spec.expand('piecewise')` at its own door. The refusal of a curve has one +wording, `UnexpandedCurveError`, which names the blocks and the expansion to +pass: ```python from math_spec import UnexpandedCurveError diff --git a/src/math_spec/__main__.py b/src/math_spec/__main__.py index 1dc9cd23..e369a34a 100644 --- a/src/math_spec/__main__.py +++ b/src/math_spec/__main__.py @@ -5,9 +5,10 @@ """``python -m math_spec model.yaml`` — the shell front. ``check`` loads the file and prints the language's advice; one further verb -per typeset format, read off :data:`math_spec.typesetting.FORMATS`. Those verbs -take ``--expand``, because a shell cannot compose -:meth:`~math_spec.model.Spec.expand` the way a caller does. +per typeset format, read off :data:`math_spec.typesetting.FORMATS`. Every verb +reads the file as written and takes ``--expand``, because a shell cannot +compose :meth:`~math_spec.model.Spec.expand` the way a caller does, and +nothing here writes a formulation out unasked. """ from __future__ import annotations @@ -29,6 +30,11 @@ def parser() -> argparse.ArgumentParser: check = verbs.add_parser('check', help='load a model, and print what the language advises') check.add_argument('model', help='path to a math_spec YAML model') + check.add_argument( + '--expand', + action='store_true', + help='check the variables and constraints the piecewise: and sos: blocks state, not the blocks', + ) for name in FORMATS: verb = verbs.add_parser(name, help=f'render a model as {name}') @@ -52,21 +58,22 @@ def parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: """Run one verb; a refused file is its message on stderr and exit status 1. - Advice is not a refusal: ``check`` prints it and exits 0. It reads the - model with its curves written out, since advice reads the rows a curve - states and a front end may make that choice for the file. + Advice is not a refusal: ``check`` prints it and exits 0. A model with a + ``piecewise:`` block is refused by ``check`` as by any consumer building + rows, and ``--expand`` is how the shell asks for the rows. """ args = parser().parse_args(argv) + model = to_spec(args.model).expand() if args.expand else args.model if args.verb == 'check': try: - notes = advice(to_spec(args.model).expand('piecewise')) + notes = advice(model) except MathSpecError as e: sys.stderr.write(f'{e}\n') return 1 sys.stdout.write(''.join(f'{note}\n' for note in notes)) return 0 text = typeset( - to_spec(args.model).expand() if args.expand else args.model, + model, args.verb, symbols=args.symbols, standalone=args.standalone, diff --git a/tests/typesetting/test_cli.py b/tests/typesetting/test_cli.py index 03e82560..74bf3011 100644 --- a/tests/typesetting/test_cli.py +++ b/tests/typesetting/test_cli.py @@ -70,10 +70,25 @@ def test_check_accepts_the_model_that_carries_every_construct(capsys): """The golden model exercises every operator and every edge policy, so `check` accepting it is the claim that the whole language loads through one door — and that none of it draws advice.""" - assert front.main(['check', str(golden.MODEL)]) == 0, 'the whole language loads' + assert front.main(['check', str(golden.MODEL), '--expand']) == 0, 'the whole language loads' assert capsys.readouterr() == ('', ''), 'no advice, no output' +def test_check_writes_no_curve_out_unasked(capsys): + """`check` expanded every curve on the user's behalf, the one verb that read a file differently from the rest. + + Nothing in the package writes a formulation out unasked: `check` refuses + a curve model as any consumer building rows does, and `--expand` is how + the shell asks for the rows, the flag the typeset verbs already take. + """ + assert front.main(['check', str(EXAMPLES / 'piecewise.yaml')]) == 1, 'a curve left as written is a refusal' + captured = capsys.readouterr() + assert captured.out == '' and 'states rows rather than being one' in captured.err, ( + 'the refusal goes to stderr and names the block' + ) + assert front.main(['check', str(EXAMPLES / 'piecewise.yaml'), '--expand']) == 0, 'asked for, the rows are checked' + + def _carries(stream: str, said: str) -> bool: """*stream* mentions *said*, or is silent where *said* is empty.""" return said in stream if said else stream == '' From b53597307bf0399cebcf8760ba40f89593ae3453 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 20:48:26 +0000 Subject: [PATCH 15/20] refactor(language): a refusal's wording belongs to whoever refuses UnexpandedCurveError is gone. advice words the refusal of a curve left as written itself, as a LanguageError, and a consumer building rows words its own; the reading page shows the idiom with the consumer's own error. A sentence is not a thing one tool should export for another to reuse, where a computation such as did_you_mean is. Docs sentences, after (before): reading.md n 76 avg 17.4 median 15 over25 15 (77, 17.2, 15, 14); piecewise.md n 70 avg 17.7 median 15 over25 13 (70, 17.7, 15, 14). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi --- docs/reference/language/piecewise.md | 3 +-- docs/reference/reading.md | 10 +++------- src/math_spec/__init__.py | 11 +---------- src/math_spec/advice.py | 13 ++++++++++--- src/math_spec/errors.py | 25 ------------------------- tests/test_advice.py | 11 +++-------- tests/test_public_surface.py | 2 +- 7 files changed, 19 insertions(+), 56 deletions(-) diff --git a/docs/reference/language/piecewise.md b/docs/reference/language/piecewise.md index 507b05b2..5e85c906 100644 --- a/docs/reference/language/piecewise.md +++ b/docs/reference/language/piecewise.md @@ -264,5 +264,4 @@ a model before and after, as whole files. curve the model still declares is under `program.piecewise`, typed, and `spec.expand('piecewise').program` carries its rows instead. A consumer building rows reads the expansion's program, and refuses a curve it finds on - a program with `UnexpandedCurveError`; one that cannot take a set reads - `spec.expand().program`. + a program; one that cannot take a set reads `spec.expand().program`. diff --git a/docs/reference/reading.md b/docs/reference/reading.md index f7bb57af..7cfc210e 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -82,17 +82,13 @@ curve and a consumer building rows wants the rows. A consumer building rows reads the sections it takes and refuses the rest: a curve or a set still on the program is a block it did not ask to have written out. Nothing in the package writes a block out unasked, so a consumer that wants the rows calls -`spec.expand('piecewise')` at its own door. The refusal of a curve has one -wording, `UnexpandedCurveError`, which names the blocks and the expansion to -pass: +`spec.expand('piecewise')` at its own door, and a consumer that refuses a +curve does so in its own words, naming that call: ```python -from math_spec import UnexpandedCurveError - - def rows_of(program): if program.piecewise: - raise UnexpandedCurveError(program.piecewise) + raise ValueError(f"{sorted(program.piecewise)} are curves; pass spec.expand('piecewise')") return program diff --git a/src/math_spec/__init__.py b/src/math_spec/__init__.py index db066896..edfe35fc 100644 --- a/src/math_spec/__init__.py +++ b/src/math_spec/__init__.py @@ -13,15 +13,7 @@ from math_spec import program from math_spec.advice import advice -from math_spec.errors import ( - Advice, - DimensionError, - LanguageError, - MathSpecError, - SchemaError, - UnexpandedCurveError, - did_you_mean, -) +from math_spec.errors import Advice, DimensionError, LanguageError, MathSpecError, SchemaError, did_you_mean from math_spec.model import Spec from math_spec.operators import BUILTIN_NAMES from math_spec.typesetting import ( @@ -45,7 +37,6 @@ 'SchemaError', 'Spec', 'SymbolTable', - 'UnexpandedCurveError', 'advice', 'did_you_mean', 'program', diff --git a/src/math_spec/advice.py b/src/math_spec/advice.py index 489e9340..a97a214b 100644 --- a/src/math_spec/advice.py +++ b/src/math_spec/advice.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING from math_spec.boundedness import unbounded_notes -from math_spec.errors import Advice, UnexpandedCurveError +from math_spec.errors import Advice, LanguageError from math_spec.program import GroupSum, Program, Pullback, walk from math_spec.validation import to_spec @@ -38,11 +38,18 @@ def advice(model: str | Path | Mapping[str, object] | Spec | Program) -> tuple[A advice; ``str()`` of each is its sentence. Raises: - UnexpandedCurveError: A ``piecewise:`` block still in the model. + LanguageError: A ``piecewise:`` block still in the model, naming the + expansion to pass. """ program = model if isinstance(model, Program) else to_spec(model).program if program.piecewise: - raise UnexpandedCurveError(program.piecewise) + named = ', '.join(f"'{name}'" for name in program.piecewise) + msg = ( + f'piecewise: {named} states rows rather than being one, and advice reads the rows. Pass ' + f"spec.expand('piecewise'), which writes each block out as the variables and constraints it states " + f'and keeps every sos: block — or spec.expand(), which writes the sets out as binaries too.' + ) + raise LanguageError(msg) return tuple(_never_an_axis(program) + unbounded_notes(program)) diff --git a/src/math_spec/errors.py b/src/math_spec/errors.py index 21192543..eb27a7ed 100644 --- a/src/math_spec/errors.py +++ b/src/math_spec/errors.py @@ -59,31 +59,6 @@ class DimensionError(LanguageError): """A dim-set rule was violated. Raised at load time, before any data.""" -class UnexpandedCurveError(LanguageError): - """A program still carries a ``piecewise:`` block, and the consumer raising this builds rows. - - The one sentence for that refusal, so every consumer says it in the - language's words: ``if program.piecewise: raise - UnexpandedCurveError(program.piecewise)``. The language does not raise it - at load — a model with a curve is a model like any other, printed and - edited as written — because only a consumer building rows knows it wants - the rows. - """ - - def __init__(self, blocks: Iterable[str]) -> None: - self.blocks = tuple(blocks) - named = ', '.join(f"'{block}'" for block in self.blocks) - super().__init__( - f'piecewise: {named} states rows rather than being one, and a program holds the rows. Pass ' - f"spec.expand('piecewise').program, which writes each block out as the variables and constraints " - f'it states and keeps every sos: block for a consumer that takes a set — or spec.expand().program, ' - f'which writes the sets out as binaries and linking rows too.' - ) - - def __reduce__(self) -> tuple[type[UnexpandedCurveError], tuple[tuple[str, ...]]]: - return type(self), (self.blocks,) - - def did_you_mean(name: str, known: Iterable[str], *, label: str = 'Declared') -> str: """The repair clause for an unrecognised name: the near miss, or the set.""" candidates = sorted(known) diff --git a/tests/test_advice.py b/tests/test_advice.py index 24765174..ac4647e4 100644 --- a/tests/test_advice.py +++ b/tests/test_advice.py @@ -16,7 +16,7 @@ import pytest -from math_spec import LanguageError, UnexpandedCurveError, advice, to_spec +from math_spec import LanguageError, advice, to_spec from math_spec.errors import AdviceKind from tests.fixtures import SMALL_MODEL, override @@ -119,11 +119,6 @@ def test_a_curve_left_as_written_is_refused_however_the_model_arrives(): 'the expansion is what advice reads, with or without its sets' ) for arrived in (CURVED, to_spec(CURVED), to_spec(CURVED).program): - with pytest.raises( - UnexpandedCurveError, match="piecewise: 'curve' states rows rather than being one" - ) as refusal: + with pytest.raises(LanguageError, match="piecewise: 'curve' states rows rather than being one") as refusal: advice(arrived) - assert refusal.value.blocks == ('curve',) and "expand('piecewise')" in str(refusal.value), ( - 'the refusal names the block and the expansion to pass' - ) - assert isinstance(refusal.value, LanguageError), "a consumer catching the language's refusals catches this one" + assert "expand('piecewise')" in str(refusal.value), 'the refusal names the block and the expansion to pass' diff --git a/tests/test_public_surface.py b/tests/test_public_surface.py index a2607729..188e23b7 100644 --- a/tests/test_public_surface.py +++ b/tests/test_public_surface.py @@ -26,7 +26,7 @@ # the two public states, and the door to both 'Spec', 'to_spec', 'program', # the error tree, and the one wording a consumer's own refusals share - 'MathSpecError', 'LanguageError', 'SchemaError', 'DimensionError', 'UnexpandedCurveError', + 'MathSpecError', 'LanguageError', 'SchemaError', 'DimensionError', 'did_you_mean', # the verdicts a consumer asks for rather than re-deriving 'advice', 'Advice', From 5b4496d0859dfb5c05c1574407acbbc1eb6a70d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 21:47:08 +0000 Subject: [PATCH 16/20] fix(advice): the refusal of a curve says it is still a curve, and says expand "states rows rather than being one" was hard to parse on first read, and the sentence named the fix with a different word from the method the user calls. The refusal now says the block is still a curve, that advice reads the rows a curve is expanded into, and to expand first. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi --- src/math_spec/advice.py | 6 +++--- tests/test_advice.py | 2 +- tests/typesetting/test_cli.py | 4 +--- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/math_spec/advice.py b/src/math_spec/advice.py index a97a214b..e3d1bd42 100644 --- a/src/math_spec/advice.py +++ b/src/math_spec/advice.py @@ -44,10 +44,10 @@ def advice(model: str | Path | Mapping[str, object] | Spec | Program) -> tuple[A program = model if isinstance(model, Program) else to_spec(model).program if program.piecewise: named = ', '.join(f"'{name}'" for name in program.piecewise) + still = 'is still a curve' if len(program.piecewise) == 1 else 'are still curves' msg = ( - f'piecewise: {named} states rows rather than being one, and advice reads the rows. Pass ' - f"spec.expand('piecewise'), which writes each block out as the variables and constraints it states " - f'and keeps every sos: block — or spec.expand(), which writes the sets out as binaries too.' + f'piecewise: {named} {still}, and advice reads the rows a curve is expanded into. Expand first: ' + f"spec.expand('piecewise') keeps every sos: block, and spec.expand() expands the sets into binaries too." ) raise LanguageError(msg) return tuple(_never_an_axis(program) + unbounded_notes(program)) diff --git a/tests/test_advice.py b/tests/test_advice.py index ac4647e4..39eeaea8 100644 --- a/tests/test_advice.py +++ b/tests/test_advice.py @@ -119,6 +119,6 @@ def test_a_curve_left_as_written_is_refused_however_the_model_arrives(): 'the expansion is what advice reads, with or without its sets' ) for arrived in (CURVED, to_spec(CURVED), to_spec(CURVED).program): - with pytest.raises(LanguageError, match="piecewise: 'curve' states rows rather than being one") as refusal: + with pytest.raises(LanguageError, match="piecewise: 'curve' is still a curve") as refusal: advice(arrived) assert "expand('piecewise')" in str(refusal.value), 'the refusal names the block and the expansion to pass' diff --git a/tests/typesetting/test_cli.py b/tests/typesetting/test_cli.py index 74bf3011..35fdc205 100644 --- a/tests/typesetting/test_cli.py +++ b/tests/typesetting/test_cli.py @@ -83,9 +83,7 @@ def test_check_writes_no_curve_out_unasked(capsys): """ assert front.main(['check', str(EXAMPLES / 'piecewise.yaml')]) == 1, 'a curve left as written is a refusal' captured = capsys.readouterr() - assert captured.out == '' and 'states rows rather than being one' in captured.err, ( - 'the refusal goes to stderr and names the block' - ) + assert captured.out == '' and 'is still a curve' in captured.err, 'the refusal goes to stderr and names the block' assert front.main(['check', str(EXAMPLES / 'piecewise.yaml'), '--expand']) == 0, 'asked for, the rows are checked' From 655ebdadb9c0281f02e24d5c8c9a04e8d5751f9d Mon Sep 17 00:00:00 2001 From: FBumann Date: Wed, 23 Sep 2026 13:31:12 +0000 Subject: [PATCH 17/20] feat(language): a piecewise block states its dims and names its links, and its where reaches links that walk a relation Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015h57WkBDnpxrknuJ5zZy9F --- .prettierignore | 4 + docs/examples/dispatch.md | 18 +- docs/examples/index.md | 11 + docs/examples/piecewise.md | 173 ++++ docs/examples/piecewise_adjacency.md | 157 ++++ docs/examples/piecewise_lp.md | 184 ++++ docs/examples/sos.md | 155 ++++ docs/howto/curve-by-hand.md | 45 - docs/howto/see-an-expansion.md | 27 +- docs/reference/language/piecewise.md | 304 +++++-- docs/reference/notation.md | 28 +- docs/reference/reading.md | 11 +- examples/piecewise.yaml | 7 +- examples/piecewise_adjacency.yaml | 69 ++ examples/piecewise_coupling.yaml | 109 +++ examples/piecewise_lp.yaml | 7 +- examples/piecewise_ragged.yaml | 17 +- examples/ports/transport_pwl.yaml | 7 +- examples/sos.yaml | 7 +- examples/symbols/piecewise_adjacency.yaml | 15 + examples/symbols/piecewise_coupling.yaml | 14 + mkdocs.yml | 5 +- schema/math-spec.schema.json | 78 +- src/math_spec/lowering.py | 36 +- src/math_spec/model.py | 186 +++- src/math_spec/piecewise.py | 710 ++++++++++----- src/math_spec/program.py | 46 +- src/math_spec/typesetting/format.py | 9 +- src/math_spec/typesetting/walk.py | 114 ++- src/math_spec/validation.py | 147 +++- tests/expand/curve-activity/after.yaml | 6 +- tests/expand/curve-activity/before.yaml | 7 +- .../expand/curve-adjacency-points/after.yaml | 12 +- .../expand/curve-adjacency-points/before.yaml | 9 +- tests/expand/curve-adjacency/after.yaml | 6 +- tests/expand/curve-adjacency/before.yaml | 7 +- tests/expand/curve-convex/after.yaml | 6 +- tests/expand/curve-convex/before.yaml | 7 +- tests/expand/curve-lp-points/after.yaml | 6 +- tests/expand/curve-lp-points/before.yaml | 9 +- tests/expand/curve-lp/after.yaml | 2 +- tests/expand/curve-lp/before.yaml | 7 +- tests/expand/curve-sos2-piecewise/after.yaml | 6 +- tests/expand/curve-sos2-piecewise/before.yaml | 7 +- tests/expand/curve-sos2/after.yaml | 6 +- tests/expand/curve-sos2/before.yaml | 7 +- tests/test_advice.py | 2 +- tests/test_boundedness.py | 2 +- tests/test_expand.py | 4 +- tests/test_piecewise.py | 824 ++++++++++++++++-- tests/test_sos.py | 9 +- tests/test_validation.py | 2 +- tests/typesetting/golden/latex.out | 14 +- tests/typesetting/golden/markdown.out | 30 +- tests/typesetting/golden/model.yaml | 54 +- tests/typesetting/golden/typst.out | 14 +- tests/typesetting/test_golden.py | 2 + tests/typesetting/test_symbols.py | 6 +- tests/typesetting/test_walk.py | 43 +- tools/gallery.py | 15 +- 60 files changed, 3110 insertions(+), 721 deletions(-) create mode 100644 docs/examples/piecewise.md create mode 100644 docs/examples/piecewise_adjacency.md create mode 100644 docs/examples/piecewise_lp.md create mode 100644 docs/examples/sos.md delete mode 100644 docs/howto/curve-by-hand.md create mode 100644 examples/piecewise_adjacency.yaml create mode 100644 examples/piecewise_coupling.yaml create mode 100644 examples/symbols/piecewise_adjacency.yaml create mode 100644 examples/symbols/piecewise_coupling.yaml diff --git a/.prettierignore b/.prettierignore index 66add10f..397e893b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -23,6 +23,10 @@ CHANGELOG.md docs/examples/dispatch.md docs/examples/commitment.md docs/examples/operators.md +docs/examples/piecewise.md +docs/examples/piecewise_adjacency.md +docs/examples/sos.md +docs/examples/piecewise_lp.md docs/examples/pypsa.md docs/examples/pypsa_quadratic.md docs/examples/pypsa_linearized_uc.md diff --git a/docs/examples/dispatch.md b/docs/examples/dispatch.md index d80bde87..30d596ca 100644 --- a/docs/examples/dispatch.md +++ b/docs/examples/dispatch.md @@ -49,29 +49,27 @@ Least-cost dispatch of a generator fleet against an hourly load. | Symbol | Meaning | |---|---| -| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | +| $`\mathcal{S}`$ | index $`s`$ — `snapshot` — dispatch periods | | $`\mathcal{G}`$ | index $`g`$ — `generator` — generating units | #### Parameters | Symbol | Meaning | |---|---| -| $`\mathrm{capacity}`$ | `capacity` over $`\mathcal{G}`$ — installed capacity | -| $`\mathrm{load}`$ | `load` over $`\mathcal{T}`$ — demand to be met | -| $`\mathrm{cost}`$ | `cost` over $`\mathcal{G}`$ — marginal cost | +| $`\bar p`$ | `capacity` over $`\mathcal{G}`$ — installed capacity | +| $`\ell`$ | `load` over $`\mathcal{S}`$ — demand to be met | +| $`c`$ | `cost` over $`\mathcal{G}`$ — marginal cost | #### Variables | Symbol | Meaning | |---|---| -| $`\mathit{dispatch}`$ | `dispatch` over $`\mathcal{T} \times \mathcal{G}`$ — output of a generator in a snapshot | - -Upright is what the model is given — a parameter such as $`\mathrm{capacity}`$, a coordinate map, a label — and italic is what the solver chooses, such as $`\mathit{dispatch}`$. An index is italic too, being what a quantifier chooses, and a set is script. +| $`\mathit{dispatch}`$ | `dispatch` over $`\mathcal{S} \times \mathcal{G}`$ — output of a generator in a snapshot | #### Objective ```math -\min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} \mathit{dispatch}_{t,g} \cdot \mathrm{cost}_{g} +\min \sum_{s \in \mathcal{S},\ g \in \mathcal{G}} \mathit{dispatch}_{s,g} \cdot c_{g} ``` #### Subject to @@ -79,7 +77,7 @@ Upright is what the model is given — a parameter such as $`\mathrm{capacity}`$ **`power_balance`** ```math -\sum_{g \in \mathcal{G}} \mathit{dispatch}_{t,g} = \mathrm{load}_{t} \qquad \forall\, t \in \mathcal{T} +\sum_{g \in \mathcal{G}} \mathit{dispatch}_{s,g} = \ell_{s} \qquad \forall\, s \in \mathcal{S} ``` #### Variable domains @@ -87,7 +85,7 @@ Upright is what the model is given — a parameter such as $`\mathrm{capacity}`$ **`dispatch`** ```math -0 \le \mathit{dispatch}_{t,g} \le \mathrm{capacity}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{capacity}_{g} > 0 +0 \le \mathit{dispatch}_{s,g} \le \bar p_{g} \qquad \forall\, s \in \mathcal{S},\ g \in \mathcal{G} \,:\, \bar p_{g} > 0 ``` diff --git a/docs/examples/index.md b/docs/examples/index.md index e446a64c..71fd97fe 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -14,6 +14,17 @@ Every model is a file under `examples/` in the repository. by region, so a single inequality covers both regimes. - [One construct per model](operators.md) declares each operator in the smallest file that can, and prints the equation beside it. +- [A curve by convex combination](piecewise.md) is the floor of the `piecewise` + family: one weight per breakpoint, one row summing them to 1, and one row per + link. The three pages after it are the same model, restricted another way. +- [A curve that is not convex](piecewise_adjacency.md) adds a binary per segment + and the two rows that hold the weights on it. This is what the default method + builds. +- [A curve as a special-ordered set](sos.md) hands that same restriction to the + solver. The binaries and their rows are gone, and a declaration stands where + they were. +- [A curve as segment lines](piecewise_lp.md) states the curve as inequalities + instead of breakpoints, and declares no auxiliary variable at all. - [PyPSA in one file](pypsa.md) states the model `n.optimize()` builds, one declaration at a time. PyPSA's name for each row sits beside the YAML and the equation. diff --git a/docs/examples/piecewise.md b/docs/examples/piecewise.md new file mode 100644 index 00000000..5b4dfdee --- /dev/null +++ b/docs/examples/piecewise.md @@ -0,0 +1,173 @@ + + +# A curve by convex combination + +A generator's cost curve, tied to its dispatch through one weight per +breakpoint. This is the floor of the [`piecewise`](../reference/language/piecewise.md) +family: the three pages after it are this same model with the weights +restricted a different way. + +Read the math for what the block expands to. The file declares no weight, and +`cost_curve_lam` appears below because the block emits it. One row makes the +weights sum to 1, and one row per link ties that link's expression to the +weighted breakpoints. `method: convex` adds nothing further. A convex curve +under a minimised cost settles on one segment without being held there. + + +```yaml +description: >- + Least-cost dispatch where each generator's cost curve is piecewise-linear in + its output, expanded into a lambda formulation. + +dimensions: + snapshot: + description: dispatch periods + dtype: int + generator: + description: dispatchable units + dtype: str + bp: + description: breakpoints of the cost curve + dtype: int + +parameters: + capacity: + description: maximum dispatch + dims: [generator] + load: + description: demand to be met + dims: [snapshot] + bp_x: + description: breakpoint dispatch levels, one curve per generator + dims: [generator, bp] + bp_y: + description: cost at each breakpoint, one curve per generator + dims: [generator, bp] + +variables: + dispatch: + description: dispatched power + dims: [snapshot, generator] + bounds: + lower: 0 + upper: capacity + op_cost: + description: operating cost, piecewise-linear in dispatch + dims: [snapshot, generator] + bounds: + lower: 0 + +piecewise: + cost_curve: + description: >- + cost read off the generator's curve — convex, so the weights need no + binaries to keep them on one segment + along: bp + dims: [snapshot, generator] + links: + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y] + method: convex + +constraints: + balance: + dims: [snapshot] + expression: sum(dispatch, over=generator) == load + +objective: + sense: minimize + description: total operating cost, taken off the curves rather than from a marginal rate + expression: sum(op_cost) +``` + +Least-cost dispatch where each generator's cost curve is piecewise-linear in its output, expanded into a lambda formulation. + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | +| $`\mathcal{G}`$ | index $`g`$ — `generator` — dispatchable units | +| $`\mathcal{B}`$ | index $`b`$ — `bp` — breakpoints of the cost curve | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{capacity}`$ | `capacity` over $`\mathcal{G}`$ — maximum dispatch | +| $`\mathrm{load}`$ | `load` over $`\mathcal{T}`$ — demand to be met | +| $`\mathrm{x}`$ | `bp_x` over $`\mathcal{G} \times \mathcal{B}`$ — breakpoint dispatch levels, one curve per generator | +| $`\mathrm{y}`$ | `bp_y` over $`\mathcal{G} \times \mathcal{B}`$ — cost at each breakpoint, one curve per generator | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`\mathit{dispatch}`$ | `dispatch` over $`\mathcal{T} \times \mathcal{G}`$ — dispatched power | +| $`\mathit{op\_cost}`$ | `op_cost` over $`\mathcal{T} \times \mathcal{G}`$ — operating cost, piecewise-linear in dispatch | + +Upright is what the model is given — a parameter such as $`\mathrm{capacity}`$, a coordinate map, a label — and italic is what the solver chooses, such as $`\mathit{dispatch}`$. An index is italic too, being what a quantifier chooses, and a set is script. + +$`t \boxminus_{v} k`$ denotes translation with $`v`$ standing where index $`t-k`$ leaves the dimension (`shift(edge=v)`), so the row at that boundary is built and carries $`v`$ rather than being dropped. + +$`\mathrm{pos}(t)`$ denotes where index $`t`$ sits along its dimension's own order — the order `shift` steps along, not the order labels sort in — counted from $`0`$. The index itself stays the coordinate, so $`t`$ compares against labels and $`\mathrm{pos}(t)`$ against positions. + +$`\lvert \mathcal{T} \rvert`$ denotes the size of the set being counted along, and a position counted from the end prints against it — $`\lvert \mathcal{T} \rvert - 1`$ is the last position, one less than the size because the first is $`0`$. + +#### Objective + +```math +\min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} \mathit{op\_cost}_{t,g} +``` + +#### Subject to + +**`balance`** + +```math +\sum_{g \in \mathcal{G}} \mathit{dispatch}_{t,g} = \mathrm{load}_{t} \qquad \forall\, t \in \mathcal{T} +``` + +**`cost_curve`** + +```math +\left( \mathit{dispatch}_{t,g},\ \mathit{op\_cost}_{t,g} \right) \in \mathrm{conv}_{b \in \mathcal{B}}(\mathrm{x}_{g,b},\ \mathrm{y}_{g,b}) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +#### Variable domains + +**`dispatch`** + +```math +0 \le \mathit{dispatch}_{t,g} \le \mathrm{capacity}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +**`op_cost`** + +```math +\mathit{op\_cost}_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +#### Assumptions + +**`cost_curve_complete`** + +```math +\mathrm{x}_{g,b} \text{ is defined} \wedge \mathrm{y}_{g,b} \text{ is defined} \qquad \forall\, g \in \mathcal{G},\ b \in \mathcal{B} +``` + +**`cost_curve_increasing`** + +```math +\mathrm{x}_{g,b \boxminus_{0} 1} < \mathrm{x}_{g,b} \qquad \forall\, g \in \mathcal{G},\ b \in \mathcal{B} \,:\, \mathrm{pos}(b) > 0 +``` + +**`cost_curve_curvature`** + +```math +\lvert \{ b \in \mathcal{B} \,:\, \left( \mathrm{y}_{g,b} - \mathrm{y}_{g,b \boxminus_{0} 1} \right) \cdot \left( \mathrm{x}_{g,b \boxplus_{0} 1} - \mathrm{x}_{g,b} \right) > \left( \mathrm{y}_{g,b \boxplus_{0} 1} - \mathrm{y}_{g,b} \right) \cdot \left( \mathrm{x}_{g,b} - \mathrm{x}_{g,b \boxminus_{0} 1} \right) \wedge \mathrm{pos}(b) > 0 \wedge \mathrm{pos}(b) \neq \lvert \mathcal{B} \rvert - 1 \} \rvert = 0 \vee \lvert \{ b \in \mathcal{B} \,:\, \left( \mathrm{y}_{g,b} - \mathrm{y}_{g,b \boxminus_{0} 1} \right) \cdot \left( \mathrm{x}_{g,b \boxplus_{0} 1} - \mathrm{x}_{g,b} \right) < \left( \mathrm{y}_{g,b \boxplus_{0} 1} - \mathrm{y}_{g,b} \right) \cdot \left( \mathrm{x}_{g,b} - \mathrm{x}_{g,b \boxminus_{0} 1} \right) \wedge \mathrm{pos}(b) > 0 \wedge \mathrm{pos}(b) \neq \lvert \mathcal{B} \rvert - 1 \} \rvert = 0 \qquad \forall\, g \in \mathcal{G} +``` + diff --git a/docs/examples/piecewise_adjacency.md b/docs/examples/piecewise_adjacency.md new file mode 100644 index 00000000..6e97354e --- /dev/null +++ b/docs/examples/piecewise_adjacency.md @@ -0,0 +1,157 @@ + + +# A curve that is not convex + +The same dispatch model, with a curve that bends both ways. Nothing about the +objective now keeps the weights on one segment, so the method builds the +restriction out of binaries. `adjacency` is the default, and this is what it +costs. + +Compare the math with [the convex page](piecewise.md). A second variable +appears, `cost_curve_seg`, one binary per segment. Two rows come with it: +`cost_curve_pick` picks exactly one segment, and `cost_curve_adjacency` holds +each weight under the segments it borders. The link rows and the convexity row +are unchanged. + + +```yaml +description: >- + The same least-cost dispatch as `piecewise.yaml`, with a cost curve that is + not convex. The weights need binaries to hold them on one segment, which is + what the default method builds. + +dimensions: + snapshot: + description: dispatch periods + dtype: int + generator: + description: dispatchable units + dtype: str + bp: + description: breakpoints of the cost curve + dtype: int + +parameters: + capacity: + description: maximum dispatch + dims: [generator] + load: + description: demand to be met + dims: [snapshot] + bp_x: + description: breakpoint dispatch levels, one curve per generator + dims: [generator, bp] + bp_y: + description: cost at each breakpoint, one curve per generator + dims: [generator, bp] + +variables: + dispatch: + description: dispatched power + dims: [snapshot, generator] + bounds: + lower: 0 + upper: capacity + op_cost: + description: operating cost, piecewise-linear in dispatch + dims: [snapshot, generator] + bounds: + lower: 0 + +piecewise: + cost_curve: + description: >- + cost read off the generator's curve. The curve bends both ways, so + nothing but the restriction keeps the weights on one segment: a binary + per segment picks the one they may sit on + along: bp + dims: [snapshot, generator] + links: + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y] + method: adjacency + +constraints: + balance: + dims: [snapshot] + expression: sum(dispatch, over=generator) == load + +objective: + sense: minimize + description: total operating cost, taken off the curves rather than from a marginal rate + expression: sum(op_cost) +``` + +The same least-cost dispatch as `piecewise.yaml`, with a cost curve that is not convex. The weights need binaries to hold them on one segment, which is what the default method builds. + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | +| $`\mathcal{G}`$ | index $`g`$ — `generator` — dispatchable units | +| $`\mathcal{B}`$ | index $`b`$ — `bp` — breakpoints of the cost curve | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{capacity}`$ | `capacity` over $`\mathcal{G}`$ — maximum dispatch | +| $`\mathrm{load}`$ | `load` over $`\mathcal{T}`$ — demand to be met | +| $`\mathrm{x}`$ | `bp_x` over $`\mathcal{G} \times \mathcal{B}`$ — breakpoint dispatch levels, one curve per generator | +| $`\mathrm{y}`$ | `bp_y` over $`\mathcal{G} \times \mathcal{B}`$ — cost at each breakpoint, one curve per generator | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`\mathit{dispatch}`$ | `dispatch` over $`\mathcal{T} \times \mathcal{G}`$ — dispatched power | +| $`\mathit{op\_cost}`$ | `op_cost` over $`\mathcal{T} \times \mathcal{G}`$ — operating cost, piecewise-linear in dispatch | + +Upright is what the model is given — a parameter such as $`\mathrm{capacity}`$, a coordinate map, a label — and italic is what the solver chooses, such as $`\mathit{dispatch}`$. An index is italic too, being what a quantifier chooses, and a set is script. + +#### Objective + +```math +\min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} \mathit{op\_cost}_{t,g} +``` + +#### Subject to + +**`balance`** + +```math +\sum_{g \in \mathcal{G}} \mathit{dispatch}_{t,g} = \mathrm{load}_{t} \qquad \forall\, t \in \mathcal{T} +``` + +**`cost_curve`** + +```math +\left( \mathit{dispatch}_{t,g},\ \mathit{op\_cost}_{t,g} \right) \in \mathrm{pwl}_{b \in \mathcal{B}}(\mathrm{x}_{g,b},\ \mathrm{y}_{g,b}) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +#### Variable domains + +**`dispatch`** + +```math +0 \le \mathit{dispatch}_{t,g} \le \mathrm{capacity}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +**`op_cost`** + +```math +\mathit{op\_cost}_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +#### Assumptions + +**`cost_curve_complete`** + +```math +\mathrm{x}_{g,b} \text{ is defined} \wedge \mathrm{y}_{g,b} \text{ is defined} \qquad \forall\, g \in \mathcal{G},\ b \in \mathcal{B} +``` + diff --git a/docs/examples/piecewise_lp.md b/docs/examples/piecewise_lp.md new file mode 100644 index 00000000..39c6f65c --- /dev/null +++ b/docs/examples/piecewise_lp.md @@ -0,0 +1,184 @@ + + +# A curve as segment lines + +The same curve again, stated as the lines its segments lie on rather than as +breakpoints to interpolate between. The `>=` on the second link says which side +of the lines the cost sits on. + +This is the one method that declares no auxiliary variable. No weights appear in +the math below, so nothing has to be restricted and the model stays a linear +program. `cost_curve_chord` is one inequality per segment, and the two domain +rows hold dispatch between the curve's ends. The form reads correctly only where +the curvature matches the sign. Lines that envelope a convex curve would cut a +concave one, and the solve comes back optimal either way. + + +```yaml +description: >- + The same least-cost dispatch as `piecewise.yaml`, with each generator's cost + curve stated as the lines its segments lie on rather than interpolated + between its breakpoints. The curve is convex and the objective pushes the + cost down, so a cost above every segment line settles on the curve — which + needs no interpolation weights, and so declares no auxiliary variable at all. + +dimensions: + snapshot: + description: dispatch periods + dtype: int + generator: + description: dispatchable units + dtype: str + bp: + description: breakpoints of the cost curve + dtype: int + +parameters: + capacity: + description: maximum dispatch + dims: [generator] + load: + description: demand to be met + dims: [snapshot] + bp_x: + description: breakpoint dispatch levels, one curve per generator + dims: [generator, bp] + bp_y: + description: cost at each breakpoint, one curve per generator + dims: [generator, bp] + +variables: + dispatch: + description: dispatched power + dims: [snapshot, generator] + bounds: + lower: 0 + upper: capacity + op_cost: + description: operating cost, held above every segment of the generator's curve + dims: [snapshot, generator] + bounds: + lower: 0 + +piecewise: + cost_curve: + description: >- + cost bounded below by the curve — the `>=` is what says which side of the + lines the cost sits on, and the curvature has to match it: lines that + envelope a convex curve would cut a concave one, and the solve comes back + optimal either way + along: bp + dims: [snapshot, generator] + links: + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y, ">="] + method: lp + +constraints: + balance: + dims: [snapshot] + expression: sum(dispatch, over=generator) == load + +objective: + sense: minimize + description: total operating cost, taken off the curves rather than from a marginal rate + expression: sum(op_cost) +``` + +The same least-cost dispatch as `piecewise.yaml`, with each generator's cost curve stated as the lines its segments lie on rather than interpolated between its breakpoints. The curve is convex and the objective pushes the cost down, so a cost above every segment line settles on the curve — which needs no interpolation weights, and so declares no auxiliary variable at all. + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | +| $`\mathcal{G}`$ | index $`g`$ — `generator` — dispatchable units | +| $`\mathcal{B}`$ | index $`b`$ — `bp` — breakpoints of the cost curve | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{capacity}`$ | `capacity` over $`\mathcal{G}`$ — maximum dispatch | +| $`\mathrm{load}`$ | `load` over $`\mathcal{T}`$ — demand to be met | +| $`\mathrm{x}`$ | `bp_x` over $`\mathcal{G} \times \mathcal{B}`$ — breakpoint dispatch levels, one curve per generator | +| $`\mathrm{y}`$ | `bp_y` over $`\mathcal{G} \times \mathcal{B}`$ — cost at each breakpoint, one curve per generator | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`\mathit{dispatch}`$ | `dispatch` over $`\mathcal{T} \times \mathcal{G}`$ — dispatched power | +| $`\mathit{op\_cost}`$ | `op_cost` over $`\mathcal{T} \times \mathcal{G}`$ — operating cost, held above every segment of the generator's curve | + +Upright is what the model is given — a parameter such as $`\mathrm{capacity}`$, a coordinate map, a label — and italic is what the solver chooses, such as $`\mathit{dispatch}`$. An index is italic too, being what a quantifier chooses, and a set is script. + +$`t \boxminus_{v} k`$ denotes translation with $`v`$ standing where index $`t-k`$ leaves the dimension (`shift(edge=v)`), so the row at that boundary is built and carries $`v`$ rather than being dropped. + +$`\mathrm{pos}(t)`$ denotes where index $`t`$ sits along its dimension's own order — the order `shift` steps along, not the order labels sort in — counted from $`0`$. The index itself stays the coordinate, so $`t`$ compares against labels and $`\mathrm{pos}(t)`$ against positions. + +$`\lvert \mathcal{T} \rvert`$ denotes the size of the set being counted along, and a position counted from the end prints against it — $`\lvert \mathcal{T} \rvert - 1`$ is the last position, one less than the size because the first is $`0`$. + +#### Objective + +```math +\min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} \mathit{op\_cost}_{t,g} +``` + +#### Subject to + +**`balance`** + +```math +\sum_{g \in \mathcal{G}} \mathit{dispatch}_{t,g} = \mathrm{load}_{t} \qquad \forall\, t \in \mathcal{T} +``` + +**`cost_curve`** + +```math +\mathit{op\_cost}_{t,g} \ge \mathrm{pwl}_{b \in \mathcal{B}}(\mathrm{x}_{g,b},\ \mathrm{y}_{g,b})(\mathit{dispatch}_{t,g}) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +#### Variable domains + +**`dispatch`** + +```math +0 \le \mathit{dispatch}_{t,g} \le \mathrm{capacity}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +**`op_cost`** + +```math +\mathit{op\_cost}_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +#### Assumptions + +**`cost_curve_complete`** + +```math +\mathrm{x}_{g,b} \text{ is defined} \wedge \mathrm{y}_{g,b} \text{ is defined} \qquad \forall\, g \in \mathcal{G},\ b \in \mathcal{B} +``` + +**`cost_curve_increasing`** + +```math +\mathrm{x}_{g,b \boxminus_{0} 1} < \mathrm{x}_{g,b} \qquad \forall\, g \in \mathcal{G},\ b \in \mathcal{B} \,:\, \mathrm{pos}(b) > 0 +``` + +**`cost_curve_curvature`** + +```math +\left( \mathrm{y}_{g,b} - \mathrm{y}_{g,b \boxminus_{0} 1} \right) \cdot \left( \mathrm{x}_{g,b \boxplus_{0} 1} - \mathrm{x}_{g,b} \right) \le \left( \mathrm{y}_{g,b \boxplus_{0} 1} - \mathrm{y}_{g,b} \right) \cdot \left( \mathrm{x}_{g,b} - \mathrm{x}_{g,b \boxminus_{0} 1} \right) \qquad \forall\, g \in \mathcal{G},\ b \in \mathcal{B} \,:\, \mathrm{pos}(b) > 0 \wedge \mathrm{pos}(b) \neq \lvert \mathcal{B} \rvert - 1 +``` + +**`cost_curve_breakpoints`** + +```math +\lvert \{ b \in \mathcal{B} \,:\, \mathrm{x}_{g,b} \text{ is defined} \} \rvert \ge 2 \qquad \forall\, g \in \mathcal{G} +``` + diff --git a/docs/examples/sos.md b/docs/examples/sos.md new file mode 100644 index 00000000..411af536 --- /dev/null +++ b/docs/examples/sos.md @@ -0,0 +1,155 @@ + + +# A curve as a special-ordered set + +The same restriction as [the adjacency page](piecewise_adjacency.md), handed to +the solver instead of built. `method: sos2` says that at most two weights may +be non-zero and they must be neighbours, which is the definition of a type-2 +set. + +Read the two pages together. The binaries are gone here, and so are the two +rows that constrained them. What replaces them is a declaration rather than a +row, because a special-ordered set is something a solver enforces directly. +Whether a given solver does is that solver's business, not the file's. + + +```yaml +description: >- + A piecewise-linear cost curve stated as a special-ordered set, so the solver + is handed the adjacency restriction rather than binaries that encode it. + +dimensions: + snapshot: + description: dispatch periods + dtype: int + generator: + description: dispatchable units + dtype: str + bp: + description: breakpoints of the cost curve + dtype: int + +parameters: + capacity: + description: maximum dispatch + dims: [generator] + load: + description: demand to be met + dims: [snapshot] + bp_x: + description: breakpoint dispatch levels, one curve per generator + dims: [generator, bp] + bp_y: + description: cost at each breakpoint, one curve per generator + dims: [generator, bp] + +variables: + dispatch: + description: dispatched power + dims: [snapshot, generator] + bounds: + lower: 0 + upper: capacity + op_cost: + description: operating cost, piecewise-linear in dispatch + dims: [snapshot, generator] + bounds: + lower: 0 + +piecewise: + cost_curve: + description: >- + cost read off the generator's curve, with at most two adjacent weights + non-zero — the restriction the default method builds out of binaries, + declared as a set instead + along: bp + dims: [snapshot, generator] + links: + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y] + method: sos2 + +constraints: + balance: + dims: [snapshot] + expression: sum(dispatch, over=generator) == load + +objective: + sense: minimize + description: total operating cost, taken off the curves rather than from a marginal rate + expression: sum(op_cost) +``` + +A piecewise-linear cost curve stated as a special-ordered set, so the solver is handed the adjacency restriction rather than binaries that encode it. + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | +| $`\mathcal{G}`$ | index $`g`$ — `generator` — dispatchable units | +| $`\mathcal{B}`$ | index $`b`$ — `bp` — breakpoints of the cost curve | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{capacity}`$ | `capacity` over $`\mathcal{G}`$ — maximum dispatch | +| $`\mathrm{load}`$ | `load` over $`\mathcal{T}`$ — demand to be met | +| $`\mathrm{x}`$ | `bp_x` over $`\mathcal{G} \times \mathcal{B}`$ — breakpoint dispatch levels, one curve per generator | +| $`\mathrm{y}`$ | `bp_y` over $`\mathcal{G} \times \mathcal{B}`$ — cost at each breakpoint, one curve per generator | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`\mathit{dispatch}`$ | `dispatch` over $`\mathcal{T} \times \mathcal{G}`$ — dispatched power | +| $`\mathit{op\_cost}`$ | `op_cost` over $`\mathcal{T} \times \mathcal{G}`$ — operating cost, piecewise-linear in dispatch | + +Upright is what the model is given — a parameter such as $`\mathrm{capacity}`$, a coordinate map, a label — and italic is what the solver chooses, such as $`\mathit{dispatch}`$. An index is italic too, being what a quantifier chooses, and a set is script. + +#### Objective + +```math +\min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} \mathit{op\_cost}_{t,g} +``` + +#### Subject to + +**`balance`** + +```math +\sum_{g \in \mathcal{G}} \mathit{dispatch}_{t,g} = \mathrm{load}_{t} \qquad \forall\, t \in \mathcal{T} +``` + +**`cost_curve`** + +```math +\left( \mathit{dispatch}_{t,g},\ \mathit{op\_cost}_{t,g} \right) \in \mathrm{pwl}_{b \in \mathcal{B}}(\mathrm{x}_{g,b},\ \mathrm{y}_{g,b}) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +#### Variable domains + +**`dispatch`** + +```math +0 \le \mathit{dispatch}_{t,g} \le \mathrm{capacity}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +**`op_cost`** + +```math +\mathit{op\_cost}_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +#### Assumptions + +**`cost_curve_complete`** + +```math +\mathrm{x}_{g,b} \text{ is defined} \wedge \mathrm{y}_{g,b} \text{ is defined} \qquad \forall\, g \in \mathcal{G},\ b \in \mathcal{B} +``` + diff --git a/docs/howto/curve-by-hand.md b/docs/howto/curve-by-hand.md deleted file mode 100644 index caa05314..00000000 --- a/docs/howto/curve-by-hand.md +++ /dev/null @@ -1,45 +0,0 @@ - - -# Write a piecewise curve out by hand - -Tie a number of flows to one curve where that number is data: a boiler ties two -flows and a CHP unit ties three, in one model. A -[`piecewise:`](../reference/language/piecewise.md) block lists its links in the -file, so it cannot say this. The formulation written out can. - -1. **Declare the weights as a variable over the breakpoint dimension**, masked - to how far each curve runs: - - ```yaml - variables: - weight: # the convex combination, one per converter and period - dims: [converter, time, bp] - where: bp_present # how far each curve runs - bounds: { lower: 0, upper: 1 } - ``` - -2. **Restrict the weights with an `sos:` block.** `type: 2` states the - restriction that `method: sos2` emits: - - ```yaml - sos: - on_one_segment: { variable: weight, over: bp, type: 2 } - ``` - -3. **Write the convexity row, and one row per flow.** The row per flow is where - the count goes, and a relation carries it: - - ```yaml - constraints: - one_operating_point: - dims: [converter, time] - expression: sum(weight, over=bp) == 1 - on_the_curve: # one row per flow - dims: [flow, time] - expression: rate == sum(at(weight, by=converter_of, over=converter, into=flow) * bp_rate, over=bp) - ``` - -A converter with a fourth flow is then a row in a table. diff --git a/docs/howto/see-an-expansion.md b/docs/howto/see-an-expansion.md index 8a90a4d0..3f4ac17f 100644 --- a/docs/howto/see-an-expansion.md +++ b/docs/howto/see-an-expansion.md @@ -175,11 +175,12 @@ writes out in two steps. Compare the tabs from left to right: piecewise: curve: - over: bp + along: bp + dims: [] method: sos2 links: - - [x, x_bp] - - [y, y_bp] + x: [x, x_bp] + y: [y, y_bp] ``` === "Math" @@ -238,10 +239,10 @@ writes out in two steps. Compare the tabs from left to right: curve_convexity: dims: [] expression: sum(curve_lam, over=bp) == 1 - curve_link0: + curve_x: dims: [] expression: (x) == sum(curve_lam * x_bp, over=bp) - curve_link1: + curve_y: dims: [] expression: (y) == sum(curve_lam * y_bp, over=bp) @@ -258,7 +259,7 @@ writes out in two steps. Compare the tabs from left to right: piecewise 'curve': every breakpoint the curve runs through needs a row in 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter curve, so it sits the curve on the origin. Bind the rows, or declare - points: to say how far the curve runs. + where: to say how far the curve runs. ``` === "Math" @@ -271,13 +272,13 @@ writes out in two steps. Compare the tabs from left to right: \sum_{b \in \mathcal{B}} \mathit{curve\_lam}_{b} = 1 ``` - **`curve_link0`** + **`curve_x`** ```math x = \sum_{b \in \mathcal{B}} \mathit{curve\_lam}_{b} \cdot \mathrm{x}^{\mathrm{bp}}_{b} ``` - **`curve_link1`** + **`curve_y`** ```math y = \sum_{b \in \mathcal{B}} \mathit{curve\_lam}_{b} \cdot \mathrm{y}^{\mathrm{bp}}_{b} @@ -345,10 +346,10 @@ writes out in two steps. Compare the tabs from left to right: curve_convexity: dims: [] expression: sum(curve_lam, over=bp) == 1 - curve_link0: + curve_x: dims: [] expression: (x) == sum(curve_lam * x_bp, over=bp) - curve_link1: + curve_y: dims: [] expression: (y) == sum(curve_lam * y_bp, over=bp) curve_pick: @@ -365,7 +366,7 @@ writes out in two steps. Compare the tabs from left to right: piecewise 'curve': every breakpoint the curve runs through needs a row in 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter curve, so it sits the curve on the origin. Bind the rows, or declare - points: to say how far the curve runs. + where: to say how far the curve runs. ``` === "Math" @@ -378,13 +379,13 @@ writes out in two steps. Compare the tabs from left to right: \sum_{b \in \mathcal{B}} \mathit{curve\_lam}_{b} = 1 ``` - **`curve_link0`** + **`curve_x`** ```math x = \sum_{b \in \mathcal{B}} \mathit{curve\_lam}_{b} \cdot \mathrm{x}^{\mathrm{bp}}_{b} ``` - **`curve_link1`** + **`curve_y`** ```math y = \sum_{b \in \mathcal{B}} \mathit{curve\_lam}_{b} \cdot \mathrm{y}^{\mathrm{bp}}_{b} diff --git a/docs/reference/language/piecewise.md b/docs/reference/language/piecewise.md index 5e85c906..a667be2c 100644 --- a/docs/reference/language/piecewise.md +++ b/docs/reference/language/piecewise.md @@ -15,42 +15,47 @@ out. ## `piecewise` -A `piecewise` block ties two or more expressions to one piecewise-linear curve. -The curve is given as breakpoints: the corner values each expression takes -together. +A `piecewise` block ties expressions to one piecewise-linear curve for every +coordinate of its `dims:`. The curve is given as breakpoints: the corner values +each expression takes together. ```yaml piecewise: chp: - over: bp # breakpoint dimension - links: - - [power, power_bp] # [expression, values-parameter] - - [fuel, fuel_bp] - - [heat, heat_bp] + along: bp # the dimension each curve runs along + dims: [generator, snapshot] # one curve per coordinate of these + links: # each link by the name of the row it writes, chp_ + power: [power, power_bp] # [expression, values-parameter] + fuel: [fuel, fuel_bp] + heat: [heat, heat_bp] method: adjacency # how the weights are restricted — below activity: null # optional: a binary variable that the weights sum to - # a two-link block may bound one side instead of pinning it + # a link may be bounded by the curve instead of pinned to it fuel_cap: - over: bp + along: bp + dims: [generator, snapshot] links: - - [power, power_bp] - - [fuel, fuel_bp, "<="] + power: [power, power_bp] + fuel: [fuel, fuel_bp, "<="] ``` -| Part of a link | | -| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| _expression_ | Any affine expression. The simplest is a bare variable name | -| _values_ | A parameter that carries the `over` dimension, plus any dimensions the link expressions carry. A dimension the links do not carry is refused | -| _sign_ | `<=` or `>=`. At most one per block, and only in a block with exactly two links. It bounds the link instead of pinning it | +| Part of a link | | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| _name_ | The key. The link's row in the expansion is `_` | +| _expression_ | Any affine expression over the link's row. The simplest is a bare variable name | +| _values_ | A parameter that carries the `along` dimension. Every other dimension it carries is one the link's row carries | +| _sign_ | `<=` or `>=`. It bounds the link by the curve instead of pinning it to it. Any number of links may carry one, as long as at least one link does not ([below](#signs)) | +| _by_, _over_, _into_ | A relation walk from the curve's `dims:` to the link's row ([below](#a-link-that-walks-a-relation)) | | Key | | | | ---------- | ---------------------------------------------------------------------------------------- | ------------------- | -| `over` | required. The breakpoint dimension | | -| `links` | required. Two or more links | | +| `along` | required. The dimension each curve runs along | | +| `dims` | required. The dimensions the block builds one curve per coordinate of ([below](#dims)) | | +| `links` | required. Two or more links, or one that walks a relation | | +| `where` | which coordinates have a curve, and how far each runs ([below](#where)) | default `null` | | `method` | `adjacency`, `sos2`, `convex` or `lp`: how the weights are restricted ([below](#method)) | default `adjacency` | | `activity` | a binary variable that gates the curve ([below](#activity)) | default `null` | -| `points` | how far each curve runs, where the curves are not all the same length ([below](#points)) | default `null` | A block states plain variables and constraints: one weight per breakpoint in `[0, 1]`, one row making the weights sum to 1, and one row per link tying its @@ -59,7 +64,10 @@ consumer builds them; the [typeset output](../typeset.md) prints the curve itself, and [`spec.expand()`](#writing-a-formulation-out) is what writes the rows into a model of their own. -The breakpoint order is the declared order of `over`. A curve whose breakpoints +A link names the row it writes, so a link may not take a name the block +already writes for itself, such as `convexity` or `lam`. + +The breakpoint order is the declared order of `along`. A curve whose breakpoints decrease in that order is refused when the data binds. Every condition this page says is checked "when the data binds" is an @@ -68,14 +76,100 @@ states. The `method:` implies it rather than the file writing it, so [`expand()`](#writing-a-formulation-out) writes it into `assumptions:` under the block's own name, and a model that still declares the block derives the same text when it loads. Both print under one heading, and the consumer that -binds the numbers runs them. +binds the numbers runs them. Each one is asked only where the block's +[`where:`](#where) says a curve runs. !!! warning "A values parameter short of a row does not build a shorter curve" The missing row reads as a breakpoint at the origin. Every block states - `_complete` for this, whatever its `method:`, so the table is - refused when the data binds and the refusal names `points:` as the way to - say how far a curve runs. + `_complete` for this, whatever its `method:`, and a link that walks + a relation states `__complete` for its own values. So the + table is refused when the data binds, and the refusal names `where:` as the + way to say how far a curve runs. + +### `dims` + +A block builds one curve for every coordinate of `dims:`. Each curve is one set +of weights. `dims:` may not carry the breakpoint dimension, because every curve +runs along it. + +**A link expression carries exactly the dimensions of its row.** The row of a +link is `dims:`, or the dimensions a [walk](#a-link-that-walks-a-relation) +reaches. A dimension the expression carries and the row does not multiplies the +rows the link builds. A dimension the row carries and the expression does not +repeats one row across it, which pins the expression to a single operating point +along a dimension the curve varies over. Both are refused, and the message +names which one it is. + +A quantity that varies along a dimension the curve does not, such as a rate per +period read off a curve that has none, is said by adding that dimension to +`dims:`. The curve then varies along it too. Whether the breakpoint values also +vary along it is the data's business: values that do not carry it give one curve +shape and a per-period operating point. + +An [`activity:`](#activity) gate carries no dimension that `dims:` does not. A +gate over fewer dimensions switches every curve it covers: a gate per generator +switches that generator's curve in every snapshot. + +### `where` + +`where:` says which coordinates of `dims:` have a curve: + +```yaml +piecewise: + cost_curve: + along: bp + dims: [generator] + where: has_curve # only some generators run on a cost curve + links: + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y] +``` + +Off the mask the block builds nothing. There are no weights, no convexity row +and no link row, so the linked expressions are left free. The breakpoint values +are not read there either: a generator with no curve needs no row in `bp_x` or +`bp_y`. + +`where:` is not [`activity:`](#activity). A coordinate outside the mask has no +curve. A gated coordinate has a curve that the solver may switch off, and its +rows are built either way. + +A mask carrying a dimension that `dims:` does not carry is refused, because a +mask cannot add coordinates. The breakpoint dimension is the one exception, and +reading it is how a block says how far each curve runs. + +#### Curves of unequal length + +A curve with fewer breakpoints than the dimension holds says so with a `where:` +that reads the breakpoint dimension. Name one of the block's own values +parameters, and the curve is as long as that parameter has rows: + +```yaml +piecewise: + cost_curve: + along: bp + dims: [generator] + where: bp_x # this curve runs as far as its own breakpoints do + links: + p: [p, bp_x] + op_cost: [op_cost, bp_y] +``` + +The other links are still read against the parameter you named, so a row missing +from `bp_y` is refused. Where the length is its own data, name a boolean +parameter over `dims:` and the breakpoint dimension instead. Either composes +with a mask over `dims:`: `has_curve AND bp_x` says which generators have a +curve and how far each one runs. + +The marked breakpoints must be consecutive. They need not start at the head of +the axis. A gap is refused when the data binds, and a coordinate the mask +leaves with no breakpoint has no curve. + +The rows a block writes over `dims:` alone, such as the one making the weights +sum to 1, cannot read the breakpoint dimension. There the mask reads as +`count(where, over=bp) > 0`: a curve exists where it admits at least one +breakpoint. ### `activity` @@ -92,31 +186,114 @@ variables: where: committable # only some units have a commitment decision ``` -Where the gate does not exist, the curve is ungated. To have no curve there -instead, put `absence: zero` on the gate. +Where the gate does not exist, the curve is ungated. To pin the curve off +there instead, put `absence: zero` on the gate. To build no curve there at all, +use [`where:`](#where). -### `points` +### A link that walks a relation -A curve with fewer breakpoints than the dimension holds says so with `points:`. -Name one of the block's own values parameters, and the curve is as long as that -parameter has rows: +A link that names `by:`, `over:` and `into:` reads the curve's weights through a +[relation](relations.md#how-a-relation-is-used), as [`at`](operators.md#at) +does. It builds one row per coordinate that the walk reaches, and every row +reads the curve of the coordinate it maps back to. So the number of **rows** a +link builds is data. A converter with two flows and a converter with five share +one block: ```yaml +relations: + generator_of: { key: flow, values: generator } + piecewise: - cost_curve: - over: bp - points: bp_x # this curve runs as far as its own breakpoints do + coupling: + along: bp + dims: [generator, snapshot] # one curve per generator links: - - [p, bp_x] - - [op_cost, bp_y] + power: { expression: power, values: bp_power, by: generator_of, over: generator, into: flow } + fuel: [fuel, bp_fuel] ``` -The other links are still read against the parameter you named, so a row missing -from `bp_y` is refused. Where the length is its own data, name a boolean -parameter instead. +`power` is per flow and the curve is per generator, so the `power` link builds +one row for each flow of a generator. A sixth flow is a row in `generator_of`, +not an edit to the model. -The marked breakpoints must be consecutive. They need not start at the head of -the axis. A gap, or a curve with no points, is refused when the data binds. +The row of a walked link is `dims:` with the dimension that `over:` consumes +replaced by the one that `into:` produces: `[flow, snapshot]` above. The block +writes `at(coupling_lam, by=generator_of, over=generator, into=flow)` into that +row, so the weights stay on `dims:` and the model never names them. + +`by:`, `over:` and `into:` are written together. A walk states the relation, the +columns it consumes and the columns it produces, and none is defaulted. A link +whose row is finer than `dims:` is always a walk: a link that names only +`into:` is refused. + +A block whose only link walks a relation is a curve. Two links is what a curve +needs when a link is one row; a walked link is one row per fine coordinate, so +the relation supplies the second. + +**A walked row reads the block's `where:` through its relation.** The mask is +over `dims:` and the row is over the dimensions the walk produces, so the row +takes `at(, by=…, over=…, into=…)`, a +[predicate read through a relation](expressions.md#reading-a-predicate-through-a-relation). +Only some generators have a curve: + +```yaml +piecewise: + coupling: + along: bp + dims: [generator, snapshot] + where: has_curve # over generator: a generator with no curve has no weights and no rows + links: + power: { expression: power, values: bp_power, by: generator_of, over: generator, into: flow } +``` + +The `power` row is built where +`at(has_curve, by=generator_of, over=generator, into=flow)` holds, which is at +every flow of a generator with a curve. The values of a walked link are asked +for at the same rows, so a flow of a generator with no curve needs no row in +`bp_power`. A mask over dimensions the walk keeps, such as `snapshot` alone, +reaches the row as written. A mask that carries some of the dimensions the walk +reads through and not the others is refused, and the message names the ones +missing. + +| A walked link | | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| _over_ | names a column over a dimension of `dims:` | +| _into_ | names a column over a dimension that `dims:` does not carry, and that is not `along` | +| _values_ | follows the **link's** row: `bp_power` is per flow, not per generator | +| `where:` | on the block reaches the link's row read through the relation, or as written where the mask carries none of the dimensions the walk reads through | +| `method:` | `adjacency` or `sos2`. `lp` loses the abscissa its segment line is written against, and `convex` loses the pair of values parameters it reads a shape from | + +### Signs + +A link with no sign is **pinned** to the curve: its expression equals the +weighted breakpoints. A link carrying `<=` or `>=` is **bounded** by the curve +instead, and each link carries its own. + +**At least one link is pinned.** A pinned link fixes the operating point every +other link is read at. With every link bounded the weights are free, and the +block no longer says that its quantities sit together on a curve. It says only +that some point on the curve satisfies the bounds. That is a different model, +so it is refused. + +```yaml +piecewise: + chp: + along: bp + dims: [generator, snapshot] + links: + power: [power, power_bp] # pinned: it fixes the operating point + fuel: [fuel, fuel_bp, ">="] # bounded below by the curve + heat: [heat, heat_bp, "<="] # bounded above, at that same point +``` + +The typeset line prints this block as the point `(power, fuel, heat)` on the +curve plus `{0} × ℝ≥0 × ℝ≤0`: the pinned coordinate moves by nothing, and each +bounded one by the half-line its sign allows. A block with exactly two links +prints its bounded link as a function of the pinned one instead. + +`convex` and `lp` take exactly two links, so there a sign is one link's at +most. Under `adjacency` and `sos2` each link is its own row against the shared +weights, so the count is whatever the model needs. ### `method` @@ -133,38 +310,39 @@ the axis. A gap, or a curve with no points, is refused when the data binds. They differ in what the solver is handed: `adjacency` **is** `sos2` with the set written out, so the two emit the same rows under the same names. -`convex` is a different model: the weights range over the hull the breakpoints -span rather than over the curve itself. It takes exactly two links and no -`activity:`. +`convex` is a different model. It relaxes the weights onto the hull the +breakpoints span, which is exact only for a curve whose curvature matches the +optimisation pressure. That match is checked against the breakpoint values when +the data binds, and the sign on the bounded link is what names the direction to +check it in. So `convex` takes exactly two links: the rows it builds would serve +any number, but past two there is no single direction left to certify the +relaxation against. It takes no `activity:`. A bounded link binds from one side, and that side is the part of the hull the -weights are driven onto. `>=` requires a convex curve and `<=` a concave one. -With both links pinned the weights reach the whole hull. What drives them -within it is the rest of the model rather than the block, so the curve must -bend one way only. Each of the three conditions is checked against the -breakpoint values when the data binds. +weights are driven onto: `>=` requires a convex curve and `<=` a concave one. +With both links pinned the weights reach the whole hull, so the curve must bend +one way only. -`lp` states the curve as its segment lines. It needs **exactly two links**, one -of them bounded with `<=` or `>=`, and no `activity:`: +`lp` states the curve as its segment lines. It takes **exactly two links**, +because a line is one quantity against another: one link names the abscissa and +one is bounded by the lines. It takes no `activity:`: ```yaml piecewise: cost_curve: - over: bp + along: bp + dims: [generator] method: lp links: - - [p, bp_x] - - [op_cost, bp_y, ">="] # cost bounded below by the curve + p: [p, bp_x] + op_cost: [op_cost, bp_y, ">="] # cost bounded below by the curve ``` The bounded link decides the shape, as it does under `convex` above. The two -domain rows hold the pinned link inside the breakpoint range: under `points:`, -each sits where the mask holds and does not one breakpoint outward, which is -the first and the last breakpoint of each curve. - -`links:` is a list, so the number of expressions a block ties is written in the -file. Where that number is data, write the formulation out -([a curve by hand](../../howto/curve-by-hand.md)). +domain rows hold the pinned link inside the breakpoint range: under a `where:` +that reads the breakpoint dimension, each sits where the mask holds and does +not one breakpoint outward, which is the first and the last breakpoint of each +curve. ## `sos` @@ -257,8 +435,8 @@ a model before and after, as whole files. - **A model with nothing to write out is the model that comes back.** So is a second call with the same kinds. - **The same data binds a model and its expansion.** Neither a set nor a curve - emits a parameter. A curve under `points:` sits its rows on `where:` - predicates over the mask the file named, and the expansion is a file like any + emits a parameter. A curve of unequal lengths sits its rows on `where:` + predicates over the mask the file wrote, and the expansion is a file like any other: `to_yaml()` writes it, and loading it back changes nothing. - **`spec.program` writes nothing out.** The program mirrors the model: a curve the model still declares is under `program.piecewise`, typed, and diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 0187bc3d..32abe106 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -1127,10 +1127,11 @@ names: ```yaml economies_of_scale: - over: bp + along: bp + dims: [plant, market] links: - - [shipment, bp_x] - - [scaled, bp_y] + shipment: [shipment, bp_x] + scaled: [scaled, bp_y] ``` ```math @@ -1188,10 +1189,11 @@ names: ```yaml cost_curve: - over: bp + along: bp + dims: [snapshot, generator] links: - - [dispatch, bp_x] - - [op_cost, bp_y] + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y] method: sos2 ``` @@ -1242,10 +1244,11 @@ names: ```yaml cost_curve: - over: bp + along: bp + dims: [snapshot, generator] links: - - [dispatch, bp_x] - - [op_cost, bp_y] + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y] method: convex ``` @@ -1299,10 +1302,11 @@ names: ```yaml cost_curve: - over: bp + along: bp + dims: [snapshot, generator] links: - - [dispatch, bp_x] - - [op_cost, bp_y, ">="] + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y, ">="] method: lp ``` diff --git a/docs/reference/reading.md b/docs/reference/reading.md index 7cfc210e..f387585c 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -40,10 +40,11 @@ variables: bounds: { lower: 0 } piecewise: curve: - over: bp + along: bp + dims: [generator] links: - - [p, bp_x] - - [cost, bp_y, ">="] + p: [p, bp_x] + cost: [cost, bp_y, ">="] method: convex assumptions: cost_is_never_negative: @@ -67,7 +68,7 @@ sorted(program.constraints) # ['target'] sorted(program.piecewise) # ['curve'] rows = spec.expand('piecewise').program -sorted(rows.constraints) # ['curve_convexity', 'curve_link0', 'curve_link1', 'target'] +sorted(rows.constraints) # ['curve_convexity', 'curve_cost', 'curve_p', 'target'] sorted(rows.variables) # ['cost', 'curve_lam', 'p'] ``` @@ -110,7 +111,7 @@ rather than the curve: ```python sorted(spec.expand().variables) # ['cost', 'curve_lam', 'p'] -sorted(spec.expand().constraints) # ['curve_convexity', 'curve_link0', 'curve_link1', 'target'] +sorted(spec.expand().constraints) # ['curve_convexity', 'curve_cost', 'curve_p', 'target'] spec.expand() is spec.expand() # True ``` diff --git a/examples/piecewise.yaml b/examples/piecewise.yaml index 60953048..080611ec 100644 --- a/examples/piecewise.yaml +++ b/examples/piecewise.yaml @@ -49,10 +49,11 @@ piecewise: description: >- cost read off the generator's curve — convex, so the weights need no binaries to keep them on one segment - over: bp + along: bp + dims: [snapshot, generator] links: - - [dispatch, bp_x] - - [op_cost, bp_y] + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y] method: convex constraints: diff --git a/examples/piecewise_adjacency.yaml b/examples/piecewise_adjacency.yaml new file mode 100644 index 00000000..e769415e --- /dev/null +++ b/examples/piecewise_adjacency.yaml @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +description: >- + The same least-cost dispatch as `piecewise.yaml`, with a cost curve that is + not convex. The weights need binaries to hold them on one segment, which is + what the default method builds. + +dimensions: + snapshot: + description: dispatch periods + dtype: int + generator: + description: dispatchable units + dtype: str + bp: + description: breakpoints of the cost curve + dtype: int + +parameters: + capacity: + description: maximum dispatch + dims: [generator] + load: + description: demand to be met + dims: [snapshot] + bp_x: + description: breakpoint dispatch levels, one curve per generator + dims: [generator, bp] + bp_y: + description: cost at each breakpoint, one curve per generator + dims: [generator, bp] + +variables: + dispatch: + description: dispatched power + dims: [snapshot, generator] + bounds: + lower: 0 + upper: capacity + op_cost: + description: operating cost, piecewise-linear in dispatch + dims: [snapshot, generator] + bounds: + lower: 0 + +piecewise: + cost_curve: + description: >- + cost read off the generator's curve. The curve bends both ways, so + nothing but the restriction keeps the weights on one segment: a binary + per segment picks the one they may sit on + along: bp + dims: [snapshot, generator] + links: + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y] + method: adjacency + +constraints: + balance: + dims: [snapshot] + expression: sum(dispatch, over=generator) == load + +objective: + sense: minimize + description: total operating cost, taken off the curves rather than from a marginal rate + expression: sum(op_cost) diff --git a/examples/piecewise_coupling.yaml b/examples/piecewise_coupling.yaml new file mode 100644 index 00000000..70538754 --- /dev/null +++ b/examples/piecewise_coupling.yaml @@ -0,0 +1,109 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +description: >- + A heat and power system whose converters mix. A CHP unit runs on one + piecewise curve that ties all of its flows, and a boiler turns gas into heat + at a fixed ratio. The curve reads `has_curve` through `converter_of`, so a + boiler flow has no row on it, and the fixed-ratio row reads the same mask + negated. How many flows a converter ties is a row in `converter_of`, not a + line here. + +dimensions: + snapshot: + description: dispatch periods + dtype: int + converter: + description: units that turn one carrier into others + dtype: str + flow: + description: one carrier entering or leaving one converter + dtype: str + carrier: + description: what a flow carries — gas, heat, electricity + dtype: str + bp: + description: breakpoints of the operating curve + dtype: int + +relations: + converter_of: + description: which converter each flow belongs to + key: flow + values: converter + carrier_of: + description: what each flow carries + key: flow + values: carrier + +parameters: + demand: + description: what each carrier has to deliver in each period + dims: [carrier, snapshot] + price: + description: what a unit bought on the market costs + dims: [carrier] + has_curve: + description: whether a converter runs on an operating curve rather than at a fixed ratio + dims: [converter] + dtype: bool + bp_rate: + description: >- + the rate of each flow at each corner of its converter's curve — the + table that says a CHP's fuel, heat and power move together + dims: [flow, bp] + ratio: + description: the rate of each flow of a fixed-ratio converter per unit of the level it runs at + dims: [flow] + +variables: + rate: + description: >- + how much of each flow runs in each period, positive where the flow leaves + its converter and negative where it enters + dims: [flow, snapshot] + level: + description: how hard a converter with no curve runs + dims: [converter, snapshot] + where: NOT has_curve + bounds: + lower: 0 + bought: + description: what the market supplies where the converters do not + dims: [carrier, snapshot] + bounds: + lower: 0 + +piecewise: + operating_point: + description: >- + every flow of a converter with a curve is read off that curve at one + operating point, so the converter's flows move together + along: bp + dims: [converter, snapshot] + where: has_curve + links: + rate: + expression: rate + values: bp_rate + by: converter_of + over: converter + into: flow + method: sos2 + +constraints: + fixed_ratio: + description: every flow of a converter with no curve runs in proportion to that converter's level + dims: [flow, snapshot] + where: NOT at(has_curve, by=converter_of, over=converter, into=flow) + expression: rate == ratio * at(level, by=converter_of, over=converter, into=flow) + balance: + description: every carrier is delivered by the converters, or bought + dims: [carrier, snapshot] + expression: sum(rate, by=carrier_of, over=flow, into=carrier) + bought == demand + +objective: + sense: minimize + description: what the market supplies, priced by carrier + expression: sum(bought * price) diff --git a/examples/piecewise_lp.yaml b/examples/piecewise_lp.yaml index ff59a8c9..6a73f04d 100644 --- a/examples/piecewise_lp.yaml +++ b/examples/piecewise_lp.yaml @@ -54,10 +54,11 @@ piecewise: lines the cost sits on, and the curvature has to match it: lines that envelope a convex curve would cut a concave one, and the solve comes back optimal either way - over: bp + along: bp + dims: [snapshot, generator] links: - - [dispatch, bp_x] - - [op_cost, bp_y, ">="] + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y, ">="] method: lp constraints: diff --git a/examples/piecewise_ragged.yaml b/examples/piecewise_ragged.yaml index e2db0bed..9f98a46f 100644 --- a/examples/piecewise_ragged.yaml +++ b/examples/piecewise_ragged.yaml @@ -5,10 +5,10 @@ description: >- The same least-cost dispatch as `piecewise_lp.yaml`, with curves of different lengths. A large unit is metered at every breakpoint and a small one at the - first few, so the breakpoint table is ragged. `points:` is what says how far - each curve runs: without it every curve claims the whole axis, and a - breakpoint with no row is read as a zero rather than as a shorter curve — - which sits the curve on the origin instead of ending it. + first few, so the breakpoint table is ragged. A `where:` reading `bp` is what + says how far each curve runs: without it every curve claims the whole axis, + and a breakpoint with no row is read as a zero rather than as a shorter curve + — which sits the curve on the origin instead of ending it. dimensions: snapshot: @@ -61,11 +61,12 @@ piecewise: mask narrows every condition the method states: the breakpoints are counted over it, and the increasing and curvature tests are checked between admitted neighbours rather than along the whole axis. - over: bp - points: metered + along: bp + dims: [snapshot, generator] + where: metered links: - - [dispatch, bp_x] - - [op_cost, bp_y, ">="] + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y, ">="] method: lp constraints: diff --git a/examples/ports/transport_pwl.yaml b/examples/ports/transport_pwl.yaml index febb2e56..59aa7112 100644 --- a/examples/ports/transport_pwl.yaml +++ b/examples/ports/transport_pwl.yaml @@ -63,10 +63,11 @@ piecewise: chord underneath the true curve and buy transport cheaper than the model allows. The binaries are what make the answer right — and what make this port a MILP. - over: bp + along: bp + dims: [plant, market] links: - - [shipment, bp_x] - - [scaled, bp_y] + shipment: [shipment, bp_x] + scaled: [scaled, bp_y] constraints: within_capacity: diff --git a/examples/sos.yaml b/examples/sos.yaml index 94a2978e..69fbeaef 100644 --- a/examples/sos.yaml +++ b/examples/sos.yaml @@ -50,10 +50,11 @@ piecewise: cost read off the generator's curve, with at most two adjacent weights non-zero — the restriction the default method builds out of binaries, declared as a set instead - over: bp + along: bp + dims: [snapshot, generator] links: - - [dispatch, bp_x] - - [op_cost, bp_y] + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y] method: sos2 constraints: diff --git a/examples/symbols/piecewise_adjacency.yaml b/examples/symbols/piecewise_adjacency.yaml new file mode 100644 index 00000000..e406454d --- /dev/null +++ b/examples/symbols/piecewise_adjacency.yaml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +# The sidecar symbol table for `examples/piecewise_adjacency.yaml`, the same +# one `piecewise.yaml` carries plus the segment binary this method adds. Papers +# write the convex-combination weight as lambda and the binary that admits a +# segment as z, and the adjacency row names both in one line. +notation: latex + +names: + cost_curve_lam: "\\lambda" + cost_curve_seg: "z" + bp_x: "\\mathrm{x}" + bp_y: "\\mathrm{y}" diff --git a/examples/symbols/piecewise_coupling.yaml b/examples/symbols/piecewise_coupling.yaml new file mode 100644 index 00000000..9adf77b6 --- /dev/null +++ b/examples/symbols/piecewise_coupling.yaml @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +# The sidecar symbol table for `examples/piecewise_coupling.yaml`. The weights +# are named after the block that declared them, which reads well in the file +# and badly in an equation that carries the name twice with a relation applied +# to its first index. Papers write the convex-combination weight as lambda. + +notation: latex + +names: + operating_point_lam: "\\lambda" + bp_rate: "\\mathrm{r}" diff --git a/mkdocs.yml b/mkdocs.yml index 5fc61277..2a5541d9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -40,7 +40,6 @@ nav: - State a rule that differs by regime: howto/regimes.md - Declare a column of data: howto/declare-a-column.md - Fix a quantity that is data in one model and a decision in another: howto/pin-a-variable.md - - Write a piecewise curve out by hand: howto/curve-by-hand.md - See what a curve or a set expands to: howto/see-an-expansion.md - Reference: - Language: @@ -64,6 +63,10 @@ nav: - Least-cost dispatch: examples/dispatch.md - Unit commitment: examples/commitment.md - One construct per model: examples/operators.md + - A curve by convex combination: examples/piecewise.md + - A curve that is not convex: examples/piecewise_adjacency.md + - A curve as a special-ordered set: examples/sos.md + - A curve as segment lines: examples/piecewise_lp.md - PyPSA in one file: examples/pypsa.md - PyPSA, the quadratic class: examples/pypsa_quadratic.md - PyPSA, the relaxed commitment: examples/pypsa_linearized_uc.md diff --git a/schema/math-spec.schema.json b/schema/math-spec.schema.json index 99300c13..2c1e7d4c 100644 --- a/schema/math-spec.schema.json +++ b/schema/math-spec.schema.json @@ -384,7 +384,7 @@ }, "PiecewiseBlock": { "additionalProperties": false, - "description": "N expressions jointly pinned to a breakpoint-indexed piecewise curve.\n\nMirrors ``linopy.Spec.add_piecewise_formulation``. Each link is\n``[expression, values_parameter]`` or ``[expression, values_parameter,\nsign]``: *expression* is any affine expression string, *values_parameter*\nnames a parameter carrying the ``over`` dim, and *sign* bounds the link by\nthe curve instead of pinning it (at most one non-``\"==\"``, and only with\nexactly two links).", + "description": "Expressions tied to one breakpoint-indexed piecewise curve per coordinate of ``dims:``.\n\nMirrors ``linopy.Spec.add_piecewise_formulation``. ``links:`` maps a name\nto ``[expression, values_parameter]`` or ``[expression, values_parameter,\nsign]``: *expression* is any affine expression string over ``dims:``,\n*values_parameter* names a parameter carrying ``along`` and no dim the\nlink's row lacks, and *sign* bounds the link by the curve instead of\npinning it. The name is the\nlink's row in the expansion, ``_``.\n\n``dims:`` alone decides how many curves the block builds: one set of\nweights per coordinate of it. ``where:`` says which of those coordinates\nhave a curve, and how far each runs along ``along`` where it reads that\ndim too; ``activity:`` whether a curve that exists is switched on. A link\nthat walks a relation builds a row per fine coordinate, each reading the\none curve its coarse coordinate has, so how many expressions a curve ties\nis data.", "properties": { "activity": { "anyOf": [ @@ -398,6 +398,10 @@ "default": null, "title": "Activity" }, + "along": { + "title": "Along", + "type": "string" + }, "description": { "anyOf": [ { @@ -410,12 +414,19 @@ "default": null, "title": "Description" }, - "links": { + "dims": { "items": { + "type": "string" + }, + "title": "Dims", + "type": "array" + }, + "links": { + "additionalProperties": { "$ref": "#/$defs/PiecewiseLink" }, "title": "Links", - "type": "array" + "type": "object" }, "method": { "default": "adjacency", @@ -428,11 +439,7 @@ "title": "Method", "type": "string" }, - "over": { - "title": "Over", - "type": "string" - }, - "points": { + "where": { "anyOf": [ { "type": "string" @@ -442,11 +449,12 @@ } ], "default": null, - "title": "Points" + "title": "Where" } }, "required": [ - "over", + "along", + "dims", "links" ], "title": "PiecewiseBlock", @@ -456,12 +464,60 @@ "anyOf": [ { "additionalProperties": false, - "description": "One link of a piecewise block: an expression pinned to a values curve.\n\nWritten in YAML as ``[expression, values]`` or ``[expression, values,\nsign]`` and serialised back to exactly that form, so a round trip through\n:meth:`Spec.to_yaml` reproduces the file.", + "description": "One link of a piecewise block: an expression tied to the curve through a values parameter.\n\nWritten in YAML as ``[expression, values]`` or ``[expression, values,\nsign]``, and serialised back to exactly that form, so a round trip through\n:meth:`Spec.to_yaml` reproduces the file.\n\nA link that names ``by:``, ``over:`` and ``into:`` reads the curve's\nweights through a relation, as :func:`at` reads an array, and is written as\na mapping. Its row is the block's ``dims:`` with the consumed columns'\ndims replaced by the produced ones, so one link emits a row per fine\ncoordinate and every one of them reads the curve of the coarse coordinate\nit maps to. That is what lets one curve tie as many expressions as the\ndata says.", "properties": { + "by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "By" + }, "expression": { "title": "Expression", "type": "string" }, + "into": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Into" + }, + "over": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Over" + }, "sign": { "default": "==", "enum": [ diff --git a/src/math_spec/lowering.py b/src/math_spec/lowering.py index d637b712..8b7e8ced 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -20,7 +20,7 @@ from math_spec.dimensions import check_schema, dims_of from math_spec.errors import SchemaError, prefixed from math_spec.expansion import expand, parse_template -from math_spec.piecewise import assumptions_of, curve_frame, lp_domain_refusal, resolve_links +from math_spec.piecewise import assumptions_of, declaration_of, lp_domain_refusal, resolve_links from math_spec.program import ( Assumption, BooleanLiteral, @@ -29,13 +29,11 @@ ConstraintDeclaration, DimensionDeclaration, ExpressionDeclaration, - Link, Mask, Named, ObjectiveDeclaration, Parameter, ParameterDeclaration, - PiecewiseDeclaration, Program, SosDeclaration, VariableDeclaration, @@ -78,11 +76,12 @@ def lower(schema: Spec) -> Program: curve as lowered; - every dim rule (``dimensions.check_schema``), once names resolve. - A ``piecewise:`` block's links are resolved and its frame checked here, on - the link the file wrote, so the expansion writes rows the language has - already held to every rule; what its method assumes of the breakpoints - stands under the program's assumptions with the file's own, so a model - states what it assumes whether or not its curves are written out. + A ``piecewise:`` block's links and ``where:`` are resolved here, and each + link's row and its fit decided, on the link the file wrote, so the + expansion writes rows the language has already held to every rule; what + its method assumes of the breakpoints stands under the program's + assumptions with the file's own, so a model states what it assumes whether + or not its curves are written out. Returns: The program of what *schema* declares, section for section. @@ -157,14 +156,15 @@ def lower(schema: Spec) -> Program: if (assumption := _assumption(aname, adef, ns, errors)) is not None: assumptions[aname] = assumption - curves: dict[str, tuple[Expression, ...]] = {} + curves: dict[str, tuple[tuple[Expression, ...], Mask | None]] = {} for pname, pdef in schema.piecewise.items(): links = resolve_links(pname, pdef, ns, errors) + where = mask_of(resolve_where_text(pdef.where, ns, f"piecewise '{pname}' where", errors)) if links is None: continue if pdef.method == 'lp' and (refusal := lp_domain_refusal(pname, pdef, links)) is not None: errors.append(refusal) - curves[pname] = links + curves[pname] = (links, where) if errors: raise SchemaError('\n'.join(errors)) @@ -172,22 +172,14 @@ def lower(schema: Spec) -> Program: roots = [side for c in constraints.values() for side in (c.lhs, c.rhs)] if objective is not None: roots.append(objective.expression) - roots.extend(link for links in curves.values() for link in links) + roots.extend(link for links, _ in curves.values() for link in links) in_math = frozenset(node.name for node in walk(*roots) if isinstance(node, Named)) piecewise = {} - for pname, links in curves.items(): + for pname, (links, where) in curves.items(): pdef = schema.piecewise[pname] - piecewise[pname] = PiecewiseDeclaration( - over=pdef.over, - links=tuple(Link(node, link.values, link.sign) for node, link in zip(links, pdef.links, strict=True)), - method=pdef.method, - frame=curve_frame(schema, pname, pdef, links), - activity=pdef.activity, - points=pdef.points, - description=pdef.description, - ) - for aname, assumed in assumptions_of(pname, piecewise[pname]).items(): + piecewise[pname] = declaration_of(schema, pname, pdef, links, where) + for aname, assumed in assumptions_of(pname, piecewise[pname], pdef.where).items(): assumption = _assumption(aname, assumed, ns, errors) assert assumption is not None and not errors, 'what a method assumes is stated in the language' assumptions[aname] = assumption diff --git a/src/math_spec/model.py b/src/math_spec/model.py index efc11f3c..ea3b893a 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -498,11 +498,19 @@ def _as_written(self) -> str | dict[str, object]: class PiecewiseLink(_StrictBlock): - """One link of a piecewise block: an expression pinned to a values curve. + """One link of a piecewise block: an expression tied to the curve through a values parameter. Written in YAML as ``[expression, values]`` or ``[expression, values, - sign]`` and serialised back to exactly that form, so a round trip through + sign]``, and serialised back to exactly that form, so a round trip through :meth:`Spec.to_yaml` reproduces the file. + + A link that names ``by:``, ``over:`` and ``into:`` reads the curve's + weights through a relation, as :func:`at` reads an array, and is written as + a mapping. Its row is the block's ``dims:`` with the consumed columns' + dims replaced by the produced ones, so one link emits a row per fine + coordinate and every one of them reads the curve of the coarse coordinate + it maps to. That is what lets one curve tie as many expressions as the + data says. """ _label: ClassVar[str] = 'a piecewise link' @@ -510,6 +518,29 @@ class PiecewiseLink(_StrictBlock): expression: str values: str sign: ComparisonOperator = '==' + #: The relation the link reads the curve's weights through, where it walks one. + by: str | None = None + #: The relation columns the walk consumes, over the block's own dims. + over: str | list[str] | None = None + #: The relation columns the walk produces, which index the link's rows. + into: str | list[str] | None = None + + @property + def walks(self) -> bool: + """Whether the link reads the curve's weights through a relation, rather than on the block's own dims.""" + return self.by is not None + + @model_validator(mode='after') + def _check_walk(self) -> PiecewiseLink: + written = {'by': self.by, 'over': self.over, 'into': self.into} + if (missing := [k for k, v in written.items() if v is None]) and len(missing) < len(written): + msg = ( + f'a link reads the curve through a relation with by, over and into together — {missing} ' + f'{"is" if len(missing) == 1 else "are"} missing. A walk states the relation, the columns it ' + f'consumes and the columns it produces, as at() does; none is defaulted.' + ) + raise ValueError(msg) + return self @model_validator(mode='before') @classmethod @@ -529,8 +560,19 @@ def __get_pydantic_json_schema__(cls, core_schema: CoreSchema, handler: GetJsonS return _also_written_as(core_schema, handler, list_form) @model_serializer - def _as_list(self) -> list[str]: - return [self.expression, self.values] if self.sign == '==' else [self.expression, self.values, self.sign] + def _as_written(self) -> list[str] | dict[str, str | list[str]]: + """The list form, or the mapping form a walk cannot be written in a list.""" + if not self.walks: + return [self.expression, self.values] if self.sign == '==' else [self.expression, self.values, self.sign] + assert self.by is not None and self.over is not None and self.into is not None + written: dict[str, str | list[str]] = { + 'expression': self.expression, + 'values': self.values, + 'by': self.by, + 'over': self.over, + 'into': self.into, + } + return written if self.sign == '==' else {**written, 'sign': self.sign} #: How a ``piecewise:`` block restricts its interpolation weights, and what @@ -547,44 +589,97 @@ def _as_list(self) -> list[str]: } +#: Why ``convex`` and ``lp`` take exactly two links. The two reasons are not +#: one: ``lp`` needs an abscissa to write a line *against*, and ``convex`` +#: needs one to certify its relaxation against. ``convex``'s own formulation +#: is n-ary — weights on the simplex and a row per link — and only its +#: exactness argument is not. +_TWO_LINKS = { + 'lp': ( + 'It states the curve as its segment lines, and a line is one quantity against another: without ' + 'a link for the abscissa there is no line to write.' + ), + 'convex': ( + 'It relaxes the weights onto the hull, which is exact only where the optimisation pressure meets ' + 'the curve, and the sign on the bounded link is what names that direction. Past two links there ' + 'is no single direction to check against, so the relaxation would ship uncertified.' + ), +} + +#: Why neither takes a link that walks a relation. Again two reasons: ``lp`` +#: cannot tell which of the walked rows is its abscissa, and ``convex`` has no +#: pair of values parameters on one frame to read a shape from. +_NO_WALK = { + 'lp': ( + 'Its segment line is written against an abscissa, and a walked link is one quantity at many fine ' + 'coordinates, so which row plays it is data rather than declaration.' + ), + 'convex': ( + 'It reads one values parameter against another to certify its relaxation, and a walk puts them ' + 'on different frames, so there is no shape left to check.' + ), +} + + class PiecewiseBlock(_StrictBlock): - """N expressions jointly pinned to a breakpoint-indexed piecewise curve. - - Mirrors ``linopy.Spec.add_piecewise_formulation``. Each link is - ``[expression, values_parameter]`` or ``[expression, values_parameter, - sign]``: *expression* is any affine expression string, *values_parameter* - names a parameter carrying the ``over`` dim, and *sign* bounds the link by - the curve instead of pinning it (at most one non-``"=="``, and only with - exactly two links). + """Expressions tied to one breakpoint-indexed piecewise curve per coordinate of ``dims:``. + + Mirrors ``linopy.Spec.add_piecewise_formulation``. ``links:`` maps a name + to ``[expression, values_parameter]`` or ``[expression, values_parameter, + sign]``: *expression* is any affine expression string over ``dims:``, + *values_parameter* names a parameter carrying ``along`` and no dim the + link's row lacks, and *sign* bounds the link by the curve instead of + pinning it. The name is the + link's row in the expansion, ``_``. + + ``dims:`` alone decides how many curves the block builds: one set of + weights per coordinate of it. ``where:`` says which of those coordinates + have a curve, and how far each runs along ``along`` where it reads that + dim too; ``activity:`` whether a curve that exists is switched on. A link + that walks a relation builds a row per fine coordinate, each reading the + one curve its coarse coordinate has, so how many expressions a curve ties + is data. """ _label: ClassVar[str] = 'a piecewise declaration' - #: The breakpoint dimension. - over: str - links: list[PiecewiseLink] + #: The dimension each curve runs along — its breakpoints, in that dimension's declared order. + along: str + #: The curve's frame — one curve per coordinate of it. + dims: list[str] + #: Each link by the name its row takes, ``_``. + links: dict[str, PiecewiseLink] + #: Which coordinates have a curve — none builds one everywhere. Over the + #: frame it says which curves exist; reading ``along`` too, it says which + #: breakpoints each runs through, for curves of unequal length. + where: str | None = None #: Which of :data:`PIECEWISE_METHODS` restricts the weights. method: PiecewiseMethod = 'adjacency' #: What the weights sum to — 1 where absent, or a binary that pins the formulation to 0 when it is 0. activity: str | None = None - #: A boolean parameter saying how far each curve runs, for curves of unequal length. - points: str | None = None description: str | None = None - @property - def nominated(self) -> str | None: - """The block's own values parameter ``points:`` names, so the mask is derived from it — or ``None``.""" - return self.points if self.points in {link.values for link in self.links} else None - @property def curve(self) -> tuple[PiecewiseLink, PiecewiseLink]: """The two links as ``(x, y)``, the bounded one last. Two-link blocks only. """ - x, y = self.links + x, y = self.links.values() return (y, x) if x.sign != '==' else (x, y) + @model_validator(mode='before') + @classmethod + def _check_dims(cls, data: object) -> object: + if isinstance(data, dict) and data.get('dims') is None: + msg = ( + 'a piecewise block states dims:, the dimensions it builds one curve per coordinate of — as a ' + 'variable or a constraint states the coordinates it has. Write the dims its links are over, ' + 'less any a link walks into: dims: [generator, snapshot] for a link on p over [generator, snapshot].' + ) + raise ValueError(msg) + return data + @field_validator('method', mode='wrap') @classmethod def _check_method(cls, v: object, handler: ValidatorFunctionWrapHandler) -> PiecewiseMethod: @@ -597,13 +692,22 @@ def _check_method(cls, v: object, handler: ValidatorFunctionWrapHandler) -> Piec @model_validator(mode='after') def _check_method_shape(self) -> PiecewiseBlock: - if self.method == 'convex' and len(self.links) != 2: + walked = [key for key, link in self.links.items() if link.walks] + if walked and self.method in ('convex', 'lp'): msg = ( - 'method: convex requires exactly two links (the hull relaxation ' - 'is only well-defined for a single y=f(x) curve).' + f"method: {self.method} does not take a link that walks a relation (link '{walked[0]}'). " + f'{_NO_WALK[self.method]} Use method: adjacency or sos2, which state the curve through its ' + f'weights instead.' ) raise ValueError(msg) - if self.method == 'lp' and sum(link.sign != '==' for link in self.links) != 1: + if self.method in ('convex', 'lp') and len(self.links) != 2: + msg = ( + f'method: {self.method} requires exactly two links. {_TWO_LINKS[self.method]} Use method: ' + f'adjacency or sos2, which state the curve through its weights and tie as many links as ' + f'the data says.' + ) + raise ValueError(msg) + if self.method == 'lp' and sum(link.sign != '==' for link in self.links.values()) != 1: msg = ( "method: lp needs exactly one link bounded by the curve — a '<=' or '>=' third " 'element on it. With every link pinned the segment lines have nothing to bound.' @@ -616,16 +720,27 @@ def _check_method_shape(self) -> PiecewiseBlock: @field_validator('links') @classmethod - def _check_links(cls, v: list[PiecewiseLink]) -> list[PiecewiseLink]: - if len(v) < 2: - msg = 'piecewise needs at least two links ([expression, values, sign?]).' + def _check_links(cls, v: dict[str, PiecewiseLink]) -> dict[str, PiecewiseLink]: + if unnamed := [key for key in v if not re.fullmatch(NAME, key)]: + msg = ( + f'links: {unnamed} {"is" if len(unnamed) == 1 else "are"} not a name. A link names the row it ' + f'emits, _, so it is named the way a declaration is — a letter or an underscore, ' + f'then letters, digits or underscores.' + ) raise ValueError(msg) - non_eq = [link.sign for link in v if link.sign != '=='] - if len(non_eq) > 1: - msg = "at most one link may carry a non-'==' sign." + if len(v) < 2 and not any(link.walks for link in v.values()): + msg = ( + 'piecewise needs at least two links ([expression, values, sign?]). One quantity on a curve is ' + 'a bound rather than a curve — a curve ties two or more through shared weights. A single link ' + 'that walks a relation is enough, because how many rows it builds is data.' + ) raise ValueError(msg) - if non_eq and len(v) != 2: - msg = "a non-'==' sign is only supported with exactly two links." + if all(link.sign != '==' for link in v.values()): + msg = ( + 'every link is bounded by the curve, so nothing pins the operating point they are read ' + 'at. The weights are then free, and the block states only that some point on the curve ' + "satisfies the bounds. Pin at least one link with '=='." + ) raise ValueError(msg) return v @@ -652,6 +767,7 @@ class SosBlock(_StrictBlock): _label: ClassVar[str] = 'a sos declaration' variable: str + #: The dimension the set runs along — one set per coordinate of the rest. along: str type: SosType description: str | None = None diff --git a/src/math_spec/piecewise.py b/src/math_spec/piecewise.py index 7bd122b9..d456bfc7 100644 --- a/src/math_spec/piecewise.py +++ b/src/math_spec/piecewise.py @@ -7,36 +7,110 @@ A block becomes ordinary affine declarations before anything reads the model, under names prefixed with the block's own; what each method emits is tabled in ``docs/reference/language/piecewise.md``. Every rule a block is held to is -decided at load, before this runs: the names it references in -:class:`~math_spec.model.Spec`, its links where every expression is typed, and -its frame in :func:`curve_frame`. +decided as the model loads, before its rows are written: the names it +references in :func:`~math_spec.validation.reference_errors`, its links and +its ``where:`` as lowering types them, and the fit of each link's row to its +expression, its values and the mask in :func:`declaration_of`. A refusal names +the link or key the file wrote rather than an emitted declaration. """ from __future__ import annotations +import re from dataclasses import dataclass from typing import TYPE_CHECKING, Literal import math_spec.sos as sos +from math_spec._expression_parser import NAME from math_spec.dimensions import dims_of from math_spec.errors import DimensionError -from math_spec.model import AssumptionBlock, Curvature, PiecewiseBlock, Spec -from math_spec.program import PiecewiseDeclaration, PiecewiseMethod, carries_variable +from math_spec.model import AssumptionBlock, Curvature, PiecewiseBlock, PiecewiseLink, Spec +from math_spec.program import Link, PiecewiseDeclaration, PiecewiseMethod, carries_variable from math_spec.resolution import resolve_expression_text if TYPE_CHECKING: from collections.abc import Iterable - from math_spec.program import Expression + from math_spec.program import Expression, Mask from math_spec.resolution import Namespace -#: The suffix on the second gate row, where the gate variable does not exist. -_UNGATED = '_ungated' +# --------------------------------------------------------------------------- +# the mask, as each shape of row reads it +# --------------------------------------------------------------------------- + + +def _masks(where: str | None, along: str, *, ragged: bool) -> tuple[str | None, str | None, str | None]: + """The block's ``where:`` as three shapes of row read it: ``(mask, frame, exists)``. + + A **ragged** where reads the breakpoint dim, so it says how far each curve + runs: a row over the frame and that dim takes it as written (*mask*), and + a row over the frame alone, which cannot read that dim, takes the count of + breakpoints it admits (*exists*). A where over the frame alone says which + curves exist: every row conjoins it as written (*frame*, and *exists*), + and no row is ragged (*mask* is ``None``). + """ + if ragged: + return where, None, f'count({where}, over={along}) > 0' + return None, where, where + + +def _all_of(*clauses: str | None) -> str | None: + """The where admitting a row only where every clause given does, or ``None`` where none of them speaks. + + A lone clause passes through as it was written, so a block with no + ``where:`` emits exactly the string it always did. Joined clauses are + parenthesised, because a disjunction inside one of them would otherwise + bind only its last operand to the ``AND``. + """ + kept = [clause for clause in clauses if clause] + if len(kept) <= 1: + return kept[0] if kept else None + return ' AND '.join(f'({clause})' for clause in kept) + + +def _operand(mask: str) -> str: + """The mask as one operand of a connective: a bare name as it is, anything else parenthesised.""" + return mask if re.fullmatch(NAME, mask) else f'({mask})' -def _curvature_required(pw: PiecewiseDeclaration) -> Curvature | None: - """The curvature *pw*'s method is only exact for, or ``None`` if any shape works. +def _shifted(over: str, mask: str, offset: int) -> str: + """*mask* read *offset* breakpoints back, false where that vacates.""" + return f'shift({mask}, along={over}, offset={offset})' + + +def _neighbours(over: str, mask: str | None) -> str: + """Where a breakpoint and the one before it are both there: the rows a claim about a segment is true of.""" + if mask is None: + return f'position({over}) > 0' + return f'{_operand(mask)} AND {_shifted(over, mask, 1)}' + + +def _edge(over: str, mask: str | None, end: Literal['first', 'last']) -> str: + """The first or last breakpoint of each curve: where the mask holds and does not one step outward. + + The vacated edge of a ``shift`` in a ``where`` is false, which is what + makes the head and the tail of the axis their own edge. + """ + if mask is None: + return f'position({over}) == {0 if end == "first" else -1}' + return f'{_operand(mask)} AND NOT {_shifted(over, mask, 1 if end == "first" else -1)}' + + +def _interior(over: str, mask: str | None) -> str: + """Where a breakpoint has one on either side: the rows a claim about a bend is true of.""" + if mask is None: + return f'position({over}) > 0 AND position({over}) != -1' + return f'{_operand(mask)} AND {_shifted(over, mask, 1)} AND {_shifted(over, mask, -1)}' + + +# --------------------------------------------------------------------------- +# what a block assumes of its numbers +# --------------------------------------------------------------------------- + + +def _curvature_required(curve: PiecewiseDeclaration) -> Curvature | None: + """The curvature *curve*'s method is only exact for, or ``None`` if any shape works. A bounded link binds from one side, and that side is the hull boundary the weights are driven onto: ``>=`` reaches the lower one, which is the curve @@ -50,104 +124,87 @@ def _curvature_required(pw: PiecewiseDeclaration) -> Curvature | None: most a method states is ``'either'``: a mixed curve is wrong whichever way the pressure runs, and a single bend is exact one of the two ways. """ - if pw.method not in ('convex', 'lp'): + if curve.method not in ('convex', 'lp'): return None - if (sign := pw.curve[1].sign) == '==': + if (sign := curve.curve[1].sign) == '==': return 'either' return 'convex' if sign == '>=' else 'concave' -def resolve_links(name: str, pw: PiecewiseBlock, ns: Namespace, errors: list[str]) -> tuple[Expression, ...] | None: - """Block *name*'s link expressions typed, in link order, or ``None`` once one failed, its refusal appended. - - A link is read affinely, so it is held to degree 1 where it is read. - """ - links = [ - resolve_expression_text(link.expression, ns, f"piecewise '{name}' link {i}", errors, ceiling=1) - for i, link in enumerate(pw.links) - ] - if any(link is None for link in links): - return None - return tuple(link for link in links if link is not None) - - -def lp_domain_refusal(name: str, pw: PiecewiseBlock, links: tuple[Expression, ...]) -> str | None: - """The refusal for a ``method: lp`` curve whose x-link carries no variable, or ``None``. - - The method bounds the curve's domain with two rows comparing the x-link - against the first and the last breakpoint, and a row with no variable - decides nothing. Decided on the link the file wrote, rather than on the - row the expansion would write under a name the file never declared. - """ - x = pw.curve[0] - i = next(i for i, link in enumerate(pw.links) if link is x) - if carries_variable(links[i]): - return None - return ( - f"piecewise '{name}' link {i}: method: lp bounds the curve's domain by rows comparing this link's expression " - f'against its first and last breakpoint, and {x.expression!r} carries no variable, so those rows decide ' - f'nothing. Name a variable in the link, or use method: convex, sos2 or adjacency, whose weights pin the ' - f'domain themselves.' - ) - - -def assumptions_of(block: str, pw: PiecewiseDeclaration) -> dict[str, AssumptionBlock]: - """What *block* assumes of its numbers, by the name the document prints and a refusal quotes. +def assumptions_of(name: str, curve: PiecewiseDeclaration, where: str | None) -> dict[str, AssumptionBlock]: + """What block *name* assumes of its numbers, by the name the document prints and a refusal quotes. Every curve assumes its breakpoints are there: a missing parameter row is not absence, it is a zero, so an undeclared breakpoint sits the curve on - the origin rather than shortening it. A curve has an x-axis only where two - links tie it, so the increasing condition — and the shape it is checked - with — exist only there; ``lp`` alone needs a segment to state a line for; - a mask must be one run. - - Read off the block rather than off an expansion, so a model states what it - assumes whether or not its curves have been written out. Each condition is - an ``assumptions:`` entry over the parameters the file declared, its - ``description`` naming the method and the rewrite that takes a curve of any - shape: the expansion writes them into the model, and a model that still - declares the block resolves the same entries at load. + the origin rather than shortening it. A walked link's breakpoints are over + its own rows, so each is asked of the rows that link reads the curve at, + under a name of its own. A curve has an x-axis only where two links tie + it, so the increasing condition — and the shape it is checked with — + exist only there; ``lp`` alone needs a segment to state a line for; a + ragged ``where:`` must mark one run. + + Each condition is a where string over the parameters the file declared, + so *where* is the block's ``where:`` as the file wrote it — a typed mask + has no text — and *curve* says how each row reads it. The expansion + writes the conditions into ``assumptions:``, and a model that still + declares the block derives the same text at load. Each is asked only + where a curve runs, so the ``where:`` goes into every one of them: a + model written out and read back holds the data to what the block did. """ - d, mask = pw.over, pw.points - assumed: dict[str, AssumptionBlock] = {} - assumed[f'{block}_complete'] = AssumptionBlock( - holds=' AND '.join(dict.fromkeys(link.values for link in pw.links)), - where=mask, - description=f"piecewise '{block}': every breakpoint the curve runs through needs a row in " - f'{_quoted(link.values for link in pw.links)} — a missing row is read as a zero rather than as a ' - f'shorter curve, so it sits the curve on the origin. ' - + ( - f"Bind the rows, or narrow points: '{mask}' to where the curve runs." - if mask is not None - else 'Bind the rows, or declare points: to say how far the curve runs.' - ), + d = curve.along + mask, frame, exists = _masks(where, d, ragged=curve.ragged) + rewrite = ( + f'Bind the rows, or narrow where: {mask!r} to where the curve runs.' + if mask is not None + else 'Bind the rows, or declare where: to say how far the curve runs.' ) - curvature = _curvature_required(pw) + assumed: dict[str, AssumptionBlock] = {} + if values := [link.values for link in curve.links if not link.walks]: + assumed[f'{name}_complete'] = AssumptionBlock( + holds=' AND '.join(dict.fromkeys(values)), + where=where, + description=f"piecewise '{name}': every breakpoint the curve runs through needs a row in " + f'{_quoted(values)} — a missing row is read as a zero rather than as a shorter curve, so it sits ' + f'the curve on the origin. {rewrite}', + ) + for link in curve.links: + if link.walks: + assumed[f'{name}_{link.name}_complete'] = AssumptionBlock( + holds=link.values, + where=through(where, link) if link.reads else _all_of(link.by, where), + description=f"piecewise '{name}' link '{link.name}': every breakpoint the curve runs through needs a " + f"row in '{link.values}' at every row the link reads the curve at — a missing row is read as a zero " + f'rather than as a shorter curve, so it sits that row on the origin. {rewrite}', + ) + curvature = _curvature_required(curve) if curvature is not None: - x, y = (link.values for link in pw.curve) - assumed[f'{block}_increasing'] = AssumptionBlock( + x, y = (link.values for link in curve.curve) + assumed[f'{name}_increasing'] = AssumptionBlock( holds=f'{_back(x, d, 1)} < {x}', - where=_neighbours(d, mask), - description=f"piecewise '{block}': method: {pw.method} requires strictly increasing breakpoints in '{x}' along '{d}'", + where=_all_of(frame, _neighbours(d, mask)), + description=f"piecewise '{name}': method: {curve.method} requires strictly increasing breakpoints " + f"in '{x}' along '{d}'", ) - assumed[f'{block}_curvature'] = _bends(block, pw, x, y, curvature) - if pw.method == 'lp': - assumed[f'{block}_breakpoints'] = AssumptionBlock( - holds=f'count({mask or pw.curve[0].values}, over={d}) >= 2', - description=f"piecewise '{block}': method: lp needs at least two breakpoints per curve — the method *is* its " - f'segment lines, so a curve with no segment states nothing and leaves the bounded link on its own ' - f'bound. Use method: adjacency, sos2 or convex, which pin it to the points it does have.', + assumed[f'{name}_curvature'] = _bends(name, curve, x, y, curvature, mask=mask, frame=frame, exists=exists) + if curve.method == 'lp': + assumed[f'{name}_breakpoints'] = AssumptionBlock( + holds=f'count({mask or curve.curve[0].values}, over={d}) >= 2', + where=exists, + description=f"piecewise '{name}': method: lp needs at least two breakpoints per curve — the method " + f'*is* its segment lines, so a curve with no segment states nothing and leaves the bounded link on ' + f'its own bound. Use method: adjacency, sos2 or convex, which pin it to the points it does have.', ) if mask is not None: - assumed[f'{block}_contiguous'] = AssumptionBlock( + assumed[f'{name}_contiguous'] = AssumptionBlock( holds=f'count({_edge(d, mask, "first")}, over={d}) == 1', - description=f"piecewise '{block}': points: '{mask}' must mark a consecutive run of at least one breakpoint per " - f'curve — {_GAP[pw.method]}.', + where=exists, + description=f"piecewise '{name}': where: {mask!r} must mark a consecutive run of at least one " + f'breakpoint per curve — {_GAP[curve.method]}.', ) return assumed -#: Why a gap in ``points:`` breaks each method, in the rows that method writes. +#: Why a gap in a ragged ``where:`` breaks each method, in the rows that method writes. _GAP: dict[PiecewiseMethod, str] = { 'adjacency': 'the weights are nonzero only on two neighbouring breakpoints, and a gap leaves no neighbour across it', 'sos2': 'the weights are nonzero only on two neighbouring breakpoints, and a gap leaves no neighbour across it', @@ -157,13 +214,25 @@ def assumptions_of(block: str, pw: PiecewiseDeclaration) -> dict[str, Assumption } +#: What a block may assume of its numbers, by suffix — the names +#: :func:`assumptions_of` writes, reserved whether or not the method states each. +_ASSUMED = ('complete', 'increasing', 'curvature', 'breakpoints', 'contiguous') + + +def through(text: str | None, link: Link) -> str | None: + """*text* as *link*'s row reads it: through the link's relation where the row reads the where so, else as written.""" + if text is None or not link.reads: + return text + return f'at({text}, by={link.by}, over={_columns(link.over)}, into={_columns(link.into)})' + + def _quoted(names: Iterable[str]) -> str: """Parameter names as a refusal lists them, in link order and without repeats.""" return ', '.join(f"'{name}'" for name in dict.fromkeys(names)) def _back(parameter: str, over: str, offset: int) -> str: - """One breakpoint along *over* from here, the vacated row filled with zero and excluded by the ``where``. + """*parameter* read *offset* breakpoints back, the vacated row filled with zero and excluded by the ``where``. ``edge=0`` is what the language admits over data, and the mask beside it is what keeps the invented zero from ever being read. @@ -171,32 +240,17 @@ def _back(parameter: str, over: str, offset: int) -> str: return f'shift({parameter}, along={over}, offset={offset}, edge=0)' -def _neighbours(over: str, mask: str | None) -> str: - """Where a breakpoint and the one before it are both there: the rows a claim about a segment is true of.""" - if mask is None: - return f'position({over}) > 0' - return f'{mask} AND shift({mask}, along={over}, offset=1)' - - -def _edge(over: str, mask: str | None, end: Literal['first', 'last']) -> str: - """The first or last breakpoint of each curve: where the mask holds and does not one step outward. - - The vacated edge of a ``shift`` in a ``where`` is false, which is what - makes the head and the tail of the axis their own edge. - """ - if mask is None: - return f'position({over}) == {0 if end == "first" else -1}' - return f'{mask} AND NOT shift({mask}, along={over}, offset={1 if end == "first" else -1})' - - -def _interior(over: str, mask: str | None) -> str: - """Where a breakpoint has one on either side: the rows a claim about a bend is true of.""" - if mask is None: - return f'position({over}) > 0 AND position({over}) != -1' - return f'{mask} AND shift({mask}, along={over}, offset=1) AND shift({mask}, along={over}, offset=-1)' - - -def _bends(block: str, pw: PiecewiseDeclaration, x: str, y: str, curvature: Curvature) -> AssumptionBlock: +def _bends( + name: str, + curve: PiecewiseDeclaration, + x: str, + y: str, + curvature: Curvature, + *, + mask: str | None, + frame: str | None, + exists: str | None, +) -> AssumptionBlock: """The curve bends the way *curvature* says, as a comparison of the two slopes at each breakpoint. The slopes are compared as a cross-product rather than as two quotients, @@ -205,14 +259,14 @@ def _bends(block: str, pw: PiecewiseDeclaration, x: str, y: str, curvature: Curv whole axis rather than about a breakpoint: it counts the bends that go the wrong way and asks that one of the two directions has none. """ - d, mask = pw.over, pw.points + d = curve.along rise, run = f'({y} - {_back(y, d, 1)})', f'({x} - {_back(x, d, 1)})' next_rise, next_run = f'({_back(y, d, -1)} - {y})', f'({_back(x, d, -1)} - {x})' bend = f'{rise} * {next_run} {{}} {next_rise} * {run}' interior = _interior(d, mask) shape = 'a single bend' if curvature == 'either' else f'a {curvature} curve' description = ( - f"piecewise '{block}': method: {pw.method} is exact only for {shape}, and '{y}' over '{x}' along " + f"piecewise '{name}': method: {curve.method} is exact only for {shape}, and '{y}' over '{x}' along " f"'{d}' is not one, so the answer is wrong rather than loose. Use method: adjacency " f'or sos2, which take a curve of any shape.' ) @@ -220,20 +274,32 @@ def _bends(block: str, pw: PiecewiseDeclaration, x: str, y: str, curvature: Curv up, down = bend.format('>'), bend.format('<') return AssumptionBlock( holds=f'count({up} AND {interior}, over={d}) == 0 OR count({down} AND {interior}, over={d}) == 0', + where=exists, description=description, ) return AssumptionBlock( - holds=bend.format('<=' if curvature == 'convex' else '>='), where=interior, description=description + holds=bend.format('<=' if curvature == 'convex' else '>='), + where=_all_of(frame, interior), + description=description, ) +# --------------------------------------------------------------------------- +# the names a block writes +# --------------------------------------------------------------------------- + +#: The suffix on the second gate row, where the gate variable does not exist. +_UNGATED = '_ungated' + + @dataclass(frozen=True) class Emitted: """Every name one block's expansion may write, spelled once for the emitter and the collision check. - The set a block states writes names of its own, and they are reserved - whichever method the block declares: which of the two write them is the - method's business, and a collision is the file's either way. + Every name is reserved whichever method the block declares: which method + writes which is the method's business, and a collision is the file's + either way. ``set`` holds the names a method that states a set writes + through :func:`math_spec.sos.emit`. """ name: str @@ -247,8 +313,8 @@ class Emitted: assumptions: tuple[str, ...] @classmethod - def of(cls, name: str, pw: PiecewiseDeclaration) -> Emitted: - """The names block *name* writes.""" + def of(cls, name: str, curve: PiecewiseDeclaration) -> Emitted: + """The names block *name* writes, a link's row named after the link.""" return cls( name, f'{name}_lam', @@ -257,82 +323,274 @@ def of(cls, name: str, pw: PiecewiseDeclaration) -> Emitted: f'{name}_chord', f'{name}_domain_lo', f'{name}_domain_hi', - tuple(f'{name}_link{i}' for i in range(len(pw.links))), - tuple(assumptions_of(name, pw)), + tuple(f'{name}_{link.name}' for link in curve.links), + ( + *(f'{name}_{what}' for what in _ASSUMED), + *(f'{name}_{link.name}_complete' for link in curve.links if link.walks), + ), ) + @property + def ungated(self) -> str: + """The second gate row, where the gate variable does not exist.""" + return self.convexity + _UNGATED + + @property + def rows(self) -> tuple[str, ...]: + """Every constraint the block writes for itself, its link rows aside.""" + return ( + self.convexity, + self.ungated, + self.set.pick, + self.set.link, + self.set.below, + self.chord, + self.domain_lo, + self.domain_hi, + ) + + @property + def reused(self) -> tuple[str, ...]: + """Each link row whose name the block's own rows or variables already take.""" + own = {self.lam, self.set.seg, *self.rows} + return tuple(row for row in self.links if row in own) + @property def by_kind(self) -> tuple[tuple[str, tuple[str, ...]], ...]: """Each name by the kind of declaration it would collide with.""" return ( ('variable', (self.lam, self.set.seg)), - ( - 'constraint', - ( - self.convexity, - self.convexity + _UNGATED, - self.set.pick, - self.set.link, - self.chord, - self.domain_lo, - self.domain_hi, - *self.links, - ), - ), + ('constraint', (*self.rows, *self.links)), ('sos', (self.name,)), ('assumption', self.assumptions), ) -def curve_frame(schema: Spec, name: str, pw: PiecewiseBlock, links: Iterable[Expression]) -> tuple[str, ...]: - """The dimensions block *name* builds one curve per coordinate of: every one its links and its gate carry. +# --------------------------------------------------------------------------- +# the block as lowering types it +# --------------------------------------------------------------------------- + + +def resolve_links(name: str, pw: PiecewiseBlock, ns: Namespace, errors: list[str]) -> tuple[Expression, ...] | None: + """Block *name*'s link expressions typed, in link order, or ``None`` once one failed, its refusal appended. + + A link is read affinely, so it is held to degree 1 where it is read. + """ + links = [ + resolve_expression_text(link.expression, ns, f"piecewise '{name}' link '{key}'", errors, ceiling=1) + for key, link in pw.links.items() + ] + if any(link is None for link in links): + return None + return tuple(link for link in links if link is not None) + + +def lp_domain_refusal(name: str, pw: PiecewiseBlock, links: tuple[Expression, ...]) -> str | None: + """The refusal for a ``method: lp`` curve whose x-link carries no variable, or ``None``. + + The method bounds the curve's domain with two rows comparing the x-link + against the first and the last breakpoint, and a row with no variable + decides nothing. Decided on the link the file wrote, rather than on the + row the expansion would write under a name the file never declared. + """ + x = pw.curve[0] + i, key = next((i, key) for i, (key, link) in enumerate(pw.links.items()) if link is x) + if carries_variable(links[i]): + return None + return ( + f"piecewise '{name}' link '{key}': method: lp bounds the curve's domain by rows comparing this link's " + f'expression against its first and last breakpoint, and {x.expression!r} carries no variable, so those rows ' + f'decide nothing. Name a variable in the link, or use method: convex, sos2 or adjacency, whose weights pin ' + f'the domain themselves.' + ) + + +def declaration_of( + schema: Spec, name: str, pw: PiecewiseBlock, links: tuple[Expression, ...], where: Mask | None +) -> PiecewiseDeclaration: + """Block *name* as the program carries it, with *links* and *where* typed, every fit rule decided. + + Each link's row is ``dims:``, or its refinement through the link's + relation; its expression carries exactly that row, its values parameter + varies along it and the breakpoint dim and nothing else, and the + ``where:`` tests ``dims:`` and the breakpoint dim alone. A walked row + reads the where through its relation when the mask carries every dim the + walk reads the curve at. Decided here, on the link the file wrote, rather + than on the emitted declarations, whose refusal would name ``_lam`` + — a variable the author never wrote. + + Raises: + DimensionError: A link that does not fit its row, a where outside + ``dims:``, or a mask carrying part of what a walk reads through. + """ + ctx = f"piecewise '{name}'" + rows = {key: _row(schema, pw, link) for key, link in pw.links.items()} + for node, (key, row) in zip(links, rows.items(), strict=True): + _link_fits(ctx, key, pw, dims_of(node, schema, f"{ctx} link '{key}'"), row) + for (key, link), row in zip(pw.links.items(), rows.values(), strict=True): + _values_fit(schema, ctx, key, pw, link, row) + _where_fits(ctx, pw, where) + carried = (where.dims if where is not None else frozenset()) - {pw.along} + typed = tuple( + Link( + key, + node, + link.values, + rows[key], + link.sign, + link.by, + _named(link.over), + _named(link.into), + _reads(schema, ctx, key, pw, link, carried), + ) + for node, (key, link) in zip(links, pw.links.items(), strict=True) + ) + return PiecewiseDeclaration( + pw.along, typed, pw.method, tuple(pw.dims), where, activity=pw.activity, description=pw.description + ) + + +def _row(schema: Spec, block: PiecewiseBlock, link: PiecewiseLink) -> tuple[str, ...]: + """The dims one link's row is built over: ``dims:``, or its refinement through the link's relation. - In declaration order, because iterating a set would vary the emitted - ``dims`` — and every column index behind it — per process. *links* are the - block's link expressions typed, as :func:`resolve_links` answers. + The produced dims stand where the consumed ones did, so a walked row + reads in the shape of the curve it ties rather than in relation order. + """ + if not link.walks: + return tuple(block.dims) + consumed, produced = _walk(schema, link) + refined: list[str] = [] + for d in block.dims: + if d in consumed: + refined.extend(p for p in schema.dimensions if p in produced and p not in refined) + elif d not in refined: + refined.append(d) + return tuple(refined) + + +def _reads( + schema: Spec, ctx: str, key: str, block: PiecewiseBlock, link: PiecewiseLink, carried: frozenset[str] +) -> bool: + """Whether a walked link's row reads the block's ``where:`` through its relation; ``False`` for one that does not walk. + + A walked row is over the dims the walk produces, where a mask over the + ones it consumes cannot be read as written. Read through the relation it + can, as ``at`` reads it, when the mask carries every dim the walk consumes + or joins on (*carried* is what the mask carries, the breakpoint dim + aside). A mask carrying none of them is over dims the row keeps, and + reads as written. Raises: - DimensionError: A link or the gate carries the breakpoint dimension, or - a values or ``points:`` parameter varies along a dimension no link - expression carries. + DimensionError: The mask carries some of the dims the walk reads + through and not the rest. """ - context = f"piecewise '{name}'" - carried = [(f'link {i} expression', dims_of(node, schema, f'{context} link {i}')) for i, node in enumerate(links)] - if pw.activity is not None: - carried.append(('activity', frozenset(schema.variables[pw.activity].dims))) - frame: list[str] = [] - for what, found in carried: - for d in (d for d in schema.dimensions if d in found): - if d == pw.over: - raise DimensionError(f"{context}: {what} already carries the breakpoint dim '{pw.over}'") - if d not in frame: - frame.append(d) - for i, link in enumerate(pw.links): - if stray := [d for d in schema.parameters[link.values].dims if d != pw.over and d not in frame]: - raise DimensionError( - f"{context}: link {i} values parameter '{link.values}' carries {stray}, which no link " - f'expression does — the block builds one curve per coordinate of {frame}, so a curve ' - f'varying along {stray} has nothing to vary against. Declare a link expression over ' - f"it, or drop it from '{link.values}'." - ) - if pw.points is not None and pw.nominated is None: - mask = schema.parameters[pw.points].dims - if stray := [d for d in mask if d != pw.over and d not in frame]: - raise DimensionError( - f"{context}: points parameter '{pw.points}' carries {stray}, which the links do not — " - f"a mask says which of the block's own coordinates exist, and cannot add coordinates" - ) - return tuple(frame) + if not link.walks: + return False + assert link.by is not None + consumed, _ = _walk(schema, link) + relation = schema.relations[link.by] + roles = dict(relation.pairs) + written = {*_named(link.over), *_named(link.into)} + needed = consumed | {roles[c] for c in relation.key_roles if c not in written} + if (partial := sorted(needed - carried)) and needed & carried: + raise DimensionError( + f"{ctx} link '{key}': where {block.where!r} carries {sorted(needed & carried)} and not {partial}, and " + f"the link reads the curve through '{link.by}' at all of {sorted(needed)}. Carry all of them in the " + f'where, so the row reads it through the relation, or none, so the row reads it as written.' + ) + return bool(needed & carried) + + +def _walk(schema: Spec, link: PiecewiseLink) -> tuple[frozenset[str], frozenset[str]]: + """The dims one walked link consumes and produces, read off the relation it names.""" + assert link.by is not None + roles = dict(schema.relations[link.by].pairs) + consumed, produced = (frozenset(roles[c] for c in _named(written)) for written in (link.over, link.into)) + return consumed, produced + + +def _named(written: str | list[str] | None) -> tuple[str, ...]: + """The relation columns a walk names on one side, as written: none, one bare, or a list.""" + if written is None: + return () + return (written,) if isinstance(written, str) else tuple(written) + + +def _columns(columns: tuple[str, ...]) -> str: + """One relation column as its bare name, several as the bracketed list the operators take.""" + return columns[0] if len(columns) == 1 else f'[{", ".join(columns)}]' + + +def _link_fits(ctx: str, key: str, block: PiecewiseBlock, found: frozenset[str], own: tuple[str, ...]) -> None: + """A link expression carries exactly its row's frame — the rule a constraint's own ``dims:`` holds to. + + Both directions are refused because both broadcast one side of the row. + A stray dim multiplies the rows the link builds; a missing one repeats + the same row across it, which pins the expression to one operating point + along a dimension the curve varies over. Neither is sayable another way, + so neither is guessed. + """ + if block.along in found: + raise DimensionError(f"{ctx}: link '{key}' expression already carries the breakpoint dim '{block.along}'") + if stray := sorted(found - set(own)): + raise DimensionError( + f"{ctx}: link '{key}' expression carries {stray}, which its row {list(own)} does not — " + f'every stray dim multiplies the rows the link builds. Add it to dims:, sum it out, or read ' + f'it through a relation with by, over and into.' + ) + if missing := sorted(set(own) - found): + raise DimensionError( + f"{ctx}: link '{key}' expression does not carry {missing}, which its row {list(own)} does — " + f'the same row would repeat across {missing}, pinning the expression to one operating point ' + f'along {"it" if len(missing) == 1 else "them"}. Drop {missing} from dims:, or vary the ' + f'expression along {missing}.' + ) + + +def _values_fit( + schema: Spec, ctx: str, key: str, block: PiecewiseBlock, link: PiecewiseLink, own: tuple[str, ...] +) -> None: + """A values parameter varies along its own link's row and the breakpoint dim, and nothing else. + + Its own link's, because a walked link's curve is read per fine + coordinate: ``bp_power`` is per flow where the block's frame is per + converter, and comparing it against the frame would refuse it. + """ + if stray := [d for d in schema.parameters[link.values].dims if d != block.along and d not in own]: + raise DimensionError( + f"{ctx}: link '{key}' values parameter '{link.values}' carries {stray}, which its row " + f'{list(own)} does not — the link reads one curve per coordinate of {list(own)}, so a curve ' + f"varying along {stray} has nothing to vary against. Drop it from '{link.values}', or add it to " + f'dims:.' + ) + + +def _where_fits(ctx: str, block: PiecewiseBlock, where: Mask | None) -> None: + """A block's ``where:`` tests ``dims:`` and the breakpoint dim, and nothing else. + + A walked link's values parameter carries the link's own row, so a where + naming it is refused here too: raggedness is the curve's. + """ + dims = where.dims if where is not None else frozenset() + if stray := sorted(dims - set(block.dims) - {block.along}): + raise DimensionError( + f'{ctx}: where {block.where!r} tests {stray}, which dims {block.dims} does not carry — a mask says ' + f'which of the curves the block builds exist, and cannot add coordinates. Add {stray} to dims:, ' + f'or drop it from the where.' + ) + + +# --------------------------------------------------------------------------- +# the rows a block writes +# --------------------------------------------------------------------------- class _Block: - """One ``piecewise:`` block being expanded into the raw model it writes. + """One ``piecewise:`` block, written out into the raw model. - ``mask`` is the parameter masking the weights, or ``None`` for a whole - curve: the ``bool`` the file named, or one of the block's own values - parameters, which as a bare name in a ``where`` is true wherever it has a - row. Nothing here can fail: every rule a block is held to was decided when - *schema* loaded. + Every rule the block is held to was decided as the model loaded, so what + is left is writing: the link text the file wrote, on the row the program + says each link builds, under the where each row reads. """ def __init__( @@ -341,13 +599,14 @@ def __init__( self.schema = schema self.raw = raw self.name = name - #: The block as the file wrote it, for the link text the rows repeat. + #: The block as the file wrote it, for the link text the rows repeat and the where they carry. self.pw = pw - #: The block as the program carries it, for its frame and the names it writes. + #: The block as the program carries it, for each link's row, how it reads the where, and the names it writes. self.curve = curve self.emitted = Emitted.of(name, curve) - self.mask = pw.points - self.frame = curve.frame + self.frame = list(curve.frame) + #: The where as a ragged row, a frame row, and a row over the frame alone read it. + self.mask, self.frame_mask, self.exists = _masks(pw.where, pw.along, ragged=curve.ragged) def expand(self) -> None: """Write the block's declarations into the raw model.""" @@ -365,47 +624,54 @@ def _assumptions(self) -> None: as something a consumer has to know to ask for. """ assumptions = sos.section(self.raw, 'assumptions') - for name, assumed in assumptions_of(self.name, self.curve).items(): + for name, assumed in assumptions_of(self.name, self.curve, self.pw.where).items(): assumptions[name] = assumed.model_dump() # -- emitters ---------------------------------------------------------- - def _weight(self, name: str, **fields: object) -> None: - """A variable over the frame and the breakpoint dim, masked as the block is.""" - sos.section(self.raw, 'variables')[name] = { - 'dims': [*self.frame, self.pw.over], - **({'where': self.mask} if self.mask else {}), - **fields, - } - - def _constraint(self, name: str, dims: list[str], expression: str, where: str | None = None) -> None: + def _constraint(self, name: str, dims: Iterable[str], expression: str, where: str | None = None) -> None: sos.section(self.raw, 'constraints')[name] = { - 'dims': dims, + 'dims': list(dims), **({'where': where} if where else {}), 'expression': expression, } def _weights(self) -> None: """The convex-combination form: weights, their convexity, a row per link, and the method's restriction.""" - d = self.pw.over - self._weight( - self.emitted.lam, - bounds={'lower': 0.0, 'upper': 1.0}, - description='convex-combination weight on a breakpoint', - ) - gated = self._gate_rows() - for suffix, where, rhs in gated: + pw, emitted, d = self.pw, self.emitted, self.pw.along + sos.section(self.raw, 'variables')[emitted.lam] = { + 'dims': [*self.frame, d], + **({'where': pw.where} if pw.where else {}), + 'bounds': {'lower': 0.0, 'upper': 1.0}, + 'description': 'convex-combination weight on a breakpoint', + } + for suffix, gate, rhs in self._gate_rows(): self._constraint( - self.emitted.convexity + suffix, list(self.frame), f'sum({self.emitted.lam}, over={d}) == {rhs}', where + emitted.convexity + suffix, + self.frame, + f'sum({emitted.lam}, over={d}) == {rhs}', + _all_of(self.exists, gate), ) - for cname, link in zip(self.emitted.links, self.pw.links, strict=True): + for cname, written, link in zip(emitted.links, pw.links.values(), self.curve.links, strict=True): self._constraint( cname, - list(self.frame), - f'({link.expression}) {link.sign} sum({self.emitted.lam} * {link.values}, over={d})', + link.dims, + f'({written.expression}) {link.sign} sum({self._weights_read(link)} * {link.values}, over={d})', + through(self.exists, link), ) - if self.pw.method in ('sos2', 'adjacency'): - sos.section(self.raw, 'sos')[self.name] = {'variable': self.emitted.lam, 'along': d, 'type': 2} + if pw.method in ('sos2', 'adjacency'): + sos.section(self.raw, 'sos')[self.name] = {'variable': emitted.lam, 'along': d, 'type': 2} + + def _weights_read(self, link: Link) -> str: + """How one link reads the curve's weights: by name, or through the relation that refines its frame. + + The walk is an ``at``, so the weights stay on the curve's own frame and + the model never names them — which is the whole reason the block emits + the row rather than the file writing it. + """ + if not link.walks: + return self.emitted.lam + return f'at({self.emitted.lam}, by={link.by}, over={_columns(link.over)}, into={_columns(link.into)})' def _gate_rows(self) -> tuple[tuple[str, str | None, str], ...]: """What the weights sum to, as ``(name suffix, where, right-hand side)``. @@ -439,23 +705,27 @@ def _segment_lines(self) -> None: run rather than dividing, which keeps its sense only because the breakpoints are strictly monotone. The domain rows are ``linopy``'s ``_add_lp`` rows under its names, each sitting on the edge of the curve - the mask marks, which is why the mask has to be one run. + the mask marks, which is why the mask has to be one run. Every row here + is written from the link expressions and the breakpoint values, none of + which the block masks, so a ``where`` over the frame alone is conjoined + onto each rather than inherited as the weight rows inherit it. """ - x_link, y_link = self.pw.curve - d = self.pw.over - run = f'({x_link.values} - shift({x_link.values}, along={d}, offset=1, edge=0))' - rise = f'({y_link.values} - shift({y_link.values}, along={d}, offset=1, edge=0))' + pw, emitted, d = self.pw, self.emitted, self.pw.along + x_link, y_link = pw.curve + mask, frame = self.mask, self.frame_mask + dims = (*self.frame, d) + run = f'({x_link.values} - {_back(x_link.values, d, 1)})' + rise = f'({y_link.values} - {_back(y_link.values, d, 1)})' self._constraint( - self.emitted.chord, - [*self.frame, d], + emitted.chord, + dims, f'({y_link.expression}) * {run} {y_link.sign} ' f'{rise} * (({x_link.expression}) - {x_link.values}) + {y_link.values} * {run}', - _neighbours(d, self.mask), + _all_of(frame, _neighbours(d, mask)), ) - edges = ((self.emitted.domain_lo, '>=', 'first'), (self.emitted.domain_hi, '<=', 'last')) - for cname, sense, end in edges: + for cname, sense, end in ((emitted.domain_lo, '>=', 'first'), (emitted.domain_hi, '<=', 'last')): self._constraint( - cname, [*self.frame, d], f'({x_link.expression}) {sense} {x_link.values}', _edge(d, self.mask, end) + cname, dims, f'({x_link.expression}) {sense} {x_link.values}', _all_of(frame, _edge(d, mask, end)) ) @@ -466,7 +736,7 @@ def expand_piecewise(schema: Spec) -> Spec: ``method: sos2`` states, and then that set is written out here too: the binaries are what the method *is*, so the model that comes back carries no set of its own (:func:`math_spec.sos.emit` is where they are spelled). - Each block's frame and names are read off the program *schema* lowered to. + Each block's rows and names are read off the program *schema* lowered to. """ if not schema.piecewise: return schema diff --git a/src/math_spec/program.py b/src/math_spec/program.py index 9ef1174a..400b86ba 100644 --- a/src/math_spec/program.py +++ b/src/math_spec/program.py @@ -710,13 +710,32 @@ class ExpressionDeclaration: class Link: """One link of a ``piecewise:`` block: an expression tied to the breakpoints a values parameter holds. - ``sign`` is ``'=='`` where the link is pinned to the curve, and one side - of it where the link is bounded by the curve instead. + ``name`` is the link's key in the block, and ``_`` is the row + the expansion writes for it. ``sign`` is ``'=='`` where the link is pinned + to the curve, and one side of it where the link is bounded by the curve + instead. A link that walks a relation reads the curve's weights through + it, as ``at`` reads an array: ``by`` names the relation, ``over`` the + columns it consumes and ``into`` the ones it produces. ``dims`` is the row + the link builds — the block's frame, or, for a walked link, that frame + with the consumed dims replaced by the produced ones — and ``reads`` says + whether that row reads the block's ``where`` through the relation, which + it does when the mask carries every dim the walk consumes or joins on. """ + name: str expression: Expression values: str + dims: tuple[str, ...] sign: ConstraintSense = '==' + by: str | None = None + over: tuple[str, ...] = () + into: tuple[str, ...] = () + reads: bool = False + + @property + def walks(self) -> bool: + """Whether the link reads the curve's weights through a relation, rather than on the block's own frame.""" + return self.by is not None @dataclass(frozen=True) @@ -730,28 +749,31 @@ class PiecewiseDeclaration: consumer building rows takes the expanded model. Attributes: - over: The breakpoint dimension. - links: The links, in the order the file wrote them. + along: The dimension each curve runs along. + links: The links, in the order the file wrote them, each with the row + it builds. method: How the weights are restricted. + frame: The ``dims:`` the block builds one curve per coordinate of, in + the order the file wrote them. + where: Which coordinates of ``frame`` have a curve — and, where it + reads ``along`` too, how far each runs — or ``None`` where every + coordinate has a whole curve. activity: The binary the weights sum to, or ``None`` where they sum to 1. - points: The parameter saying how far each curve runs, or ``None``. - frame: The dimensions the block builds one curve per coordinate of, - in declaration order. """ - over: str + along: str links: tuple[Link, ...] method: PiecewiseMethod frame: tuple[str, ...] + where: Mask | None = None activity: str | None = None - points: str | None = None description: str | None = None @property - def nominated(self) -> str | None: - """The block's own values parameter ``points:`` names, so the mask is derived from it — or ``None``.""" - return self.points if self.points in {link.values for link in self.links} else None + def ragged(self) -> bool: + """Whether ``where`` reads ``along``, and so says how far each curve runs rather than only which exist.""" + return self.where is not None and self.along in self.where.dims @property def curve(self) -> tuple[Link, Link]: diff --git a/src/math_spec/typesetting/format.py b/src/math_spec/typesetting/format.py index b6cf4613..f80cbd60 100644 --- a/src/math_spec/typesetting/format.py +++ b/src/math_spec/typesetting/format.py @@ -58,6 +58,9 @@ 'sos_set', 'curve', 'hull', + 'origin', + 'nonnegative', + 'nonpositive', 'position', 'dual', 'minimize', @@ -69,7 +72,8 @@ #: so no format can be missing one. ``such_that`` is the colon in #: "∀ t ∈ T : condition", ``times`` sits between sets in the legend, #: ``maps_to`` is the → in a coordinate map, ``curve`` and ``hull`` are the two -#: sets a ``piecewise:`` block states its links lie on, and the three +#: sets a ``piecewise:`` block states its links lie on, ``origin``, +#: ``nonnegative`` and ``nonpositive`` the cone its signs add to them, and the three #: translations are three models: plain leaves the vacated position absent, #: ``cyclic_*`` wraps, ``edge_*`` fills it with the value it carries as a #: subscript. @@ -104,6 +108,9 @@ 'sos_set': (r'\mathrm{SOS}', 'upright("SOS")'), 'curve': (r'\mathrm{pwl}', 'upright("pwl")'), 'hull': (r'\mathrm{conv}', 'upright("conv")'), + 'origin': (r'\{0\}', '{0}'), + 'nonnegative': (r'\mathbb{R}_{\ge 0}', 'RR_(>= 0)'), + 'nonpositive': (r'\mathbb{R}_{\le 0}', 'RR_(<= 0)'), 'position': (r'\mathrm{pos}', 'upright("pos")'), 'dual': (r'\lambda', 'lambda'), 'minimize': (r'\min', 'min'), diff --git a/src/math_spec/typesetting/walk.py b/src/math_spec/typesetting/walk.py index 1ccef519..e5e62e40 100644 --- a/src/math_spec/typesetting/walk.py +++ b/src/math_spec/typesetting/walk.py @@ -63,8 +63,8 @@ import datetime from collections.abc import Iterable, Mapping - from math_spec._expression_parser import BinaryOperator - from math_spec.program import PiecewiseDeclaration, Program, SosDeclaration + from math_spec._expression_parser import BinaryOperator, ComparisonOperator + from math_spec.program import Link, PiecewiseDeclaration, Program, SosDeclaration from math_spec.typesetting.format import Format from math_spec.typesetting.symbols import Symbols @@ -99,6 +99,10 @@ '>': 'gt', } +#: What a link's sign leaves between its expression and the curve's coordinate: +#: nothing where it is pinned, a half-line where the curve bounds it. +_HALF_LINES: dict[ComparisonOperator, OperatorName] = {'==': 'origin', '>=': 'nonnegative', '<=': 'nonpositive'} + #: Edge policy -> the operator pair that renders it, backward then forward — #: the vacated row dropped, wrapped, or filled. @@ -851,31 +855,76 @@ def _assumption(self, name: str) -> Line: return Line(label=name, left=left, right=right, condition=self._quantifier(frame, self._condition(ctx, where))) def _piecewise(self, name: str) -> Line: - """One ``piecewise:`` block as the curve it states, over the frame it states one per coordinate of. + """One ``piecewise:`` block as the curve it states, over the ``dims:`` it states one per coordinate of. The links' expressions are a point, and the block says that point lies on the piecewise-linear locus through the breakpoints. A bounded link - states one side of the locus instead, so there the locus prints as the - function of the pinned link that it is and the link's own sign says - which side. + states one side of the locus instead. Beside one pinned link the locus + prints as the function of it that it is, and the bounded link's own + sign says which side; beside more, the point lies on the locus plus + the cone the signs span, ``{0}`` for a pinned coordinate and a + half-line for a bounded one. """ block = self.program.piecewise[name] - links = [link.expression for link in block.links] frame = list(block.frame) - ctx = self._context([*frame, block.over]) - locus = self._locus(block, ctx) - bounded = next((i for i, link in enumerate(block.links) if link.sign != '=='), None) - if bounded is None: - left = self._tuple([self._expression(node, ctx) for node in links]) - right = f'{self._op("in")} {locus}' + ctx = self._context([*frame, block.along]) + points, values = zip(*(self._link(link, ctx) for link in block.links), strict=True) + locus = self._locus(block, list(values), block.where if block.ragged else None, ctx) + bounded = [i for i, link in enumerate(block.links) if link.sign != '=='] + if not bounded: + left, right = self._tuple(list(points)), f'{self._op("in")} {locus}' + elif len(block.links) == 2: + (i,) = bounded + left = points[i] + right = f'{self._op(_PREDICATES[block.links[i].sign])} {self.format.apply(locus, points[1 - i])}' else: - pinned = links[1 - bounded] - left = self._expression(links[bounded], ctx) - sign = self._op(_PREDICATES[block.links[bounded].sign]) - right = f'{sign} {self.format.apply(locus, self._expression(pinned, ctx))}' - return Line(label=name, left=left, right=right, condition=self._quantifier(frame, '')) + cone = self.format.joined([self._op(_HALF_LINES[link.sign]) for link in block.links], self._op('times')) + left, right = self._tuple(list(points)), f'{self._op("in")} {locus} {self._op("plus")} {cone}' + condition = '' if block.ragged else self._condition(ctx, block.where) + return Line(label=name, left=left, right=right, condition=self._quantifier(frame, condition)) + + def _link(self, link: Link, ctx: _Context) -> tuple[str, str]: + """One link's point coordinate and its breakpoints, each a family where the link walks a relation. + + A walked link is one coordinate per fine index that maps to the curve's + own, so it prints as the family over those, which is what ties them to + the one curve rather than to a curve each. + """ + dims = list(self.program.parameters[link.values].dims) + if not link.walks: + return self._expression(link.expression, ctx), ctx.indexed(self.symbols.name[link.values], dims) + domain, inner = self._walked(link, ctx) + return ( + self.format.subscript(self.format.parenthesise(self._expression(link.expression, inner)), [domain]), + self.format.subscript( + self.format.parenthesise(inner.indexed(self.symbols.name[link.values], dims)), [domain] + ), + ) + + def _walked(self, link: Link, ctx: _Context) -> tuple[str, _Context]: + """The fine indices a walked link's family runs over, and the context its members read under. - def _locus(self, block: PiecewiseDeclaration, ctx: _Context) -> str: + The members are every produced index whose row in the relation reads + the curve's own coordinate at the consumed columns. + """ + assert link.by is not None + relation = self.program.relations[link.by] + roles = dict(relation.columns) + dummies: dict[str, str] = {} + inner = ctx + for column in link.into: + dummies[column], inner = inner.reducing(roles[column]) + at = {c: dummies.get(c) or ctx.subscript(roles[c]) for c in roles} + fixed = [c for c in relation.values if c in at] + conditions = ( + [f'{self._relation_read(link.by, at, c)} {self._op("equal")} {at[c]}' for c in fixed] + if fixed + else [self._relation_row(link.by, at)] + ) + members = self.format.joined([self._membership(roles[c], dummies[c]) for c in link.into], '') + return f'{members} {self._op("such_that")} {self.format.joined(conditions, self._op("and"))}', inner + + def _locus(self, block: PiecewiseDeclaration, values: list[str], admitted: Mask | None, ctx: _Context) -> str: """The set the links lie on: the curve through the breakpoints, or the hull ``convex`` relaxes it onto. A gate multiplies it, which is what gating a curve does — the weights @@ -883,30 +932,21 @@ def _locus(self, block: PiecewiseDeclaration, ctx: _Context) -> str: curve where it is 1. """ operator = self._op('hull' if block.method == 'convex' else 'curve') - through = self.format.subscript(operator, [self._breakpoints(block, ctx)]) - values = self.format.joined( - [ - ctx.indexed(self.symbols.name[link.values], list(self.program.parameters[link.values].dims)) - for link in block.links - ], - '', - ) - locus = self.format.apply(through, values) + through = self.format.subscript(operator, [self._breakpoints(block, admitted, ctx)]) + locus = self.format.apply(through, self.format.joined(values, '')) gate = self._gate(block, ctx) return f'{gate} {self._op("cdot")} {locus}' if gate else locus - def _breakpoints(self, block: PiecewiseDeclaration, ctx: _Context) -> str: - """Which breakpoints the curve runs through: every one of the dimension, or the ones ``points:`` admits. + def _breakpoints(self, block: PiecewiseDeclaration, admitted: Mask | None, ctx: _Context) -> str: + """Which breakpoints the curve runs through: every one of the dimension, or the ones a ragged ``where:`` admits. - A ``points:`` naming a boolean parameter reads as the flag it is, and - one naming a values parameter as the rows that parameter has, which is - the same reading a ``where`` gives either of them. + A ``where:`` over the frame alone prints on the quantifier instead, + because it says which curves exist rather than how far each runs. """ - over = self._membership(block.over) - if block.points is None: + over = self._membership(block.along) + if admitted is None: return over - admitted = ParameterDefined(block.points, tuple(self.program.parameters[block.points].dims)) - return f'{over} {self._op("such_that")} {self._predicate(admitted, ctx)}' + return f'{over} {self._op("such_that")} {self._predicate(admitted.root, ctx)}' def _gate(self, block: PiecewiseDeclaration, ctx: _Context) -> str: """The factor an ``activity:`` puts on the locus, or ``''`` where the block has none. diff --git a/src/math_spec/validation.py b/src/math_spec/validation.py index 096c10de..2d2babec 100644 --- a/src/math_spec/validation.py +++ b/src/math_spec/validation.py @@ -32,6 +32,7 @@ from collections.abc import Iterable, Iterator from pathlib import Path + from math_spec.model import PiecewiseBlock, PiecewiseLink from math_spec.program import Program @@ -69,11 +70,20 @@ def emitted_name_errors(schema: Spec, program: Program) -> list[str]: Read off the program rather than the file, since what a curve writes is decided by the curve as lowered — its links, its method, its mask. """ - by_block = [ - *((f"Sos '{name}'", EmittedSet.of(name, block.sos_type).by_kind) for name, block in program.sos.items()), - *((f"piecewise '{name}'", EmittedCurve.of(name, curve).by_kind) for name, curve in program.piecewise.items()), + errors = [ + error + for name, block in program.sos.items() + for error in _collisions(schema, f"Sos '{name}'", EmittedSet.of(name, block.sos_type).by_kind) ] - return [error for context, by_kind in by_block for error in _collisions(schema, context, by_kind)] + for name, curve in program.piecewise.items(): + written = EmittedCurve.of(name, curve) + errors.extend( + f"piecewise '{name}': link '{row.removeprefix(f'{name}_')}' names its row '{row}', which the block " + f'already writes for itself. Rename the link.' + for row in written.reused + ) + errors.extend(_collisions(schema, f"piecewise '{name}'", written.by_kind)) + return errors def reference_errors(schema: Spec) -> list[str]: @@ -267,49 +277,114 @@ def _sos_bounds(schema: Spec) -> Iterator[str]: def _piecewise_references(schema: Spec) -> Iterator[str]: - """A curve runs along a declared dimension through numeric values parameters carrying it, gated by a binary, masked by a bool.""" - for name, pw in schema.piecewise.items(): + """Every declaration a block names by key exists and has the shape the block needs. + + The breakpoint dim, the frame ``dims:`` states, each link's values + parameter, the relation and columns a walk reads through, and the gate. + What a link's expression and the where carry is resolution's to say, and + whether the pieces fit together is decided as the block is lowered + (:func:`math_spec.piecewise.declaration_of`). + """ + for name, block in schema.piecewise.items(): context = f"piecewise '{name}'" - if pw.over not in schema.dimensions: - yield undeclared_dimension('piecewise', name, pw.over) + if block.along not in schema.dimensions: + yield undeclared_dimension('piecewise', name, block.along) continue - for i, link in enumerate(pw.links): - if link.values not in schema.parameters: - yield f"{context}: link {i} values references undeclared parameter '{link.values}'" - elif (dtype := schema.parameters[link.values].dtype) not in NUMERIC_DTYPES: - yield ( - f"{context}: link {i} values parameter '{link.values}' is declared dtype: {dtype}, and a " - f'breakpoint is a number. Declare it dtype: float or int.' - ) - elif pw.over not in schema.parameters[link.values].dims: - yield ( - f"{context}: link {i} values parameter '{link.values}' must carry dim " - f"'{pw.over}' (has {schema.parameters[link.values].dims})" - ) - if (activity := pw.activity) is not None: - if activity not in schema.variables: + for d in block.dims: + if d not in schema.dimensions: + yield undeclared_dimension('piecewise', name, d) + elif d == block.along: yield ( - f"{context}: activity '{activity}' is not a declared variable. A gate is a binary variable; " - f'declare it, or drop activity: for weights that sum to 1.' + f"{context}: dims carries '{block.along}', the breakpoint dim. The frame is what the block " + f'builds one curve per, and every curve runs along the breakpoints — drop it from dims:.' ) - elif schema.variables[activity].domain != 'binary': - yield f"{context}: activity variable '{activity}' must be binary" - if (points := pw.points) is None or pw.nominated is not None: + if len(set(block.dims)) != len(block.dims): + yield f'{context}: dims repeats a dimension: {block.dims}' + for key, link in block.links.items(): + yield from _piecewise_link_shape(schema, name, block, key, link) + if (activity := block.activity) is None: continue - if points not in schema.parameters: - yield f"{context}: points references undeclared parameter '{points}'" - elif (dtype := schema.parameters[points].dtype) != 'bool': + if activity not in schema.variables: yield ( - f"{context}: points parameter '{points}' is {dtype}, and a mask is a bool parameter — one " - f'saying, per breakpoint, whether the curve reaches it. Declare it dtype: bool.' + f"{context}: activity '{activity}' is not a declared variable. A gate is a binary variable; " + f'declare it, or drop activity: for weights that sum to 1.' ) - elif pw.over not in schema.parameters[points].dims: + elif schema.variables[activity].domain != 'binary': + yield f"{context}: activity variable '{activity}' must be binary" + elif stray := [d for d in schema.variables[activity].dims if d not in block.dims]: yield ( - f"{context}: points parameter '{points}' must carry dim '{pw.over}' — " - f'it says how far each curve runs along it (has {schema.parameters[points].dims})' + f"{context}: activity '{activity}' carries {stray}, which dims {block.dims} does not. The gate " + f'switches the curve of one coordinate of dims:, and a gate varying along {stray} would need a ' + f'curve per coordinate of it — add {stray} to dims:, or gate with a variable over dims:.' ) +def _piecewise_link_shape( + schema: Spec, name: str, block: PiecewiseBlock, key: str, link: PiecewiseLink +) -> Iterator[str]: + """One link's values parameter, and the relation its walk names, exist as the link needs them.""" + context = f"piecewise '{name}' link '{key}'" + if link.values not in schema.parameters: + yield f"{context}: values references undeclared parameter '{link.values}'" + elif (dtype := schema.parameters[link.values].dtype) not in NUMERIC_DTYPES: + yield ( + f"{context}: values parameter '{link.values}' is declared dtype: {dtype}, and a breakpoint is a " + f'number. Declare it dtype: float or int.' + ) + elif block.along not in schema.parameters[link.values].dims: + yield ( + f"{context}: values parameter '{link.values}' must carry dim " + f"'{block.along}' (has {schema.parameters[link.values].dims})" + ) + if link.walks: + yield from _piecewise_walk_shape(schema, context, block, link) + + +def _piecewise_walk_shape(schema: Spec, context: str, block: PiecewiseBlock, link: PiecewiseLink) -> Iterator[str]: + """A walk's relation is declared, it consumes the block's own dims, and it produces dims of its own.""" + assert link.by is not None and link.over is not None and link.into is not None + if link.by not in schema.relations: + yield ( + f"{context}: by references undeclared relation '{link.by}'. A walked link reads the curve's " + f'weights through a declared relation — declare it, or drop by, over and into.' + ) + return + roles = dict(schema.relations[link.by].pairs) + sides: list[frozenset[str]] = [] + for side, written in (('over', link.over), ('into', link.into)): + named = [written] if isinstance(written, str) else list(written) + if stray := [c for c in named if c not in roles]: + yield f"{context}: {side} names {stray}, which relation '{link.by}' has no column for (it has {sorted(roles)})" + return + if len(set(named)) != len(named): + yield f'{context}: {side} repeats a column: {named}' + return + sides.append(frozenset(roles[c] for c in named)) + consumed, produced = sides + if shared := sorted(consumed & produced): + yield ( + f'{context}: over and into both reach {shared}, so the walk consumes and produces one dimension. ' + f'Name different columns on each side.' + ) + elif missing := sorted(consumed - set(block.dims)): + yield ( + f"{context}: over reaches {missing}, which the block's dims {block.dims} do not carry. A walk " + f"consumes one of the curve's own dimensions — name a column over one of {block.dims}, or declare " + f'it in dims:.' + ) + elif framed := sorted(produced & set(block.dims)): + yield ( + f"{context}: into reaches {framed}, which the block's dims {block.dims} already carry. The block " + f"builds one curve per coordinate of dims:, so {framed} cannot also index this link's rows — drop " + f'it from dims:, or walk into a dimension of its own.' + ) + elif block.along in produced: + yield ( + f"{context}: into reaches '{block.along}', the breakpoint dim. A walk indexes the link's rows, " + f'and every row runs along the breakpoints.' + ) + + def _collisions(schema: Spec, context: str, by_kind: Iterable[tuple[str, Iterable[str]]]) -> Iterator[str]: """The refusal for each name *context*'s expansion writes that the file already declares, by kind.""" declared: dict[str, Iterable[str]] = { diff --git a/tests/expand/curve-activity/after.yaml b/tests/expand/curve-activity/after.yaml index 39de860c..4dc4ed14 100644 --- a/tests/expand/curve-activity/after.yaml +++ b/tests/expand/curve-activity/after.yaml @@ -22,10 +22,10 @@ constraints: curve_convexity: dims: [] expression: sum(curve_lam, over=bp) == (running) - curve_link0: + curve_x: dims: [] expression: (x) == sum(curve_lam * x_bp, over=bp) - curve_link1: + curve_y: dims: [] expression: (y) == sum(curve_lam * y_bp, over=bp) curve_pick: @@ -42,4 +42,4 @@ assumptions: piecewise 'curve': every breakpoint the curve runs through needs a row in 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter curve, so it sits the curve on the origin. Bind the rows, or declare - points: to say how far the curve runs. + where: to say how far the curve runs. diff --git a/tests/expand/curve-activity/before.yaml b/tests/expand/curve-activity/before.yaml index 41948c3d..88865dff 100644 --- a/tests/expand/curve-activity/before.yaml +++ b/tests/expand/curve-activity/before.yaml @@ -12,8 +12,9 @@ variables: piecewise: curve: - over: bp + along: bp + dims: [] activity: running links: - - [x, x_bp] - - [y, y_bp] + x: [x, x_bp] + y: [y, y_bp] diff --git a/tests/expand/curve-adjacency-points/after.yaml b/tests/expand/curve-adjacency-points/after.yaml index d3892213..68c2f357 100644 --- a/tests/expand/curve-adjacency-points/after.yaml +++ b/tests/expand/curve-adjacency-points/after.yaml @@ -22,12 +22,15 @@ variables: constraints: curve_convexity: dims: [] + where: count(x_bp, over=bp) > 0 expression: sum(curve_lam, over=bp) == 1 - curve_link0: + curve_x: dims: [] + where: count(x_bp, over=bp) > 0 expression: (x) == sum(curve_lam * x_bp, over=bp) - curve_link1: + curve_y: dims: [] + where: count(x_bp, over=bp) > 0 expression: (y) == sum(curve_lam * y_bp, over=bp) curve_pick: dims: [] @@ -44,10 +47,11 @@ assumptions: piecewise 'curve': every breakpoint the curve runs through needs a row in 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter curve, so it sits the curve on the origin. Bind the rows, or narrow - points: 'x_bp' to where the curve runs. + where: 'x_bp' to where the curve runs. curve_contiguous: holds: count(x_bp AND NOT shift(x_bp, along=bp, offset=1), over=bp) == 1 + where: count(x_bp, over=bp) > 0 description: >- - piecewise 'curve': points: 'x_bp' must mark a consecutive run of at least + piecewise 'curve': where: 'x_bp' must mark a consecutive run of at least one breakpoint per curve — the weights are nonzero only on two neighbouring breakpoints, and a gap leaves no neighbour across it. diff --git a/tests/expand/curve-adjacency-points/before.yaml b/tests/expand/curve-adjacency-points/before.yaml index 45b82dfe..af3612ac 100644 --- a/tests/expand/curve-adjacency-points/before.yaml +++ b/tests/expand/curve-adjacency-points/before.yaml @@ -11,8 +11,9 @@ variables: piecewise: curve: - over: bp - points: x_bp + along: bp + dims: [] + where: x_bp links: - - [x, x_bp] - - [y, y_bp] + x: [x, x_bp] + y: [y, y_bp] diff --git a/tests/expand/curve-adjacency/after.yaml b/tests/expand/curve-adjacency/after.yaml index fcad551f..5ff77b48 100644 --- a/tests/expand/curve-adjacency/after.yaml +++ b/tests/expand/curve-adjacency/after.yaml @@ -21,10 +21,10 @@ constraints: curve_convexity: dims: [] expression: sum(curve_lam, over=bp) == 1 - curve_link0: + curve_x: dims: [] expression: (x) == sum(curve_lam * x_bp, over=bp) - curve_link1: + curve_y: dims: [] expression: (y) == sum(curve_lam * y_bp, over=bp) curve_pick: @@ -41,4 +41,4 @@ assumptions: piecewise 'curve': every breakpoint the curve runs through needs a row in 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter curve, so it sits the curve on the origin. Bind the rows, or declare - points: to say how far the curve runs. + where: to say how far the curve runs. diff --git a/tests/expand/curve-adjacency/before.yaml b/tests/expand/curve-adjacency/before.yaml index 133038e2..1782e097 100644 --- a/tests/expand/curve-adjacency/before.yaml +++ b/tests/expand/curve-adjacency/before.yaml @@ -11,7 +11,8 @@ variables: piecewise: curve: - over: bp + along: bp + dims: [] links: - - [x, x_bp] - - [y, y_bp] + x: [x, x_bp] + y: [y, y_bp] diff --git a/tests/expand/curve-convex/after.yaml b/tests/expand/curve-convex/after.yaml index 7d1cc4e1..9f19f4f5 100644 --- a/tests/expand/curve-convex/after.yaml +++ b/tests/expand/curve-convex/after.yaml @@ -17,10 +17,10 @@ constraints: curve_convexity: dims: [] expression: sum(curve_lam, over=bp) == 1 - curve_link0: + curve_x: dims: [] expression: (x) == sum(curve_lam * x_bp, over=bp) - curve_link1: + curve_y: dims: [] expression: (y) >= sum(curve_lam * y_bp, over=bp) @@ -31,7 +31,7 @@ assumptions: piecewise 'curve': every breakpoint the curve runs through needs a row in 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter curve, so it sits the curve on the origin. Bind the rows, or declare - points: to say how far the curve runs. + where: to say how far the curve runs. curve_increasing: where: position(bp) > 0 holds: shift(x_bp, along=bp, offset=1, edge=0) < x_bp diff --git a/tests/expand/curve-convex/before.yaml b/tests/expand/curve-convex/before.yaml index 269b4761..3fa71750 100644 --- a/tests/expand/curve-convex/before.yaml +++ b/tests/expand/curve-convex/before.yaml @@ -11,8 +11,9 @@ variables: piecewise: curve: - over: bp + along: bp + dims: [] method: convex links: - - [x, x_bp] - - [y, y_bp, ">="] + x: [x, x_bp] + y: [y, y_bp, ">="] diff --git a/tests/expand/curve-lp-points/after.yaml b/tests/expand/curve-lp-points/after.yaml index db719efc..bcb2c00f 100644 --- a/tests/expand/curve-lp-points/after.yaml +++ b/tests/expand/curve-lp-points/after.yaml @@ -34,7 +34,7 @@ assumptions: piecewise 'curve': every breakpoint the curve runs through needs a row in 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter curve, so it sits the curve on the origin. Bind the rows, or narrow - points: 'x_bp' to where the curve runs. + where: 'x_bp' to where the curve runs. curve_increasing: where: x_bp AND shift(x_bp, along=bp, offset=1) holds: shift(x_bp, along=bp, offset=1, edge=0) < x_bp @@ -55,6 +55,7 @@ assumptions: shape. curve_breakpoints: holds: count(x_bp, over=bp) >= 2 + where: count(x_bp, over=bp) > 0 description: >- piecewise 'curve': method: lp needs at least two breakpoints per curve — the method *is* its segment lines, so a curve with no segment states @@ -62,7 +63,8 @@ assumptions: adjacency, sos2 or convex, which pin it to the points it does have. curve_contiguous: holds: count(x_bp AND NOT shift(x_bp, along=bp, offset=1), over=bp) == 1 + where: count(x_bp, over=bp) > 0 description: >- - piecewise 'curve': points: 'x_bp' must mark a consecutive run of at least + piecewise 'curve': where: 'x_bp' must mark a consecutive run of at least one breakpoint per curve — the chord row joins a breakpoint to the one before it, and the domain rows sit on the curve's own first and last. diff --git a/tests/expand/curve-lp-points/before.yaml b/tests/expand/curve-lp-points/before.yaml index 17748873..9e006175 100644 --- a/tests/expand/curve-lp-points/before.yaml +++ b/tests/expand/curve-lp-points/before.yaml @@ -11,9 +11,10 @@ variables: piecewise: curve: - over: bp + along: bp + dims: [] method: lp - points: x_bp + where: x_bp links: - - [x, x_bp] - - [y, y_bp, ">="] + x: [x, x_bp] + y: [y, y_bp, ">="] diff --git a/tests/expand/curve-lp/after.yaml b/tests/expand/curve-lp/after.yaml index 3c9564c3..2b0918a5 100644 --- a/tests/expand/curve-lp/after.yaml +++ b/tests/expand/curve-lp/after.yaml @@ -33,7 +33,7 @@ assumptions: piecewise 'curve': every breakpoint the curve runs through needs a row in 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter curve, so it sits the curve on the origin. Bind the rows, or declare - points: to say how far the curve runs. + where: to say how far the curve runs. curve_increasing: where: position(bp) > 0 holds: shift(x_bp, along=bp, offset=1, edge=0) < x_bp diff --git a/tests/expand/curve-lp/before.yaml b/tests/expand/curve-lp/before.yaml index 7f281533..abc2f843 100644 --- a/tests/expand/curve-lp/before.yaml +++ b/tests/expand/curve-lp/before.yaml @@ -11,8 +11,9 @@ variables: piecewise: curve: - over: bp + along: bp + dims: [] method: lp links: - - [x, x_bp] - - [y, y_bp, ">="] + x: [x, x_bp] + y: [y, y_bp, ">="] diff --git a/tests/expand/curve-sos2-piecewise/after.yaml b/tests/expand/curve-sos2-piecewise/after.yaml index 0c66ddea..7848cf21 100644 --- a/tests/expand/curve-sos2-piecewise/after.yaml +++ b/tests/expand/curve-sos2-piecewise/after.yaml @@ -17,10 +17,10 @@ constraints: curve_convexity: dims: [] expression: sum(curve_lam, over=bp) == 1 - curve_link0: + curve_x: dims: [] expression: (x) == sum(curve_lam * x_bp, over=bp) - curve_link1: + curve_y: dims: [] expression: (y) == sum(curve_lam * y_bp, over=bp) @@ -37,4 +37,4 @@ assumptions: piecewise 'curve': every breakpoint the curve runs through needs a row in 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter curve, so it sits the curve on the origin. Bind the rows, or declare - points: to say how far the curve runs. + where: to say how far the curve runs. diff --git a/tests/expand/curve-sos2-piecewise/before.yaml b/tests/expand/curve-sos2-piecewise/before.yaml index c3c82ec7..1709fe97 100644 --- a/tests/expand/curve-sos2-piecewise/before.yaml +++ b/tests/expand/curve-sos2-piecewise/before.yaml @@ -11,8 +11,9 @@ variables: piecewise: curve: - over: bp + along: bp + dims: [] method: sos2 links: - - [x, x_bp] - - [y, y_bp] + x: [x, x_bp] + y: [y, y_bp] diff --git a/tests/expand/curve-sos2/after.yaml b/tests/expand/curve-sos2/after.yaml index fcad551f..5ff77b48 100644 --- a/tests/expand/curve-sos2/after.yaml +++ b/tests/expand/curve-sos2/after.yaml @@ -21,10 +21,10 @@ constraints: curve_convexity: dims: [] expression: sum(curve_lam, over=bp) == 1 - curve_link0: + curve_x: dims: [] expression: (x) == sum(curve_lam * x_bp, over=bp) - curve_link1: + curve_y: dims: [] expression: (y) == sum(curve_lam * y_bp, over=bp) curve_pick: @@ -41,4 +41,4 @@ assumptions: piecewise 'curve': every breakpoint the curve runs through needs a row in 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter curve, so it sits the curve on the origin. Bind the rows, or declare - points: to say how far the curve runs. + where: to say how far the curve runs. diff --git a/tests/expand/curve-sos2/before.yaml b/tests/expand/curve-sos2/before.yaml index c3c82ec7..1709fe97 100644 --- a/tests/expand/curve-sos2/before.yaml +++ b/tests/expand/curve-sos2/before.yaml @@ -11,8 +11,9 @@ variables: piecewise: curve: - over: bp + along: bp + dims: [] method: sos2 links: - - [x, x_bp] - - [y, y_bp] + x: [x, x_bp] + y: [y, y_bp] diff --git a/tests/test_advice.py b/tests/test_advice.py index 39eeaea8..013b2a82 100644 --- a/tests/test_advice.py +++ b/tests/test_advice.py @@ -40,7 +40,7 @@ dimensions={'g': {'dtype': 'str'}, 'h': {'dtype': 'str'}, 'bp': {'dtype': 'int'}}, parameters={'c': {'dims': ['g']}, 'bp_x': {'dims': ['bp']}, 'bp_y': {'dims': ['bp']}}, variables={'p': {'dims': ['g']}, 'cost': {'dims': ['g']}}, - piecewise={'curve': {'over': 'bp', 'links': [['p', 'bp_x'], ['cost', 'bp_y']]}}, + piecewise={'curve': {'along': 'bp', 'dims': ['g'], 'links': {'p': ['p', 'bp_x'], 'cost': ['cost', 'bp_y']}}}, ) diff --git a/tests/test_boundedness.py b/tests/test_boundedness.py index f701e17d..bc8f3358 100644 --- a/tests/test_boundedness.py +++ b/tests/test_boundedness.py @@ -147,6 +147,6 @@ def test_a_curve_holds_its_variables_through_the_rows_it_emits(): 'dimensions.bp': {'dtype': 'int'}, 'parameters.bx': {'dims': ['bp']}, 'parameters.by': {'dims': ['bp']}, - 'piecewise': {'curve': {'over': 'bp', 'links': [['v', 'bx'], ['w', 'by']]}}, + 'piecewise': {'curve': {'along': 'bp', 'dims': ['g'], 'links': {'v': ['v', 'bx'], 'w': ['w', 'by']}}}, } assert _notes(**curve) == [], 'the emitted link rows pin v and w, so neither is unopposed' diff --git a/tests/test_expand.py b/tests/test_expand.py index a463c1d7..b5825f5c 100644 --- a/tests/test_expand.py +++ b/tests/test_expand.py @@ -31,8 +31,8 @@ CURVE, **{ 'piecewise.cost_curve.method': 'lp', - 'piecewise.cost_curve.points': 'bp_x', - 'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '>=']], + 'piecewise.cost_curve.where': 'bp_x', + 'piecewise.cost_curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=']}, }, ) diff --git a/tests/test_piecewise.py b/tests/test_piecewise.py index 6f39d1e9..3340fbfd 100644 --- a/tests/test_piecewise.py +++ b/tests/test_piecewise.py @@ -17,8 +17,9 @@ from math_spec.errors import LanguageError, SchemaError from math_spec.model import Curvature -from math_spec.piecewise import expand_piecewise +from math_spec.piecewise import Emitted, assumptions_of, expand_piecewise from math_spec.program import Assumption, Variable, assumption_message +from math_spec.validation import to_spec from tests.fixtures import DISPATCH_MODEL, expanded, override, raw_of, schema_of #: Larger than a minimal probe on purpose: a curve that exercises adjacency @@ -43,10 +44,11 @@ piecewise: cost_curve: - over: bp + along: bp + dims: [snapshot] links: - - [p, bp_x] - - [op_cost, bp_y] + p: [p, bp_x] + op_cost: [op_cost, bp_y] constraints: balance: @@ -66,17 +68,18 @@ raw_of(NONCONVEX_YAML), **{ 'piecewise.cost_curve.method': 'lp', - 'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '>=']], + 'piecewise.cost_curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=']}, 'variables.running': {'dims': ['snapshot'], 'domain': 'binary'}, }, ) #: The ``lp`` curve masked by one of its own values-parameters, so every check a block can carry is on it. -LP_MASKED = override(LP, **{'piecewise.cost_curve.points': 'bp_x'}) +LP_MASKED = override(LP, **{'piecewise.cost_curve.where': 'bp_x'}) #: Two dims in the frame, so the emitted ``dims`` has an order to get wrong. TWO_DIM = override( raw_of(NONCONVEX_YAML), **{ 'dimensions.generator': {'dtype': 'str'}, + 'piecewise.cost_curve.dims': ['snapshot', 'generator'], 'parameters.bp_x.dims': ['generator', 'bp'], 'parameters.bp_y.dims': ['generator', 'bp'], 'variables.p.dims': ['snapshot', 'generator'], @@ -143,18 +146,16 @@ def test_expansion_is_memoised_and_idempotent(): @pytest.mark.parametrize( - 'order', + 'dims', [ - pytest.param(['snapshot', 'generator', 'bp'], id='snapshot-first'), - pytest.param(['generator', 'snapshot', 'bp'], id='generator-first'), + pytest.param(['snapshot', 'generator'], id='snapshot-first'), + pytest.param(['generator', 'snapshot'], id='generator-first'), ], ) -def test_the_emitted_foreach_follows_declaration_order(order): - """The frame is a set until something orders it, and a set iterates the - same way for the same names within one process — so a run that reads the - set rather than the declaration fails one of the two orderings.""" - schema = schema_of(TWO_DIM, dimensions={d: TWO_DIM['dimensions'][d] for d in order}) - assert expand_piecewise(schema).variables['cost_curve_lam'].dims == order +def test_the_emitted_foreach_follows_the_dims_the_block_writes(dims): + """The weights are over ``dims:`` as written, then the breakpoint dim, whatever order the dimensions are declared in.""" + schema = schema_of(TWO_DIM, **{'piecewise.cost_curve.dims': dims}) + assert expand_piecewise(schema).variables['cost_curve_lam'].dims == [*dims, 'bp'] @pytest.mark.parametrize( @@ -172,9 +173,9 @@ def test_any_affine_expression_is_a_legal_link(link): NONCONVEX_YAML, macros={'twice': {'args': ['x'], 'template': 'x * 2'}}, expressions={'doubled': 'p * 2'}, - **{'piecewise.cost_curve.links': [[link, 'bp_x'], ['op_cost', 'bp_y']]}, + **{'piecewise.cost_curve.links': {'x': [link, 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}}, ) - assert expand_piecewise(schema).constraints['cost_curve_link0'].expression.startswith(f'({link}) ==') + assert expand_piecewise(schema).constraints['cost_curve_x'].expression.startswith(f'({link}) ==') @pytest.mark.parametrize( @@ -182,25 +183,19 @@ def test_any_affine_expression_is_a_legal_link(link): [ pytest.param( NONCONVEX_YAML, - {'piecewise.cost_curve.links': [['p', 'bp_x', '<='], ['op_cost', 'bp_y', '>=']]}, - 'at most one link', - id='at-most-one-link', + {'piecewise.cost_curve.links': {'p': ['p', 'bp_x', '<='], 'op_cost': ['op_cost', 'bp_y', '>=']}}, + 'nothing pins the operating point', + id='every-link-bounded', ), pytest.param( NONCONVEX_YAML, - {'piecewise.cost_curve.links': [['p', 'bp_x']]}, + {'piecewise.cost_curve.links': {'p': ['p', 'bp_x']}}, 'at least two links', id='a-single-link', ), pytest.param( NONCONVEX_YAML, - {'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '>='], ['p', 'bp_x']]}, - "a non-'==' sign is only supported with exactly two links", - id='a-bound-link-among-three', - ), - pytest.param( - NONCONVEX_YAML, - {'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '>=', 'extra']]}, + {'piecewise.cost_curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=', 'extra']}}, r'each link must be \[expression, values\] or \[expression, values, sign\]', id='a-link-of-four-elements', ), @@ -208,7 +203,7 @@ def test_any_affine_expression_is_a_legal_link(link): NONCONVEX_YAML, { 'piecewise.cost_curve.method': 'convex', - 'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y'], ['p', 'bp_x']], + 'piecewise.cost_curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y'], 'p2': ['p', 'bp_x']}, }, 'exactly two links', id='convex-needs-exactly-two-links', @@ -227,27 +222,21 @@ def test_any_affine_expression_is_a_legal_link(link): ), pytest.param( NONCONVEX_YAML, - {'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'nope']]}, + {'piecewise.cost_curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'nope']}}, "undeclared parameter 'nope'", id='undeclared-parameter', ), - pytest.param( - NONCONVEX_YAML, - {'parameters.reach': {'dims': ['bp']}, 'piecewise.cost_curve.points': 'reach'}, - "points parameter 'reach' is float, and a mask is a bool parameter", - id='points-that-are-not-a-mask', - ), pytest.param( LP, - {'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y']]}, + {'piecewise.cost_curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}}, 'needs exactly one link bounded by the curve', id='lp-with-both-links-pinned', ), pytest.param( LP, - {'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y'], ['p', 'bp_x']]}, - 'needs exactly one link bounded by the curve', - id='lp-with-three-links-none-bounded', + {'piecewise.cost_curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y'], 'p2': ['p', 'bp_x']}}, + 'requires exactly two links', + id='lp-with-three-links', ), pytest.param( LP, @@ -258,49 +247,43 @@ def test_any_affine_expression_is_a_legal_link(link): pytest.param( NONCONVEX_YAML, {'parameters.bp_x.dtype': 'bool'}, - "link 0 values parameter 'bp_x' is declared dtype: bool, and a breakpoint is a number", + "link 'p': values parameter 'bp_x' is declared dtype: bool, and a breakpoint is a number", id='values-that-are-not-numbers', ), pytest.param( LP, - {'piecewise.cost_curve.links': [['load', 'bp_x'], ['op_cost', 'bp_y', '>=']]}, - "link 0: method: lp bounds the curve's domain by rows comparing this link's expression", + {'piecewise.cost_curve.links': {'x': ['load', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=']}}, + "link 'x': method: lp bounds the curve's domain by rows comparing this link's expression", id='lp-with-an-x-link-carrying-no-variable', ), pytest.param( NONCONVEX_YAML, {'parameters.bp_x.dims': []}, - "link 0 values parameter 'bp_x' must carry dim 'bp'", + "link 'p': values parameter 'bp_x' must carry dim 'bp'", id='a-breakpoint-parameter-without-the-breakpoint-dim', ), pytest.param( NONCONVEX_YAML, - {'piecewise.cost_curve.points': 'nope'}, - "points references undeclared parameter 'nope'", - id='undeclared-points', - ), - pytest.param( - NONCONVEX_YAML, - {'parameters.reach': {'dims': [], 'dtype': 'bool'}, 'piecewise.cost_curve.points': 'reach'}, - "points parameter 'reach' must carry dim 'bp'", - id='points-without-the-breakpoint-dim', + {'piecewise.cost_curve.where': 'nope'}, + "piecewise 'cost_curve' where: 'nope' not found", + id='an-undeclared-name-in-the-where', ), pytest.param( NONCONVEX_YAML, - {'piecewise.cost_curve.links': [['p + bp_x', 'bp_x'], ['op_cost', 'bp_y']]}, - "link 0 expression already carries the breakpoint dim 'bp'", + {'piecewise.cost_curve.links': {'p': ['p + bp_x', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}}, + "link 'p' expression already carries the breakpoint dim 'bp'", id='a-link-carrying-the-breakpoint-dim', ), pytest.param( NONCONVEX_YAML, {'variables.u': {'dims': ['snapshot', 'bp'], 'domain': 'binary'}, 'piecewise.cost_curve.activity': 'u'}, - "activity already carries the breakpoint dim 'bp'", - id='a-gate-carrying-the-breakpoint-dim', + r"activity 'u' carries \['bp'\], which dims \['snapshot'\] does not", + id='a-gate-carrying-a-dim-the-block-does-not', ), pytest.param( NONCONVEX_YAML, {'dimensions.generator': {'dtype': 'str'}, 'parameters.bp_x.dims': ['generator', 'bp']}, - r"values parameter 'bp_x' carries \['generator'\], which no link expression does", + r"link 'p' values parameter 'bp_x' carries \['generator'\], which its row", id='a-breakpoint-varying-along-a-dim-no-link-carries', ), pytest.param( @@ -308,10 +291,10 @@ def test_any_affine_expression_is_a_legal_link(link): { 'dimensions.generator': {'dtype': 'str'}, 'parameters.reach': {'dims': ['generator', 'bp'], 'dtype': 'bool'}, - 'piecewise.cost_curve.points': 'reach', + 'piecewise.cost_curve.where': 'reach', }, - r"points parameter 'reach' carries \['generator'\], which the links do not", - id='a-mask-adding-a-coordinate-the-curve-does-not-have', + r"where 'reach' tests \['generator'\], which dims \['snapshot'\] does not carry", + id='a-where-adding-a-coordinate-the-curve-does-not-have', ), ], ) @@ -332,10 +315,13 @@ def test_a_malformed_block_is_refused(model, patch, match): ], ) def test_a_link_outside_the_language_is_named_where_the_user_wrote_it(link_expression, message): - """Lowering would catch these too, but naming ``cost_curve_link0`` — a declaration the user never wrote.""" + """Lowering would catch these too, but naming ``cost_curve_x`` — a declaration the user never wrote.""" with pytest.raises(SchemaError, match=message) as exc: - schema_of(NONCONVEX_YAML, **{'piecewise.cost_curve.links': [[link_expression, 'bp_x'], ['op_cost', 'bp_y']]}) - assert "piecewise 'cost_curve' link 0" in str(exc.value) + schema_of( + NONCONVEX_YAML, + **{'piecewise.cost_curve.links': {'x': [link_expression, 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}}, + ) + assert "piecewise 'cost_curve' link 'x'" in str(exc.value) @pytest.mark.parametrize( @@ -344,7 +330,7 @@ def test_a_link_outside_the_language_is_named_where_the_user_wrote_it(link_expre pytest.param(NONCONVEX_YAML, {'parameters.bp_x.dtype': 'str'}, id='a-label-as-a-breakpoint'), pytest.param( LP, - {'piecewise.cost_curve.links': [['load', 'bp_x'], ['op_cost', 'bp_y', '>=']]}, + {'piecewise.cost_curve.links': {'x': ['load', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=']}}, id='a-variable-free-x-link', ), ], @@ -353,14 +339,14 @@ def test_a_block_is_refused_on_the_link_the_file_wrote_and_not_on_a_row_it_would """Both were refused only once written out, under `cost_curve_increasing` or `cost_curve_domain_lo` — rows the file never declared.""" with pytest.raises(SchemaError) as exc: schema_of(model, **patch) - assert "piecewise 'cost_curve'" in str(exc.value) and 'link 0' in str(exc.value) + assert "piecewise 'cost_curve' link '" in str(exc.value) assert 'cost_curve_' not in str(exc.value), 'the refusal names the block, not a declaration the expansion writes' def test_an_undeclared_breakpoint_dimension_is_refused_once(): - """`over: nope` also said, per link, that the values parameter must carry `nope` — lines that follow from the first.""" + """`along: nope` also said, per link, that the values parameter must carry `nope` — lines that follow from the first.""" with pytest.raises(SchemaError) as exc: - schema_of(NONCONVEX_YAML, **{'piecewise.cost_curve.over': 'nope'}) + schema_of(NONCONVEX_YAML, **{'piecewise.cost_curve.along': 'nope'}) assert str(exc.value).splitlines() == [ "piecewise 'cost_curve' references undeclared dimension 'nope'. Declare it under 'dimensions:'." ] @@ -371,12 +357,16 @@ def test_a_link_reading_a_refused_entry_names_it_and_its_refusal_is_listed(): with pytest.raises(SchemaError) as exc: schema_of( NONCONVEX_YAML, - **{'expressions': {'bad': 'nope'}, 'piecewise.cost_curve.links': [['bad', 'bp_x'], ['op_cost', 'bp_y']]}, + **{ + 'expressions': {'bad': 'nope'}, + 'piecewise.cost_curve.links': {'p': ['bad', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}, + }, ) message = str(exc.value) assert "Named expression 'bad': 'nope' not found" in message assert ( - "piecewise 'cost_curve' link 0: named expression 'bad' does not load. Its refusal is listed with it." in message + "piecewise 'cost_curve' link 'p': named expression 'bad' does not load. Its refusal is listed with it." + in message ) @@ -394,10 +384,10 @@ def test_a_link_reading_a_nonlinear_entry_is_refused(): NONCONVEX_YAML, **{ 'expressions': {'ratio': 'op_cost / sum(p, over=snapshot)'}, - 'piecewise.cost_curve.links': [['ratio', 'bp_x'], ['op_cost', 'bp_y']], + 'piecewise.cost_curve.links': {'ratio': ['ratio', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}, }, ) - assert "piecewise 'cost_curve' link 0" in str(exc.value) + assert "piecewise 'cost_curve' link 'ratio'" in str(exc.value) def test_a_link_reading_a_degree_two_product_entry_is_refused(): @@ -413,17 +403,20 @@ def test_a_link_reading_a_degree_two_product_entry_is_refused(): NONCONVEX_YAML, **{ 'expressions': {'sq': 'p * op_cost'}, - 'piecewise.cost_curve.links': [['sq', 'bp_x'], ['op_cost', 'bp_y']], + 'piecewise.cost_curve.links': {'sq': ['sq', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}, }, ) - assert "piecewise 'cost_curve' link 0" in str(exc.value) + assert "piecewise 'cost_curve' link 'sq'" in str(exc.value) def test_an_entry_a_link_reads_is_in_the_math(): """A link's expression stands inside the constraints its expansion emits, so an entry it names is one the math reads.""" schema = schema_of( NONCONVEX_YAML, - **{'expressions': {'twice': 'p * 2'}, 'piecewise.cost_curve.links': [['twice', 'bp_x'], ['op_cost', 'bp_y']]}, + **{ + 'expressions': {'twice': 'p * 2'}, + 'piecewise.cost_curve.links': {'twice': ['twice', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}, + }, ) assert schema.expand('piecewise').program.expressions['twice'].in_math is True @@ -440,7 +433,7 @@ def test_a_link_reading_a_dual_entry_is_refused(): NONCONVEX_YAML, **{ 'expressions': {'price': 'dual(balance)'}, - 'piecewise.cost_curve.links': [['price', 'bp_x'], ['op_cost', 'bp_y']], + 'piecewise.cost_curve.links': {'price': ['price', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}, }, ) @@ -464,15 +457,19 @@ def test_a_gate_that_is_not_a_variable_is_refused(activity, match): raw_of(NONCONVEX_YAML), **{ 'piecewise.cost_curve.method': 'lp', - 'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '<=']], + 'piecewise.cost_curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '<=']}, }, ) #: Both links pinned, so nothing says which way the weights are pushed. CONVEX = override(raw_of(NONCONVEX_YAML), **{'piecewise.cost_curve.method': 'convex'}) #: The hull bounded below, which is the same relaxation ``lp`` states as its segment lines. -CONVEX_BOUNDED = override(CONVEX, **{'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '>=']]}) +CONVEX_BOUNDED = override( + CONVEX, **{'piecewise.cost_curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=']}} +) #: The hull bounded above, so the binding side is the upper one. -CONVEX_BOUNDED_BELOW = override(CONVEX, **{'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '<=']]}) +CONVEX_BOUNDED_BELOW = override( + CONVEX, **{'piecewise.cost_curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '<=']}} +) #: Named so the completeness check below can read the answers back off them. @@ -529,9 +526,9 @@ def test_a_masked_lp_curve_sits_its_rows_on_predicates_rather_than_on_parameters def test_a_file_supplied_mask_is_what_the_contiguity_condition_reads(): - """A ``points:`` naming a parameter the file declared is bound like any other, and the mask check names it.""" + """A ``where:`` naming a parameter the file declared is bound like any other, and the mask check names it.""" program = expanded( - override(LP, **{'parameters.reach': {'dims': ['bp'], 'dtype': 'bool'}, 'piecewise.cost_curve.points': 'reach'}), + override(LP, **{'parameters.reach': {'dims': ['bp'], 'dtype': 'bool'}, 'piecewise.cost_curve.where': 'reach'}), 'piecewise', ).program @@ -553,15 +550,15 @@ def test_a_file_supplied_mask_is_what_the_contiguity_condition_reads(): def test_a_gap_is_explained_by_the_rows_the_method_writes(method, reason): """Every method gave the ``lp`` reason, naming a chord row and domain rows that only ``lp`` writes.""" links = ( - [['p', 'bp_x'], ['op_cost', 'bp_y', '>=']] + {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=']} if method in {'convex', 'lp'} - else [['p', 'bp_x'], ['op_cost', 'bp_y']] + else {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']} ) spec = schema_of( NONCONVEX_YAML, **{ 'piecewise.cost_curve.method': method, - 'piecewise.cost_curve.points': 'bp_x', + 'piecewise.cost_curve.where': 'bp_x', 'piecewise.cost_curve.links': links, }, ) @@ -612,3 +609,662 @@ def test_every_check_has_a_sentence(suffix): 'the refusal names the columns a consumer has to look at before it says why' ) assert "— piecewise 'cost_curve':" in message, 'and trails the sentence the method implies' + + +#: A curve only some members have: the frame is two dims, and the mask names one of them. +MASKED = override( + TWO_DIM, + **{ + 'parameters.has_curve': {'dims': ['generator'], 'dtype': 'bool'}, + 'piecewise.cost_curve.where': 'has_curve', + }, +) +#: The same mask on the block that states its curve as segment lines, which emits no weights to inherit one. +LP_WHERE = override( + override(LP, **{'parameters.has_curve': {'dims': ['bp'], 'dtype': 'bool'}}), + **{'parameters.has_curve.dims': ['snapshot'], 'piecewise.cost_curve.where': 'has_curve'}, +) + + +@pytest.mark.parametrize( + 'emitted', + [ + pytest.param('cost_curve_p', id='link-p'), + pytest.param('cost_curve_op_cost', id='link-op_cost'), + pytest.param('cost_curve_convexity', id='convexity'), + ], +) +def test_a_where_reaches_every_row_the_block_emits(emitted): + """A link row left unmasked is the bug: the weighted sum is empty off the mask, so the row pins `p == 0`. + + The convexity row is a reduction too, and absence does not spread out of + one — unmasked it would read `0 == 1` at a member with no curve. + """ + expanded = expand_piecewise(schema_of(MASKED)) + assert expanded.constraints[emitted].where == 'has_curve' + + +def test_the_row_the_set_states_needs_no_mask_of_its_own(): + """`adjacency` states its restriction as a set, and the set's row is an inequality. + + Unmasked it reads `0 <= 1` at a member with no curve, which every row is + free to say. The rows that would read `0 == 1` there are the block's own, + and those carry the mask. + """ + expanded = expand_piecewise(schema_of(MASKED)) + assert expanded.constraints['cost_curve_pick'].expression == 'sum(cost_curve_seg, over=bp) <= 1' + assert expanded.constraints['cost_curve_pick'].where is None, 'the inequality holds off the mask on its own' + + +@pytest.mark.parametrize( + 'emitted', [pytest.param('cost_curve_lam', id='lam'), pytest.param('cost_curve_seg', id='seg')] +) +def test_a_where_reaches_the_weights(emitted): + assert expand_piecewise(schema_of(MASKED)).variables[emitted].where == 'has_curve' + + +def test_the_adjacency_row_inherits_the_mask_rather_than_restating_it(): + """Its every term is a weight, and absence spreads through arithmetic — which is how a ragged `where:` reaches it too.""" + expanded = expand_piecewise(schema_of(MASKED)) + assert expanded.constraints['cost_curve_adjacency'].where is None + + +def test_a_ragged_where_reaches_the_weights_as_written_and_the_frame_rows_as_a_count(): + """One mask says which coordinates have a curve and how far each runs; a row over the frame alone cannot read it.""" + expanded = expand_piecewise(schema_of(MASKED, **{'piecewise.cost_curve.where': 'has_curve AND bp_x'})) + + assert expanded.variables['cost_curve_lam'].where == 'has_curve AND bp_x' + assert expanded.constraints['cost_curve_convexity'].where == 'count(has_curve AND bp_x, over=bp) > 0' + assert expanded.constraints['cost_curve_p'].where == 'count(has_curve AND bp_x, over=bp) > 0' + + +def test_a_where_joins_both_gate_rows(): + """`activity:` splits the convexity row across the gate's own mask, and the block's where holds over both halves.""" + schema = schema_of( + MASKED, + **{ + 'variables.u': {'dims': ['snapshot', 'generator'], 'domain': 'binary', 'where': 'committable'}, + 'parameters.committable': {'dims': ['generator'], 'dtype': 'bool'}, + 'piecewise.cost_curve.activity': 'u', + }, + ) + expanded = expand_piecewise(schema) + assert expanded.constraints['cost_curve_convexity'].where == '(has_curve) AND (u)' + assert expanded.constraints['cost_curve_convexity_ungated'].where == '(has_curve) AND (NOT u)' + + +def test_a_ragged_where_is_grouped_where_an_edge_row_shifts_it(): + """Unparenthesised, `a OR b AND shift(…)` binds the AND to `b` alone and the edge is read off half the mask.""" + schema = schema_of( + MASKED, + **{ + 'parameters.also_curved': {'dims': ['generator'], 'dtype': 'bool'}, + 'piecewise.cost_curve.where': 'has_curve OR also_curved AND bp_x', + 'piecewise.cost_curve.method': 'lp', + 'piecewise.cost_curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=']}, + }, + ) + expanded = expand_piecewise(schema) + + assert expanded.constraints['cost_curve_domain_lo'].where == ( + '(has_curve OR also_curved AND bp_x) AND NOT shift(has_curve OR also_curved AND bp_x, along=bp, offset=1)' + ) + + +@pytest.mark.parametrize( + ('patch', 'match'), + [ + pytest.param( + { + 'dimensions.region': {'dtype': 'str'}, + 'parameters.onshore': {'dims': ['region'], 'dtype': 'bool'}, + 'piecewise.cost_curve.where': 'onshore', + }, + 'cannot add coordinates', + id='outside-the-frame', + ), + pytest.param({'piecewise.cost_curve.where': 'nowhere'}, 'nowhere', id='naming-nothing'), + ], +) +def test_a_where_the_block_cannot_read_is_refused(patch, match): + with pytest.raises(LanguageError, match=match): + schema_of(MASKED, **patch) + + +def test_segment_lines_carry_the_mask_that_no_weight_can_hand_them(): + """`method: lp` emits no weights, so its three rows take the block's where themselves or stand everywhere.""" + expanded = expand_piecewise(schema_of(LP_WHERE)) + assert expanded.constraints['cost_curve_chord'].where == '(has_curve) AND (position(bp) > 0)' + assert expanded.constraints['cost_curve_domain_lo'].where == '(has_curve) AND (position(bp) == 0)' + assert expanded.constraints['cost_curve_domain_hi'].where == '(has_curve) AND (position(bp) == -1)' + + +@pytest.mark.parametrize( + ('where', 'method'), + [ + pytest.param('has_curve', 'lp', id='a-mask-over-the-frame'), + pytest.param('has_curve AND bp_x', 'lp', id='a-ragged-mask'), + pytest.param('has_curve', 'convex', id='a-single-bend-over-the-frame'), + ], +) +def test_a_model_written_out_and_read_back_asks_its_conditions_only_where_a_curve_runs(where, method): + """The mask lived only on the program's declaration, so the file `to_yaml()` wrote asked every generator. + + Read back, that file held a generator with no curve to breakpoints it has + no rows for. A ragged mask had the same gap on the conditions over the + frame alone: a generator the mask admits no breakpoint of failed + `count(...) == 1`, though it has no curve. + """ + links = {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=' if method == 'lp' else '==']} + model = override(MASKED, **{'piecewise.cost_curve.where': where, 'piecewise.cost_curve.method': method}) + written = schema_of(model, **{'piecewise.cost_curve.links': links}).expand('piecewise') + read_back = to_spec(raw_of(written.to_yaml())).program + + unmasked = { + name + for name, assumption in read_back.assumptions.items() + if assumption.where is None or 'has_curve' not in assumption.where.names_read + } + assert not unmasked, f'{sorted(unmasked)} would be asked at a generator with no curve' + + +#: fluxopt's converter: one curve per generator, tying however many flows the +#: relation gives it. The link that carries `flow` walks the relation from the +#: curve's `generator` to its own `flow`. +WALKED = { + 'dimensions': { + 'snapshot': {'dtype': 'int'}, + 'generator': {'dtype': 'str'}, + 'flow': {'dtype': 'str'}, + 'bp': {'dtype': 'int'}, + }, + 'relations': {'generator_of': {'key': 'flow', 'values': 'generator'}}, + 'parameters': { + 'load': {'dims': ['snapshot']}, + 'bp_power': {'dims': ['flow', 'bp']}, + 'bp_fuel': {'dims': ['generator', 'bp']}, + }, + 'variables': { + 'power': {'dims': ['flow', 'snapshot'], 'bounds': {'lower': 0}}, + 'fuel': {'dims': ['generator', 'snapshot'], 'bounds': {'lower': 0}}, + }, + 'piecewise': { + 'coupling': { + 'along': 'bp', + 'dims': ['generator', 'snapshot'], + 'links': { + 'power': { + 'expression': 'power', + 'values': 'bp_power', + 'by': 'generator_of', + 'over': 'generator', + 'into': 'flow', + }, + 'fuel': ['fuel', 'bp_fuel'], + }, + } + }, + 'constraints': {'balance': {'dims': ['snapshot'], 'expression': 'sum(power, over=flow) == load'}}, + 'objective': {'sense': 'minimize', 'expression': 'sum(fuel)'}, +} +#: The walked link alone, which the relation gives its arity. +POWER_ONLY = { + 'piecewise.coupling.links': { + 'power': { + 'expression': 'power', + 'values': 'bp_power', + 'by': 'generator_of', + 'over': 'generator', + 'into': 'flow', + } + }, + 'objective.expression': 'sum(power)', +} + + +def test_a_block_builds_one_curve_per_coordinate_of_its_dims(): + """The curve is per generator, though one of its links is per flow.""" + expanded = expand_piecewise(schema_of(WALKED)) + assert expanded.variables['coupling_lam'].dims == ['generator', 'snapshot', 'bp'] + assert expanded.constraints['coupling_convexity'].dims == ['generator', 'snapshot'] + + +def test_a_walked_link_emits_one_row_per_fine_coordinate(): + """The link reads the curve's weights through the relation, so a generator's flows share one curve.""" + link = expand_piecewise(schema_of(WALKED)).constraints['coupling_power'] + assert link.dims == ['flow', 'snapshot'], 'dims: with the consumed dim replaced by the produced one' + assert link.expression == ( + '(power) == sum(at(coupling_lam, by=generator_of, over=generator, into=flow) * bp_power, over=bp)' + ) + + +def test_a_link_that_walks_nothing_stays_on_the_blocks_dims(): + link = expand_piecewise(schema_of(WALKED)).constraints['coupling_fuel'] + assert link.dims == ['generator', 'snapshot'] + assert link.expression == '(fuel) == sum(coupling_lam * bp_fuel, over=bp)' + + +def test_a_link_row_is_named_after_the_link_and_not_after_its_place(): + """Rows named by position renamed every constraint after the one a reordering moved.""" + swapped = override( + WALKED, **{'piecewise.coupling.links': dict(reversed(WALKED['piecewise']['coupling']['links'].items()))} + ) + for model in (WALKED, swapped): + rows = expand_piecewise(schema_of(model)).constraints + assert rows['coupling_fuel'].expression == '(fuel) == sum(coupling_lam * bp_fuel, over=bp)' + + +def test_the_checks_still_name_the_values_parameters_a_walked_block_ties(): + curve = schema_of(WALKED).program.piecewise['coupling'] + assert [link.values for link in curve.links] == ['bp_power', 'bp_fuel'], 'the values parameters, in link order' + + +def test_a_walked_block_round_trips_through_yaml(): + """A link the file wrote as a mapping cannot serialise back as a two-item list.""" + schema = schema_of(WALKED) + assert to_spec(raw_of(schema.to_yaml())).piecewise['coupling'] == schema.piecewise['coupling'] + + +def _walk(**written: object) -> dict[str, object]: + """The `power` link with *written* in place of its walk keys, and `None` dropping one.""" + link = {'expression': 'power', 'values': 'bp_power', 'by': 'generator_of', 'over': 'generator', 'into': 'flow'} + link |= written + return {'piecewise.coupling.links.power': {k: v for k, v in link.items() if v is not None}} + + +@pytest.mark.parametrize( + ('patch', 'match'), + [ + pytest.param({'piecewise.coupling.dims': None}, 'one curve per coordinate of', id='a-block-without-dims'), + pytest.param(_walk(over=None, into=None), r"\['over', 'into'\] are missing", id='a-walk-naming-no-columns'), + pytest.param(_walk(by=None, over=None), r"\['by', 'over'\] are missing", id='into-without-a-relation'), + pytest.param(_walk(by=None, into=None), r"\['by', 'into'\] are missing", id='over-without-a-relation'), + pytest.param( + {'piecewise.coupling.dims': ['generator', 'snapshot', 'bp']}, + 'breakpoint dim', + id='dims-carrying-the-breakpoint-dim', + ), + pytest.param( + {'piecewise.coupling.dims': ['generator']}, + r"link 'power' expression carries \['snapshot'\], which its row \['flow'\] does not", + id='dims-a-link-expression-leaves', + ), + pytest.param(_walk(by='nowhere_of'), 'nowhere_of', id='a-walk-through-an-undeclared-relation'), + pytest.param( + {'piecewise.coupling.dims': ['snapshot']}, + r"link 'power': over reaches \['generator'\], which the block's dims \['snapshot'\] do not carry", + id='a-walk-consuming-a-dim-the-block-lacks', + ), + pytest.param( + {'piecewise.coupling.dims': ['generator', 'flow', 'snapshot']}, + r"link 'power': into reaches \['flow'\], which the block's dims .* already carry", + id='a-walk-into-a-dim-the-block-has', + ), + pytest.param(_walk(into=['flow', 'flow']), r"link 'power': into repeats a column", id='a-repeated-column'), + pytest.param( + {'relations.slot_of': {'key': 'bp', 'values': 'generator'}} + | _walk(by='slot_of', over='generator', into='bp'), + r"link 'power': into reaches 'bp', the breakpoint dim", + id='a-walk-into-the-breakpoint-dim', + ), + pytest.param( + {'piecewise.coupling.links.fuel': ['fuel', 'bp_fuel', '>=']} | _walk(sign='<='), + 'nothing pins the operating point', + id='every-row-bounded', + ), + ], +) +def test_a_walked_block_the_language_cannot_read_is_refused(patch, match): + """Each refusal names the link the file wrote, not a constraint its expansion would write.""" + with pytest.raises(LanguageError, match=match): + schema_of(WALKED, **patch) + + +def test_a_link_that_only_gains_a_dimension_is_refused_and_names_the_walk(): + """A row finer than the curve is reached through a relation, which says which curve each fine row reads.""" + model = override( + WALKED, + **{ + 'dimensions.carrier': {'dtype': 'str'}, + 'parameters.bp_rate': {'dims': ['generator', 'carrier', 'bp']}, + 'variables.rate': {'dims': ['generator', 'carrier', 'snapshot']}, + 'piecewise.coupling.links.rate': {'expression': 'rate', 'values': 'bp_rate', 'into': 'carrier'}, + }, + ) + with pytest.raises(LanguageError, match=r"\['by', 'over'\] are missing"): + schema_of(model) + + +@pytest.mark.parametrize( + ('link', 'match'), + [ + pytest.param('convexity', "link 'convexity' names its row 'coupling_convexity'", id='the-convexity-row'), + pytest.param('lam', "link 'lam' names its row 'coupling_lam'", id='the-weights'), + pytest.param('adjacency_below', "link 'adjacency_below'", id='a-row-the-set-writes'), + ], +) +def test_a_link_named_after_a_row_the_block_writes_is_refused(link, match): + with pytest.raises(LanguageError, match=match): + schema_of(WALKED, **{f'piecewise.coupling.links.{link}': ['fuel', 'bp_fuel']}) + + +def test_a_link_name_no_row_could_take_is_refused(): + with pytest.raises(LanguageError, match=r"links: \['2nd'\] is not a name"): + schema_of(WALKED, **{'piecewise.coupling.links.2nd': ['fuel', 'bp_fuel']}) + + +def test_a_gate_over_more_than_the_blocks_dims_is_refused(): + """The gate widened a declared `dims:` silently, and built the weights over a dimension the file never gave the curve.""" + with pytest.raises( + LanguageError, match=r"activity 'u' carries \['generator'\], which dims \['snapshot'\] does not" + ): + schema_of( + TWO_DIM, + **{ + 'piecewise.cost_curve.dims': ['snapshot'], + 'piecewise.cost_curve.links': {'p': ['load', 'bp_z'], 'op_cost': ['load * 2', 'bp_z']}, + 'parameters.bp_z': {'dims': ['bp']}, + 'variables.u': {'dims': ['snapshot', 'generator'], 'domain': 'binary'}, + 'piecewise.cost_curve.activity': 'u', + }, + ) + + +def test_a_gate_over_fewer_dims_than_the_block_switches_each_curve_it_covers(): + """A unit commitment per generator gates that generator's curve in every snapshot.""" + expanded = expand_piecewise( + schema_of( + TWO_DIM, + **{'variables.u': {'dims': ['generator'], 'domain': 'binary'}, 'piecewise.cost_curve.activity': 'u'}, + ) + ) + assert expanded.constraints['cost_curve_convexity'].expression == 'sum(cost_curve_lam, over=bp) == (u)' + assert expanded.variables['cost_curve_lam'].dims == ['snapshot', 'generator', 'bp'] + + +#: fluxopt's system: only some generators run on a curve, and the rest have none at all. +CURVED = override( + WALKED, + **{ + 'parameters.curved': {'dims': ['generator'], 'dtype': 'bool'}, + 'piecewise.coupling.where': 'curved', + }, +) + + +def test_a_block_mask_reaches_a_walked_link_through_its_relation(): + """The row is over flows and the mask over generators, so the row reads the mask at each flow's generator. + + The block refused a mask beside a walk, so a model with one curved + generator built a curve, a convexity row and its binaries for every + generator it declared. + """ + expanded = expand_piecewise(schema_of(CURVED)) + assert expanded.variables['coupling_lam'].where == 'curved', 'no weights where a generator has no curve' + assert expanded.constraints['coupling_convexity'].where == 'curved' + assert expanded.constraints['coupling_fuel'].where == 'curved', 'a link on dims: reads the mask as written' + assert expanded.constraints['coupling_power'].where == ('at(curved, by=generator_of, over=generator, into=flow)'), ( + 'a walked link reads it at the generator each flow maps to' + ) + + +def test_a_ragged_mask_reaches_a_walked_link_as_the_count_of_its_curves_breakpoints(): + """A walked row is over the curve's dims less the walk, so it takes what a row over dims: alone takes, read through.""" + expanded = expand_piecewise( + schema_of( + WALKED, + **{ + 'parameters.reach': {'dims': ['generator', 'bp'], 'dtype': 'bool'}, + 'piecewise.coupling.where': 'reach', + }, + ) + ) + assert expanded.variables['coupling_lam'].where == 'reach' + assert expanded.constraints['coupling_power'].where == ( + 'at(count(reach, over=bp) > 0, by=generator_of, over=generator, into=flow)' + ) + + +def test_a_mask_over_dims_the_walk_keeps_reaches_the_walked_row_as_written(): + """A mask over `snapshot` alone says nothing about generators, and the walked row keeps `snapshot`.""" + expanded = expand_piecewise( + schema_of( + WALKED, + **{ + 'parameters.season': {'dims': ['snapshot'], 'dtype': 'bool'}, + 'piecewise.coupling.where': 'season', + }, + ) + ) + assert expanded.constraints['coupling_power'].where == 'season' + + +def test_a_mask_carrying_part_of_what_a_walk_reads_through_is_refused(): + """The relation is keyed by flow and snapshot, so the read joins on snapshot and needs the mask to carry it too.""" + model = override( + CURVED, + **{ + 'relations.generator_of': {'key': ['flow', 'snapshot'], 'values': 'generator'}, + }, + ) + with pytest.raises(LanguageError, match=r"where 'curved' carries \['generator'\] and not \['snapshot'\]"): + schema_of(model) + + +@pytest.mark.parametrize( + ('model', 'read'), + [ + pytest.param(WALKED, {'generator_of'}, id='no-mask-asks-only-where-the-relation-reaches'), + pytest.param(CURVED, {'curved', 'generator_of'}, id='a-mask-read-through'), + ], +) +def test_a_walked_links_breakpoints_are_asked_only_at_the_rows_it_reads_the_curve_at(model, read): + """Asked with the other links, `bp_power` was demanded at every flow, including those of a generator with no curve.""" + assumptions = schema_of(model).expand('piecewise').program.assumptions + walked = assumptions['coupling_power_complete'] + assert walked.predicate.names_read == frozenset({'bp_power'}) + assert walked.where is not None and walked.where.dims == frozenset({'flow'}), 'asked per flow the walk reaches' + assert walked.where.names_read == frozenset(read) + assert assumptions['coupling_complete'].predicate.names_read == frozenset({'bp_fuel'}), ( + 'the link on dims: keeps the block condition to itself' + ) + + +def test_a_walked_links_own_condition_is_a_name_the_block_reserves(): + with pytest.raises( + LanguageError, match="writes assumption 'coupling_power_complete', which this file already declares" + ): + schema_of(WALKED, assumptions={'coupling_power_complete': 'bp_power >= 0'}) + + +def test_a_mask_on_the_links_own_variable_leaves_the_walked_row_unbuilt(): + """Absence spreads through arithmetic, so a flow with no variable has no row, with or without a block mask.""" + expanded = expand_piecewise( + schema_of( + WALKED, + **{ + 'parameters.on_a_curve': {'dims': ['flow'], 'dtype': 'bool'}, + 'variables.power.where': 'on_a_curve', + }, + ) + ) + assert expanded.variables['power'].where == 'on_a_curve' + + +def test_one_walked_link_is_a_curve_because_the_relation_gives_it_its_arity(): + """A converter whose coupled quantities are all flows of one variable is one link, and it ties them all. + + Two links is what a curve needs when a link is one row. A walked link is + one row per fine coordinate, so the relation supplies the arity that the + second link otherwise would. + """ + expanded = expand_piecewise(schema_of(WALKED, **POWER_ONLY)) + assert expanded.constraints['coupling_convexity'].dims == ['generator', 'snapshot'], 'one curve per generator' + assert expanded.constraints['coupling_power'].dims == ['flow', 'snapshot'], 'one row per flow, sharing it' + assert 'coupling_fuel' not in expanded.constraints + + +def test_one_link_that_walks_nothing_is_still_a_bound_rather_than_a_curve(): + with pytest.raises(LanguageError, match='a bound rather than a curve'): + schema_of(NONCONVEX_YAML, **{'piecewise.cost_curve.links': {'p': ['p', 'bp_x']}}) + + +@pytest.mark.parametrize( + ('method', 'match'), + [ + pytest.param('convex', 'no shape left to check', id='convex'), + pytest.param('lp', 'which row plays it is data', id='lp'), + ], +) +def test_the_two_restricted_methods_refuse_a_walked_link_for_their_own_reasons(method, match): + """`lp` loses the abscissa its line is written against; `convex` loses the pair it reads a shape from. + + The two reasons are not one, so neither message may stand in for the other. + """ + with pytest.raises(LanguageError, match=match): + schema_of(WALKED, **{'piecewise.coupling.method': method}) + + +def test_links_that_disagree_on_their_dims_are_refused_rather_than_read_as_one_curve_each(): + """A link finer than `dims:` would multiply the rows it builds, each pinning the same `fuel` to a curve of its own.""" + with pytest.raises(LanguageError, match=r"link 'power' expression carries \['flow'\]"): + schema_of(WALKED, **{'piecewise.coupling.links': {'power': ['power', 'bp_power'], 'fuel': ['fuel', 'bp_fuel']}}) + + +@pytest.mark.parametrize( + ('patch', 'match'), + [ + pytest.param( + {'variables.power.dims': ['flow', 'snapshot', 'period']}, + r'carries \[.period.\]', + id='finer-than-its-row', + ), + pytest.param( + { + 'piecewise.coupling.dims': ['generator', 'snapshot', 'period'], + 'variables.fuel.dims': ['generator', 'snapshot', 'period'], + }, + r'does not carry \[.period.\]', + id='coarser-than-its-row', + ), + ], +) +def test_a_link_spanning_a_dimension_its_row_does_not_is_refused_both_ways(patch, match): + """A curve and the quantity on it vary together or the file says which — neither direction is guessed.""" + model = override( + WALKED, + **{ + 'dimensions.period': {'dtype': 'int'}, + 'parameters.load': {'dims': ['snapshot', 'period']}, + 'constraints.balance': {'dims': ['snapshot', 'period'], 'expression': 'sum(power, over=flow) == load'}, + }, + ) + with pytest.raises(LanguageError, match=match): + schema_of(model, **patch) + + +def test_a_period_the_curve_and_its_links_both_carry_loads(): + """The rewrite both refusals name: put the dimension in dims:, and the curve varies along it.""" + expanded = expand_piecewise( + schema_of( + WALKED, + **{ + 'dimensions.period': {'dtype': 'int'}, + 'parameters.load': {'dims': ['snapshot', 'period']}, + 'constraints.balance': { + 'dims': ['snapshot', 'period'], + 'expression': 'sum(power, over=flow) == load', + }, + 'variables.power.dims': ['flow', 'snapshot', 'period'], + 'variables.fuel.dims': ['generator', 'snapshot', 'period'], + 'piecewise.coupling.dims': ['generator', 'snapshot', 'period'], + }, + ) + ) + assert expanded.variables['coupling_lam'].dims == ['generator', 'snapshot', 'period', 'bp'] + assert expanded.constraints['coupling_power'].dims == ['flow', 'snapshot', 'period'] + + +#: Three quantities on one curve, two of them bounded rather than pinned. +THREE_WAY = override( + raw_of(NONCONVEX_YAML), + **{ + 'parameters.bp_z': {'dims': ['bp']}, + 'variables.heat': {'dims': ['snapshot'], 'bounds': {'lower': 0}}, + }, +) + + +@pytest.mark.parametrize( + 'links', + [ + pytest.param( + {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>='], 'heat': ['heat', 'bp_z']}, + id='three-links-one-bounded', + ), + pytest.param( + {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>='], 'heat': ['heat', 'bp_z', '<=']}, + id='two-bounded-signs-at-once', + ), + pytest.param( + {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=']}, id='the-two-link-case-that-always-worked' + ), + ], +) +def test_a_curve_bounds_as_many_links_as_it_likes_while_one_pins_it(links): + """Under adjacency each link is its own row against the shared weights, so a sign is per link. + + The old rule capped a block at one non-`==` sign and only with exactly two + links. Nothing in the emission needed that: `_weights` writes + `(expr) sign sum(lam * values, along=bp)` per link and reaches for no other. + """ + expanded = expand_piecewise(schema_of(THREE_WAY, **{'piecewise.cost_curve.links': links})) + for key, link in links.items(): + sign = link[2] if len(link) == 3 else '==' + expression = expanded.constraints[f'cost_curve_{key}'].expression + assert f') {sign} sum(' in expression, f'link on {key} carries its own {sign}' + + +@pytest.mark.parametrize( + ('method', 'match'), + [ + pytest.param('convex', 'would ship uncertified', id='convex'), + pytest.param('lp', 'no line to write', id='lp'), + ], +) +def test_the_two_restricted_methods_take_exactly_two_links_for_their_own_reasons(method, match): + """`lp` had no such rule and leaned on the sign cap for it, so three links raised `ValueError`. + + `convex` builds the same rows for any number of links; what it cannot do + past two is certify that relaxing onto the hull is exact, because the sign + on the bounded link is what names the direction to check. + """ + with pytest.raises(LanguageError, match=match): + schema_of( + THREE_WAY, + **{ + 'piecewise.cost_curve.method': method, + 'piecewise.cost_curve.links': { + 'p': ['p', 'bp_x'], + 'op_cost': ['op_cost', 'bp_y', '>='], + 'heat': ['heat', 'bp_z'], + }, + }, + ) + + +@pytest.mark.parametrize('method', ['adjacency', 'sos2', 'convex', 'lp']) +def test_every_assumption_a_block_may_derive_is_a_name_it_reserves(method): + """The collision check reserves the assumption names at load, before the mask that decides which are written is typed.""" + links = ( + {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=']} + if method in {'convex', 'lp'} + else {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']} + ) + spec = schema_of(NONCONVEX_YAML, **{'piecewise.cost_curve.method': method, 'piecewise.cost_curve.links': links}) + curve = spec.program.piecewise['cost_curve'] + + derived = set(assumptions_of('cost_curve', curve, spec.piecewise['cost_curve'].where)) + assert derived <= set(Emitted.of('cost_curve', curve).assumptions), ( + 'a condition the block derives under no reserved name' + ) diff --git a/tests/test_sos.py b/tests/test_sos.py index cd1c059a..24726917 100644 --- a/tests/test_sos.py +++ b/tests/test_sos.py @@ -35,7 +35,14 @@ 'p': {'dims': ['snapshot'], 'bounds': {'lower': 0, 'upper': 100}}, 'op_cost': {'dims': ['snapshot'], 'bounds': {'lower': 0}}, }, - 'piecewise': {'cost_curve': {'over': 'bp', 'method': 'sos2', 'links': [['p', 'bp_x'], ['op_cost', 'bp_y']]}}, + 'piecewise': { + 'cost_curve': { + 'along': 'bp', + 'dims': ['snapshot'], + 'method': 'sos2', + 'links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}, + } + }, 'constraints': {'balance': {'dims': ['snapshot'], 'expression': 'p == load'}}, 'objective': {'sense': 'minimize', 'expression': 'sum(op_cost, over=snapshot)'}, } diff --git a/tests/test_validation.py b/tests/test_validation.py index 37e07254..8c7cb7c9 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -2033,7 +2033,7 @@ def test_a_name_no_expression_could_write_is_refused(self, section: str, name: s 'expressions': {'expression': 'c'}, 'macros': {'args': ['x'], 'template': 'x * 2'}, 'constraints': {'dims': ['g'], 'expression': 'p <= c'}, - 'piecewise': {'over': 'g', 'links': [['p', 'c'], ['q', 'c']], 'method': 'convex'}, + 'piecewise': {'along': 'g', 'dims': [], 'links': {'p': ['p', 'c'], 'q': ['q', 'c']}, 'method': 'convex'}, 'sos': {'variable': 'p', 'along': 'g', 'type': 1}, } model = copy.deepcopy(SMALL_MODEL) diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index acebb2d1..bad78dcf 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -136,7 +136,9 @@ \text{cost\_curve} && \mathit{op\_cost}_{t,g} & \ge \begin{cases} \mathit{warm}_{t,g} & \text{if } \mathrm{is\_flexible}_{g} \\ 1 & \text{otherwise} \end{cases} \cdot \mathrm{pwl}_{a \in \mathcal{A} \,:\, \mathrm{bp\_run}_{g,a}}(\mathrm{bp\_x}_{g,a},\ \mathrm{bp\_y}_{g,a})(p_{t,g}) && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{hull\_curve} && \left( p_{t,g},\ \mathit{fuel}_{t,g} \right) & \in \mathrm{conv}_{a \in \mathcal{A}}(\mathrm{bp\_x}_{g,a},\ \mathrm{bp\_heat}_{g,a}) && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{lp\_curve} && \mathit{fuel}_{t,g} & \ge \mathrm{pwl}_{a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined}}(\mathrm{bp\_x}_{g,a},\ \mathrm{bp\_y}_{g,a})(p_{t,g}) && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ -\text{ramp\_curve} && \mathit{heat}_{t,g} & \le \mathrm{pwl}_{a \in \mathcal{A}}(\mathrm{bp\_x}_{g,a},\ \mathrm{bp\_heat}_{g,a})(p_{t,g}) && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +\text{ramp\_curve} && \mathit{heat}_{t,g} & \le \mathrm{pwl}_{a \in \mathcal{A}}(\mathrm{bp\_x}_{g,a},\ \mathrm{bp\_heat}_{g,a})(p_{t,g}) && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{chp\_curve} && \left( p_{t,g},\ \mathit{fuel}_{t,g},\ \mathit{heat}_{t,g} \right) & \in \mathrm{pwl}_{a \in \mathcal{A}}(\mathrm{bp\_x}_{g,a},\ \mathrm{bp\_y}_{g,a},\ \mathrm{bp\_heat}_{g,a}) + \{0\} \times \mathbb{R}_{\ge 0} \times \mathbb{R}_{\le 0} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{bus\_curve} && \left( p_{t,g} \right)_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} & \in \mathrm{pwl}_{a \in \mathcal{A}}(\left( \mathrm{bp\_x}_{g,a} \right)_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b}) && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{load}_{t,b} \text{ is defined} \end{align} \paragraph{Definitions} @@ -179,19 +181,21 @@ \text{northern\_demand\_is\_real} && \mathrm{load}_{t,b} & \ge 0 && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{zone\_of}(b) = \text{'}\mathrm{north}\text{'} \\ \text{fuel\_curve\_complete} && \mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \mathrm{bp\_y}_{g,a} \text{ is defined} \wedge \mathrm{bp\_heat}_{g,a} \text{ is defined} & && \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \\ \text{cost\_curve\_complete} && \mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \mathrm{bp\_y}_{g,a} \text{ is defined} & && \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{bp\_run}_{g,a} \\ -\text{cost\_curve\_contiguous} && \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_run}_{g,a} \wedge \neg \mathrm{bp\_run}_{g,a - 1} \} \rvert & = 1 && \forall\, g \in \mathcal{G} \\ +\text{cost\_curve\_contiguous} && \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_run}_{g,a} \wedge \neg \mathrm{bp\_run}_{g,a - 1} \} \rvert & = 1 && \forall\, g \in \mathcal{G} \,:\, \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_run}_{g,a} \} \rvert > 0 \\ \text{hull\_curve\_complete} && \mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \mathrm{bp\_heat}_{g,a} \text{ is defined} & && \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \\ \text{hull\_curve\_increasing} && \mathrm{bp\_x}_{g,a \boxminus_{0} 1} & < \mathrm{bp\_x}_{g,a} && \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{pos}(a) > 0 \\ \text{hull\_curve\_curvature} && \lvert \{ a \in \mathcal{A} \,:\, \left( \mathrm{bp\_heat}_{g,a} - \mathrm{bp\_heat}_{g,a \boxminus_{0} 1} \right) \cdot \left( \mathrm{bp\_x}_{g,a \boxplus_{0} 1} - \mathrm{bp\_x}_{g,a} \right) > \left( \mathrm{bp\_heat}_{g,a \boxplus_{0} 1} - \mathrm{bp\_heat}_{g,a} \right) \cdot \left( \mathrm{bp\_x}_{g,a} - \mathrm{bp\_x}_{g,a \boxminus_{0} 1} \right) \wedge \mathrm{pos}(a) > 0 \wedge \mathrm{pos}(a) \neq \lvert \mathcal{A} \rvert - 1 \} \rvert = 0 \vee \lvert \{ a \in \mathcal{A} \,:\, \left( \mathrm{bp\_heat}_{g,a} - \mathrm{bp\_heat}_{g,a \boxminus_{0} 1} \right) \cdot \left( \mathrm{bp\_x}_{g,a \boxplus_{0} 1} - \mathrm{bp\_x}_{g,a} \right) < \left( \mathrm{bp\_heat}_{g,a \boxplus_{0} 1} - \mathrm{bp\_heat}_{g,a} \right) \cdot \left( \mathrm{bp\_x}_{g,a} - \mathrm{bp\_x}_{g,a \boxminus_{0} 1} \right) \wedge \mathrm{pos}(a) > 0 \wedge \mathrm{pos}(a) \neq \lvert \mathcal{A} \rvert - 1 \} \rvert = 0 & && \forall\, g \in \mathcal{G} \\ \text{lp\_curve\_complete} && \mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \mathrm{bp\_y}_{g,a} \text{ is defined} & && \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \\ \text{lp\_curve\_increasing} && \mathrm{bp\_x}_{g,a \boxminus_{0} 1} & < \mathrm{bp\_x}_{g,a} && \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \mathrm{bp\_x}_{g,a - 1} \text{ is defined} \\ \text{lp\_curve\_curvature} && \left( \mathrm{bp\_y}_{g,a} - \mathrm{bp\_y}_{g,a \boxminus_{0} 1} \right) \cdot \left( \mathrm{bp\_x}_{g,a \boxplus_{0} 1} - \mathrm{bp\_x}_{g,a} \right) & \le \left( \mathrm{bp\_y}_{g,a \boxplus_{0} 1} - \mathrm{bp\_y}_{g,a} \right) \cdot \left( \mathrm{bp\_x}_{g,a} - \mathrm{bp\_x}_{g,a \boxminus_{0} 1} \right) && \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \mathrm{bp\_x}_{g,a - 1} \text{ is defined} \wedge \mathrm{bp\_x}_{g,a + 1} \text{ is defined} \\ -\text{lp\_curve\_breakpoints} && \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \} \rvert & \ge 2 && \forall\, g \in \mathcal{G} \\ -\text{lp\_curve\_contiguous} && \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \neg \left( \mathrm{bp\_x}_{g,a - 1} \text{ is defined} \right) \} \rvert & = 1 && \forall\, g \in \mathcal{G} \\ +\text{lp\_curve\_breakpoints} && \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \} \rvert & \ge 2 && \forall\, g \in \mathcal{G} \,:\, \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \} \rvert > 0 \\ +\text{lp\_curve\_contiguous} && \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \neg \left( \mathrm{bp\_x}_{g,a - 1} \text{ is defined} \right) \} \rvert & = 1 && \forall\, g \in \mathcal{G} \,:\, \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \} \rvert > 0 \\ \text{ramp\_curve\_complete} && \mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \mathrm{bp\_heat}_{g,a} \text{ is defined} & && \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \\ \text{ramp\_curve\_increasing} && \mathrm{bp\_x}_{g,a \boxminus_{0} 1} & < \mathrm{bp\_x}_{g,a} && \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{pos}(a) > 0 \\ \text{ramp\_curve\_curvature} && \left( \mathrm{bp\_heat}_{g,a} - \mathrm{bp\_heat}_{g,a \boxminus_{0} 1} \right) \cdot \left( \mathrm{bp\_x}_{g,a \boxplus_{0} 1} - \mathrm{bp\_x}_{g,a} \right) & \ge \left( \mathrm{bp\_heat}_{g,a \boxplus_{0} 1} - \mathrm{bp\_heat}_{g,a} \right) \cdot \left( \mathrm{bp\_x}_{g,a} - \mathrm{bp\_x}_{g,a \boxminus_{0} 1} \right) && \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{pos}(a) > 0 \wedge \mathrm{pos}(a) \neq \lvert \mathcal{A} \rvert - 1 \\ -\text{ramp\_curve\_breakpoints} && \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \} \rvert & \ge 2 && \forall\, g \in \mathcal{G} +\text{ramp\_curve\_breakpoints} && \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \} \rvert & \ge 2 && \forall\, g \in \mathcal{G} \\ +\text{chp\_curve\_complete} && \mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \mathrm{bp\_y}_{g,a} \text{ is defined} \wedge \mathrm{bp\_heat}_{g,a} \text{ is defined} & && \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \\ +\text{bus\_curve\_p\_complete} && \mathrm{bp\_x}_{g,a} \text{ is defined} & && \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{load}_{t,\mathrm{gen\_bus}(g)} \text{ is defined} \end{align} \end{document} diff --git a/tests/typesetting/golden/markdown.out b/tests/typesetting/golden/markdown.out index 672578c3..7d65bce1 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -392,6 +392,18 @@ p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \mathit{heat}_{t,g} \le \mathrm{pwl}_{a \in \mathcal{A}}(\mathrm{bp\_x}_{g,a},\ \mathrm{bp\_heat}_{g,a})(p_{t,g}) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} ``` +**`chp_curve`** + +```math +\left( p_{t,g},\ \mathit{fuel}_{t,g},\ \mathit{heat}_{t,g} \right) \in \mathrm{pwl}_{a \in \mathcal{A}}(\mathrm{bp\_x}_{g,a},\ \mathrm{bp\_y}_{g,a},\ \mathrm{bp\_heat}_{g,a}) + \{0\} \times \mathbb{R}_{\ge 0} \times \mathbb{R}_{\le 0} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +**`bus_curve`** + +```math +\left( p_{t,g} \right)_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} \in \mathrm{pwl}_{a \in \mathcal{A}}(\left( \mathrm{bp\_x}_{g,a} \right)_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b}) \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{load}_{t,b} \text{ is defined} +``` + #### Definitions **`spend_cap`** @@ -581,7 +593,7 @@ p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ g **`cost_curve_contiguous`** ```math -\lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_run}_{g,a} \wedge \neg \mathrm{bp\_run}_{g,a - 1} \} \rvert = 1 \qquad \forall\, g \in \mathcal{G} +\lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_run}_{g,a} \wedge \neg \mathrm{bp\_run}_{g,a - 1} \} \rvert = 1 \qquad \forall\, g \in \mathcal{G} \,:\, \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_run}_{g,a} \} \rvert > 0 ``` **`hull_curve_complete`** @@ -623,13 +635,13 @@ p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ g **`lp_curve_breakpoints`** ```math -\lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \} \rvert \ge 2 \qquad \forall\, g \in \mathcal{G} +\lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \} \rvert \ge 2 \qquad \forall\, g \in \mathcal{G} \,:\, \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \} \rvert > 0 ``` **`lp_curve_contiguous`** ```math -\lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \neg \left( \mathrm{bp\_x}_{g,a - 1} \text{ is defined} \right) \} \rvert = 1 \qquad \forall\, g \in \mathcal{G} +\lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \neg \left( \mathrm{bp\_x}_{g,a - 1} \text{ is defined} \right) \} \rvert = 1 \qquad \forall\, g \in \mathcal{G} \,:\, \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \} \rvert > 0 ``` **`ramp_curve_complete`** @@ -655,3 +667,15 @@ p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ g ```math \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \} \rvert \ge 2 \qquad \forall\, g \in \mathcal{G} ``` + +**`chp_curve_complete`** + +```math +\mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \mathrm{bp\_y}_{g,a} \text{ is defined} \wedge \mathrm{bp\_heat}_{g,a} \text{ is defined} \qquad \forall\, g \in \mathcal{G},\ a \in \mathcal{A} +``` + +**`bus_curve_p_complete`** + +```math +\mathrm{bp\_x}_{g,a} \text{ is defined} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{load}_{t,\mathrm{gen\_bus}(g)} \text{ is defined} +``` diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index bc7ac8fa..f3d315c2 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -104,39 +104,57 @@ sos: piecewise: fuel_curve: # three expressions on one curve, gated by a binary every unit has - over: bp + along: bp + dims: [snapshot, generator] links: - - [p, bp_x] - - [fuel, bp_y] - - [heat, bp_heat] + p: [p, bp_x] + fuel: [fuel, bp_y] + heat: [heat, bp_heat] activity: on cost_curve: # a bounded link, which prints as the side of the curve it is on, and a gate only some units have - over: bp + along: bp + dims: [snapshot, generator] method: sos2 - points: bp_run + where: bp_run activity: warm links: - - [p, bp_x] - - [op_cost, bp_y, ">="] + p: [p, bp_x] + op_cost: [op_cost, bp_y, ">="] hull_curve: # the convex method, whose weights range over the hull the breakpoints span rather than the curve - over: bp + along: bp + dims: [snapshot, generator] method: convex links: - - [p, bp_x] - - [fuel, bp_heat] + p: [p, bp_x] + fuel: [fuel, bp_heat] lp_curve: # the lp method masked by one of its own breakpoints: every condition that method puts on data - over: bp + along: bp + dims: [snapshot, generator] method: lp - points: bp_x + where: bp_x links: - - [p, bp_x] - - [fuel, bp_y, ">="] + p: [p, bp_x] + fuel: [fuel, bp_y, ">="] ramp_curve: # the same method bounded the other way over a whole axis, so the shape is concave and nothing masks it - over: bp + along: bp + dims: [snapshot, generator] method: lp links: - - [p, bp_x] - - [heat, bp_heat, "<="] + p: [p, bp_x] + heat: [heat, bp_heat, "<="] + chp_curve: # bounds on two sides of one point, which print as the cone their signs add to the curve + along: bp + dims: [snapshot, generator] + links: + p: [p, bp_x] + fuel: [fuel, bp_y, ">="] + heat: [heat, bp_heat, "<="] + bus_curve: # one curve per bus, read by each generator on it: a link that walks a relation prints as the family it ties, and the mask is read through the relation too + along: bp + dims: [snapshot, bus] + where: load + links: + p: { expression: p, values: bp_x, by: gen_bus, over: bus, into: generator } expressions: spend_cap: cost * 2 # a data-only entry, so a where may compare it diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index 68bda848..202a7aa9 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -123,7 +123,9 @@ $ upright("budgeted") & italic("spend")_(t) & <= upright("budget") & forall t in upright("cost_curve") & italic("op_cost")_(t,g) & >= cases(italic("warm")_(t,g) & upright("if ") upright("is_flexible")_(g), 1 & upright("otherwise")) dot upright("pwl")_(a in cal(A) colon upright("bp_run")_(g,a))(upright("bp_x")_(g,a), upright("bp_y")_(g,a))(p_(t,g)) & forall t in cal(T), g in cal(G) \ upright("hull_curve") & (p_(t,g), italic("fuel")_(t,g)) & in upright("conv")_(a in cal(A))(upright("bp_x")_(g,a), upright("bp_heat")_(g,a)) & forall t in cal(T), g in cal(G) \ upright("lp_curve") & italic("fuel")_(t,g) & >= upright("pwl")_(a in cal(A) colon upright("bp_x")_(g,a) upright(" is defined"))(upright("bp_x")_(g,a), upright("bp_y")_(g,a))(p_(t,g)) & forall t in cal(T), g in cal(G) \ - upright("ramp_curve") & italic("heat")_(t,g) & <= upright("pwl")_(a in cal(A))(upright("bp_x")_(g,a), upright("bp_heat")_(g,a))(p_(t,g)) & forall t in cal(T), g in cal(G) $ + upright("ramp_curve") & italic("heat")_(t,g) & <= upright("pwl")_(a in cal(A))(upright("bp_x")_(g,a), upright("bp_heat")_(g,a))(p_(t,g)) & forall t in cal(T), g in cal(G) \ + upright("chp_curve") & (p_(t,g), italic("fuel")_(t,g), italic("heat")_(t,g)) & in upright("pwl")_(a in cal(A))(upright("bp_x")_(g,a), upright("bp_y")_(g,a), upright("bp_heat")_(g,a)) + {0} times RR_(>= 0) times RR_(<= 0) & forall t in cal(T), g in cal(G) \ + upright("bus_curve") & (p_(t,g))_(g in cal(G) colon upright("gen_bus")(g) = b) & in upright("pwl")_(a in cal(A))((upright("bp_x")_(g,a))_(g in cal(G) colon upright("gen_bus")(g) = b)) & forall t in cal(T), b in cal(B) colon upright("load")_(t,b) upright(" is defined") $ == Definitions #set math.equation(numbering: "(1)") @@ -163,16 +165,18 @@ $ upright("bounds_do_not_cross") & upright("p")^(upright("min"))_(g) & <= uprigh upright("northern_demand_is_real") & upright("load")_(t,b) & >= 0 & forall t in cal(T), b in cal(B) colon upright("zone_of")(b) = upright("'north'") \ upright("fuel_curve_complete") & upright("bp_x")_(g,a) upright(" is defined") and upright("bp_y")_(g,a) upright(" is defined") and upright("bp_heat")_(g,a) upright(" is defined") & & forall g in cal(G), a in cal(A) \ upright("cost_curve_complete") & upright("bp_x")_(g,a) upright(" is defined") and upright("bp_y")_(g,a) upright(" is defined") & & forall g in cal(G), a in cal(A) colon upright("bp_run")_(g,a) \ - upright("cost_curve_contiguous") & abs({a in cal(A) colon upright("bp_run")_(g,a) and not upright("bp_run")_(g,a - 1)}) & = 1 & forall g in cal(G) \ + upright("cost_curve_contiguous") & abs({a in cal(A) colon upright("bp_run")_(g,a) and not upright("bp_run")_(g,a - 1)}) & = 1 & forall g in cal(G) colon abs({a in cal(A) colon upright("bp_run")_(g,a)}) > 0 \ upright("hull_curve_complete") & upright("bp_x")_(g,a) upright(" is defined") and upright("bp_heat")_(g,a) upright(" is defined") & & forall g in cal(G), a in cal(A) \ upright("hull_curve_increasing") & upright("bp_x")_(g,a minus.square_(0) 1) & < upright("bp_x")_(g,a) & forall g in cal(G), a in cal(A) colon upright("pos")(a) > 0 \ upright("hull_curve_curvature") & abs({a in cal(A) colon (upright("bp_heat")_(g,a) - upright("bp_heat")_(g,a minus.square_(0) 1)) dot (upright("bp_x")_(g,a plus.square_(0) 1) - upright("bp_x")_(g,a)) > (upright("bp_heat")_(g,a plus.square_(0) 1) - upright("bp_heat")_(g,a)) dot (upright("bp_x")_(g,a) - upright("bp_x")_(g,a minus.square_(0) 1)) and upright("pos")(a) > 0 and upright("pos")(a) != abs(cal(A)) - 1}) = 0 or abs({a in cal(A) colon (upright("bp_heat")_(g,a) - upright("bp_heat")_(g,a minus.square_(0) 1)) dot (upright("bp_x")_(g,a plus.square_(0) 1) - upright("bp_x")_(g,a)) < (upright("bp_heat")_(g,a plus.square_(0) 1) - upright("bp_heat")_(g,a)) dot (upright("bp_x")_(g,a) - upright("bp_x")_(g,a minus.square_(0) 1)) and upright("pos")(a) > 0 and upright("pos")(a) != abs(cal(A)) - 1}) = 0 & & forall g in cal(G) \ upright("lp_curve_complete") & upright("bp_x")_(g,a) upright(" is defined") and upright("bp_y")_(g,a) upright(" is defined") & & forall g in cal(G), a in cal(A) colon upright("bp_x")_(g,a) upright(" is defined") \ upright("lp_curve_increasing") & upright("bp_x")_(g,a minus.square_(0) 1) & < upright("bp_x")_(g,a) & forall g in cal(G), a in cal(A) colon upright("bp_x")_(g,a) upright(" is defined") and upright("bp_x")_(g,a - 1) upright(" is defined") \ upright("lp_curve_curvature") & (upright("bp_y")_(g,a) - upright("bp_y")_(g,a minus.square_(0) 1)) dot (upright("bp_x")_(g,a plus.square_(0) 1) - upright("bp_x")_(g,a)) & <= (upright("bp_y")_(g,a plus.square_(0) 1) - upright("bp_y")_(g,a)) dot (upright("bp_x")_(g,a) - upright("bp_x")_(g,a minus.square_(0) 1)) & forall g in cal(G), a in cal(A) colon upright("bp_x")_(g,a) upright(" is defined") and upright("bp_x")_(g,a - 1) upright(" is defined") and upright("bp_x")_(g,a + 1) upright(" is defined") \ - upright("lp_curve_breakpoints") & abs({a in cal(A) colon upright("bp_x")_(g,a) upright(" is defined")}) & >= 2 & forall g in cal(G) \ - upright("lp_curve_contiguous") & abs({a in cal(A) colon upright("bp_x")_(g,a) upright(" is defined") and not (upright("bp_x")_(g,a - 1) upright(" is defined"))}) & = 1 & forall g in cal(G) \ + upright("lp_curve_breakpoints") & abs({a in cal(A) colon upright("bp_x")_(g,a) upright(" is defined")}) & >= 2 & forall g in cal(G) colon abs({a in cal(A) colon upright("bp_x")_(g,a) upright(" is defined")}) > 0 \ + upright("lp_curve_contiguous") & abs({a in cal(A) colon upright("bp_x")_(g,a) upright(" is defined") and not (upright("bp_x")_(g,a - 1) upright(" is defined"))}) & = 1 & forall g in cal(G) colon abs({a in cal(A) colon upright("bp_x")_(g,a) upright(" is defined")}) > 0 \ upright("ramp_curve_complete") & upright("bp_x")_(g,a) upright(" is defined") and upright("bp_heat")_(g,a) upright(" is defined") & & forall g in cal(G), a in cal(A) \ upright("ramp_curve_increasing") & upright("bp_x")_(g,a minus.square_(0) 1) & < upright("bp_x")_(g,a) & forall g in cal(G), a in cal(A) colon upright("pos")(a) > 0 \ upright("ramp_curve_curvature") & (upright("bp_heat")_(g,a) - upright("bp_heat")_(g,a minus.square_(0) 1)) dot (upright("bp_x")_(g,a plus.square_(0) 1) - upright("bp_x")_(g,a)) & >= (upright("bp_heat")_(g,a plus.square_(0) 1) - upright("bp_heat")_(g,a)) dot (upright("bp_x")_(g,a) - upright("bp_x")_(g,a minus.square_(0) 1)) & forall g in cal(G), a in cal(A) colon upright("pos")(a) > 0 and upright("pos")(a) != abs(cal(A)) - 1 \ - upright("ramp_curve_breakpoints") & abs({a in cal(A) colon upright("bp_x")_(g,a) upright(" is defined")}) & >= 2 & forall g in cal(G) $ + upright("ramp_curve_breakpoints") & abs({a in cal(A) colon upright("bp_x")_(g,a) upright(" is defined")}) & >= 2 & forall g in cal(G) \ + upright("chp_curve_complete") & upright("bp_x")_(g,a) upright(" is defined") and upright("bp_y")_(g,a) upright(" is defined") and upright("bp_heat")_(g,a) upright(" is defined") & & forall g in cal(G), a in cal(A) \ + upright("bus_curve_p_complete") & upright("bp_x")_(g,a) upright(" is defined") & & forall t in cal(T), g in cal(G), a in cal(A) colon upright("load")_(t,upright("gen_bus")(g)) upright(" is defined") $ diff --git a/tests/typesetting/test_golden.py b/tests/typesetting/test_golden.py index 93639781..819ec28d 100644 --- a/tests/typesetting/test_golden.py +++ b/tests/typesetting/test_golden.py @@ -144,6 +144,8 @@ def _rendered_trees() -> Iterator[object]: yield program.expressions[name].expression for curve in program.piecewise.values(): yield from (link.expression for link in curve.links) + if curve.where is not None: + yield curve.where.root #: A dataclass the walk steps *through* rather than renders: a region has no diff --git a/tests/typesetting/test_symbols.py b/tests/typesetting/test_symbols.py index 872d3219..525611bc 100644 --- a/tests/typesetting/test_symbols.py +++ b/tests/typesetting/test_symbols.py @@ -103,7 +103,11 @@ def test_a_named_expression_has_a_legend_row_exactly_while_its_symbol_prints(nam 'parameters.bp_x': {'dims': ['generator', 'bp']}, 'parameters.bp_y': {'dims': ['generator', 'bp']}, 'variables.op_cost': {'dims': ['snapshot', 'generator'], 'bounds': {'lower': 0}}, - 'piecewise.curve': {'over': 'bp', 'links': [['p', 'bp_x'], ['op_cost', 'bp_y']]}, + 'piecewise.curve': { + 'along': 'bp', + 'dims': ['snapshot', 'generator'], + 'links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}, + }, }, ) diff --git a/tests/typesetting/test_walk.py b/tests/typesetting/test_walk.py index a66b9c7f..465e07c5 100644 --- a/tests/typesetting/test_walk.py +++ b/tests/typesetting/test_walk.py @@ -947,7 +947,9 @@ def test_a_condition_a_method_states_is_a_line_that_may_be_asked_for_before_it_i 'on': {'dims': ['snapshot'], 'domain': 'binary'}, 'warm': {'dims': ['snapshot'], 'domain': 'binary', 'where': 'committable'}, }, - 'piecewise': {'curve': {'over': 'bp', 'links': [['p', 'bp_x'], ['op_cost', 'bp_y']]}}, + 'piecewise': { + 'curve': {'along': 'bp', 'dims': ['snapshot'], 'links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}} + }, 'objective': {'sense': 'minimize', 'expression': 'sum(op_cost, over=snapshot)'}, } @@ -967,15 +969,31 @@ def test_a_condition_a_method_states_is_a_line_that_may_be_asked_for_before_it_i id='the-convex-method-relaxes-it-onto-the-hull', ), pytest.param( - {'piecewise.curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '>=']], 'piecewise.curve.method': 'lp'}, + { + 'piecewise.curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=']}, + 'piecewise.curve.method': 'lp', + }, r'\mathit{op\_cost}_{t} \ge \mathrm{pwl}_{b \in \mathcal{B}}' r'(\mathrm{bp\_x}_{b},\ \mathrm{bp\_y}_{b})(p_{t})', id='a-bounded-link-states-one-side-of-the-curve', ), pytest.param( - {'piecewise.curve.points': 'reaches'}, + { + 'piecewise.curve.links': { + 'p': ['p', 'bp_x'], + 'op_cost': ['op_cost', 'bp_y', '>='], + 'twice': ['p * 2', 'bp_x', '<='], + }, + }, + r'\left( p_{t},\ \mathit{op\_cost}_{t},\ p_{t} \cdot 2 \right) \in \mathrm{pwl}_{b \in \mathcal{B}}' + r'(\mathrm{bp\_x}_{b},\ \mathrm{bp\_y}_{b},\ \mathrm{bp\_x}_{b}) + \{0\} \times \mathbb{R}_{\ge 0} ' + r'\times \mathbb{R}_{\le 0}', + id='bounded-links-beside-more-than-one-other-add-the-cone-their-signs-span', + ), + pytest.param( + {'piecewise.curve.where': 'reaches'}, r'\mathrm{pwl}_{b \in \mathcal{B} \,:\, \mathrm{reaches}_{b}}', - id='points-narrows-the-breakpoints-to-the-ones-it-admits', + id='a-ragged-where-narrows-the-breakpoints-to-the-ones-it-admits', ), pytest.param( {'piecewise.curve.activity': 'on'}, @@ -1004,11 +1022,12 @@ def test_a_gate_that_does_not_exist_everywhere_prints_the_two_arms_the_expansion def test_a_curve_prints_over_the_frame_its_expansion_builds_one_per_coordinate_of(): - """Two homes for one union, so the line's quantifier is held to the rows the expansion emits.""" + """The quantifier is the block's `dims:`, as the rows the expansion emits are.""" model = override( _CURVE, **{ 'dimensions.generator': {'dtype': 'str'}, + 'piecewise.curve.dims': ['snapshot', 'generator'], 'parameters.bp_x.dims': ['generator', 'bp'], 'parameters.bp_y.dims': ['generator', 'bp'], 'variables.p.dims': ['snapshot', 'generator'], @@ -1017,13 +1036,25 @@ def test_a_curve_prints_over_the_frame_its_expansion_builds_one_per_coordinate_o }, ) spec = to_spec(model) - emitted = spec.expand('piecewise').constraints['curve_link0'].dims + emitted = spec.expand('piecewise').constraints['curve_p'].dims printed = typeset_declaration(spec, 'curve', 'latex') assert printed.endswith(r'\forall\, t \in \mathcal{T},\ g \in \mathcal{G}') assert emitted == ['snapshot', 'generator'], 'the quantifier above is that frame, in that order' +def test_a_walked_link_prints_as_the_family_of_rows_that_read_one_curve(): + """Printed off the union of the links' dims, every flow sat on every converter's curve and no relation showed.""" + printed = typeset_declaration(to_spec(EXAMPLES / 'piecewise_coupling.yaml'), 'operating_point', 'latex') + + family = r'_{f \in \mathcal{F} \,:\, \mathrm{converter\_of}(f) = c}' + assert printed.startswith(rf'\left( \mathit{{rate}}_{{f,t}} \right){family} \in'), 'a converter ties its own flows' + assert rf'(\left( \mathrm{{bp\_rate}}_{{f,b}} \right){family})' in printed, 'each flow reads its own breakpoints' + assert printed.endswith(r'\forall\, c \in \mathcal{C},\ t \in \mathcal{T} \,:\, \mathrm{has\_curve}_{c}'), ( + 'one curve per converter that has one, not per flow' + ) + + def test_the_expansion_prints_the_rows_the_block_states(): """Which is the whole reason the block prints as one line: the two readings are one call apart.""" spec = to_spec(_CURVE) diff --git a/tools/gallery.py b/tools/gallery.py index 74fba0f5..26a6d42a 100644 --- a/tools/gallery.py +++ b/tools/gallery.py @@ -37,6 +37,10 @@ MODELS = { 'dispatch.md': ROOT / 'examples' / 'dispatch.yaml', 'commitment.md': ROOT / 'examples' / 'commitment.yaml', + 'piecewise.md': ROOT / 'examples' / 'piecewise.yaml', + 'piecewise_adjacency.md': ROOT / 'examples' / 'piecewise_adjacency.yaml', + 'sos.md': ROOT / 'examples' / 'sos.yaml', + 'piecewise_lp.md': ROOT / 'examples' / 'piecewise_lp.yaml', } #: Page -> the model it shows one declaration at a time — its YAML, then the @@ -59,8 +63,15 @@ def model_block(path: Path) -> str: - """One model, then the whole document the typesetter prints from it.""" - return f'```yaml\n{without_header(path)}\n```\n\n{to_markdown(path, numbered=False).strip()}' + """One model, then the whole document the typesetter prints from it. + + Under the model's own symbol table where it has one, as + :func:`declared_block` is: a weight named after the block that declared it + is right in the file and unreadable in the equation that names it six + times. + """ + page = to_markdown(path, symbols=sidecar_for(path), numbered=False) + return f'```yaml\n{without_header(path)}\n```\n\n{page.strip()}' def probe_block() -> str: From 279997aee559aaaae9972875ba6291f51a8fd675 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 22:07:08 +0000 Subject: [PATCH 18/20] feat(advice): a curve is advised on as written, as a set already was The unboundedness pass read a set as it is, every variable it restricts being named by a row, and refused a curve. A curve states its rows the same way: each link names the variables a link row would, so the pass reads the links and the answer is the expansion's with nothing expanded. The refusal goes, and check loses --expand, a flag that would change no answer. Guard: with the line reading the links deleted, three tests fail: the advice test over the file, the Spec and the Program; the boundedness case carried-by-a-curve; and the existing test that a curve holds its variables, which now runs on the block. Docs sentences, after (before): check.md n 20 avg 12.8 median 14 over25 1 (21, 13.3, 14, 2); what-counts-as-public-api.md n 20 avg 18.7 median 15 over25 4 (20, 18.1, 15, 4). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TrvhjFQCQJ6ATBQkfhcoMi --- docs/about/what-counts-as-public-api.md | 5 +-- docs/howto/check.md | 12 +++----- src/math_spec/__main__.py | 21 +++++-------- src/math_spec/advice.py | 21 +++---------- src/math_spec/boundedness.py | 6 ++-- tests/test_advice.py | 41 ++++++++++++++----------- tests/test_boundedness.py | 11 ++++++- tests/typesetting/test_cli.py | 22 +++++++------ 8 files changed, 69 insertions(+), 70 deletions(-) diff --git a/docs/about/what-counts-as-public-api.md b/docs/about/what-counts-as-public-api.md index a3fce6ab..e732ff50 100644 --- a/docs/about/what-counts-as-public-api.md +++ b/docs/about/what-counts-as-public-api.md @@ -40,8 +40,9 @@ file, it is one. - **Nothing is written out unasked.** A `piecewise:` or `sos:` block is the block until a caller writes it out with `spec.expand(...)`. No door, verb or check expands a model on the caller's behalf: `spec.program` mirrors the - file, `advice` and the `check` verb refuse a curve left as written, and - `--expand` is how the shell asks. An engine that writes curves out at its + file, `advice` reads a block as the rows it states, and `--expand` on a + typeset verb is how the shell asks for the rows as a document of their own. + An engine that writes curves out at its own door makes that choice for its users, not for the language. ## Three things a function never decides diff --git a/docs/howto/check.md b/docs/howto/check.md index a6f442a4..96a506ff 100644 --- a/docs/howto/check.md +++ b/docs/howto/check.md @@ -21,9 +21,9 @@ machine and in CI. ``` Advice prints on stdout and exits with status 0. A model the language - accepts with nothing to advise prints nothing. A model with a `piecewise:` - block is refused as written, because advice reads the rows a curve states - and nothing writes them out unasked: pass `--expand` to check the rows. + accepts with nothing to advise prints nothing. A `piecewise:` or `sos:` + block is read as the rows it states, so the answer is the one its + expansion gets, with nothing expanded. ```text Variable 'slack' makes this model unbounded: no constraint names it, and bounds.lower is -inf, which is the direction a +slack term improves a minimize objective in. No data can change that, so the solve would answer `unbounded` and name nothing. @@ -39,14 +39,12 @@ machine and in CI. 3. **Ask from Python** where the check is one step of a longer script. `to_spec` raises a `MathSpecError` for anything the language refuses, and - `advice` returns what it would print. Advice reads the rows a curve states, - so pass the model with its curves written out: + `advice` returns what it would print: ```python import math_spec as ms - spec = ms.to_spec('model.yaml') - for note in ms.advice(spec.expand('piecewise')): + for note in ms.advice('model.yaml'): print(note) ``` diff --git a/src/math_spec/__main__.py b/src/math_spec/__main__.py index e369a34a..0702030b 100644 --- a/src/math_spec/__main__.py +++ b/src/math_spec/__main__.py @@ -6,9 +6,11 @@ ``check`` loads the file and prints the language's advice; one further verb per typeset format, read off :data:`math_spec.typesetting.FORMATS`. Every verb -reads the file as written and takes ``--expand``, because a shell cannot -compose :meth:`~math_spec.model.Spec.expand` the way a caller does, and -nothing here writes a formulation out unasked. +reads the file as written, and nothing here writes a formulation out unasked. +The typeset verbs take ``--expand``, because a shell cannot compose +:meth:`~math_spec.model.Spec.expand` the way a caller does and the rows are a +different document; ``check`` has no such flag, because advice reads a block +as the rows it states. """ from __future__ import annotations @@ -30,11 +32,6 @@ def parser() -> argparse.ArgumentParser: check = verbs.add_parser('check', help='load a model, and print what the language advises') check.add_argument('model', help='path to a math_spec YAML model') - check.add_argument( - '--expand', - action='store_true', - help='check the variables and constraints the piecewise: and sos: blocks state, not the blocks', - ) for name in FORMATS: verb = verbs.add_parser(name, help=f'render a model as {name}') @@ -58,20 +55,18 @@ def parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: """Run one verb; a refused file is its message on stderr and exit status 1. - Advice is not a refusal: ``check`` prints it and exits 0. A model with a - ``piecewise:`` block is refused by ``check`` as by any consumer building - rows, and ``--expand`` is how the shell asks for the rows. + Advice is not a refusal: ``check`` prints it and exits 0. """ args = parser().parse_args(argv) - model = to_spec(args.model).expand() if args.expand else args.model if args.verb == 'check': try: - notes = advice(model) + notes = advice(args.model) except MathSpecError as e: sys.stderr.write(f'{e}\n') return 1 sys.stdout.write(''.join(f'{note}\n' for note in notes)) return 0 + model = to_spec(args.model).expand() if args.expand else args.model text = typeset( model, args.verb, diff --git a/src/math_spec/advice.py b/src/math_spec/advice.py index e3d1bd42..8a77d99e 100644 --- a/src/math_spec/advice.py +++ b/src/math_spec/advice.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING from math_spec.boundedness import unbounded_notes -from math_spec.errors import Advice, LanguageError +from math_spec.errors import Advice from math_spec.program import GroupSum, Program, Pullback, walk from math_spec.validation import to_spec @@ -28,28 +28,15 @@ def advice(model: str | Path | Mapping[str, object] | Spec | Program) -> tuple[A Args: model: A YAML path, a mapping, a loaded :class:`Spec`, or a - :class:`Program`, read as it arrived. Both passes read the rows a - curve states, so a model still carrying a ``piecewise:`` block is - refused the way any consumer building rows refuses one: pass - ``spec.expand('piecewise')``. + :class:`Program`, read as it arrived. A ``piecewise:`` or ``sos:`` + block is read as the rows it states, so the answer is the one its + expansion gets, with nothing expanded. Returns: The never-an-axis advice in declaration order, then the unboundedness advice; ``str()`` of each is its sentence. - - Raises: - LanguageError: A ``piecewise:`` block still in the model, naming the - expansion to pass. """ program = model if isinstance(model, Program) else to_spec(model).program - if program.piecewise: - named = ', '.join(f"'{name}'" for name in program.piecewise) - still = 'is still a curve' if len(program.piecewise) == 1 else 'are still curves' - msg = ( - f'piecewise: {named} {still}, and advice reads the rows a curve is expanded into. Expand first: ' - f"spec.expand('piecewise') keeps every sos: block, and spec.expand() expands the sets into binaries too." - ) - raise LanguageError(msg) return tuple(_never_an_axis(program) + unbounded_notes(program)) diff --git a/src/math_spec/boundedness.py b/src/math_spec/boundedness.py index 210c78c8..a9a456dd 100644 --- a/src/math_spec/boundedness.py +++ b/src/math_spec/boundedness.py @@ -59,8 +59,8 @@ def unbounded_notes(program: Program) -> list[Advice]: """Name every variable the objective can drive to infinity unopposed. Args: - program: The lowered program, in which ``piecewise:`` has already - become the constraints it expands into. + program: The program as it is. A curve's links and a set's variable + count as named by a row, as the rows they expand into would be. Returns: One note per variable that is unbounded on the side its objective term @@ -70,6 +70,8 @@ def unbounded_notes(program: Program) -> list[Advice]: return [] constrained = {block.variable for block in program.sos.values()} + for curve in program.piecewise.values(): + constrained |= variables_of(*(link.expression for link in curve.links)) for constraint in program.constraints.values(): constrained |= variables_of(constraint.lhs, constraint.rhs) diff --git a/tests/test_advice.py b/tests/test_advice.py index 39eeaea8..54c5761e 100644 --- a/tests/test_advice.py +++ b/tests/test_advice.py @@ -12,16 +12,16 @@ from __future__ import annotations import json -from typing import TYPE_CHECKING, get_args +from pathlib import Path +from typing import get_args import pytest -from math_spec import LanguageError, advice, to_spec +from math_spec import advice, to_spec from math_spec.errors import AdviceKind from tests.fixtures import SMALL_MODEL, override -if TYPE_CHECKING: - from pathlib import Path +EXAMPLES = Path(__file__).resolve().parents[1] / 'examples' #: ``h`` is the target of ``lk`` and nothing else reaches it; ``g`` is an axis. TARGET_ONLY = override( @@ -34,9 +34,11 @@ UNREACHED = override(TARGET_ONLY, relations={}) #: A curve on ``p``, so the program of the file as written carries a block and -#: the program of its expansion carries the rows. +#: the program of its expansion carries the rows. The objective drives ``p`` +#: down unopposed by anything but the curve. CURVED = override( UNREACHED, + objective={'sense': 'minimize', 'expression': 'sum(p)'}, dimensions={'g': {'dtype': 'str'}, 'h': {'dtype': 'str'}, 'bp': {'dtype': 'int'}}, parameters={'c': {'dims': ['g']}, 'bp_x': {'dims': ['bp']}, 'bp_y': {'dims': ['bp']}}, variables={'p': {'dims': ['g']}, 'cost': {'dims': ['g']}}, @@ -106,19 +108,22 @@ def test_the_answer_does_not_turn_on_which_state_it_is_asked_of(form, tmp_path): ], 'one model, one answer, whichever of the four the caller happens to hold' -def test_a_curve_left_as_written_is_refused_however_the_model_arrives(): - """Advice reads the rows a curve states and writes nothing out on the caller's behalf. +def test_a_curve_is_read_as_the_rows_it_states_however_the_model_arrives(): + """Advice expanded a curve on the caller's behalf, then refused one left as written; a program with a + block was once let through and advised on the file's rows as if the curve stated none. - It once expanded a file or a Spec itself, which is the choice every other - door leaves to the caller; a program with a block was let through when the - guard was deleted, advising on the file's own rows as if the curve stated - none. + A curve states its rows the way a set does: each link names the variables + a link row would. Nothing is expanded, and the answer is the expansion's. """ - rows = advice(to_spec(CURVED).expand('piecewise')) - assert [(n.kind, n.subject) for n in rows] == [(n.kind, n.subject) for n in advice(to_spec(CURVED).expand())], ( - 'the expansion is what advice reads, with or without its sets' - ) + rows = [(n.kind, n.subject) for n in advice(to_spec(CURVED).expand('piecewise'))] + assert rows == [('never-an-axis', 'h')], 'the link row holds p, so only the unreached dimension draws a note' for arrived in (CURVED, to_spec(CURVED), to_spec(CURVED).program): - with pytest.raises(LanguageError, match="piecewise: 'curve' is still a curve") as refusal: - advice(arrived) - assert "expand('piecewise')" in str(refusal.value), 'the refusal names the block and the expansion to pass' + assert [(n.kind, n.subject) for n in advice(arrived)] == rows, 'the block and its rows get one answer' + + +@pytest.mark.parametrize('example', ['piecewise', 'piecewise_lp', 'piecewise_ragged', 'sos']) +def test_every_shipped_formulation_gets_the_answer_its_expansion_gets(example): + """The claim of the test above on every model the repository ships with a block.""" + spec = to_spec(EXAMPLES / f'{example}.yaml') + as_written = [(n.kind, n.subject) for n in advice(spec)] + assert as_written == [(n.kind, n.subject) for n in advice(spec.expand())], 'one model, one answer, block or rows' diff --git a/tests/test_boundedness.py b/tests/test_boundedness.py index f701e17d..77181383 100644 --- a/tests/test_boundedness.py +++ b/tests/test_boundedness.py @@ -24,7 +24,7 @@ def _advice(**patch): - return unbounded_notes(schema_of(BASE, **patch).expand('piecewise').program) + return unbounded_notes(schema_of(BASE, **patch).program) def _notes(**patch) -> list[str]: @@ -78,6 +78,15 @@ def test_a_variable_the_objective_drives_unopposed_is_named_with_its_side(patch, }, id='carried-by-a-set', ), + pytest.param( + { + 'dimensions.bp': {'dtype': 'int'}, + 'parameters.bp_x': {'dims': ['bp']}, + 'parameters.bp_y': {'dims': ['bp']}, + 'piecewise': {'curve': {'over': 'bp', 'links': [['v', 'bp_x'], ['w', 'bp_y']]}}, + }, + id='carried-by-a-curve', + ), pytest.param({'objective.expression': 'sum(c * v, over=g)'}, id='a-parameter-coefficient-may-be-zero'), pytest.param({'objective.expression': 'sum(v - v, over=g)'}, id='both-signs-may-cancel'), pytest.param({'objective.expression': 'sum(v * v, over=g)'}, id='a-degree-two-term-carries-no-sign'), diff --git a/tests/typesetting/test_cli.py b/tests/typesetting/test_cli.py index 35fdc205..873a50cd 100644 --- a/tests/typesetting/test_cli.py +++ b/tests/typesetting/test_cli.py @@ -70,21 +70,23 @@ def test_check_accepts_the_model_that_carries_every_construct(capsys): """The golden model exercises every operator and every edge policy, so `check` accepting it is the claim that the whole language loads through one door — and that none of it draws advice.""" - assert front.main(['check', str(golden.MODEL), '--expand']) == 0, 'the whole language loads' + assert front.main(['check', str(golden.MODEL)]) == 0, 'the whole language loads' assert capsys.readouterr() == ('', ''), 'no advice, no output' -def test_check_writes_no_curve_out_unasked(capsys): - """`check` expanded every curve on the user's behalf, the one verb that read a file differently from the rest. +def test_check_reads_a_curve_as_written(capsys): + """`check` expanded every curve on the user's behalf, then refused a curve without `--expand`; either way + it read a file differently from the typeset verbs, which print it as written. - Nothing in the package writes a formulation out unasked: `check` refuses - a curve model as any consumer building rows does, and `--expand` is how - the shell asks for the rows, the flag the typeset verbs already take. + Advice reads a block as the rows it states, so `check` takes the file as + written and has no `--expand`: the rows are a different document to + print, not a different model to advise on. """ - assert front.main(['check', str(EXAMPLES / 'piecewise.yaml')]) == 1, 'a curve left as written is a refusal' - captured = capsys.readouterr() - assert captured.out == '' and 'is still a curve' in captured.err, 'the refusal goes to stderr and names the block' - assert front.main(['check', str(EXAMPLES / 'piecewise.yaml'), '--expand']) == 0, 'asked for, the rows are checked' + assert front.main(['check', str(EXAMPLES / 'piecewise.yaml')]) == 0, 'a curve left as written is checked as written' + assert capsys.readouterr() == ('', ''), 'no advice, no output' + with pytest.raises(SystemExit) as left: + front.main(['check', str(EXAMPLES / 'piecewise.yaml'), '--expand']) + assert left.value.code == 2, 'check has no --expand, since it would change nothing' def _carries(stream: str, said: str) -> bool: From 0e4b1b1483a91bc3ec64475ebb3d742ab028316b Mon Sep 17 00:00:00 2001 From: Felix <117816358+FBumann@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:02:54 +0200 Subject: [PATCH 19/20] fix(language): a piecewise block its expansion cannot write is refused as the model loads (#685) A walked link is read as at() reads its relation, so into must land on the key, the read must be single-valued, and every joined key column must be a dim of dims:. An empty over or into is refused. No two blocks may write the same name. A where over a dim the walk only joins on reads as written. The missing-breakpoint advice and the where-over-a-walked-dim refusal name a rewrite the block can take. Claude-Session: https://claude.ai/code/session_01WasiCKk1SebD7S1CsAFQog Co-authored-by: Claude --- docs/reference/language/piecewise.md | 31 +++-- src/math_spec/_expression_resolver.py | 4 +- src/math_spec/lowering.py | 17 +-- src/math_spec/model.py | 6 + src/math_spec/piecewise.py | 185 ++++++++++++++++++-------- src/math_spec/validation.py | 92 ++++--------- tests/test_piecewise.py | 156 +++++++++++++++++++++- 7 files changed, 347 insertions(+), 144 deletions(-) diff --git a/docs/reference/language/piecewise.md b/docs/reference/language/piecewise.md index 9730c3b1..eed01470 100644 --- a/docs/reference/language/piecewise.md +++ b/docs/reference/language/piecewise.md @@ -65,7 +65,9 @@ curve, and the [typeset output](../typeset.md) prints the curve itself. their own, which is the model a consumer that builds rows reads. A link names the row it writes, so a link may not take a name the block -already writes for itself, such as `convexity` or `lam`. +already writes for itself, such as `convexity` or `lam`. No two blocks may write +the same name: in a file with blocks `a` and `a_b`, a link `b_x` of `a` is +refused, because its row `a_b_x` is also the row of the link `x` of `a_b`. The breakpoint order is the declared order of `along`. A curve whose breakpoints decrease in that order is refused when the data binds. @@ -222,9 +224,14 @@ writes `at(coupling_lam, by=generator_of, over=generator, into=flow)` into that row, so the weights stay on `dims:` and the model never names them. `by:`, `over:` and `into:` are written together. A walk states the relation, the -columns it consumes and the columns it produces, and none is defaulted. A link -whose row is finer than `dims:` is always a walk: a link that names only -`into:` is refused. +columns it consumes and the columns it produces, and none is defaulted. Each of +`over:` and `into:` names at least one column. A link whose row is finer than +`dims:` is always a walk: a link that names only `into:` is refused. + +A walk is held to every rule of `at`, as the model loads, and a refusal names +the link. `into:` names key columns of the relation, and the read has one value +at each coordinate it lands on. A key column that the walk does not name is +joined on, so its dimension is one of `dims:`. A block whose only link walks a relation is a curve. Two links is what a curve needs when a link is one row; a walked link is one row per fine coordinate, so @@ -250,17 +257,21 @@ The `power` row is built where `at(has_curve, by=generator_of, over=generator, into=flow)` holds, which is at every flow of a generator with a curve. The values of a walked link are asked for at the same rows, so a flow of a generator with no curve needs no row in -`bp_power`. A mask over dimensions the walk keeps, such as `snapshot` alone, -reaches the row as written. A mask that carries some of the dimensions the walk -reads through and not the others is refused, and the message names the ones -missing. +`bp_power`. A mask that carries no dimension the walk consumes, such as +`snapshot` alone, reaches the row as written. This is also true when the +relation is keyed on `snapshot` too, because the row keeps every dimension the +walk joins on. A mask that carries a dimension the walk consumes and not every +dimension the walk joins on is refused, and the message names the ones missing. +A mask over a dimension a walk produces, such as `flow`, is refused: the mask +says which curves exist, and there is one curve per coordinate of `dims:`. | A walked link | | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | _over_ | names a column over a dimension of `dims:` | -| _into_ | names a column over a dimension that `dims:` does not carry, and that is not `along` | +| _into_ | names key columns over dimensions that `dims:` does not carry, and that are not `along` | +| _by_ | a relation whose other key columns are over dimensions of `dims:` | | _values_ | follows the **link's** row: `bp_power` is per flow, not per generator | -| `where:` | on the block reaches the link's row read through the relation, or as written where the mask carries none of the dimensions the walk reads through | +| `where:` | on the block reaches the link's row read through the relation, or as written where the mask carries none of the dimensions the walk consumes | | `method:` | `adjacency` or `sos2`. `lp` loses the abscissa its segment line is written against, and `convex` loses the pair of values parameters it reads a shape from | ### Signs diff --git a/src/math_spec/_expression_resolver.py b/src/math_spec/_expression_resolver.py index 2d0f7423..7a9bd07f 100644 --- a/src/math_spec/_expression_resolver.py +++ b/src/math_spec/_expression_resolver.py @@ -502,7 +502,7 @@ def relation_ref( return self.partition(name, operator, along, named['within']) if not ({'over', 'into'} <= set(named)): return None # refused already, by the call shape or by the role that named no column - return self._direction(name, operator, named['over'], named['into']) + return self.direction(name, operator, named['over'], named['into']) def _role_name(self, value: ArithmeticNode, operator: str, key: str) -> tuple[str, ...] | None: """``over=`` or ``into=`` as the column names it must be — one bare name, or a bracketed list of them.""" @@ -513,7 +513,7 @@ def _role_name(self, value: ArithmeticNode, operator: str, key: str) -> tuple[st ) return None - def _direction( + def direction( self, name: str, operator: str, diff --git a/src/math_spec/lowering.py b/src/math_spec/lowering.py index 3de54aed..3e4559ba 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -20,7 +20,7 @@ from math_spec.dimensions import check_schema, dims_of from math_spec.errors import SchemaError, prefixed from math_spec.expansion import expand, parse_template -from math_spec.piecewise import assumptions_of, declaration_of, lp_domain_refusal, resolve_links +from math_spec.piecewise import assumptions_of, declaration_of, lp_domain_refusal, resolve_links, resolve_walks from math_spec.program import ( Assumption, BooleanLiteral, @@ -52,7 +52,7 @@ if TYPE_CHECKING: from math_spec.model import AssumptionBlock, Spec - from math_spec.program import Expression + from math_spec.program import Direction, Expression def lower(schema: Spec) -> Program: @@ -159,15 +159,16 @@ def lower(schema: Spec) -> Program: if (assumption := _assumption(aname, adef, ns, errors)) is not None: assumptions[aname] = assumption - curves: dict[str, tuple[tuple[Expression, ...], Mask | None]] = {} + curves: dict[str, tuple[tuple[Expression, ...], dict[str, Direction], Mask | None]] = {} for pname, pdef in schema.piecewise.items(): links = resolve_links(pname, pdef, ns, errors) + walks = resolve_walks(pname, pdef, ns, errors) where = mask_of(resolve_where_text(pdef.where, ns, f"piecewise '{pname}' where", errors)) - if links is None: + if links is None or walks is None: continue if pdef.method == 'lp' and (refusal := lp_domain_refusal(pname, pdef, links)) is not None: errors.append(refusal) - curves[pname] = (links, where) + curves[pname] = (links, walks, where) if errors: raise SchemaError('\n'.join(errors)) @@ -175,13 +176,13 @@ def lower(schema: Spec) -> Program: roots = [side for c in constraints.values() for side in (c.lhs, c.rhs)] if objective is not None: roots.append(objective.expression) - roots.extend(link for links, _ in curves.values() for link in links) + roots.extend(link for links, _, _ in curves.values() for link in links) in_math = frozenset(node.name for node in walk(*roots) if isinstance(node, Named)) piecewise = {} - for pname, (links, where) in curves.items(): + for pname, (links, walks, where) in curves.items(): pdef = schema.piecewise[pname] - piecewise[pname] = declaration_of(schema, pname, pdef, links, where) + piecewise[pname] = declaration_of(schema, pname, pdef, links, walks, where) for aname, assumed in assumptions_of(pname, piecewise[pname], pdef.where).items(): assumption = _assumption(aname, assumed, ns, errors) assert assumption is not None and not errors, 'what a method assumes is stated in the language' diff --git a/src/math_spec/model.py b/src/math_spec/model.py index cca5c76d..7f5d8e82 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -539,6 +539,12 @@ def _check_walk(self) -> PiecewiseLink: f'consumes and the columns it produces, as at() does; none is defaulted.' ) raise ValueError(msg) + if empty := [k for k in ('over', 'into') if written[k] == []]: + msg = ( + f'{empty[0]}: [] names no column — a walk consumes at least one column of the relation and produces ' + f'at least one. Name a column, or a list of them.' + ) + raise ValueError(msg) return self @model_validator(mode='before') diff --git a/src/math_spec/piecewise.py b/src/math_spec/piecewise.py index 5dddb674..a6b885e8 100644 --- a/src/math_spec/piecewise.py +++ b/src/math_spec/piecewise.py @@ -23,7 +23,8 @@ import math_spec.sos as sos from math_spec._expression_parser import NAME -from math_spec.dimensions import dims_of +from math_spec._expression_resolver import ExpressionResolver +from math_spec.dimensions import dims_of, pulled_back_dims from math_spec.errors import DimensionError from math_spec.model import AssumptionBlock, Curvature, PiecewiseBlock, PiecewiseLink, Spec, VariableBlock from math_spec.program import Link, PiecewiseDeclaration, PiecewiseMethod, VariableDeclaration, carries_variable @@ -32,7 +33,7 @@ if TYPE_CHECKING: from collections.abc import Iterable - from math_spec.program import Expression, Mask + from math_spec.program import Direction, Expression, Mask from math_spec.resolution import Namespace @@ -154,11 +155,12 @@ def assumptions_of(name: str, curve: PiecewiseDeclaration, where: str | None) -> """ d = curve.along mask, frame, exists = _masks(where, d, ragged=curve.ragged) - rewrite = ( - f'Bind the rows, or narrow where: {mask!r} to where the curve runs.' - if mask is not None - else 'Bind the rows, or declare where: to say how far the curve runs.' - ) + if mask is not None: + rewrite = f'Bind the rows, or narrow where: {mask!r} to where the curve runs.' + elif where is not None: + rewrite = f"Bind the rows, or let where: {where!r} test '{d}' too, to say how far each curve runs." + else: + rewrite = 'Bind the rows, or declare where: to say how far the curve runs.' assumed: dict[str, AssumptionBlock] = {} if values := [link.values for link in curve.links if not link.walks]: assumed[f'{name}_complete'] = AssumptionBlock( @@ -408,6 +410,31 @@ def resolve_links(name: str, pw: PiecewiseBlock, ns: Namespace, errors: list[str return tuple(link for link in links if link is not None) +def resolve_walks(name: str, pw: PiecewiseBlock, ns: Namespace, errors: list[str]) -> dict[str, Direction] | None: + """Block *name*'s walks by link key, each read as ``at`` reads its relation, or ``None`` once one failed. + + The expansion writes a walked row as ``at(_lam, by=, over=, + into=)``, so a walk is held to every rule that call is held to, and + refused here on the link the file wrote. Each refusal is appended to + *errors*. + """ + walks: dict[str, Direction] = {} + failed = False + for key, link in pw.links.items(): + if not link.walks: + continue + assert link.by is not None + resolver = ExpressionResolver(ns, f"piecewise '{name}' link '{key}'", errors) + if (problem := resolver.not_a_relation(link.by, 'at', 'by')) is not None: + errors.append(problem) + failed = True + elif (direction := resolver.direction(link.by, 'at', _named(link.over), _named(link.into))) is None: + failed = True + else: + walks[key] = direction + return None if failed else walks + + def lp_domain_refusal(name: str, pw: PiecewiseBlock, links: tuple[Expression, ...]) -> str | None: """The refusal for a ``method: lp`` curve whose x-link carries no variable, or ``None``. @@ -429,30 +456,40 @@ def lp_domain_refusal(name: str, pw: PiecewiseBlock, links: tuple[Expression, .. def declaration_of( - schema: Spec, name: str, pw: PiecewiseBlock, links: tuple[Expression, ...], where: Mask | None + schema: Spec, + name: str, + pw: PiecewiseBlock, + links: tuple[Expression, ...], + walks: dict[str, Direction], + where: Mask | None, ) -> PiecewiseDeclaration: - """Block *name* as the program carries it, with *links* and *where* typed, every fit rule decided. + """Block *name* as the program carries it, with *links*, *walks* and *where* typed, every fit rule decided. - Each link's row is ``dims:``, or its refinement through the link's - relation; its expression carries exactly that row, its values parameter + A walk reads the curve's weights at the block's own dims, so it consumes + dims of ``dims:``, joins on dims of ``dims:``, and produces dims of its + own. Each link's row is ``dims:``, or its refinement through the link's + walk; its expression carries exactly that row, its values parameter varies along it and the breakpoint dim and nothing else, and the ``where:`` tests ``dims:`` and the breakpoint dim alone. A walked row - reads the where through its relation when the mask carries every dim the - walk reads the curve at. Decided here, on the link the file wrote, rather - than on the emitted declarations, whose refusal would name ``_lam`` - — a variable the author never wrote. + reads the where through its relation when the mask carries a dim the + walk consumes. Decided here, on the link the file wrote, rather than on + the emitted declarations, whose refusal would name ``_lam`` — a + variable the author never wrote. Raises: - DimensionError: A link that does not fit its row, a where outside - ``dims:``, or a mask carrying part of what a walk reads through. + DimensionError: A walk that does not fit ``dims:``, a link that does + not fit its row, a where outside ``dims:``, or a mask carrying + part of what a walk reads through. """ ctx = f"piecewise '{name}'" - rows = {key: _row(schema, pw, link) for key, link in pw.links.items()} + for key, walk in walks.items(): + _walk_fits(f"{ctx} link '{key}'", pw, walk) + rows = {key: _row(schema, pw, walks.get(key)) for key in pw.links} for node, (key, row) in zip(links, rows.items(), strict=True): _link_fits(ctx, key, pw, dims_of(node, schema, f"{ctx} link '{key}'"), row) for (key, link), row in zip(pw.links.items(), rows.values(), strict=True): _values_fit(schema, ctx, key, pw, link, row) - _where_fits(ctx, pw, where) + _where_fits(ctx, pw, where, walks) carried = (where.dims if where is not None else frozenset()) - {pw.along} typed = tuple( Link( @@ -464,7 +501,7 @@ def declaration_of( link.by, _named(link.over), _named(link.into), - _reads(schema, ctx, key, pw, link, carried), + _reads(ctx, key, pw, walks.get(key), carried), ) for node, (key, link) in zip(links, pw.links.items(), strict=True) ) @@ -473,15 +510,49 @@ def declaration_of( ) -def _row(schema: Spec, block: PiecewiseBlock, link: PiecewiseLink) -> tuple[str, ...]: - """The dims one link's row is built over: ``dims:``, or its refinement through the link's relation. +def _walk_fits(ctx: str, block: PiecewiseBlock, walk: Direction) -> None: + """A walk reads the curve's weights, which are over ``dims:`` and the breakpoint dim, as ``at`` would. + + The rules ``at`` holds its operand to are :func:`pulled_back_dims`'s. + The ones checked first are the same rules, refused in terms of the + block, since there the rewrite is an edit to ``dims:``. + """ + consumed, produced = set(walk.consumed_dims), set(walk.produced_dims) + if missing := sorted(consumed - set(block.dims)): + raise DimensionError( + f"{ctx}: over reaches {missing}, which the block's dims {block.dims} do not carry. A walk " + f"consumes one of the curve's own dimensions — name a column over one of {block.dims}, or declare " + f'it in dims:.' + ) + if framed := sorted(produced & set(block.dims)): + raise DimensionError( + f"{ctx}: into reaches {framed}, which the block's dims {block.dims} already carry. The block " + f"builds one curve per coordinate of dims:, so {framed} cannot also index this link's rows — drop " + f'it from dims:, or walk into a dimension of its own.' + ) + if block.along in produced: + raise DimensionError( + f"{ctx}: into reaches '{block.along}', the breakpoint dim. A walk indexes the link's rows, " + f'and every row runs along the breakpoints.' + ) + if joined := sorted(set(walk.joined_dims) - set(block.dims)): + raise DimensionError( + f"{ctx}: '{walk.name}' is keyed on {joined} too, which the block's dims {block.dims} do not carry. " + f'A walk reads the curve at every key column it does not name, so the curve varies along them — add ' + f'{joined} to dims:, or walk through a relation keyed by the columns into names.' + ) + pulled_back_dims(walk, frozenset((*block.dims, block.along)), ctx, "the curve's weights") + + +def _row(schema: Spec, block: PiecewiseBlock, walk: Direction | None) -> tuple[str, ...]: + """The dims one link's row is built over: ``dims:``, or its refinement through the link's walk. The produced dims stand where the consumed ones did, so a walked row reads in the shape of the curve it ties rather than in relation order. """ - if not link.walks: + if walk is None: return tuple(block.dims) - consumed, produced = _walk(schema, link) + consumed, produced = set(walk.consumed_dims), set(walk.produced_dims) refined: list[str] = [] for d in block.dims: if d in consumed: @@ -491,45 +562,34 @@ def _row(schema: Spec, block: PiecewiseBlock, link: PiecewiseLink) -> tuple[str, return tuple(refined) -def _reads( - schema: Spec, ctx: str, key: str, block: PiecewiseBlock, link: PiecewiseLink, carried: frozenset[str] -) -> bool: +def _reads(ctx: str, key: str, block: PiecewiseBlock, walk: Direction | None, carried: frozenset[str]) -> bool: """Whether a walked link's row reads the block's ``where:`` through its relation; ``False`` for one that does not walk. - A walked row is over the dims the walk produces, where a mask over the - ones it consumes cannot be read as written. Read through the relation it - can, as ``at`` reads it, when the mask carries every dim the walk consumes - or joins on (*carried* is what the mask carries, the breakpoint dim - aside). A mask carrying none of them is over dims the row keeps, and - reads as written. + A walked row is over the dims the walk produces, where a mask over a + dim it consumes cannot be read as written. Read through the relation it + can, as ``at`` reads it, when the mask carries every dim the walk + consumes or joins on (*carried* is what the mask carries, the breakpoint + dim aside). A mask carrying none the walk consumes is over dims the row + keeps, the joined ones among them, and reads as written. Raises: - DimensionError: The mask carries some of the dims the walk reads - through and not the rest. + DimensionError: The mask carries a dim the walk consumes, and not + every dim the walk reads through. """ - if not link.walks: + if walk is None: + return False + consumed = frozenset(walk.consumed_dims) + if not consumed & carried: return False - assert link.by is not None - consumed, _ = _walk(schema, link) - relation = schema.relations[link.by] - roles = dict(relation.pairs) - written = {*_named(link.over), *_named(link.into)} - needed = consumed | {roles[c] for c in relation.key_roles if c not in written} - if (partial := sorted(needed - carried)) and needed & carried: + needed = consumed | frozenset(walk.joined_dims) + if partial := sorted(needed - carried): raise DimensionError( f"{ctx} link '{key}': where {block.where!r} carries {sorted(needed & carried)} and not {partial}, and " - f"the link reads the curve through '{link.by}' at all of {sorted(needed)}. Carry all of them in the " - f'where, so the row reads it through the relation, or none, so the row reads it as written.' + f"the link reads the curve through '{walk.name}' at all of {sorted(needed)}. Carry all of them in the " + f'where, so the row reads it through the relation, or none of {sorted(consumed)}, so the row reads it ' + f'as written.' ) - return bool(needed & carried) - - -def _walk(schema: Spec, link: PiecewiseLink) -> tuple[frozenset[str], frozenset[str]]: - """The dims one walked link consumes and produces, read off the relation it names.""" - assert link.by is not None - roles = dict(schema.relations[link.by].pairs) - consumed, produced = (frozenset(roles[c] for c in _named(written)) for written in (link.over, link.into)) - return consumed, produced + return True def _named(written: str | list[str] | None) -> tuple[str, ...]: @@ -588,14 +648,25 @@ def _values_fit( ) -def _where_fits(ctx: str, block: PiecewiseBlock, where: Mask | None) -> None: +def _where_fits(ctx: str, block: PiecewiseBlock, where: Mask | None, walks: dict[str, Direction]) -> None: """A block's ``where:`` tests ``dims:`` and the breakpoint dim, and nothing else. A walked link's values parameter carries the link's own row, so a where - naming it is refused here too: raggedness is the curve's. + naming it is refused here too: raggedness is the curve's. A dim a walk + produces is refused without the advice to add it to ``dims:``, which the + walk would then refuse. """ dims = where.dims if where is not None else frozenset() - if stray := sorted(dims - set(block.dims) - {block.along}): + stray = sorted(dims - set(block.dims) - {block.along}) + for key, walk in walks.items(): + if into := [d for d in stray if d in walk.produced_dims]: + raise DimensionError( + f"{ctx}: where {block.where!r} tests {into}, and {into} is what link '{key}' walks into — the " + f'where says which curves exist, one per coordinate of dims {block.dims}, and {into} indexes only ' + f"that link's rows. Test {block.dims} in the where, or mask the link's own variable over {into} " + f'to leave its rows unbuilt.' + ) + if stray: raise DimensionError( f'{ctx}: where {block.where!r} tests {stray}, which dims {block.dims} does not carry — a mask says ' f'which of the curves the block builds exist, and cannot add coordinates. Add {stray} to dims:, ' diff --git a/src/math_spec/validation.py b/src/math_spec/validation.py index 669dd537..c06dbeab 100644 --- a/src/math_spec/validation.py +++ b/src/math_spec/validation.py @@ -67,27 +67,40 @@ def to_spec(model: str | Path | Mapping[str, object] | Spec) -> Spec: def emitted_name_errors(schema: Spec, program: Program) -> list[str]: - """Every name a set or curve of *program* would write out that *schema* already declares. + """Every name a set or curve of *program* would write out that *schema* declares, or another one writes too. Read off the program rather than the file, since what a curve writes is decided by the curve as lowered — its links, its method, its mask. """ + emitters = [ + *((f"Sos '{name}'", EmittedSet.of(name, block.sos_type).by_kind) for name, block in program.sos.items()), + *((f"piecewise '{name}'", EmittedCurve.of(name, curve).by_kind) for name, curve in program.piecewise.items()), + ] errors = [ - error - for name, block in program.sos.items() - for error in _collisions(schema, f"Sos '{name}'", EmittedSet.of(name, block.sos_type).by_kind) + f"piecewise '{name}': link '{row.removeprefix(f'{name}_')}' names its row '{row}', which the block " + f'already writes for itself. Rename the link.' + for name, curve in program.piecewise.items() + for row in EmittedCurve.of(name, curve).reused ] - for name, curve in program.piecewise.items(): - written = EmittedCurve.of(name, curve) - errors.extend( - f"piecewise '{name}': link '{row.removeprefix(f'{name}_')}' names its row '{row}', which the block " - f'already writes for itself. Rename the link.' - for row in written.reused - ) - errors.extend(_collisions(schema, f"piecewise '{name}'", written.by_kind)) + for context, by_kind in emitters: + errors.extend(_collisions(schema, context, by_kind)) + errors.extend(_shared(emitters)) return errors +def _shared(emitters: Iterable[tuple[str, Iterable[tuple[str, Iterable[str]]]]]) -> Iterator[str]: + """The refusal for each name two expansions both write, since the second would overwrite the first.""" + first: dict[tuple[str, str], str] = {} + for context, by_kind in emitters: + for kind, names in by_kind: + for one in names: + if (owner := first.setdefault((kind, one), context)) != context: + yield ( + f"{context}: its expansion writes {kind} '{one}', which {owner} also writes. Rename one of " + f'the blocks, or the link whose row it is.' + ) + + def reference_errors(schema: Spec) -> list[str]: """Every cross-declaration rule *schema* breaks, collected rather than raised on the first.""" return [ @@ -286,9 +299,9 @@ def _piecewise_references(schema: Spec) -> Iterator[str]: """Every declaration a block names by key exists and has the shape the block needs. The breakpoint dim, the frame ``dims:`` states, each link's values - parameter, the relation and columns a walk reads through, and the gate. - What a link's expression and the where carry is resolution's to say, and - whether the pieces fit together is decided as the block is lowered + parameter, and the gate. What a link's expression, its walk and the + where carry is resolution's to say, and whether the pieces fit together + is decided as the block is lowered (:func:`math_spec.piecewise.declaration_of`). """ for name, block in schema.piecewise.items(): @@ -328,7 +341,7 @@ def _piecewise_references(schema: Spec) -> Iterator[str]: def _piecewise_link_shape( schema: Spec, name: str, block: PiecewiseBlock, key: str, link: PiecewiseLink ) -> Iterator[str]: - """One link's values parameter, and the relation its walk names, exist as the link needs them.""" + """One link's values parameter exists as the link needs it.""" context = f"piecewise '{name}' link '{key}'" if link.values not in schema.parameters: yield f"{context}: values references undeclared parameter '{link.values}'" @@ -342,53 +355,6 @@ def _piecewise_link_shape( f"{context}: values parameter '{link.values}' must carry dim " f"'{block.along}' (has {schema.parameters[link.values].dims})" ) - if link.walks: - yield from _piecewise_walk_shape(schema, context, block, link) - - -def _piecewise_walk_shape(schema: Spec, context: str, block: PiecewiseBlock, link: PiecewiseLink) -> Iterator[str]: - """A walk's relation is declared, it consumes the block's own dims, and it produces dims of its own.""" - assert link.by is not None and link.over is not None and link.into is not None - if link.by not in schema.relations: - yield ( - f"{context}: by references undeclared relation '{link.by}'. A walked link reads the curve's " - f'weights through a declared relation — declare it, or drop by, over and into.' - ) - return - roles = dict(schema.relations[link.by].pairs) - sides: list[frozenset[str]] = [] - for side, written in (('over', link.over), ('into', link.into)): - named = [written] if isinstance(written, str) else list(written) - if stray := [c for c in named if c not in roles]: - yield f"{context}: {side} names {stray}, which relation '{link.by}' has no column for (it has {sorted(roles)})" - return - if len(set(named)) != len(named): - yield f'{context}: {side} repeats a column: {named}' - return - sides.append(frozenset(roles[c] for c in named)) - consumed, produced = sides - if shared := sorted(consumed & produced): - yield ( - f'{context}: over and into both reach {shared}, so the walk consumes and produces one dimension. ' - f'Name different columns on each side.' - ) - elif missing := sorted(consumed - set(block.dims)): - yield ( - f"{context}: over reaches {missing}, which the block's dims {block.dims} do not carry. A walk " - f"consumes one of the curve's own dimensions — name a column over one of {block.dims}, or declare " - f'it in dims:.' - ) - elif framed := sorted(produced & set(block.dims)): - yield ( - f"{context}: into reaches {framed}, which the block's dims {block.dims} already carry. The block " - f"builds one curve per coordinate of dims:, so {framed} cannot also index this link's rows — drop " - f'it from dims:, or walk into a dimension of its own.' - ) - elif block.along in produced: - yield ( - f"{context}: into reaches '{block.along}', the breakpoint dim. A walk indexes the link's rows, " - f'and every row runs along the breakpoints.' - ) def _collisions(schema: Spec, context: str, by_kind: Iterable[tuple[str, Iterable[str]]]) -> Iterator[str]: diff --git a/tests/test_piecewise.py b/tests/test_piecewise.py index f114faf9..70a7944b 100644 --- a/tests/test_piecewise.py +++ b/tests/test_piecewise.py @@ -643,6 +643,28 @@ def test_a_curves_conditions_cannot_collide_with_a_written_assumption(): expanded(override(LP, assumptions={'cost_curve_increasing': 'bp_x > 0'}), 'piecewise') +@pytest.mark.parametrize( + ('where', 'advice'), + [ + pytest.param(None, 'declare where: to say how far the curve runs', id='no-where'), + pytest.param('curved', "let where: 'curved' test 'bp' too", id='a-where-over-dims'), + pytest.param('curved AND bp_power_on', "narrow where: 'curved AND bp_power_on'", id='a-ragged-where'), + ], +) +def test_a_missing_breakpoint_names_a_rewrite_the_block_can_take(where, advice): + """A block with a `where:` over `dims:` was told to declare `where:`, which it already had.""" + model = override( + WALKED, + **{ + 'parameters.curved': {'dims': ['generator'], 'dtype': 'bool'}, + 'parameters.bp_power_on': {'dims': ['generator', 'bp'], 'dtype': 'bool'}, + }, + ) + assumptions = expand_piecewise(schema_of(model, **{'piecewise.coupling.where': where})).assumptions + for name in ('coupling_complete', 'coupling_power_complete'): + assert advice in assumptions[name].description, f'{name} names the rewrite for its own where' + + @pytest.mark.parametrize('suffix', ['increasing', 'curvature', 'breakpoints', 'contiguous']) def test_every_check_has_a_sentence(suffix): assumptions = expanded(LP_MASKED, 'piecewise').program.assumptions @@ -775,6 +797,16 @@ def test_a_where_the_block_cannot_read_is_refused(patch, match): schema_of(MASKED, **patch) +def test_a_where_over_a_dim_a_link_walks_into_is_not_sent_to_dims(): + """The refusal said to add `flow` to `dims:`, and the walk into `flow` was then refused for that very edit.""" + with pytest.raises(LanguageError, match=r"\['flow'\] is what link 'power' walks into") as refused: + schema_of( + WALKED, + **{'parameters.on_flow': {'dims': ['flow'], 'dtype': 'bool'}, 'piecewise.coupling.where': 'on_flow'}, + ) + assert 'to dims:' not in str(refused.value), 'no advice the walk refuses' + + def test_segment_lines_carry_the_mask_that_no_weight_can_hand_them(): """`method: lp` emits no weights, so its three rows take the block's where themselves or stand everywhere.""" expanded = expand_piecewise(schema_of(LP_WHERE)) @@ -944,7 +976,7 @@ def _walk(**written: object) -> dict[str, object]: r"link 'power': into reaches \['flow'\], which the block's dims .* already carry", id='a-walk-into-a-dim-the-block-has', ), - pytest.param(_walk(into=['flow', 'flow']), r"link 'power': into repeats a column", id='a-repeated-column'), + pytest.param(_walk(into=['flow', 'flow']), r"link 'power': .*names a column twice", id='a-repeated-column'), pytest.param( {'relations.slot_of': {'key': 'bp', 'values': 'generator'}} | _walk(by='slot_of', over='generator', into='bp'), @@ -956,6 +988,56 @@ def _walk(**written: object) -> dict[str, object]: 'nothing pins the operating point', id='every-row-bounded', ), + pytest.param( + _walk(over=[]) + | { + 'variables.power.dims': ['generator', 'snapshot'], + 'parameters.bp_power.dims': ['generator', 'bp'], + 'constraints.balance.expression': 'sum(power, over=generator) == load', + }, + r'links.power: over: \[\] names no column', + id='an-empty-over', + ), + pytest.param( + _walk(into=[]) + | { + 'variables.power.dims': ['snapshot'], + 'parameters.bp_power.dims': ['bp'], + 'constraints.balance.expression': 'power == load', + }, + r'links.power: into: \[\] names no column', + id='an-empty-into', + ), + pytest.param( + { + 'piecewise.coupling.dims': ['flow', 'snapshot'], + 'piecewise.coupling.links': { + 'power': ['power', 'bp_power'], + 'fuel': { + 'expression': 'fuel', + 'values': 'bp_fuel', + 'by': 'generator_of', + 'over': 'flow', + 'into': 'generator', + }, + }, + }, + r"link 'fuel': at\(by=generator_of\): into=\['generator'\] names \['generator'\], which the key", + id='a-walk-landing-off-the-key', + ), + pytest.param( + { + 'dimensions.period': {'dtype': 'int'}, + 'relations.generator_of': {'key': ['flow', 'period'], 'values': 'generator'}, + }, + r"link 'power': 'generator_of' is keyed on \['period'\] too, which the block's dims", + id='a-walk-joining-on-a-dim-the-block-lacks', + ), + pytest.param( + {'relations.generator_of': {'key': {'flow': 'flow', 'site': 'generator'}, 'values': 'generator'}}, + r"link 'power': at\(by=generator_of\) joins 'generator_of' on \['generator'\] through more than one", + id='a-walk-joining-on-the-dim-it-consumes', + ), ], ) def test_a_walked_block_the_language_cannot_read_is_refused(patch, match): @@ -992,6 +1074,52 @@ def test_a_link_named_after_a_row_the_block_writes_is_refused(link, match): schema_of(WALKED, **{f'piecewise.coupling.links.{link}': ['fuel', 'bp_fuel']}) +#: A second curve whose name extends the first's, so a link of the first can spell one of its rows. +BESIDE = override( + WALKED, + **{ + 'piecewise.coupling_b': { + 'along': 'bp', + 'dims': ['generator', 'snapshot'], + 'links': {'fuel': ['fuel', 'bp_fuel'], 'power': WALKED['piecewise']['coupling']['links']['power']}, + } + }, +) + + +@pytest.mark.parametrize( + ('key', 'link', 'match'), + [ + pytest.param( + 'b_fuel', + ['fuel', 'bp_fuel'], + "writes constraint 'coupling_b_fuel', which piecewise 'coupling' also writes", + id='a-link-row', + ), + pytest.param( + 'b_convexity', + ['fuel', 'bp_fuel'], + "writes constraint 'coupling_b_convexity', which piecewise 'coupling' also writes", + id='a-row-the-other-block-writes-for-itself', + ), + pytest.param( + 'b', + WALKED['piecewise']['coupling']['links']['power'], + "writes assumption 'coupling_b_complete', which piecewise 'coupling' also writes", + id='a-walked-links-own-condition', + ), + ], +) +def test_a_name_two_blocks_would_both_write_is_refused(key, link, match): + """Links take any name, so `coupling`'s link `b_fuel` spelled `coupling_b`'s row `coupling_b_fuel`. + + Both blocks loaded, and the expansion wrote one row over the other, so + one block's link was never stated. + """ + with pytest.raises(LanguageError, match=match): + schema_of(BESIDE, **{f'piecewise.coupling.links.{key}': link}) + + def test_a_link_name_no_row_could_take_is_refused(): with pytest.raises(LanguageError, match=r"links: \['2nd'\] is not a name"): schema_of(WALKED, **{'piecewise.coupling.links.2nd': ['fuel', 'bp_fuel']}) @@ -1083,6 +1211,26 @@ def test_a_mask_over_dims_the_walk_keeps_reaches_the_walked_row_as_written(): assert expanded.constraints['coupling_power'].where == 'season' +def test_a_mask_over_a_dim_the_walk_joins_on_reaches_the_walked_row_as_written(): + """The relation is keyed by flow and snapshot, and `season` tests only `snapshot`, which the walked row keeps. + + The join column was counted with the ones the walk consumes, so this mask + was refused as carrying part of what the walk reads through, and no + rewrite kept it. + """ + expanded = expand_piecewise( + schema_of( + WALKED, + **{ + 'relations.generator_of': {'key': ['flow', 'snapshot'], 'values': 'generator'}, + 'parameters.season': {'dims': ['snapshot'], 'dtype': 'bool'}, + 'piecewise.coupling.where': 'season', + }, + ) + ) + assert expanded.constraints['coupling_power'].where == 'season' + + def test_a_mask_carrying_part_of_what_a_walk_reads_through_is_refused(): """The relation is keyed by flow and snapshot, so the read joins on snapshot and needs the mask to carry it too.""" model = override( @@ -1106,9 +1254,9 @@ def test_a_walked_links_breakpoints_are_asked_only_at_the_rows_it_reads_the_curv """Asked with the other links, `bp_power` was demanded at every flow, including those of a generator with no curve.""" assumptions = schema_of(model).expand('piecewise').program.assumptions walked = assumptions['coupling_power_complete'] - assert walked.predicate.names_read == frozenset({'bp_power'}) + assert walked.predicate.names_read == frozenset({'bp_power'}), 'the walked link asks for its own values alone' assert walked.where is not None and walked.where.dims == frozenset({'flow'}), 'asked per flow the walk reaches' - assert walked.where.names_read == frozenset(read) + assert walked.where.names_read == frozenset(read), 'the where reads the mask, if any, and the relation' assert assumptions['coupling_complete'].predicate.names_read == frozenset({'bp_fuel'}), ( 'the link on dims: keeps the block condition to itself' ) @@ -1145,7 +1293,7 @@ def test_one_walked_link_is_a_curve_because_the_relation_gives_it_its_arity(): expanded = expand_piecewise(schema_of(WALKED, **POWER_ONLY)) assert expanded.constraints['coupling_convexity'].dims == ['generator', 'snapshot'], 'one curve per generator' assert expanded.constraints['coupling_power'].dims == ['flow', 'snapshot'], 'one row per flow, sharing it' - assert 'coupling_fuel' not in expanded.constraints + assert 'coupling_fuel' not in expanded.constraints, 'no row for a link the block does not declare' def test_one_link_that_walks_nothing_is_still_a_bound_rather_than_a_curve(): From 21e31bd275e6ba54f9b2e28cb203d6a648eca2f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:52:55 +0000 Subject: [PATCH 20/20] test(piecewise): a block mask reaches a walk into several columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fluxopt's investment cost curve reads each effect of a sized flow off its curve, so its mask reaches the cost row as at(…, into=[flow, effect]). Without #782 the load fails on the assertion that what a method assumes is stated in the language. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015VUkQfE5dT7mEGoTQXSxN3 --- tests/test_piecewise.py | 46 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/test_piecewise.py b/tests/test_piecewise.py index 64976183..c557424c 100644 --- a/tests/test_piecewise.py +++ b/tests/test_piecewise.py @@ -1180,6 +1180,52 @@ def test_a_block_mask_reaches_a_walked_link_through_its_relation(): ) +#: fluxopt's investment cost curve (fluxopt/fluxopt#26): each effect of a sized flow is read off its curve. +INVESTED = { + 'dimensions': {name: {'dtype': 'str'} for name in ('cost_curve', 'effect', 'flow')} | {'bp': {'dtype': 'int'}}, + 'relations': {'cost_of': {'key': ['flow', 'effect'], 'values': 'cost_curve'}}, + 'parameters': { + 'has_cost_curve': {'dims': ['cost_curve'], 'dtype': 'bool'}, + 'size_bp': {'dims': ['cost_curve', 'bp']}, + 'cost_bp': {'dims': ['flow', 'effect', 'bp']}, + }, + 'variables': {'size': {'dims': ['cost_curve']}, 'invest': {'dims': ['flow', 'effect']}}, + 'piecewise': { + 'invest_curve': { + 'along': 'bp', + 'dims': ['cost_curve'], + 'where': 'has_cost_curve', + 'links': { + 'size': ['size', 'size_bp'], + 'cost': { + 'expression': 'invest', + 'values': 'cost_bp', + 'by': 'cost_of', + 'over': 'cost_curve', + 'into': ['flow', 'effect'], + }, + }, + } + }, + 'objective': {'sense': 'minimize', 'expression': 'sum(invest)'}, +} + + +def test_a_block_mask_reaches_a_walk_into_several_columns(): + """The mask is read through the walk as `at(…, into=[flow, effect])`, which a where string could not parse (#781). + + The block loaded without its `where:`, and with it the load failed on + the assertion that what a method assumes is stated in the language. + """ + expanded = schema_of(INVESTED).expand() + read = 'at(has_cost_curve, by=cost_of, over=cost_curve, into=[flow, effect])' + assert expanded.constraints['invest_curve_cost'].where == read, ( + 'the cost row reads the mask at each flow and effect' + ) + assert expanded.assumptions['invest_curve_cost_complete'].where == read, 'and so does its breakpoint check' + assert to_spec(expanded.to_yaml()).program == expanded.program, 'the rows it writes load again' + + def test_a_ragged_mask_reaches_a_walked_link_as_the_count_of_its_curves_breakpoints(): """A walked row is over the curve's dims less the walk, so it takes what a row over dims: alone takes, read through.""" expanded = expand_piecewise(