From 234ffe835a6dd3fcda03d89e045454324ea91389 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 08:20:14 +0000 Subject: [PATCH 1/2] feat(language): a where may compare arithmetic over parameters, so an assumption can say what a formula has to satisfy Either side of a comparison in a where string may be an expression, read as an expression is: macros and named expressions expand, every operator keeps its rule, and a variable or a dual is refused. Resolution types it as `ArithmeticComparisonNode` over the core syntax tree for the spec-side readers; lowering rebuilds every mask with `ExpressionComparisonNode` over program expressions, so a program's masks are program vocabulary throughout. The expression form stands aside for the plain shapes, so `p > 0` keeps its node. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013HceuCYNepeQX8SdZtiMf1 --- docs/reference/language/declarations.md | 5 ++ docs/reference/language/expressions.md | 39 ++++++++++- docs/reference/language/reading.md | 5 ++ docs/reference/notation.md | 78 ++++++++++++++++++++++ src/math_spec/_expression_parser.py | 15 +++-- src/math_spec/_where_parser.py | 81 +++++++++++++++++++++-- src/math_spec/dimensions.py | 14 ++-- src/math_spec/exclusivity.py | 15 ++++- src/math_spec/lowering.py | 36 +++++++++-- src/math_spec/program.py | 86 ++++++++++++++++++++++++- src/math_spec/resolution.py | 69 +++++++++++++++++++- src/math_spec/typesetting/walk.py | 9 +++ tests/test_lowering.py | 31 +++++++++ tests/test_parser.py | 30 +++++++++ tests/test_validation.py | 80 +++++++++++++++++++++-- tests/typesetting/golden/latex.out | 7 ++ tests/typesetting/golden/markdown.out | 37 +++++++++++ tests/typesetting/golden/model.yaml | 11 ++++ tests/typesetting/golden/typst.out | 9 ++- tests/typesetting/test_golden.py | 9 ++- tests/typesetting/test_walk.py | 10 +++ 21 files changed, 639 insertions(+), 37 deletions(-) diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index 510760ce..b0c3d62f 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -267,6 +267,11 @@ Three rules say what an assumption means: - **Two parameters may be compared.** `p_min <= p_max` reads the two coordinate by coordinate, the narrower one at every coordinate of the wider. Both are numbers, or both share a dtype. A number against a label is refused. +- **Either side may be arithmetic.** `p_min <= 0.5 * p_max`, + `sum(p_max, over=generator) >= peak` and + `p_max <= at(bus_cap, by=bus_of)` are each one assumption, read as an + [expression](expressions.md#arithmetic-in-a-comparison) is: macros and named + expressions expand, `shift` names its `edge=`, and a variable is refused. A predicate that names a variable is refused, because an assumption is about the data and a variable is what the solver decides from it. State a rule about diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index e1cd787f..b9398b84 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -157,10 +157,11 @@ A `where:` is a boolean mask, and true means "this coordinate exists". ```text where_expr ::= atom | "NOT" where_expr | where_expr ("AND"|"OR") where_expr | "(" where_expr ")" -atom ::= NAME | NAME COMPARATOR value | POSITION COMPARATOR INTEGER - | "True" | "False" +atom ::= NAME | NAME COMPARATOR value | expression COMPARATOR expression + | POSITION COMPARATOR INTEGER | "True" | "False" COMPARATOR ::= "<=" | ">=" | "==" | "!=" | "<" | ">" value ::= NUMBER | QUOTED | NAME_OR_STRING +expression ::= the arithmetic grammar above, with no variable and no dual in it POSITION ::= "position" "(" NAME [ "," "by" "=" NAME ] ")" QUOTED ::= "'" chars "'" | '"' chars '"' ``` @@ -176,6 +177,7 @@ QUOTED ::= "'" chars "'" | '"' chars '"' | `name OP value` | lookup | A filter on the lookup's value, so the `over` dimension has to be in the frame. A null compares false | | `name OP name` | two parameters | Coordinate by coordinate, the narrower one read at every coordinate of the wider. Both are numbers, or both share a dtype. A null on either side compares false | | `name OP name` | two lookups | Legal only where both lookups are over the same dimension and into the same dimension. `from != to` excludes a self-loop | +| `expression OP expression` | arithmetic over parameters | Coordinate by coordinate, over every dimension either side carries. A macro and a named expression expand as in an expression, and every operator keeps its rule, so a `shift` names its `edge=`. A side with no value at a coordinate compares false | | `position(name) OP i` | dimension | Where the row sits along the dimension's own order. `0` is first, and a negative number counts from the end | | `position(name, by=lookup) OP i` | a dimension and a lookup over it | The same, counted within each group the lookup makes | | `AND` `OR` `NOT` | — | Case-insensitive. `NOT` binds tighter than `AND`, and `AND` tighter than `OR` | @@ -217,6 +219,39 @@ so do two lookups that share both ends: over one dimension they are two columns of one table, and into one dimension they draw from one label set. Comparing two dimensions is not in the language. Precompute a boolean parameter instead. +### Arithmetic in a comparison + +Either side of a comparison may be an expression: `p_min <= 0.5 * p_max`, +`sum(p_max, over=generator) >= peak`, `p_max <= at(bus_cap, by=bus_of)`. The +side is read exactly as an [expression](#the-expression-grammar) is, so a macro +and a named expression expand into it and every operator keeps its own rule. +Two things an expression may carry are refused here, because a mask is built +before either exists: a variable, and a `dual()`. + +A comparison of expressions is checked over every dimension either side +carries. A side whose value is absent at a coordinate compares false there, as +a null does in every other comparison; under a summing operator the absent +term is one fewer. A `shift` says what its vacated positions hold, as it does +everywhere, so a comparison against the previous row names an `edge=` and a +`where:` on the position keeps the first row out: + +```yaml +dimensions: + snapshot: { dtype: int } +parameters: + load: { dims: [snapshot] } + ramp: { dims: [] } +assumptions: + load_ramps_within_limit: + holds: "load - shift(load, over=snapshot, offset=1, edge=0) <= ramp" + where: "position(snapshot) > 0" +``` + +A case `when:` that compares expressions cannot be proved apart from its +neighbours before the data arrives, so it is refused there with the rewrite: +compare one parameter against a literal or another parameter, or precompute +the test as a boolean parameter. + ### `position()` `position(dim)` is where the row sits along the dimension's own order, which is diff --git a/docs/reference/language/reading.md b/docs/reference/language/reading.md index f72c4777..f2dc6139 100644 --- a/docs/reference/language/reading.md +++ b/docs/reference/language/reading.md @@ -114,6 +114,11 @@ questions that every engine would otherwise work out for itself: - `.atoms` gives its leaves, with the connectives removed. - `.dims` gives the dimensions the mask is read at. +A comparison of expressions arrives as an `ExpressionComparisonNode`, whose two +sides are program expressions like a constraint's, and whose `dims` are every +dimension either side carries. Its `names_read` are every parameter and lookup +the sides read, the lookup a grouping joins through included. + A predicate you build yourself answers the same four questions: wrap it in `Mask`, or build it there with `~`, `&` and `|`. A mask folds as it is built: a double negation cancels, and a `True` or `False` is absorbed rather than buried in the diff --git a/docs/reference/notation.md b/docs/reference/notation.md index b8409e21..7aded40a 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -127,6 +127,7 @@ parameters: | Symbol | Meaning | |---|---| +| $`\mathrm{spend}^{\mathrm{cap}}`$ | `spend_cap` over $`\mathcal{G}`$ | | $`\mathit{spend}`$ | `spend` over $`\mathcal{T}`$ — what a snapshot's dispatch costs | | $`\mathit{lcoe}`$ | `lcoe` (scalar) | | $`\mathit{marginal\_price}`$ | `marginal_price` over $`\mathcal{T} \times \mathcal{B}`$ | @@ -595,8 +596,35 @@ never: \mathit{slack}_{t} \ge 0 \qquad \forall\, t \in \mathcal{T} \,:\, \bot ``` +#### `margin` + +a mask comparing two expressions, which prints as the arithmetic it is + +```yaml +margin: + dims: [snapshot, generator] + where: "p_max - p_min > cost / 2" + expression: p <= p_max +``` + +```math +p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{p}^{\mathrm{max}}_{g} - \mathrm{p}^{\mathrm{min}}_{g} > \frac{\mathrm{cost}_{g}}{2} +``` + ### Definitions +#### `spend_cap` + +a data-only entry, so a where or an assumption may compare it + +```yaml +spend_cap: cost * 2 +``` + +```math +\mathrm{spend}^{\mathrm{cap}}_{g} = \mathrm{cost}_{g} \cdot 2 \qquad \forall\, g \in \mathcal{G} +``` + #### `spend` a plain named expression: its symbol prints where it is used, its body once as a definition @@ -875,6 +903,56 @@ flexible_units_are_cheap: "NOT is_flexible OR cost <= budget" \neg \mathrm{is\_flexible}_{g} \vee \mathrm{cost}_{g} \le \mathrm{budget} \qquad \forall\, g \in \mathcal{G} ``` +#### `floor_is_half_the_capacity` + +arithmetic on a side, which reads as an expression does + +```yaml +floor_is_half_the_capacity: "p_min <= 0.5 * p_max" +``` + +```math +\mathrm{p}^{\mathrm{min}}_{g} \le 0.5 \cdot \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, g \in \mathcal{G} +``` + +#### `load_ramps_within_the_zone_cap` + +a translation under a comparison names its edge, and the where keeps the vacated row out + +```yaml +load_ramps_within_the_zone_cap: + holds: "load - shift(load, over=snapshot, offset=1, edge=0) <= at(zone_cap, by=zone_of)" + where: "position(snapshot) > 0" +``` + +```math +\mathrm{load}_{t,b} - \mathrm{load}_{t \boxminus_{0} 1,b} \le \mathrm{zone\_cap}_{\mathrm{zone\_of}(b)} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{pos}(t) > 0 +``` + +#### `fleet_covers_the_budget` + +a reduction on a side, so the frame is what is left + +```yaml +fleet_covers_the_budget: "sum(p_max, over=generator) >= budget" +``` + +```math +\sum_{g \in \mathcal{G}} \mathrm{p}^{\mathrm{max}}_{g} \ge \mathrm{budget} +``` + +#### `cheap_when_spent` + +an expressions: entry on a side, read by the name the file gave it + +```yaml +cheap_when_spent: "spend_cap > 0 OR NOT is_flexible" +``` + +```math +\mathrm{spend}^{\mathrm{cap}}_{g} > 0 \vee \neg \mathrm{is\_flexible}_{g} \qquad \forall\, g \in \mathcal{G} +``` + ### Curves, as what they expand to A curve is sugar: what prints is the formulation it expands to, which is the math the solver receives. One row per `method:`, each from the model named under it, so the symbols in this section are that model's. diff --git a/src/math_spec/_expression_parser.py b/src/math_spec/_expression_parser.py index 642a9f6a..c90dfb99 100644 --- a/src/math_spec/_expression_parser.py +++ b/src/math_spec/_expression_parser.py @@ -349,8 +349,12 @@ def with_children(node: ArithmeticNode, recurse: Callable[[ArithmeticNode], Arit # --------------------------------------------------------------------------- -def _build_grammar() -> pp.ParserElement: - """``inf`` is a ``pp.Keyword`` rather than a ``pp.Literal``, which would match the prefix of ``inflow``.""" +def _build_grammar() -> tuple[pp.ParserElement, pp.ParserElement]: + """The arithmetic grammar, and the expression grammar that puts one comparison over it. + + ``inf`` is a ``pp.Keyword`` rather than a ``pp.Literal``, which would + match the prefix of ``inflow``. + """ arith = pp.Forward() inf_literal = (pp.Keyword('.inf') | pp.Keyword('inf')).set_parse_action(lambda: NumberNode(float('inf'))) @@ -386,9 +390,10 @@ def _build_grammar() -> pp.ParserElement: arith <<= add_sub comparator = pp.one_of(list(get_args(ComparisonOperator))) - return (arith + pp.Optional(comparator + arith)).set_parse_action( + expression = (arith + pp.Optional(comparator + arith)).set_parse_action( lambda t: ComparisonNode(t[1], t[0], t[2]) if len(t) == 3 else t[0] ) + return arith, expression def _make_func_call(tokens: pp.ParseResults) -> FunctionCallNode: @@ -421,7 +426,9 @@ def _make_power(tokens: pp.ParseResults) -> Any: return items[0] if len(items) == 1 else BinaryOperatorNode('**', items[0], items[2]) -_GRAMMAR = _build_grammar() +#: The arithmetic half on its own, for the where grammar to put a predicate's +#: comparator over — one grammar for what a side may say, wherever it stands. +ARITHMETIC, _GRAMMAR = _build_grammar() #: How deep a tree the language admits. Every pass over an expression recurses, diff --git a/src/math_spec/_where_parser.py b/src/math_spec/_where_parser.py index ea873445..51e65add 100644 --- a/src/math_spec/_where_parser.py +++ b/src/math_spec/_where_parser.py @@ -15,12 +15,23 @@ import pyparsing as pp -from math_spec._expression_parser import NAME, REAL, parse_text +from math_spec._expression_parser import ( + ARITHMETIC, + NAME, + REAL, + FunctionCallNode, + NameNode, + NumberNode, + UnaryOperatorNode, + children, + parse_text, +) from math_spec.program import AndNode, BooleanLiteralNode, NotNode, OrNode, PredicateOperator, where_children if TYPE_CHECKING: from collections.abc import Callable + from math_spec._expression_parser import ArithmeticNode from math_spec.program import WhereNode # --------------------------------------------------------------------------- @@ -49,6 +60,19 @@ class UnresolvedComparisonNode: quoted: bool = False +@dataclass(frozen=True) +class UnresolvedExpressionComparisonNode: + """``expression expression``, both sides still the bare parse — ``resolution.py`` types and judges them. + + The grammar reaches for this only where a side is more than one name or + literal, so the simpler forms keep their own nodes and their own rules. + """ + + left: ArithmeticNode + op: PredicateOperator + right: ArithmeticNode + + @dataclass(frozen=True) class UnresolvedPositionNode: """``position(dim[, by=lookup]) i`` before the names are checked; ``resolution.py`` types it.""" @@ -61,7 +85,9 @@ class UnresolvedPositionNode: #: What resolution rewrites away on the where side — the three nodes whose #: left-hand side is still a name the schema has not been asked about. -UnresolvedWhereNode = UnresolvedNameNode | UnresolvedComparisonNode | UnresolvedPositionNode +UnresolvedWhereNode = ( + UnresolvedNameNode | UnresolvedComparisonNode | UnresolvedExpressionComparisonNode | UnresolvedPositionNode +) # --------------------------------------------------------------------------- @@ -82,6 +108,31 @@ def _position_comparison(tokens: pp.ParseResults) -> UnresolvedPositionNode: return UnresolvedPositionNode(str(dimension), op, at, None if by is None else str(by)) +def _is_plain(node: ArithmeticNode) -> bool: + """Whether *node* is one name or one signed number — a side the simpler comparison forms own.""" + if isinstance(node, UnaryOperatorNode): + return isinstance(node.operand, NumberNode) + return isinstance(node, NameNode | NumberNode) + + +def _reads_arithmetic(tokens: pp.ParseResults) -> bool: + """Whether a comparison needs the expression form at all. + + Two plain sides are ``name literal`` or ``name name``, and a + ``position(...)`` call against a plain side is the position form; each of + those has a node of its own, so this form stands aside for them. + """ + left, _, right = tokens + if _is_plain(left) and _is_plain(right): + return False + return not (isinstance(left, FunctionCallNode) and left.name == 'position' and _is_plain(right)) + + +def _expression_comparison(tokens: pp.ParseResults) -> UnresolvedExpressionComparisonNode: + left, op, right = tokens + return UnresolvedExpressionComparisonNode(left, op, right) + + def _comparison(tokens: pp.ParseResults) -> UnresolvedComparisonNode: """``name literal`` off the tokens the grammar captured, the quoted marker turned into a flag.""" name, op, value = tokens @@ -93,8 +144,11 @@ def _build_where_grammar() -> pp.ParserElement: """Build the pyparsing grammar for where strings. Both quote characters are accepted because YAML already owns one of them. - ``NOT`` binds tightest, then ``AND``, then ``OR``. ``position(...)`` leads - the alternation, since ``position`` would otherwise be read as a bare name. + ``NOT`` binds tightest, then ``AND``, then ``OR``. The three comparison + forms are matched longest-first, so ``p > 2 * q`` is not cut short at + ``p > 2``; the expression form stands aside for the two plain shapes + (:func:`_reads_arithmetic`), so ``p > 0`` keeps the node its dtype rule + is written for. """ where_expr = pp.Forward() @@ -121,14 +175,16 @@ def _build_where_grammar() -> pp.ParserElement: position_comparison = (position_call + comparator + position).set_parse_action(_position_comparison) comparison = (name + comparator + (number | quoted | name)).set_parse_action(_comparison) + expression_comparison = ( + (ARITHMETIC + comparator + ARITHMETIC).add_condition(_reads_arithmetic).add_parse_action(_expression_comparison) + ) # pyrefly: ignore[implicit-any-lambda] existence = name.copy().set_parse_action(lambda t: UnresolvedNameNode(t[0])) atom = ( true_lit | false_lit - | position_comparison - | comparison + | (position_comparison ^ comparison ^ expression_comparison) | existence | (pp.Suppress('(') + where_expr + pp.Suppress(')')) ) @@ -192,6 +248,17 @@ def _named_rewrite(text: str, loc: int) -> str | None: ) +def _nested(node: Any) -> tuple[Any, ...]: + """What a where string nests through: a connective's operands, and the arithmetic under a comparison of expressions.""" + if isinstance(node, UnresolvedExpressionComparisonNode): + return (node.left, node.right) + if isinstance(node, UnresolvedWhereNode): + return () + if isinstance(node, AndNode | OrNode | NotNode | BooleanLiteralNode): + return where_children(node) + return children(node) + + @lru_cache(maxsize=4096) def parse_where(text: str) -> WhereNode | UnresolvedWhereNode: """Parse a where string into an AST, its leaves still unresolved. @@ -208,5 +275,5 @@ def parse_where(text: str) -> WhereNode | UnresolvedWhereNode: """ return cast( 'WhereNode | UnresolvedWhereNode', - parse_text(_WHERE_GRAMMAR, text, 'where string', _named_rewrite, where_children, _DEEP_REWRITE), + parse_text(_WHERE_GRAMMAR, text, 'where string', _named_rewrite, _nested, _DEEP_REWRITE), ) diff --git a/src/math_spec/dimensions.py b/src/math_spec/dimensions.py index ecbe8e35..ef0067a4 100644 --- a/src/math_spec/dimensions.py +++ b/src/math_spec/dimensions.py @@ -40,8 +40,10 @@ from math_spec.errors import DimensionError from math_spec.operators import BUILTINS from math_spec.program import ( + ArithmeticComparisonNode, DimensionComparisonNode, DimensionPositionNode, + ExpressionComparisonNode, LookupComparisonNode, LookupDefinedNode, LookupPairComparisonNode, @@ -516,17 +518,19 @@ def _check_where_dims( continue match atom: case ParameterDefinedNode() | ParameterComparisonNode() | ParameterPairComparisonNode(): - noun = 'parameter' + leaf = f"where-parameter '{atom.name}'" case VariableDefinedNode(): - noun = 'variable' + leaf = f"where-variable '{atom.name}'" case DimensionComparisonNode() | DimensionPositionNode(): - noun = 'dimension' + leaf = f"where-dimension '{atom.name}'" case LookupComparisonNode() | LookupPairComparisonNode() | LookupDefinedNode(): - noun = 'lookup' + leaf = f"where-lookup '{atom.name}'" + case ArithmeticComparisonNode() | ExpressionComparisonNode(): + leaf = 'a where-comparison of expressions' case _: assert_never(atom) raise DimensionError( - f"{context}: where-{noun} '{atom.name}' reads dims {outside} outside the frame {sorted(frame)}. " + f'{context}: {leaf} reads dims {outside} outside the frame {sorted(frame)}. ' f'Reducing a mask over an unlisted dim would silently widen it — add the dim to dims:, ' f'or test a name the frame carries.' ) diff --git a/src/math_spec/exclusivity.py b/src/math_spec/exclusivity.py index 1ac64b81..21a42ae0 100644 --- a/src/math_spec/exclusivity.py +++ b/src/math_spec/exclusivity.py @@ -23,9 +23,11 @@ from math_spec.program import ( AndNode, + ArithmeticComparisonNode, BooleanLiteralNode, DimensionComparisonNode, DimensionPositionNode, + ExpressionComparisonNode, LookupComparisonNode, LookupDefinedNode, LookupPairComparisonNode, @@ -137,7 +139,7 @@ class Subject: a rank is further split by the ``by=`` lookup it is counted within. """ - kind: Literal['param', 'param_pair', 'dim', 'rank', 'lookup', 'lookup_pair', 'variable'] + kind: Literal['param', 'param_pair', 'expression', 'dim', 'rank', 'lookup', 'lookup_pair', 'variable'] name: str qualifier: str | None = None @@ -194,6 +196,12 @@ def _observe(node: TypedPredicateNode, subject: Subject, values: set[Any], dtype """ if isinstance(node, ParameterPairComparisonNode): return + if isinstance(node, ArithmeticComparisonNode | ExpressionComparisonNode): + msg = ( + 'it compares expressions, whose values only the data decides — compare one parameter against a ' + 'literal or another parameter, or precompute the test as a boolean parameter and test that' + ) + raise Undecidable(msg) if isinstance(node, DimensionPositionNode): values.add(node.position) elif isinstance(node, LookupPairComparisonNode): @@ -231,6 +239,8 @@ def _subject_of(node: TypedPredicateNode) -> Subject: return Subject('lookup_pair', name, other) case ParameterPairComparisonNode(name=name, other=other): return Subject('param_pair', name, other) + case ArithmeticComparisonNode() | ExpressionComparisonNode(): + return Subject('expression', 'a comparison of expressions') case _: assert_never(node) @@ -425,6 +435,9 @@ def _atom(node: TypedPredicateNode, cell: dict[Subject, Cell], grid: _Grid) -> b if value is Special.NULL: return False return _compare(value, op, 0) + case ArithmeticComparisonNode() | ExpressionComparisonNode(): + msg = 'a comparison of expressions is refused as undecidable before any cell is read' + raise AssertionError(msg) case DimensionPositionNode(op=op, position=position): return _compare(value, op, position) case ParameterComparisonNode(op=op, value=literal) | LookupComparisonNode(op=op, value=literal): diff --git a/src/math_spec/lowering.py b/src/math_spec/lowering.py index 3a89455f..aba2eca3 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -113,7 +113,7 @@ def lower_program(expanded: _ExpandedSpec) -> program.Program: lower, upper = _bound_expression(vdef.bounds.lower), _bound_expression(vdef.bounds.upper) variables[vname] = program.VariableDeclaration( tuple(vdef.dims), - where=resolved.variables[vname], + where=_Lowering(expanded, f"variable '{vname}'").mask(resolved.variables[vname]), lower=lower, upper=upper, domain=domain, @@ -129,7 +129,7 @@ def lower_program(expanded: _ExpandedSpec) -> program.Program: lhs=lowering.expr(expression.left), sense=expression.op, rhs=lowering.expr(expression.right), - where=where, + where=lowering.mask(where), ) objective = None @@ -172,7 +172,11 @@ def lower_program(expanded: _ExpandedSpec) -> program.Program: sos=sos, piecewise={name: declaration_of(ex) for name, ex in expanded.expanded_piecewise.items()}, assumptions={ - name: program.AssumptionDeclaration(holds, where) for name, (holds, where) in resolved.assumptions.items() + name: program.AssumptionDeclaration( + _Lowering(expanded, f"assumption '{name}'").mask(holds) or holds, + _Lowering(expanded, f"assumption '{name}'").mask(where), + ) + for name, (holds, where) in resolved.assumptions.items() }, named_expressions=expressions, ) @@ -252,13 +256,35 @@ def _cases(self, node: CasesNode) -> program.Cases: Every ``when`` arrives folded from resolution, and an arm that folded to a literal was refused at load — so no literal reaches a region. """ - stated = [program.Mask(arm.when) for arm in node.arms if arm.when is not None] + stated = [self._where(arm.when) for arm in node.arms if arm.when is not None] regions = [] for arm in node.arms: - when = program.Mask(arm.when) if arm.when is not None else _none_of(stated) + when = self._where(arm.when) if arm.when is not None else _none_of(stated) regions.append(program.Region(when, self.expr(arm.value))) return program.Cases(tuple(regions)) + def mask(self, mask: program.Mask | None) -> program.Mask | None: + """*mask* with every comparison of expressions lowered, so a program's masks are program vocabulary throughout. + + Every other predicate node is already the program's own and passes + through; a mask holding none comes back equal to the one handed in. + """ + return None if mask is None else self._where(mask.root) + + def _where(self, node: program.WhereNode) -> program.Mask: + return program.Mask(self._predicate(node)) + + def _predicate(self, node: program.WhereNode) -> program.WhereNode: + if isinstance(node, program.ArithmeticComparisonNode): + return program.ExpressionComparisonNode(self.expr(node.left), node.op, self.expr(node.right), node.dims) + if isinstance(node, program.NotNode): + return program.NotNode(self._predicate(node.operand)) + if isinstance(node, program.AndNode): + return program.AndNode(self._predicate(node.left), self._predicate(node.right)) + if isinstance(node, program.OrNode): + return program.OrNode(self._predicate(node.left), self._predicate(node.right)) + return node + def sum(self, node: FunctionCallNode) -> program.ExpressionNode: """``sum(x)``, ``sum(x, over=d)`` or ``sum(x, by=lookup)``. diff --git a/src/math_spec/program.py b/src/math_spec/program.py index 9aa8a44c..4bf20bf9 100644 --- a/src/math_spec/program.py +++ b/src/math_spec/program.py @@ -25,7 +25,7 @@ from typing import TYPE_CHECKING, Literal, NamedTuple, assert_never, get_args import math_spec.model as _model -from math_spec._expression_parser import ComparisonOperator +from math_spec._expression_parser import ComparisonOperator, LookupNode, ParameterNode, nodes from math_spec._sealed import Sealed from math_spec.errors import did_you_mean @@ -33,12 +33,15 @@ import datetime from collections.abc import Iterator + from math_spec._expression_parser import ArithmeticNode + #: What ``math_spec.program`` promises a consumer, sorted. __all__ = [ 'QUADRATIC_POSITIONS', 'Add', 'AndNode', + 'ArithmeticComparisonNode', 'AssumptionDeclaration', 'At', 'AtLeastTwo', @@ -59,6 +62,7 @@ 'Divide', 'Dual', 'Expression', + 'ExpressionComparisonNode', 'ExpressionDeclaration', 'ExpressionNode', 'FanIn', @@ -1089,6 +1093,38 @@ class ParameterPairComparisonNode: dims: tuple[str, ...] +@dataclass(frozen=True) +class ExpressionComparisonNode: + """Compare two variable-free expressions, coordinate by coordinate — ``p_min <= 0.5 * p_max``. + + ``dims`` is every dim either side carries. A side whose value is absent at + a coordinate — a parameter row missing, a translation that vacated it — + makes the comparison false there, as a null does in every other + comparison; under a summing operator the absent term is one fewer. + """ + + left: ExpressionNode + op: PredicateOperator + right: ExpressionNode + dims: tuple[str, ...] + + +@dataclass(frozen=True) +class ArithmeticComparisonNode: + """The same comparison as resolution types it, its sides in the core syntax tree. + + What the spec-side readers walk — the typesetter, the dim rules, the + exclusivity check. :func:`~math_spec.lowering.lower_program` rebuilds + every mask with an :class:`ExpressionComparisonNode` in its place, so a + program never carries one. + """ + + left: ArithmeticNode + op: PredicateOperator + right: ArithmeticNode + dims: tuple[str, ...] + + @dataclass(frozen=True) class DimensionComparisonNode: """Compare a dimension's own coordinates against a literal.""" @@ -1171,6 +1207,8 @@ class OrNode: | VariableDefinedNode | ParameterComparisonNode | ParameterPairComparisonNode + | ExpressionComparisonNode + | ArithmeticComparisonNode | DimensionComparisonNode | LookupComparisonNode | LookupPairComparisonNode @@ -1186,6 +1224,8 @@ class OrNode: TypedPredicateNode = ( ParameterComparisonNode | ParameterPairComparisonNode + | ExpressionComparisonNode + | ArithmeticComparisonNode | ParameterDefinedNode | VariableDefinedNode | DimensionComparisonNode @@ -1246,7 +1286,14 @@ def _atom_dims(atom: TypedPredicateNode) -> frozenset[str]: a branch, rather than a wrong dim set at the first model to use it. """ match atom: - case ParameterComparisonNode() | ParameterPairComparisonNode() | ParameterDefinedNode() | VariableDefinedNode(): + case ( + ParameterComparisonNode() + | ParameterPairComparisonNode() + | ExpressionComparisonNode() + | ArithmeticComparisonNode() + | ParameterDefinedNode() + | VariableDefinedNode() + ): return frozenset(atom.dims) case DimensionComparisonNode() | DimensionPositionNode(): return frozenset({atom.name}) @@ -1276,12 +1323,47 @@ def _atom_names(atom: TypedPredicateNode) -> frozenset[str]: return frozenset({atom.name}) case LookupPairComparisonNode() | ParameterPairComparisonNode(): return frozenset({atom.name, atom.other}) + case ExpressionComparisonNode(): + return _names_under(atom.left, atom.right) + case ArithmeticComparisonNode(): + return frozenset( + name + for node in nodes(atom.left, atom.right) + for name in ( + (node.name,) + if isinstance(node, ParameterNode) + else node.names + if isinstance(node, LookupNode) + else () + ) + ) case DimensionComparisonNode() | DimensionPositionNode(): return frozenset() case _: assert_never(atom) +def _names_under(*expressions: ExpressionNode) -> frozenset[str]: + """Every parameter and lookup the data has to supply for *expressions* — what a mask's ``names_read`` promises. + + :func:`parameters_of` alone misses the data an operator reads beside its + operand: the lookup a grouping or a pullback joins through, the one a + translation is partitioned by, and the parameter a named offset or width + is read from. + """ + names: set[str] = set(parameters_of(*expressions)) + for node in walk(*expressions): + if isinstance(node, (GroupSum, At)): + names.update(node.coordinate) + elif isinstance(node, (Translate, Window)): + if node.partition is not None: + names.add(node.partition) + amount = node.offset if isinstance(node, Translate) else node.width + if isinstance(amount, str): + names.add(amount) + return frozenset(names) + + def _conjuncts(where: WhereNode) -> tuple[WhereNode, ...]: """The flatten rule behind :attr:`Mask.conjuncts` — the one home of the split. diff --git a/src/math_spec/resolution.py b/src/math_spec/resolution.py index 51c5cc8f..198304cd 100644 --- a/src/math_spec/resolution.py +++ b/src/math_spec/resolution.py @@ -17,6 +17,7 @@ from functools import cached_property from typing import TYPE_CHECKING, Literal, NamedTuple, assert_never, cast +import math_spec.degree as degree from math_spec._expression_parser import ( ArithmeticNode, BinaryOperatorNode, @@ -45,13 +46,15 @@ ) from math_spec._where_parser import ( UnresolvedComparisonNode, + UnresolvedExpressionComparisonNode, UnresolvedNameNode, UnresolvedPositionNode, UnresolvedWhereNode, parse_where, ) +from math_spec.dimensions import dims_of from math_spec.errors import LanguageError, did_you_mean -from math_spec.expansion import parse_and_expand +from math_spec.expansion import expand, parse_and_expand from math_spec.model import NUMERIC_DTYPES from math_spec.operators import ( BUILTINS, @@ -62,6 +65,7 @@ ) from math_spec.program import ( AndNode, + ArithmeticComparisonNode, BooleanLiteralNode, DimensionComparisonNode, DimensionPositionNode, @@ -97,7 +101,7 @@ class Namespace: A name has one kind: model.py refuses one declared under two sections. """ - __slots__ = ('constraints', 'dimensions', 'dtypes', 'leaf_dims', 'lookups', 'parameters', 'variables') + __slots__ = ('constraints', 'dimensions', 'dtypes', 'leaf_dims', 'lookups', 'parameters', 'schema', 'variables') def __init__( self, @@ -108,7 +112,12 @@ def __init__( dtypes: Mapping[str, DeclaredDtype], leaf_dims: Mapping[str, tuple[str, ...]], constraints: Iterable[str], + schema: Spec, ) -> None: + #: The schema the names come from — what a where comparison's sides + #: are expanded and dim-checked against, since those read operators + #: and named expressions that the flat listing above cannot answer for. + self.schema = schema self.variables = frozenset(variables) self.parameters = frozenset(parameters) self.dimensions = frozenset(dimensions) @@ -147,6 +156,7 @@ def of(cls, schema: Spec) -> Namespace: **{v: tuple(vd.dims) for v, vd in schema.variables.items()}, }, schema.constraints, + schema, ) def kind(self, name: str) -> DeclarationKind | None: @@ -645,6 +655,8 @@ def where(self, node: WhereNode | UnresolvedWhereNode) -> WhereNode | Unresolved return self._position(node) if isinstance(node, UnresolvedComparisonNode): return self._comparison(node) + if isinstance(node, UnresolvedExpressionComparisonNode): + return self._expression_comparison(node) if isinstance(node, NotNode): return NotNode(self._child(node.operand)) if isinstance(node, AndNode): @@ -717,9 +729,16 @@ def _position(self, node: UnresolvedPositionNode) -> DimensionPositionNode | Unr return DimensionPositionNode(node.dimension, node.op, node.position, node.by) def _comparison(self, node: UnresolvedComparisonNode) -> WhereNode | UnresolvedWhereNode: - """``name literal``, or the two pair forms: ``lookup lookup`` and ``parameter parameter``.""" + """``name literal``, or the two pair forms: ``lookup lookup`` and ``parameter parameter``. + + A side that names an ``expressions:`` entry is arithmetic, whatever + the grammar first read it as, and takes the expression path. + """ ns, context = self.ns, self.context value = node.value + if node.name in ns.schema.expressions or (not node.quoted and value in ns.schema.expressions): + right: ArithmeticNode = NameNode(value) if isinstance(value, str) else NumberNode(value) + return self._expression_comparison(UnresolvedExpressionComparisonNode(NameNode(node.name), node.op, right)) if not node.quoted and isinstance(value, str) and (rhs_kind := ns.kind(value)) is not None: if rhs_kind == 'lookup' and ns.kind(node.name) == 'lookup': if (refusal := _lookup_pair_error(context, node, value, ns)) is not None: @@ -761,6 +780,50 @@ def _comparison(self, node: UnresolvedComparisonNode) -> WhereNode | UnresolvedW ) return node + def _expression_comparison( + self, node: UnresolvedExpressionComparisonNode + ) -> ArithmeticComparisonNode | UnresolvedExpressionComparisonNode: + """``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 = [] + for side in (node.left, node.right): + try: + expanded = expand(side, ns.schema, context) + except ValueError as e: + self.errors.append(str(e) if str(e).startswith(context) else f'{context}: {e}') + continue + sides.append(self._arith(expanded)) + if len(self.errors) > found: + return node + dims: set[str] = set() + for side in sides: + if degree.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, ceiling=1) + 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 + return ArithmeticComparisonNode(left, node.op, right, tuple(d for d in ns.schema.dimensions if d in dims)) + def _typed_literal( self, node: UnresolvedComparisonNode, dtype: DeclaredDtype ) -> float | str | datetime.date | None: diff --git a/src/math_spec/typesetting/walk.py b/src/math_spec/typesetting/walk.py index 8c1804c8..e1d99339 100644 --- a/src/math_spec/typesetting/walk.py +++ b/src/math_spec/typesetting/walk.py @@ -37,6 +37,7 @@ from math_spec.piecewise import declaration_of from math_spec.program import ( AndNode, + ArithmeticComparisonNode, AtLeastTwo, BooleanLiteralNode, Check, @@ -44,6 +45,7 @@ Curved, DimensionComparisonNode, DimensionPositionNode, + ExpressionComparisonNode, Increasing, LookupComparisonNode, LookupDefinedNode, @@ -83,6 +85,7 @@ RelationNode = ( ParameterComparisonNode | ParameterPairComparisonNode + | ArithmeticComparisonNode | DimensionComparisonNode | DimensionPositionNode | LookupComparisonNode @@ -545,6 +548,10 @@ def _where(self, node: WhereNode, ctx: _Context) -> tuple[str, int]: left, right = self._relation(node, ctx) return f'{left} {right}', comparison + if isinstance(node, ExpressionComparisonNode): + msg = 'a lowered comparison reached the typesetter; it prints the resolved tree, which lowering rebuilds.' + raise AssertionError(msg) + if isinstance(node, LookupDefinedNode): applied = self._lookup(node.name, ctx.subscript(node.over)) return f'{applied} {self.format.prose(" is defined")}', comparison @@ -577,6 +584,8 @@ def _relation(self, node: RelationNode, ctx: _Context) -> tuple[str, str]: left, right = self._parameter(node.name, ctx), self._literal(node.value) elif isinstance(node, ParameterPairComparisonNode): left, right = self._parameter(node.name, ctx), self._parameter(node.other, ctx) + elif isinstance(node, ArithmeticComparisonNode): + left, right = self._expression(node.left, ctx), self._expression(node.right, ctx) elif isinstance(node, DimensionComparisonNode): if isinstance(node.value, int | float): self.noticed.numeric_coordinates.add(node.name) diff --git a/tests/test_lowering.py b/tests/test_lowering.py index 4c81cc06..af20103c 100644 --- a/tests/test_lowering.py +++ b/tests/test_lowering.py @@ -34,6 +34,7 @@ DimensionDeclaration, Divide, Dual, + ExpressionComparisonNode, ExpressionNode, Footprint, GroupSum, @@ -433,6 +434,36 @@ def test_a_parameter_pair_carries_the_union_of_both_dims_left_first(): assert holds.names_read == frozenset({'p_max', 'floor'}), 'a pair names both sides it compares' +def test_a_comparison_of_expressions_lowers_to_program_expressions_on_both_sides(shapes_schema): + """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( + override( + SHAPES_MODEL, + **{ + 'parameters.zc': {'dims': ['z']}, + 'variables.p.where': 'c <= 0.5 * k', + 'assumptions': {'a': 'c <= at(zc, by=lk2) + sum_back(c, over=g, within=2, by=lk2)'}, + }, + ) + ) + where = program.variables['p'].where + assert where is not None + assert where.root == ExpressionComparisonNode( + Parameter('c'), '<=', Multiply(Constant(0.5), Parameter('k')), ('g',) + ), 'the sides are lowered as a constraint side is, and the dims are what either side carries' + holds = program.assumptions['a'].holds + assert isinstance(holds.root, ExpressionComparisonNode) + assert isinstance(holds.root.right, Add) and isinstance(holds.root.right.left, At) + assert holds.names_read == frozenset({'c', 'zc', 'lk2'}), ( + 'the lookup a pullback and a partition read through is data the consumer binds too' + ) + + +def test_a_mask_with_no_arithmetic_is_the_same_mask_after_lowering(dispatch_program): + """Every other predicate node is already the program's own, so lowering hands it through unchanged.""" + assert dispatch_program.variables['p'].where == Mask(P_MAX_POSITIVE) + + def test_a_power_lowers_to_a_node_of_its_own(dispatch_schema): lowered = _Lowering(dispatch_schema, 't').expr(resolved('cost ** cost', dispatch_schema)) assert isinstance(lowered, Power), 'a variable-free power has a plan node of its own' diff --git a/tests/test_parser.py b/tests/test_parser.py index 3748a75f..a104af82 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -27,6 +27,7 @@ ) from math_spec._where_parser import ( UnresolvedComparisonNode, + UnresolvedExpressionComparisonNode, UnresolvedNameNode, UnresolvedPositionNode, parse_where, @@ -257,6 +258,35 @@ def test_a_where_string_parses_to_its_node(text, node_type, attrs): assert getattr(node, attr) == expected +@pytest.mark.parametrize( + ('text', 'node_type'), + [ + pytest.param('p > 0', UnresolvedComparisonNode, id='a-name-against-a-literal'), + pytest.param('x >= -1', UnresolvedComparisonNode, id='a-name-against-a-signed-literal'), + pytest.param('a == b', UnresolvedComparisonNode, id='a-name-against-a-name'), + pytest.param('position(t) == -1', UnresolvedPositionNode, id='a-position'), + pytest.param('p > 0.5 * q', UnresolvedExpressionComparisonNode, id='arithmetic-on-the-right'), + pytest.param('(a + b) <= c', UnresolvedExpressionComparisonNode, id='a-bracketed-sum-on-the-left'), + pytest.param('-p < 1', UnresolvedExpressionComparisonNode, id='a-negated-name'), + pytest.param('sum(p, over=g) >= k', UnresolvedExpressionComparisonNode, id='a-reduction'), + pytest.param('position(t) + 1 == 0', UnresolvedExpressionComparisonNode, id='position-inside-arithmetic'), + ], +) +def test_a_comparison_takes_the_expression_form_only_past_the_plain_shapes(text, node_type): + """`p > 0` keeps the node its dtype rule is written for; `p > 2 * q` is not cut short at `p > 2`.""" + assert isinstance(parse_where(text), node_type) + + +def test_a_bracketed_predicate_is_still_a_predicate(): + """`(a > 0) AND b` groups a comparison; only `(a + b) <= c` brackets arithmetic.""" + assert isinstance(parse_where('(a > 0) AND b'), AndNode) + + +def test_a_where_side_is_held_to_the_depth_an_expression_is(): + with pytest.raises(SchemaError, match='nests 121 deep'): + parse_where(' + '.join(['p'] * 120) + ' > 0') + + def test_and_binds_tighter_than_or(): assert parse_where('a OR b AND c') == OrNode( UnresolvedNameNode('a'), AndNode(UnresolvedNameNode('b'), UnresolvedNameNode('c')) diff --git a/tests/test_validation.py b/tests/test_validation.py index 43704133..af215149 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -134,9 +134,9 @@ def test_a_nonlinear_entry_is_refused_where_the_math_reads_it(self, patch, fragm is a reported quantity. The constraint and the objective read it and hit the divisor ban at their own ceiling, which is the whole point of grading rather than banning at declaration. The piecewise-link - position is `test_a_link_reading_a_nonlinear_entry_is_refused`; a bound - and a where, which reference no expression at all, are - `test_a_bound_or_where_cannot_name_an_expression`. + position is `test_a_link_reading_a_nonlinear_entry_is_refused`; a bound, + which references no expression at all, and a where, which reads one + as arithmetic, are `test_a_bound_or_where_cannot_name_an_expression`. """ with pytest.raises(LanguageError) as exc: _schema(**_NONLINEAR_ENTRY, **patch) @@ -153,13 +153,13 @@ def test_a_nonlinear_entry_is_refused_where_the_math_reads_it(self, patch, fragm ), pytest.param( {'constraints': {'cap': {'dims': ['g'], 'where': 'bad > 0', 'expression': 'p <= c'}}}, - "'bad' not found", + 'a where compares expressions, and one side names a variable', id='where', ), ], ) def test_a_bound_or_where_cannot_name_an_expression(self, patch, fragment): - """A bound and a where reference parameters/variables, never a named expression, so the name fails to resolve whatever the entry's body.""" + """A bound names a parameter and nothing else; a where reads the entry as arithmetic, and a body carrying a variable is refused there.""" with pytest.raises(LanguageError) as exc: _schema(**_NONLINEAR_ENTRY, **patch) assert fragment in str(exc.value) @@ -788,6 +788,39 @@ class TestAssumptions: ("unknown key 'wher' in an assumption declaration", "Did you mean 'where'?"), id='a-misspelt-key', ), + pytest.param( + {'assumptions': {'a': 'c > 2 * p'}}, + ('one side names a variable', 'built before variables exist'), + id='a-variable-inside-arithmetic', + ), + pytest.param( + { + 'constraints': {'x': {'dims': ['g'], 'expression': 'p <= c'}}, + 'assumptions': {'a': 'dual(x) * 2 > 0'}, + }, + ('one side reads a dual', 'test the data instead'), + id='a-dual-inside-arithmetic', + ), + pytest.param( + {'assumptions': {'a': 'tag * 2 > 0'}}, + ("'tag' is declared dtype: str, and an expression is arithmetic",), + id='a-label-inside-arithmetic', + ), + pytest.param( + {'assumptions': {'a': 'c / (k + 1) > 0'}}, + ('a divisor must be a single Constant/Parameter factor',), + id='a-divisor-that-adds', + ), + pytest.param( + {'assumptions': {'a': 'shift(c, over=g, offset=1) <= k'}}, + ('shift() over a variable-free expression leaves vacated positions with no value',), + id='a-translation-with-no-edge', + ), + pytest.param( + {'assumptions': {'a': 'c * nope > 0'}}, + ("'nope' not found",), + id='an-unknown-name-inside-arithmetic', + ), ], ) def test_a_bad_assumption_is_refused_at_load(self, patch, fragments): @@ -806,12 +839,49 @@ def test_a_bad_assumption_is_refused_at_load(self, patch, fragments): pytest.param({}, "tag == 'gas'", id='a-label-against-a-literal'), pytest.param({}, 'NOT flag OR c > 0', id='a-compound-predicate'), pytest.param({}, 'k > 0', id='a-scalar'), + pytest.param({}, 'c <= 0.5 * k', id='arithmetic-on-a-side'), + pytest.param({'macros.half': {'args': ['x'], 'template': 'x / 2'}}, 'c <= half(k)', id='a-macro'), + pytest.param({'expressions.e': 'c * 2'}, 'e > 0', id='a-named-expression-on-the-left'), + pytest.param({'expressions.e': 'c * 2'}, 'k < e', id='a-named-expression-on-the-right'), + pytest.param({}, 'sum(c, over=g) >= k', id='a-reduction'), + pytest.param({'parameters.d': {'dims': ['h']}}, 'c <= at(d, by=lk)', id='a-pullback-through-a-lookup'), + pytest.param( + {}, + {'holds': 'c - shift(c, over=g, offset=1, edge=0) <= k', 'where': 'position(g) > 0'}, + id='a-translation', + ), ], ) def test_an_assumption_about_the_data_loads(self, patch, holds): spec = _schema(**patch, assumptions={'a': holds}) assert list(spec.assumptions) == ['a'] + def test_a_comparison_of_expressions_is_held_to_the_frame_it_sits_in(self): + message = _refusal( + **{ + 'parameters.d': {'dims': ['h']}, + 'constraints': {'cap': {'dims': ['g'], 'where': 'c > d * 2', 'expression': 'p <= c'}}, + } + ) + assert "a where-comparison of expressions reads dims ['h'] outside the frame ['g']" in message + + def test_a_case_comparing_expressions_is_refused_as_undecidable(self): + """Two cases split by arithmetic cannot be proved apart without the numbers, and the rewrite is named.""" + message = _refusal( + expressions={ + 'e': { + 'dims': ['g'], + 'cases': { + 'wide': {'when': 'c > 2 * k', 'expression': 'c'}, + 'narrow': {'when': 'c <= 2 * k', 'expression': 'k'}, + }, + 'otherwise': 0, + } + } + ) + assert 'cannot be told apart before the data arrives: it compares expressions' in message + assert 'precompute the test as a boolean parameter' in message + def test_an_assumption_round_trips_in_the_form_it_was_written(self): """A bare string stays a bare string, and a mapping stays a mapping, so `to_yaml` reproduces the file.""" spec = _schema( diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index e5a24467..f32282ed 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -59,6 +59,7 @@ \paragraph{Definitions} \begin{description} +\item[{$\mathrm{spend}^{\mathrm{cap}}$}] \texttt{spend\_cap} over $\mathcal{G}$ \item[{$\mathit{spend}$}] \texttt{spend} over $\mathcal{T}$ --- what a snapshot's dispatch costs \item[{$\mathit{lcoe}$}] \texttt{lcoe} (scalar) \item[{$\mathit{marginal\_price}$}] \texttt{marginal\_price} over $\mathcal{T} \times \mathcal{B}$ @@ -116,6 +117,7 @@ \text{always} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \\ \text{redundant} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \mathit{spill}_{t} \text{ exists} \\ \text{never} && \mathit{slack}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \bot \\ +\text{margin} && p_{t,g} & \le \mathrm{p}^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{p}^{\mathrm{max}}_{g} - \mathrm{p}^{\mathrm{min}}_{g} > \frac{\mathrm{cost}_{g}}{2} \\ \text{cost\_curve\_chord} && \mathit{op\_cost}_{t,g} \cdot \left( \mathrm{bp\_x}_{g,a} - \mathrm{bp\_x}_{g,a \boxminus_{0} 1} \right) & \ge \left( \mathrm{bp\_y}_{g,a} - \mathrm{bp\_y}_{g,a \boxminus_{0} 1} \right) \cdot \left( p_{t,g} - \mathrm{bp\_x}_{g,a} \right) + \mathrm{bp\_y}_{g,a} \cdot \left( \mathrm{bp\_x}_{g,a} - \mathrm{bp\_x}_{g,a \boxminus_{0} 1} \right) && \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{cost}^{\mathrm{curve,points}}_{g,a} \wedge \neg \mathrm{cost}^{\mathrm{curve,starts}}_{g,a} \\ \text{cost\_curve\_domain\_lo} && p_{t,g} & \ge \mathrm{bp\_x}_{g,a} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{cost}^{\mathrm{curve,starts}}_{g,a} \\ \text{cost\_curve\_domain\_hi} && p_{t,g} & \le \mathrm{bp\_x}_{g,a} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{cost}^{\mathrm{curve,ends}}_{g,a} \\ @@ -126,6 +128,7 @@ \paragraph{Definitions} \begin{align} +\text{spend\_cap} && \mathrm{spend}^{\mathrm{cap}}_{g} & = \mathrm{cost}_{g} \cdot 2 && \forall\, g \in \mathcal{G} \\ \text{spend} && \mathit{spend}_{t} & = \sum_{g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} && \forall\, t \in \mathcal{T} \\ \text{lcoe} && \mathit{lcoe} & = \frac{\sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g}}{\sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g}} \\ \text{marginal\_price} && \mathit{marginal\_price}_{t,b} & = \lambda_{\mathrm{balance},t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \\ @@ -155,6 +158,10 @@ \text{bounds\_are\_ordered} && \mathrm{p}^{\mathrm{min}}_{g} & \le \mathrm{p}^{\mathrm{max}}_{g} && \forall\, g \in \mathcal{G} \,:\, \mathrm{p}^{\mathrm{min}}_{g} \text{ is defined} \\ \text{budget\_is\_positive} && \mathrm{budget} & > 0 \\ \text{flexible\_units\_are\_cheap} && \neg \mathrm{is\_flexible}_{g} \vee \mathrm{cost}_{g} \le \mathrm{budget} & && \forall\, g \in \mathcal{G} \\ +\text{floor\_is\_half\_the\_capacity} && \mathrm{p}^{\mathrm{min}}_{g} & \le 0.5 \cdot \mathrm{p}^{\mathrm{max}}_{g} && \forall\, g \in \mathcal{G} \\ +\text{load\_ramps\_within\_the\_zone\_cap} && \mathrm{load}_{t,b} - \mathrm{load}_{t \boxminus_{0} 1,b} & \le \mathrm{zone\_cap}_{\mathrm{zone\_of}(b)} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{pos}(t) > 0 \\ +\text{fleet\_covers\_the\_budget} && \sum_{g \in \mathcal{G}} \mathrm{p}^{\mathrm{max}}_{g} & \ge \mathrm{budget} \\ +\text{cheap\_when\_spent} && \mathrm{spend}^{\mathrm{cap}}_{g} > 0 \vee \neg \mathrm{is\_flexible}_{g} & && \forall\, g \in \mathcal{G} \\ \text{cost\_curve increasing} && \mathrm{bp\_x}_{g,a - 1} & < \mathrm{bp\_x}_{g,a} && \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{cost}^{\mathrm{curve,points}}_{g,a} \wedge \mathrm{cost}^{\mathrm{curve,points}}_{g,a - 1} \\ \text{cost\_curve curvature} && \mathrm{bp\_y}_{g,a} & \text{ is a convex function of } \mathrm{bp\_x}_{g,a} \text{ along } a && \forall\, g \in \mathcal{G} \\ \text{cost\_curve breakpoints} && \lvert \{ a \in \mathcal{A} \,:\, \mathrm{cost}^{\mathrm{curve,points}}_{g,a} \} \rvert & \ge 2 && \forall\, g \in \mathcal{G} \\ diff --git a/tests/typesetting/golden/markdown.out b/tests/typesetting/golden/markdown.out index b3fec5de..f8a72ee0 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -59,6 +59,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 | Symbol | Meaning | |---|---| +| $`\mathrm{spend}^{\mathrm{cap}}`$ | `spend_cap` over $`\mathcal{G}`$ | | $`\mathit{spend}`$ | `spend` over $`\mathcal{T}`$ — what a snapshot's dispatch costs | | $`\mathit{lcoe}`$ | `lcoe` (scalar) | | $`\mathit{marginal\_price}`$ | `marginal_price` over $`\mathcal{T} \times \mathcal{B}`$ | @@ -266,6 +267,12 @@ p_{t,g} \le \mathrm{eta}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\ \mathit{slack}_{t} \ge 0 \qquad \forall\, t \in \mathcal{T} \,:\, \bot ``` +**`margin`** + +```math +p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{p}^{\mathrm{max}}_{g} - \mathrm{p}^{\mathrm{min}}_{g} > \frac{\mathrm{cost}_{g}}{2} +``` + **`cost_curve_chord`** ```math @@ -304,6 +311,12 @@ p_{t,g} \le \mathrm{fuel\_bp}_{a} \qquad \forall\, t \in \mathcal{T},\ g \in \ma #### Definitions +**`spend_cap`** + +```math +\mathrm{spend}^{\mathrm{cap}}_{g} = \mathrm{cost}_{g} \cdot 2 \qquad \forall\, g \in \mathcal{G} +``` + **`spend`** ```math @@ -434,6 +447,30 @@ p_{t,g} \le \mathrm{fuel\_bp}_{a} \qquad \forall\, t \in \mathcal{T},\ g \in \ma \neg \mathrm{is\_flexible}_{g} \vee \mathrm{cost}_{g} \le \mathrm{budget} \qquad \forall\, g \in \mathcal{G} ``` +**`floor_is_half_the_capacity`** + +```math +\mathrm{p}^{\mathrm{min}}_{g} \le 0.5 \cdot \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, g \in \mathcal{G} +``` + +**`load_ramps_within_the_zone_cap`** + +```math +\mathrm{load}_{t,b} - \mathrm{load}_{t \boxminus_{0} 1,b} \le \mathrm{zone\_cap}_{\mathrm{zone\_of}(b)} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{pos}(t) > 0 +``` + +**`fleet_covers_the_budget`** + +```math +\sum_{g \in \mathcal{G}} \mathrm{p}^{\mathrm{max}}_{g} \ge \mathrm{budget} +``` + +**`cheap_when_spent`** + +```math +\mathrm{spend}^{\mathrm{cap}}_{g} > 0 \vee \neg \mathrm{is\_flexible}_{g} \qquad \forall\, g \in \mathcal{G} +``` + **`cost_curve increasing`** ```math diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index 863c3985..d70569e4 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -108,6 +108,7 @@ sos: type: 2 expressions: + spend_cap: cost * 2 # a data-only entry, so a where or an assumption may compare it spend: # a plain named expression: its symbol prints where it is used, its body once as a definition description: what a snapshot's dispatch costs expression: sum(p * cost, over=generator) @@ -224,6 +225,10 @@ constraints: dims: [snapshot] where: "false" expression: slack >= 0 + margin: # a mask comparing two expressions, which prints as the arithmetic it is + dims: [snapshot, generator] + where: "p_max - p_min > cost / 2" + expression: p <= p_max assumptions: capacity_is_nonnegative: p_max >= 0 # one comparison, so the line aligns on its relation @@ -232,6 +237,12 @@ assumptions: where: "p_min" budget_is_positive: budget > 0 # a scalar, so no quantifier flexible_units_are_cheap: "NOT is_flexible OR cost <= budget" # a compound predicate, which no one relation aligns + floor_is_half_the_capacity: "p_min <= 0.5 * p_max" # arithmetic on a side, which reads as an expression does + load_ramps_within_the_zone_cap: # a translation under a comparison names its edge, and the where keeps the vacated row out + holds: "load - shift(load, over=snapshot, offset=1, edge=0) <= at(zone_cap, by=zone_of)" + where: "position(snapshot) > 0" + fleet_covers_the_budget: "sum(p_max, over=generator) >= budget" # a reduction on a side, so the frame is what is left + cheap_when_spent: "spend_cap > 0 OR NOT is_flexible" # an expressions: entry on a side, read by the name the file gave it objective: # a sense, a product of two variables, a power over two parameters, a power of one of those, and the summations a scalar objective spells out beside two scalar terms sense: maximize diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index 6d381ea4..681196a1 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -48,6 +48,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 / $italic("heat")$: `heat` over $cal(T) times cal(G)$ == Definitions +/ $upright("spend")^(upright("cap"))$: `spend_cap` over $cal(G)$ / $italic("spend")$: `spend` over $cal(T)$ --- what a snapshot's dispatch costs / $italic("lcoe")$: `lcoe` (scalar) / $italic("marginal_price")$: `marginal_price` over $cal(T) times cal(B)$ @@ -103,6 +104,7 @@ $ upright("budgeted") & italic("spend")_(t) & <= upright("budget") & forall t in upright("always") & italic("spill")_(t) & >= 0 & forall t in cal(T) \ upright("redundant") & italic("spill")_(t) & >= 0 & forall t in cal(T) colon italic("spill")_(t) upright(" exists") \ upright("never") & italic("slack")_(t) & >= 0 & forall t in cal(T) colon bot \ + upright("margin") & p_(t,g) & <= upright("p")^(upright("max"))_(g) & forall t in cal(T), g in cal(G) colon upright("p")^(upright("max"))_(g) - upright("p")^(upright("min"))_(g) > frac(upright("cost")_(g), 2) \ upright("cost_curve_chord") & italic("op_cost")_(t,g) dot (upright("bp_x")_(g,a) - upright("bp_x")_(g,a minus.square_(0) 1)) & >= (upright("bp_y")_(g,a) - upright("bp_y")_(g,a minus.square_(0) 1)) dot (p_(t,g) - upright("bp_x")_(g,a)) + upright("bp_y")_(g,a) dot (upright("bp_x")_(g,a) - upright("bp_x")_(g,a minus.square_(0) 1)) & forall t in cal(T), g in cal(G), a in cal(A) colon upright("cost")^(upright("curve,points"))_(g,a) and not upright("cost")^(upright("curve,starts"))_(g,a) \ upright("cost_curve_domain_lo") & p_(t,g) & >= upright("bp_x")_(g,a) & forall t in cal(T), g in cal(G), a in cal(A) colon upright("cost")^(upright("curve,starts"))_(g,a) \ upright("cost_curve_domain_hi") & p_(t,g) & <= upright("bp_x")_(g,a) & forall t in cal(T), g in cal(G), a in cal(A) colon upright("cost")^(upright("curve,ends"))_(g,a) \ @@ -112,7 +114,8 @@ $ upright("budgeted") & italic("spend")_(t) & <= upright("budget") & forall t in == Definitions #set math.equation(numbering: "(1)") -$ upright("spend") & italic("spend")_(t) & = sum_(g in cal(G)) p_(t,g) dot upright("cost")_(g) & forall t in cal(T) \ +$ upright("spend_cap") & upright("spend")^(upright("cap"))_(g) & = upright("cost")_(g) dot 2 & forall g in cal(G) \ + upright("spend") & italic("spend")_(t) & = sum_(g in cal(G)) p_(t,g) dot upright("cost")_(g) & forall t in cal(T) \ upright("lcoe") & italic("lcoe") & = frac(sum_(t in cal(T), g in cal(G)) p_(t,g) dot upright("cost")_(g), sum_(t in cal(T), g in cal(G)) p_(t,g)) \ upright("marginal_price") & italic("marginal_price")_(t,b) & = lambda_(upright("balance"),t,b) & forall t in cal(T), b in cal(B) \ upright("startup_cost") & upright("startup_cost")_(t,g) & = cases(upright("cost")_(g) & upright("if ") upright("pos")(t) = 0, upright("cost")_(g) dot 2 & upright("if ") upright("pos")(t) > 0 and upright("season_of")(t) = upright("'winter'"), 0 & upright("otherwise")) & forall t in cal(T), g in cal(G) $ @@ -139,6 +142,10 @@ $ upright("capacity_is_nonnegative") & upright("p")^(upright("max"))_(g) & >= 0 upright("bounds_are_ordered") & upright("p")^(upright("min"))_(g) & <= upright("p")^(upright("max"))_(g) & forall g in cal(G) colon upright("p")^(upright("min"))_(g) upright(" is defined") \ upright("budget_is_positive") & upright("budget") & > 0 \ upright("flexible_units_are_cheap") & not upright("is_flexible")_(g) or upright("cost")_(g) <= upright("budget") & & forall g in cal(G) \ + upright("floor_is_half_the_capacity") & upright("p")^(upright("min"))_(g) & <= 0.5 dot upright("p")^(upright("max"))_(g) & forall g in cal(G) \ + upright("load_ramps_within_the_zone_cap") & upright("load")_(t,b) - upright("load")_(t minus.square_(0) 1,b) & <= upright("zone_cap")_(upright("zone_of")(b)) & forall t in cal(T), b in cal(B) colon upright("pos")(t) > 0 \ + upright("fleet_covers_the_budget") & sum_(g in cal(G)) upright("p")^(upright("max"))_(g) & >= upright("budget") \ + upright("cheap_when_spent") & upright("spend")^(upright("cap"))_(g) > 0 or not upright("is_flexible")_(g) & & forall g in cal(G) \ upright("cost_curve increasing") & upright("bp_x")_(g,a - 1) & < upright("bp_x")_(g,a) & forall g in cal(G), a in cal(A) colon upright("cost")^(upright("curve,points"))_(g,a) and upright("cost")^(upright("curve,points"))_(g,a - 1) \ upright("cost_curve curvature") & upright("bp_y")_(g,a) & upright(" is a convex function of ") upright("bp_x")_(g,a) upright(" along ") a & forall g in cal(G) \ upright("cost_curve breakpoints") & abs({a in cal(A) colon upright("cost")^(upright("curve,points"))_(g,a)}) & >= 2 & forall g in cal(G) \ diff --git a/tests/typesetting/test_golden.py b/tests/typesetting/test_golden.py index 3d770c95..4a8c0194 100644 --- a/tests/typesetting/test_golden.py +++ b/tests/typesetting/test_golden.py @@ -136,17 +136,20 @@ def _rendered_trees() -> Iterator[object]: yield from resolved.expressions.values() -#: What resolution never hands the walk: the three nodes it types away, and the -#: three an expression only carries before names are resolved. The walk raises on +#: What resolution never hands the walk: the four nodes it types away, the three +#: an expression only carries before names are resolved, and the lowered form of +#: a comparison of expressions, which only a program carries. The walk raises on #: each rather than rendering it, so a fixture reaching one would be a bug in #: resolution rather than a case worth committing output for. UNRESOLVED = { 'UnresolvedNameNode', 'UnresolvedComparisonNode', + 'UnresolvedExpressionComparisonNode', 'UnresolvedPositionNode', 'NameNode', 'NameListNode', 'KeywordNode', + 'ExpressionComparisonNode', } #: A dataclass the walk steps *through* rather than renders: an arm has no @@ -197,6 +200,8 @@ def test_the_golden_model_calls_every_operator_in_the_language(): UNREACHABLE = { 'if isinstance(node, UnresolvedNode | KwargNode):', "msg = f'{type(node).__name__} reached the typesetter; resolve the expression first.'", + 'if isinstance(node, ExpressionComparisonNode):', + "msg = 'a lowered comparison reached the typesetter; it prints the resolved tree, which lowering rebuilds.'", 'if not isinstance(node, ComparisonNode):', "msg = f'{context}: expected a comparison, got {type(node).__name__}'", 'raise AssertionError(msg)', diff --git a/tests/typesetting/test_walk.py b/tests/typesetting/test_walk.py index 0788c826..a3ed3a03 100644 --- a/tests/typesetting/test_walk.py +++ b/tests/typesetting/test_walk.py @@ -807,3 +807,13 @@ def test_a_curve_prints_what_it_assumes_of_its_breakpoints(name: FormatName, fmt 'with no mask the count is the size of the breakpoint set itself' ) assert 'curve points' not in unmasked, 'nothing to be contiguous without a mask' + + +@EVERY_FORMAT +def test_a_comparison_of_expressions_prints_as_the_arithmetic_it_is(name: FormatName, fmt: Format): + """`p_min <= 0.5 * p_max` aligns on its relation like any comparison, each side rendered as an expression.""" + model = override(DISPATCH_MODEL, assumptions={'half': 'cost <= p_max / 2'}) + text = typeset(model, name, legend=False) + section = text[text.index('Assumptions') :] + p_max = fmt.subscript(fmt.superscript(fmt.upright('p'), fmt.upright('max')), ['g']) + assert f'{fmt.operators["le"]} {fmt.fraction(p_max, "2")}' in section From db8e2411b58fbb4394ec82ac1fd70731b20d9ea5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 08:23:07 +0000 Subject: [PATCH 2/2] docs(language): the arithmetic-in-a-comparison section links the page's own heading Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013HceuCYNepeQX8SdZtiMf1 --- docs/reference/language/expressions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index b9398b84..0ff22581 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -223,7 +223,7 @@ dimensions is not in the language. Precompute a boolean parameter instead. Either side of a comparison may be an expression: `p_min <= 0.5 * p_max`, `sum(p_max, over=generator) >= peak`, `p_max <= at(bus_cap, by=bus_of)`. The -side is read exactly as an [expression](#the-expression-grammar) is, so a macro +side is read exactly as an [expression](#expressions) is, so a macro and a named expression expand into it and every operator keeps its own rule. Two things an expression may carry are refused here, because a mask is built before either exists: a variable, and a `dual()`.