From 1ab79f53fed63bed65e5b41b12b056c7791ecf4b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 09:02:42 +0000 Subject: [PATCH 1/3] feat: position(dim) replaces index(dim, i), converting on the left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `snapshot > index(snapshot, 0)` had two readings and the language picked neither, so the file loaded and the model it meant was undetermined (#32). As "the coordinate at position 0" it compared values and depended on the axis arriving sorted; as "position 0" it compared positions. `==` and `!=` agreed, which is why it had not bitten. Renaming it would have fixed the label and not the shape: the token on the left changed what it denoted depending on the right-hand side — `snapshot > 5` the coordinate's value, `snapshot > index(snapshot, 0)` its position. So the conversion moves to the left instead, where what is being converted is visible: where: "position(snapshot) == 0" # first where: "position(snapshot) > 0" # every other one where: "position(snapshot) == -1" # last where: "position(snapshot, by=period_of) == 0" # first of each period Both sides are now integers, so every comparator reads one way and the wrong reading is unsayable rather than documented against — the same move `objective:` makes by holding one block. The grammar shrinks: POSITION leaves the `value` production, and resolution loses the same-dimension check that only existed because both sides named a dimension. A value comparison is still written against the dimension itself, `snapshot > '2030-01-01'`, where it always was. The typeset follows: `pos(t) = 0` rather than `t = index(T, 0)`, an application to the row, since that is what it converts. The ambiguity reached the rendered math too, so a reader of the paper could not recover the model either. Every model that used the old spelling now fails to parse, and "Expected end of text, found '('" is the wrong message for the most likely reader, so the failure names the rewrite. This is a breaking spelling change, described here rather than marked: a '!' moves the base version rather than the alpha counter, which the project refuses while the stream is pinned (.github/workflows/pr-title.yml). Closes #32. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADtfZf4V6W9XcLRSwSgHzE --- docs/reference/language/declarations.md | 2 +- docs/reference/language/expressions.md | 55 ++++++----- docs/reference/language/piecewise.md | 2 +- docs/reference/notation.md | 2 +- src/math_spec/piecewise.py | 4 +- src/math_spec/resolution.py | 33 +++---- src/math_spec/typeset/walk.py | 26 ++--- src/math_spec/where_parser.py | 120 ++++++++++++++---------- tests/test_parser.py | 59 ++++++++++++ tests/test_validation.py | 62 ++++++++++++ tests/typeset/golden/latex.out | 2 +- tests/typeset/golden/markdown.out | 2 +- tests/typeset/golden/model.yaml | 2 +- tests/typeset/golden/typst.out | 2 +- 14 files changed, 257 insertions(+), 116 deletions(-) diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index af4518ce..3882ab78 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -155,7 +155,7 @@ storage_balance: storage_balance_initial: foreach: [snapshot, storage] - where: "snapshot == index(snapshot, 0)" + where: "position(snapshot) == 0" expression: soc == soc_initial ``` diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index c1227955..a7111839 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -165,27 +165,28 @@ 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 | "True" | "False" +atom ::= NAME | NAME COMPARATOR value | POSITION COMPARATOR INTEGER + | "True" | "False" COMPARATOR ::= "<=" | ">=" | "==" | "!=" | "<" | ">" -value ::= NUMBER | QUOTED | NAME_OR_STRING | POSITION -POSITION ::= "index" "(" NAME "," INTEGER ")" +value ::= NUMBER | QUOTED | NAME_OR_STRING +POSITION ::= "position" "(" NAME [ "," "by" "=" NAME ] ")" QUOTED ::= "'" chars "'" | '"' chars '"' ``` -| Surface | Names a… | Meaning | -| ----------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `name` (bare) | parameter | what defined means is the **declaration's** to say: a `bool` is its own answer, a `str` is defined wherever the table has a row, and a number has to be finite as well — `0.0` counts, `inf` does not, though it is a value everywhere else | -| `name` (bare) | variable | the variable exists at this coordinate — the counterpart of the parameter row, and how you say which coordinates the row-dropping rule applies to | -| `name` (bare) | dimension | load error: it is true everywhere, so it reads as a condition and is not one. Compare it instead | -| `name OP value` | parameter | element-wise; a null compares false. The right-hand side is a literal number, or a bare name read as a string coordinate | -| `name OP value` | dimension | a filter on the frame's own coordinate column | -| `name` (bare) | lookup | defined: the label maps somewhere. A lookup may be [partial](dimensions.md#lookups), and this is how a declaration asks for the labels that do map | -| `name OP value` | lookup | a filter on the lookup's column of its `over` dimension's index — which therefore has to be in the frame. A null value is **false**, whatever the comparator | -| `name OP name` | two lookups | the one comparison whose both sides are structure. Legal only where both map out of the **same** dimension _and_ into the **same** one — `from != to` excludes a self-loop | -| `name OP index(name, i)` | one dimension, twice | the coordinate at position `i` of that dimension's own order — negative counts from the end. Both names must be the **same** dimension | -| `name OP index(name, i, by=lookup)` | a dimension and a lookup over it | the same, counted **within each group** the lookup makes — every period's first snapshot, whatever each period's length | -| `AND` `OR` `NOT` | — | case-insensitive; `NOT` binds tighter than `AND`, which binds tighter than `OR` | -| `True` / `False` | — | literals; `True` is the same as no `where` | +| Surface | Names a… | Meaning | +| -------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` (bare) | parameter | what defined means is the **declaration's** to say: a `bool` is its own answer, a `str` is defined wherever the table has a row, and a number has to be finite as well — `0.0` counts, `inf` does not, though it is a value everywhere else | +| `name` (bare) | variable | the variable exists at this coordinate — the counterpart of the parameter row, and how you say which coordinates the row-dropping rule applies to | +| `name` (bare) | dimension | load error: it is true everywhere, so it reads as a condition and is not one. Compare it instead | +| `name OP value` | parameter | element-wise; a null compares false. The right-hand side is a literal number, or a bare name read as a string coordinate | +| `name OP value` | dimension | a filter on the frame's own coordinate column | +| `name` (bare) | lookup | defined: the label maps somewhere. A lookup may be [partial](dimensions.md#lookups), and this is how a declaration asks for the labels that do map | +| `name OP value` | lookup | a filter on the lookup's column of its `over` dimension's index — which therefore has to be in the frame. A null value is **false**, whatever the comparator | +| `name OP name` | two lookups | the one comparison whose both sides are structure. Legal only where both map out of the **same** dimension _and_ into the **same** one — `from != to` excludes a self-loop | +| `position(name) OP i` | one dimension | where the row sits along that dimension's own order, as an integer — `0` is first, negative counts from the end. Both sides are integers, so every comparator reads the one way | +| `position(name, by=lookup) OP i` | a dimension and a lookup over it | the same, counted **within each group** the lookup makes — every period's first snapshot, whatever each period's length | +| `AND` `OR` `NOT` | — | case-insensitive; `NOT` binds tighter than `AND`, which binds tighter than `OR` | +| `True` / `False` | — | literals; `True` is the same as no `where` | The mask's dims must not exceed the frame it sits in ([dim algebra](#dim-algebra)), and an undeclared bare name is a @@ -226,8 +227,8 @@ load error naming the fix. A datetime boundary is a quoted ISO date — `snapshot > '2030-01-01'`, or `'2030-01-01T06:00'` with a time. Calendar arithmetic, resampling and timezone conversion stay data prep. -**`index(dim, i)` names a coordinate by where it sits**, so a boundary clause -survives the index being relabelled: +**`position(dim)` converts a dimension to where the row sits along it**, so a +boundary clause survives the index being relabelled: ```yaml dimensions: @@ -239,20 +240,30 @@ variables: constraints: soc_start: foreach: [snapshot] - where: "snapshot == index(snapshot, 0)" # not: snapshot == 0 + where: "position(snapshot) == 0" # not: snapshot == 0 expression: soc == soc_initial ``` A recurrence needs its first position seeded, and the label that happens to be there is a property of the data — relabel `[0, 1, 2]` to `[1, 2, 3]` and `snapshot == 0` matches nothing, leaving the recurrence unanchored. `-1` is the -last coordinate, `-2` the one before it. A position no coordinate occupies is +last position, `-2` the one before it. A position no coordinate occupies is an **error at bind**, not an empty mask: the clause exists to seed a row, and seeding none is the failure it was written to prevent. The order counted along is the dimension's own — the one `shift` walks, and the one the index declares — not the bytewise order a label comparison uses. +**The conversion is on the left, and that is what makes an ordering readable.** +`position(snapshot) > 0` is "not the first row", on any axis, because both +sides are integers. Naming the coordinate _at_ a position and comparing +coordinates against it would have made the same clause mean either that or "a +coordinate sorting after the first one" — two different masks wherever the +coordinates do not arrive sorted, and nothing in a file says they do +([#32](https://github.com/energy-models/math-spec/issues/32)). A comparison of +_values_ is still written against the dimension itself, where it always was: +`snapshot > '2030-01-01'`. + **`by=` counts inside each group a lookup makes**, which is the boundary a multi-period model wants — one seeded row per period rather than one per horizon: @@ -270,7 +281,7 @@ variables: constraints: soc_start: foreach: [snapshot] - where: "snapshot == index(snapshot, 0, by=period_of)" + where: "position(snapshot, by=period_of) == 0" expression: soc == at(soc_initial, by=period_of) ``` diff --git a/docs/reference/language/piecewise.md b/docs/reference/language/piecewise.md index 5d953452..46733060 100644 --- a/docs/reference/language/piecewise.md +++ b/docs/reference/language/piecewise.md @@ -85,7 +85,7 @@ weights with nothing making them a curve. **The breakpoint order is `over`'s index order**, the one every dimension has: the order its labels are first written in, which `shift` walks and -`index(bp, 0)` names. So the `bp` index is the curve's x-axis, and a values +`position(bp) == 0` names. So the `bp` index is the curve's x-axis, and a values parameter is a lookup against it — a table is a function of its coordinates and the order its rows arrive in means nothing, on either lane. "Strictly increasing breakpoints" below is increasing _in that order_: write the index diff --git a/docs/reference/notation.md b/docs/reference/notation.md index db12de1c..2bcee266 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -378,7 +378,7 @@ a position in a dimension, and the same position within a group ```yaml first: foreach: [snapshot, generator] - where: "snapshot == index(snapshot, 0) OR snapshot == index(snapshot, 0, by=season_of)" + where: "position(snapshot) == 0 OR position(snapshot, by=season_of) == 0" expression: on == 1 ``` diff --git a/src/math_spec/piecewise.py b/src/math_spec/piecewise.py index 4ad93aa1..61d77c15 100644 --- a/src/math_spec/piecewise.py +++ b/src/math_spec/piecewise.py @@ -254,7 +254,7 @@ def _expand_lp( d = pw.over run = f'({x_link.values} - shift({x_link.values}, over={d}, offset=1, edge=0))' rise = f'({y_link.values} - shift({y_link.values}, over={d}, offset=1, edge=0))' - interior = f'{mask} AND NOT {name}_starts' if mask else f'{d} != index({d}, 0)' + interior = f'{mask} AND NOT {name}_starts' if mask else f'position({d}) != 0' raw['constraints'][f'{name}_chord'] = { 'foreach': [*frame, d], 'where': interior, @@ -264,7 +264,7 @@ def _expand_lp( ), } edges = (('domain_lo', '>=', f'{name}_starts'), ('domain_hi', '<=', f'{name}_ends')) - axis = (('domain_lo', '>=', f'{d} == index({d}, 0)'), ('domain_hi', '<=', f'{d} == index({d}, -1)')) + axis = (('domain_lo', '>=', f'position({d}) == 0'), ('domain_hi', '<=', f'position({d}) == -1')) for suffix, sense, at in edges if mask else axis: if mask: raw.setdefault('parameters', {})[at] = { diff --git a/src/math_spec/resolution.py b/src/math_spec/resolution.py index 0c2f1384..9169a6d5 100644 --- a/src/math_spec/resolution.py +++ b/src/math_spec/resolution.py @@ -663,37 +663,28 @@ def _lookup_pair_error(context: str, node: UnresolvedComparisonNode, other: str, def _resolve_position(node: UnresolvedPositionNode, ns: Namespace, context: str, errors: list[str]) -> WhereNode: - """Type ``lhs index(dim, i)`` — both sides must name the same dimension. + """Type ``position(dim) i`` — the name has to be a dimension. - Comparing one dimension's coordinate against another's would be comparing - labels across label spaces, which can only mask everything out; and - ``index`` of anything but a dimension has no coordinate order to count - along. + ``position()`` converts a dimension to the row's place along it, so it + takes the one thing that has an order to count along. Anything else has no + coordinate order, and the comparison would have nothing to be a position + *in*. ``by=`` groups that order, so it takes a lookup *over the dimension being counted*: the groups are its target's labels, and a lookup over anything else has no position within a group to name. """ - for named in (node.name, node.dimension): - if named not in ns.dimensions: - kind = ns.kind(named) - was = f'a {kind}' if kind else 'not declared' - errors.append( - f"{context}: index() counts along a dimension's coordinates, and " - f"'{named}' is {was}.\n Dimensions: {sorted(ns.dimensions)}" - ) - return node - if node.name != node.dimension: + if node.dimension not in ns.dimensions: + kind = ns.kind(node.dimension) + was = f'a {kind}' if kind else 'not declared' errors.append( - f"{context}: '{node.name} {node.op} index({node.dimension}, {node.position})' compares " - f"a '{node.name}' coordinate against a '{node.dimension}' one. No label of one is a " - f'label of the other, so the predicate can only mask everything out — index() names ' - f'a position in the dimension being tested.' + f"{context}: position() counts along a dimension's coordinates, and " + f"'{node.dimension}' is {was}.\n Dimensions: {sorted(ns.dimensions)}" ) return node if node.by is not None and _refuse_grouping(node, node.by, ns, context, errors): return node - return DimensionPositionNode(node.name, node.op, node.position, node.by) + return DimensionPositionNode(node.dimension, node.op, node.position, node.by) def _refuse_grouping(node: UnresolvedPositionNode, by: str, ns: Namespace, context: str, errors: list[str]) -> bool: @@ -703,7 +694,7 @@ def _refuse_grouping(node: UnresolvedPositionNode, by: str, ns: Namespace, conte inside each, so a lookup over another dimension carries no row of the one being indexed — there is nothing for a position to be a position *in*. """ - shown = f'index({node.dimension}, {node.position}, by={by})' + shown = f'position({node.dimension}, by={by})' if (kind := ns.kind(by)) != 'lookup': was = f'a {kind}' if kind else 'not declared' errors.append( diff --git a/src/math_spec/typeset/walk.py b/src/math_spec/typeset/walk.py index cc159461..8316a848 100644 --- a/src/math_spec/typeset/walk.py +++ b/src/math_spec/typeset/walk.py @@ -484,8 +484,8 @@ def _where(self, node: WhereNode, ctx: _Context) -> tuple[str, int]: grouping = ( None if node.by is None else self.format.apply(self.format.upright(node.by), ctx.subscript(node.name)) ) - ordinal = self.position(node.name, node.position, grouping) - return f'{ctx.subscript(node.name)} {self.op(_PREDICATES[node.op])} {ordinal}', 2 + place = self.position(ctx.subscript(node.name), grouping) + return f'{place} {self.op(_PREDICATES[node.op])} {self.number(node.position)}', 2 if isinstance(node, LookupComparisonNode): applied = self.format.apply(self.format.upright(node.name), ctx.subscript(node.over)) @@ -521,19 +521,19 @@ def _where(self, node: WhereNode, ctx: _Context) -> tuple[str, int]: def literal(self, value: float | str | datetime.date) -> str: return self.number(value) if isinstance(value, (int, float)) else self.format.prose(str(value)) - def position(self, dimension: str, at: int, grouping: str | None = None) -> str: - """``index(dim, i)`` as the coordinate it names. + def position(self, index: str, grouping: str | None = None) -> str: + """``position(dim)`` as the row's place along the dimension. - An upright application of the operator to the set, the same shape a - lookup gets — rather than ``min``/``max``, which would read the two - ends and leave every other position without a notation. *grouping* is - the lookup already applied to the row, and prints as a third argument - so the row a position is counted for is visible where the position is. + An upright application of the operator to the row's index, the same + shape a lookup gets — rather than ``min``/``max``, which would read the + two ends and leave every other position without a notation. It applies + to the *row*, not to the set, because that is what it converts: a + coordinate to where that coordinate sits. *grouping* is the lookup + already applied to the row, and prints as a second argument so the + group a position is counted within is visible where the position is. """ - parts = [self.symbols.set[dimension], self.number(at)] - if grouping is not None: - parts.append(grouping) - return self.format.apply(self.format.upright('index'), ', '.join(parts)) + parts = [index] if grouping is None else [index, grouping] + return self.format.apply(self.format.upright('pos'), ', '.join(parts)) def conjoined(self, ctx: _Context, *nodes: WhereNode | None) -> str: r"""The mask on a quantifier, as one condition. diff --git a/src/math_spec/where_parser.py b/src/math_spec/where_parser.py index 5c695b44..7e365021 100644 --- a/src/math_spec/where_parser.py +++ b/src/math_spec/where_parser.py @@ -20,6 +20,7 @@ from __future__ import annotations +import re from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal, cast @@ -66,16 +67,15 @@ class UnresolvedComparisonNode: @dataclass class UnresolvedPositionNode: - """``lhs index(dim, i)`` before either name is checked. + """``position(dim) i`` before the name is checked. - Kept apart from :class:`UnresolvedComparisonNode` because its right-hand - side names a dimension *and* a position, which no literal can carry. - ``resolution.py`` types it into :class:`DimensionPositionNode`. + Kept apart from :class:`UnresolvedComparisonNode` because its left-hand + side is not a name but an *application* to one, which no bare name can + carry. ``resolution.py`` types it into :class:`DimensionPositionNode`. """ - name: str - op: PredicateOperator dimension: str + op: PredicateOperator position: int by: str | None = None @@ -119,16 +119,22 @@ class DimensionComparisonNode: @dataclass class DimensionPositionNode: - """Compare a dimension's coordinates against one named by *position*. + """Compare where a row sits along a dimension against a position. - ``where: "snapshot == index(snapshot, 0)"`` — the boundary of a recurrence - named by where it sits rather than by the label that happens to be there, - so the clause survives the index being relabelled. Negative counts from - the end, ``-1`` being the last. + ``where: "position(snapshot) == 0"`` — the boundary of a recurrence named + by where it sits rather than by the label that happens to be there, so the + clause survives the index being relabelled. Negative counts from the end, + ``-1`` being the last. + + **Both sides are integers**, which is what makes every comparator read one + way. Naming the coordinate *at* a position and comparing coordinates + against it left an ordering meaning either that or a comparison of + positions, and the two part company on an axis whose coordinates do not + arrive sorted (#32). With ``by`` it is the boundary of *each group* the lookup makes — - ``index(snapshot, 0, by=period_of)`` is every period's first snapshot, and - a row reads its own group's, the broadcast ``at(by=)`` already defines. + ``position(snapshot, by=period_of) == 0`` is every period's first snapshot, + and a row reads its own group's, the broadcast ``at(by=)`` already defines. Resolved rather than lowered to a literal: which label sits at a position is a property of the *data*, so the position travels and each lane reads @@ -244,35 +250,21 @@ def _bare(value: float | str) -> float | str: return str(value) if isinstance(value, _Quoted) else value -def _position(tokens: pp.ParseResults) -> _Position: - """``index(dim, i)`` off the tokens the grammar captured, ``by=`` included.""" - by = str(tokens[2]) if len(tokens) > 2 else None - return _Position(str(tokens[0]), int(cast('float', tokens[1])), by) - - -@dataclass(frozen=True) -class _Position: - """``index(dim, i)`` as the grammar saw it, before any name is checked. +def _position_comparison(tokens: pp.ParseResults) -> UnresolvedPositionNode: + """``position(dim[, by=lookup]) i`` off the tokens the grammar captured. - Like :class:`_Quoted` this lives between the grammar and the comparison's - parse action: which dimension the left-hand side names is resolution's - business, so the triple travels only that far. + The ``by=`` is optional and sits inside the call, so the operator's + arguments are the leading tokens and the comparison's are the trailing + two — read from the end, which is where the count is unambiguous. """ - - dimension: str - at: int - by: str | None = None + dimension, *rest = tokens + by = str(rest[0]) if len(rest) > 2 else None + op, at = rest[-2], rest[-1] + return UnresolvedPositionNode(str(dimension), cast('PredicateOperator', op), int(cast('float', at)), by) -def _comparison(name: str, op: Any, value: Any) -> UnresolvedComparisonNode | UnresolvedPositionNode: - """The comparison node one right-hand side asks for. - - A position is its own node from the start because it is the one right-hand - side that is neither a literal nor a name — nothing downstream could tell - it from a parameter called ``index``. - """ - if isinstance(value, _Position): - return UnresolvedPositionNode(name, op, value.dimension, value.at, value.by) +def _comparison(name: str, op: Any, value: Any) -> UnresolvedComparisonNode: + """The comparison node a name-against-a-literal right-hand side asks for.""" return UnresolvedComparisonNode(name, op, _bare(value), quoted=isinstance(value, _Quoted)) @@ -305,25 +297,34 @@ def _build_where_grammar() -> pp.ParserElement: ) grouped_by = pp.Suppress(',') + pp.Suppress(pp.Keyword('by')) + pp.Suppress('=') + name - index_call = ( - pp.Suppress(pp.Keyword('index')) - + pp.Suppress('(') - + name - + pp.Suppress(',') - + integer - + pp.Optional(grouped_by) - + pp.Suppress(')') - ).set_parse_action(_position) - comparator = pp.one_of('<= >= == != < >') - comparison = (name + comparator + (index_call | number | quoted | name)).set_parse_action( + + # `position(dim)` converts a dimension to the row's position along it, so + # the comparison that follows is between two integers and reads like any + # other. Nothing names the coordinate *at* a position, which is what kept + # an ordering here ambiguous (#32). + position_call = ( + pp.Suppress(pp.Keyword('position')) + pp.Suppress('(') + name + pp.Optional(grouped_by) + pp.Suppress(')') + ) + position_comparison = (position_call + comparator + integer).set_parse_action(_position_comparison) + + comparison = (name + comparator + (number | quoted | name)).set_parse_action( # pyrefly: ignore[implicit-any-lambda] lambda t: _comparison(t[0], t[1], t[2]) ) # pyrefly: ignore[implicit-any-lambda] existence = name.copy().set_parse_action(lambda t: UnresolvedNameNode(t[0])) - atom = true_lit | false_lit | comparison | existence | (pp.Suppress('(') + where_expr + pp.Suppress(')')) + # `position_comparison` leads: it starts with a keyword that `existence` + # would otherwise take for a bare name, and `comparison` for a parameter. + atom = ( + true_lit + | false_lit + | position_comparison + | comparison + | existence + | (pp.Suppress('(') + where_expr + pp.Suppress(')')) + ) NOT = pp.CaselessKeyword('NOT').suppress() # pyrefly: ignore[implicit-any-lambda] @@ -370,6 +371,23 @@ def parse_where(text: str) -> WhereNode: try: result = _WHERE_GRAMMAR.parse_string(text, parse_all=True) except pp.ParseException as e: - msg = f'Failed to parse where string: {text!r}\n{e}' + msg = f'Failed to parse where string: {text!r}\n{e}{_INDEX_REWRITE if _reads_as_index(text) else ""}' raise SchemaError(msg) from e return cast('WhereNode', result[0]) + + +#: What `index(dim, i)` became. It named the coordinate *at* a position and was +#: compared against a coordinate, which left an ordering meaning either that or +#: a comparison of positions (#32); `position()` converts on the left instead, +#: so the comparison is between integers and reads one way. +_INDEX_REWRITE = ( + "\n\n index() is now position(), and converts on the left: write 'position(dim) == i' " + "for 'dim == index(dim, i)', and 'position(dim, by=lookup) == i' for the grouped form." +) + +_INDEX_CALL = re.compile(r'\bindex\s*\(') + + +def _reads_as_index(text: str) -> bool: + """Whether the text uses the spelling this grammar dropped.""" + return _INDEX_CALL.search(text) is not None diff --git a/tests/test_parser.py b/tests/test_parser.py index 3de78215..99a843a2 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -12,6 +12,7 @@ import pytest +from math_spec.errors import SchemaError from math_spec.expression_parser import ( BinaryOperatorNode, ComparisonNode, @@ -28,6 +29,7 @@ OrNode, UnresolvedComparisonNode, UnresolvedNameNode, + UnresolvedPositionNode, parse_where, ) @@ -146,3 +148,60 @@ def test_a_quoted_right_hand_side_is_a_label(text, value, quoted): assert isinstance(node, UnresolvedComparisonNode) assert node.value == value assert node.quoted is quoted + + +@pytest.mark.parametrize( + ('text', 'attrs'), + [ + ('position(snapshot) == 0', {'dimension': 'snapshot', 'op': '==', 'position': 0, 'by': None}), + ('position(snapshot) != 0', {'dimension': 'snapshot', 'op': '!=', 'position': 0, 'by': None}), + ('position(snapshot) > 0', {'dimension': 'snapshot', 'op': '>', 'position': 0, 'by': None}), + ('position(snapshot) <= -2', {'dimension': 'snapshot', 'op': '<=', 'position': -2, 'by': None}), + ('position(snapshot) == -1', {'dimension': 'snapshot', 'op': '==', 'position': -1, 'by': None}), + ('position(snapshot, by=period_of) == 0', {'dimension': 'snapshot', 'position': 0, 'by': 'period_of'}), + ], + ids=['first', 'not first', 'after the first', 'band from the back', 'last', 'grouped'], +) +def test_position_converts_a_dimension_to_where_a_row_sits(text, attrs): + """`position(dim)` is the left-hand side, so the comparison is on integers. + + Naming the coordinate *at* a position and comparing coordinates to it made + an ordering mean two things — a value comparison on an axis that may not + arrive sorted, or a comparison of positions (#32). Converting on the left + leaves nothing for the value reading to attach to, and every comparator + reads the one way. + """ + node = parse_where(text) + assert isinstance(node, UnresolvedPositionNode) + for attr, expected in attrs.items(): + assert getattr(node, attr) == expected + + +def test_a_position_is_not_confused_with_a_name(): + """`position` leads the alternation, so it is not read as a bare name.""" + assert isinstance(parse_where('position(t) == 0 AND p_max > 0'), AndNode) + + +def test_a_coordinate_comparison_is_still_a_value_comparison(): + """The other half of #32: comparing the dimension itself is unchanged.""" + node = parse_where("snapshot > '2030-01-01'") + assert isinstance(node, UnresolvedComparisonNode) + assert node.value == '2030-01-01' + + +def test_the_old_index_spelling_names_its_rewrite(): + """A dropped spelling should not come back as "Expected end of text". + + `index(dim, i)` is what every model wrote before #32, so the parse failure + it now hits is the one message most likely to be read. + """ + with pytest.raises(SchemaError) as excinfo: + parse_where('snapshot == index(snapshot, 0)') + assert 'index() is now position()' in str(excinfo.value) + assert "write 'position(dim) == i'" in str(excinfo.value) + + +def test_an_unrelated_parse_failure_says_nothing_about_positions(): + with pytest.raises(SchemaError) as excinfo: + parse_where('p_max >') + assert 'position()' not in str(excinfo.value) diff --git a/tests/test_validation.py b/tests/test_validation.py index 83cd6d88..b3393d08 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -11,7 +11,10 @@ import pytest +from math_spec.errors import LanguageError +from math_spec.resolution import Namespace, where_of from math_spec.validation import load_model, validate_expressions +from math_spec.where_parser import DimensionPositionNode if TYPE_CHECKING: from math_spec.model import Model @@ -293,3 +296,62 @@ def test_the_version_gates_no_behaviour(self): bare = load_model(self._model()) declared = load_model(self._model(version=0)) assert bare.model_dump(exclude={'version'}) == declared.model_dump(exclude={'version'}) + + +class TestPositionResolves: + """`position(dim)` — the conversion #32 put on the left-hand side. + + The dimension has to be one, and a `by=` has to be a lookup over *that* + dimension: the groups are its target's labels, and a lookup over anything + else carries no row for a position to be a position in. + """ + + @staticmethod + def _schema() -> Model: + return load_model( + { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'period': {'dtype': 'int', 'values': [2030, 2040]}}, + 'lookups': { + 'period_of': {'over': 'snapshot', 'into': 'period'}, + 'starts_at': {'over': 'period', 'into': 'snapshot'}, + }, + 'parameters': {'load': {'dims': ['snapshot']}}, + 'variables': {'p': {'foreach': ['snapshot']}}, + } + ) + + @pytest.mark.parametrize( + ('mask', 'position', 'by'), + [ + ('position(snapshot) == 0', 0, None), + ('position(snapshot) > 0', 0, None), + ('position(snapshot) == -1', -1, None), + ('position(snapshot) < -2', -2, None), + ('position(snapshot, by=period_of) == 0', 0, 'period_of'), + ], + ids=['first', 'after the first', 'last', 'before the final two', 'first of each period'], + ) + def test_it_resolves(self, mask: str, position: int, by: str | None): + schema = self._schema() + node = where_of(mask, Namespace.of(schema), 'the mask') + assert isinstance(node, DimensionPositionNode) + assert node.name == 'snapshot' + assert node.position == position + assert node.by == by + + @pytest.mark.parametrize( + ('mask', 'fragments'), + [ + ('position(load) == 0', ["counts along a dimension's coordinates", "'load' is a parameter"]), + ('position(nope) == 0', ["'nope' is not declared"]), + ('position(snapshot, by=load) == 0', ['groups by', '``by=`` takes a lookup']), + ('position(snapshot, by=starts_at) == 0', ["along 'snapshot'", "lookup over 'period'"]), + ], + ids=['a parameter', 'undeclared', 'by= is not a lookup', 'by= is over another dim'], + ) + def test_it_refuses(self, mask: str, fragments: list[str]): + schema = self._schema() + with pytest.raises(LanguageError) as excinfo: + where_of(mask, Namespace.of(schema), 'the mask') + for fragment in fragments: + assert fragment in str(excinfo.value) diff --git a/tests/typeset/golden/latex.out b/tests/typeset/golden/latex.out index ddaf395f..2f7cd228 100644 --- a/tests/typeset/golden/latex.out +++ b/tests/typeset/golden/latex.out @@ -82,7 +82,7 @@ \text{total} && \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} & \le \mathrm{budget} \\ \text{scalar} && \mathit{units}_{g} & \le \mathrm{budget} && \forall\, g \in \mathcal{G} \,:\, \mathrm{cost}_{g} \text{ is defined} \\ \text{running} && \theta_{b} & \le \mathrm{load}_{t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \theta_{b} \text{ exists} \wedge t \ge 3 \\ -\text{first} && \mathit{on}_{t,g} & = 1 && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( t = \mathrm{index}(\mathcal{T}, 0) \vee t = \mathrm{index}(\mathcal{T}, 0, \mathrm{season\_of}(t)) \right) \\ +\text{first} && \mathit{on}_{t,g} & = 1 && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{pos}(t) = 0 \vee \mathrm{pos}(t, \mathrm{season\_of}(t)) = 0 \right) \\ \text{northern} && \mathit{slack}_{t} & \le \mathrm{load}_{t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{zone\_of}(b) = \text{north} \wedge \mathrm{zone\_of}(b) \neq \mathrm{area\_of}(b) \wedge \mathrm{zone\_of}(b) \text{ is defined} \\ \text{efficiency} && p_{t,g} & \le \mathrm{eta}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{always} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \\ diff --git a/tests/typeset/golden/markdown.out b/tests/typeset/golden/markdown.out index c3334bab..e64d0575 100644 --- a/tests/typeset/golden/markdown.out +++ b/tests/typeset/golden/markdown.out @@ -138,7 +138,7 @@ $$\theta_{b} \le \mathrm{load}_{t,b} \qquad \forall\thinspace t \in \mathcal{T}, **`first`** -$$\mathit{on}_{t,g} = 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G} \thinspace:\thinspace \left( t = \mathrm{index}(\mathcal{T}, 0) \vee t = \mathrm{index}(\mathcal{T}, 0, \mathrm{season\_of}(t)) \right)$$ +$$\mathit{on}_{t,g} = 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G} \thinspace:\thinspace \left( \mathrm{pos}(t) = 0 \vee \mathrm{pos}(t, \mathrm{season\_of}(t)) = 0 \right)$$ **`northern`** diff --git a/tests/typeset/golden/model.yaml b/tests/typeset/golden/model.yaml index 477f2862..902b0f0a 100644 --- a/tests/typeset/golden/model.yaml +++ b/tests/typeset/golden/model.yaml @@ -152,7 +152,7 @@ constraints: expression: theta <= load first: # a position in a dimension, and the same position within a group foreach: [snapshot, generator] - where: "snapshot == index(snapshot, 0) OR snapshot == index(snapshot, 0, by=season_of)" + where: "position(snapshot) == 0 OR position(snapshot, by=season_of) == 0" expression: on == 1 northern: # a lookup compared to a label, to another lookup, and to nothing foreach: [snapshot, bus] diff --git a/tests/typeset/golden/typst.out b/tests/typeset/golden/typst.out index ca55d692..2a612e26 100644 --- a/tests/typeset/golden/typst.out +++ b/tests/typeset/golden/typst.out @@ -71,7 +71,7 @@ $ upright("balance") & sum_(g in cal(G) colon upright("gen_bus")(g) = b) p_(t,g) upright("total") & sum_(t in cal(T), g in cal(G)) p_(t,g) & <= upright("budget") \ upright("scalar") & italic("units")_(g) & <= upright("budget") & forall g in cal(G) colon upright("cost")_(g) upright(" is defined") \ upright("running") & theta_(b) & <= upright("load")_(t,b) & forall t in cal(T), b in cal(B) colon theta_(b) upright(" exists") and t >= 3 \ - upright("first") & italic("on")_(t,g) & = 1 & forall t in cal(T), g in cal(G) colon (t = upright("index")(cal(T), 0) or t = upright("index")(cal(T), 0, upright("season_of")(t))) \ + upright("first") & italic("on")_(t,g) & = 1 & forall t in cal(T), g in cal(G) colon (upright("pos")(t) = 0 or upright("pos")(t, upright("season_of")(t)) = 0) \ upright("northern") & italic("slack")_(t) & <= upright("load")_(t,b) & forall t in cal(T), b in cal(B) colon upright("zone_of")(b) = upright("north") and upright("zone_of")(b) != upright("area_of")(b) and upright("zone_of")(b) upright(" is defined") \ upright("efficiency") & p_(t,g) & <= upright("eta")_(g) dot upright("p")^(upright("max"))_(g) & forall t in cal(T), g in cal(G) \ upright("always") & italic("spill")_(t) & >= 0 & forall t in cal(T) \ From 4569d2979451e43b5e0ed33a502e48d7098b8fc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 14:47:16 +0000 Subject: [PATCH 2/3] refactor: route the position operator through the typeset seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `OPERATOR_NAMES` is the vocabulary a walk may emit and `format.py` states the contract — "A format spells; it never decides" — so an operator name written into the walk escapes all three guards built around that set: that every format spells exactly it, that every Typst spelling compiles, and that the golden model asks for every one. `index` had the same problem, but this commit is the notation change, so it is the moment to move it. The spelling is unchanged, so no golden moves. What changes is that a format can now disagree about it, and is held to having an opinion. Also fixes what that exposed: `docs/reference/notation.md` is generated between markers from the golden model, and the earlier edit rewrote the YAML snippets by hand without the rendered math beneath them, leaving four lines reading `index(𝒯, 0)` under a snippet saying `position(...)`. `tools/notation.py` cannot run — it reads two example files that are not in the repo, on main as well — so the four lines are corrected from output rendered through the same code path rather than by regenerating. Two cleanups alongside: the position parse action reads its tokens from one end rather than three, and the migration hint is an `if` above its use site rather than a helper below it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADtfZf4V6W9XcLRSwSgHzE --- docs/reference/notation.md | 8 ++--- src/math_spec/typeset/format.py | 1 + src/math_spec/typeset/latex.py | 1 + src/math_spec/typeset/typst.py | 1 + src/math_spec/typeset/walk.py | 16 +++++----- src/math_spec/where_parser.py | 54 ++++++++++++++------------------- tests/test_parser.py | 37 +++++++++------------- 7 files changed, 50 insertions(+), 68 deletions(-) diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 2bcee266..7f06d76a 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -382,7 +382,7 @@ first: expression: on == 1 ``` -$$\mathit{on}_{t,g} = 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G} \thinspace:\thinspace \left( t = \mathrm{index}(\mathcal{T}, 0) \vee t = \mathrm{index}(\mathcal{T}, 0, \mathrm{season\_of}(t)) \right)$$ +$$\mathit{on}_{t,g} = 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G} \thinspace:\thinspace \left( \mathrm{pos}(t) = 0 \vee \mathrm{pos}(t, \mathrm{season\_of}(t)) = 0 \right)$$ #### `northern` @@ -715,11 +715,11 @@ cost_curve: method: lp ``` -$$\mathit{op\_cost}_{t,g} \cdot \left( \mathrm{x}_{g,b} - \mathrm{x}_{g,b \boxminus_{0} 1} \right) \ge \left( \mathrm{y}_{g,b} - \mathrm{y}_{g,b \boxminus_{0} 1} \right) \cdot \left( p_{t,g} - \mathrm{x}_{g,b} \right) + \mathrm{y}_{g,b} \cdot \left( \mathrm{x}_{g,b} - \mathrm{x}_{g,b \boxminus_{0} 1} \right) \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace b \neq \mathrm{index}(\mathcal{B}, 0)$$ +$$\mathit{op\_cost}_{t,g} \cdot \left( \mathrm{x}_{g,b} - \mathrm{x}_{g,b \boxminus_{0} 1} \right) \ge \left( \mathrm{y}_{g,b} - \mathrm{y}_{g,b \boxminus_{0} 1} \right) \cdot \left( p_{t,g} - \mathrm{x}_{g,b} \right) + \mathrm{y}_{g,b} \cdot \left( \mathrm{x}_{g,b} - \mathrm{x}_{g,b \boxminus_{0} 1} \right) \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace \mathrm{pos}(b) \neq 0$$ -$$p_{t,g} \ge \mathrm{x}_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace b = \mathrm{index}(\mathcal{B}, 0)$$ +$$p_{t,g} \ge \mathrm{x}_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace \mathrm{pos}(b) = 0$$ -$$p_{t,g} \le \mathrm{x}_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace b = \mathrm{index}(\mathcal{B}, -1)$$ +$$p_{t,g} \le \mathrm{x}_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace \mathrm{pos}(b) = -1$$ ### Sets carried to the solver diff --git a/src/math_spec/typeset/format.py b/src/math_spec/typeset/format.py index 844568e0..0e079e96 100644 --- a/src/math_spec/typeset/format.py +++ b/src/math_spec/typeset/format.py @@ -68,6 +68,7 @@ 'integers', 'binary_set', 'sos_set', + 'position', 'minimize', 'maximize', } diff --git a/src/math_spec/typeset/latex.py b/src/math_spec/typeset/latex.py index 6e281890..b78ca414 100644 --- a/src/math_spec/typeset/latex.py +++ b/src/math_spec/typeset/latex.py @@ -80,6 +80,7 @@ class LatexFormat: 'integers': r'\mathbb{Z}', 'binary_set': r'\{0, 1\}', 'sos_set': r'\mathrm{SOS}', + 'position': r'\mathrm{pos}', 'minimize': r'\min', 'maximize': r'\max', } diff --git a/src/math_spec/typeset/typst.py b/src/math_spec/typeset/typst.py index 70a51cb5..d27b9d45 100644 --- a/src/math_spec/typeset/typst.py +++ b/src/math_spec/typeset/typst.py @@ -90,6 +90,7 @@ class TypstFormat: 'integers': 'ZZ', 'binary_set': '{0, 1}', 'sos_set': 'upright("SOS")', + 'position': 'upright("pos")', 'minimize': 'min', 'maximize': 'max', } diff --git a/src/math_spec/typeset/walk.py b/src/math_spec/typeset/walk.py index 8316a848..fa55ef2f 100644 --- a/src/math_spec/typeset/walk.py +++ b/src/math_spec/typeset/walk.py @@ -524,16 +524,14 @@ def literal(self, value: float | str | datetime.date) -> str: def position(self, index: str, grouping: str | None = None) -> str: """``position(dim)`` as the row's place along the dimension. - An upright application of the operator to the row's index, the same - shape a lookup gets — rather than ``min``/``max``, which would read the - two ends and leave every other position without a notation. It applies - to the *row*, not to the set, because that is what it converts: a - coordinate to where that coordinate sits. *grouping* is the lookup - already applied to the row, and prints as a second argument so the - group a position is counted within is visible where the position is. + Applied to the *row* rather than to the set, because that is what it + converts: a coordinate to where that coordinate sits. *grouping* is the + lookup already applied to the row, and prints as a second argument so + the group a position is counted within is visible where the position + is. """ - parts = [index] if grouping is None else [index, grouping] - return self.format.apply(self.format.upright('pos'), ', '.join(parts)) + inner = index if grouping is None else f'{index}, {grouping}' + return self.format.apply(self.op('position'), inner) def conjoined(self, ctx: _Context, *nodes: WhereNode | None) -> str: r"""The mask on a quantifier, as one condition. diff --git a/src/math_spec/where_parser.py b/src/math_spec/where_parser.py index 7e365021..1d236cdf 100644 --- a/src/math_spec/where_parser.py +++ b/src/math_spec/where_parser.py @@ -251,16 +251,12 @@ def _bare(value: float | str) -> float | str: def _position_comparison(tokens: pp.ParseResults) -> UnresolvedPositionNode: - """``position(dim[, by=lookup]) i`` off the tokens the grammar captured. - - The ``by=`` is optional and sits inside the call, so the operator's - arguments are the leading tokens and the comparison's are the trailing - two — read from the end, which is where the count is unambiguous. - """ - dimension, *rest = tokens - by = str(rest[0]) if len(rest) > 2 else None - op, at = rest[-2], rest[-1] - return UnresolvedPositionNode(str(dimension), cast('PredicateOperator', op), int(cast('float', at)), by) + """``position(dim[, by=lookup]) i`` off the tokens the grammar captured.""" + *call, op, at = tokens + dimension, by = call[0], call[1] if len(call) > 1 else None + return UnresolvedPositionNode( + str(dimension), cast('PredicateOperator', op), int(cast('float', at)), None if by is None else str(by) + ) def _comparison(name: str, op: Any, value: Any) -> UnresolvedComparisonNode: @@ -299,10 +295,9 @@ def _build_where_grammar() -> pp.ParserElement: grouped_by = pp.Suppress(',') + pp.Suppress(pp.Keyword('by')) + pp.Suppress('=') + name comparator = pp.one_of('<= >= == != < >') - # `position(dim)` converts a dimension to the row's position along it, so - # the comparison that follows is between two integers and reads like any - # other. Nothing names the coordinate *at* a position, which is what kept - # an ordering here ambiguous (#32). + # Leads the `atom` alternation below: `position` would otherwise be taken + # for a bare name. See `DimensionPositionNode` for why it converts on the + # left rather than naming the coordinate at a position (#32). position_call = ( pp.Suppress(pp.Keyword('position')) + pp.Suppress('(') + name + pp.Optional(grouped_by) + pp.Suppress(')') ) @@ -361,6 +356,16 @@ def fold(tokens: pp.ParseResults) -> Any: _WHERE_GRAMMAR = _build_where_grammar() +#: The spelling this grammar dropped, and its rewrite (#32). A retired syntax +#: speaks before the generic mismatch, the same way a retired kwarg does in +#: `operators.call_shape_error`: "Expected end of text, found '('" is what every +#: model written against the old spelling would otherwise get. +_INDEX_CALL = re.compile(r'\bindex\s*\(') +_INDEX_REWRITE = ( + "\n\n index() is now position(), and converts on the left: write 'position(dim) == i' " + "for 'dim == index(dim, i)', and 'position(dim, by=lookup) == i' for the grouped form." +) + def parse_where(text: str) -> WhereNode: """Parse a where string into an AST. @@ -371,23 +376,8 @@ def parse_where(text: str) -> WhereNode: try: result = _WHERE_GRAMMAR.parse_string(text, parse_all=True) except pp.ParseException as e: - msg = f'Failed to parse where string: {text!r}\n{e}{_INDEX_REWRITE if _reads_as_index(text) else ""}' + msg = f'Failed to parse where string: {text!r}\n{e}' + if _INDEX_CALL.search(text): + msg += _INDEX_REWRITE raise SchemaError(msg) from e return cast('WhereNode', result[0]) - - -#: What `index(dim, i)` became. It named the coordinate *at* a position and was -#: compared against a coordinate, which left an ordering meaning either that or -#: a comparison of positions (#32); `position()` converts on the left instead, -#: so the comparison is between integers and reads one way. -_INDEX_REWRITE = ( - "\n\n index() is now position(), and converts on the left: write 'position(dim) == i' " - "for 'dim == index(dim, i)', and 'position(dim, by=lookup) == i' for the grouped form." -) - -_INDEX_CALL = re.compile(r'\bindex\s*\(') - - -def _reads_as_index(text: str) -> bool: - """Whether the text uses the spelling this grammar dropped.""" - return _INDEX_CALL.search(text) is not None diff --git a/tests/test_parser.py b/tests/test_parser.py index 99a843a2..7b7fd820 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -151,30 +151,25 @@ def test_a_quoted_right_hand_side_is_a_label(text, value, quoted): @pytest.mark.parametrize( - ('text', 'attrs'), + ('text', 'op', 'position', 'by'), [ - ('position(snapshot) == 0', {'dimension': 'snapshot', 'op': '==', 'position': 0, 'by': None}), - ('position(snapshot) != 0', {'dimension': 'snapshot', 'op': '!=', 'position': 0, 'by': None}), - ('position(snapshot) > 0', {'dimension': 'snapshot', 'op': '>', 'position': 0, 'by': None}), - ('position(snapshot) <= -2', {'dimension': 'snapshot', 'op': '<=', 'position': -2, 'by': None}), - ('position(snapshot) == -1', {'dimension': 'snapshot', 'op': '==', 'position': -1, 'by': None}), - ('position(snapshot, by=period_of) == 0', {'dimension': 'snapshot', 'position': 0, 'by': 'period_of'}), + ('position(snapshot) == 0', '==', 0, None), + ('position(snapshot) != 0', '!=', 0, None), + ('position(snapshot) > 0', '>', 0, None), + ('position(snapshot) <= -2', '<=', -2, None), + ('position(snapshot) == -1', '==', -1, None), + ('position(snapshot, by=period_of) == 0', '==', 0, 'period_of'), ], ids=['first', 'not first', 'after the first', 'band from the back', 'last', 'grouped'], ) -def test_position_converts_a_dimension_to_where_a_row_sits(text, attrs): - """`position(dim)` is the left-hand side, so the comparison is on integers. - - Naming the coordinate *at* a position and comparing coordinates to it made - an ordering mean two things — a value comparison on an axis that may not - arrive sorted, or a comparison of positions (#32). Converting on the left - leaves nothing for the value reading to attach to, and every comparator - reads the one way. - """ +def test_position_converts_a_dimension_to_where_a_row_sits(text, op, position, by): + """`position(dim)` is the left-hand side, so every comparator reads one way (#32).""" node = parse_where(text) assert isinstance(node, UnresolvedPositionNode) - for attr, expected in attrs.items(): - assert getattr(node, attr) == expected + assert node.dimension == 'snapshot' + assert node.op == op + assert node.position == position + assert node.by == by def test_a_position_is_not_confused_with_a_name(): @@ -190,11 +185,7 @@ def test_a_coordinate_comparison_is_still_a_value_comparison(): def test_the_old_index_spelling_names_its_rewrite(): - """A dropped spelling should not come back as "Expected end of text". - - `index(dim, i)` is what every model wrote before #32, so the parse failure - it now hits is the one message most likely to be read. - """ + """`index(dim, i)` is what every model wrote before #32, so this failure is read most.""" with pytest.raises(SchemaError) as excinfo: parse_where('snapshot == index(snapshot, 0)') assert 'index() is now position()' in str(excinfo.value) From f1a96323194a3b1bb1470640b066b6b8a4c4b304 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 21:12:55 +0000 Subject: [PATCH 3/3] feat: the page says which of pos(t) and t is the position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `position(dim)` prints `pos(t)`, and a coordinate comparison prints `t >= 3`. Both are an index-ish thing against a small integer, and nothing on the page said which way the unmarked one goes — while the reader's own convention says the opposite of this language's: papers write their sets as {1, …, T}, where the index *is* the ordinal and nothing needs marking. So #32's ambiguity survived into the artifact a reader recovers the model from. The mark stays on the position, since a coordinate is what a dimension denotes everywhere else in the language and a position is derived from whichever index resolution produced. What is added is the legend that makes the unmarked side readable, and two corrections to what a position prints: - `pos` is introduced where it is used, gated the way the translation notes are: one note for the symbol, one for the grouped form, one for the size — each only where that form printed. - A group rides a **subscript**, `pos_{season_of(t)}(t)`. As a second argument it sat where the first one's integer sits, saying nothing about "within". - A position counted from the end prints against the size, `|T| - 1` rather than `-1`: the order runs `0` to `|T| - 1`, so `-1` was an equation asserting a position the legend had just ruled out. Grouped, it counts back from the group's size, `|T_{season_of(t)}| - 1`. - A dimension compared against a *number* names its coordinates in the set legend (`snapshot` (`int` coordinates)) — the one comparison that can be read as a position, since every other coordinate prints as prose. Positions stay 0-based on the page, as in the file, so a clause reads off one and writes into the other. `|·|` is a `Format` method rather than an operator: `OPERATOR_NAMES` is a vocabulary of infix spellings and the Typst gate compiles every one of them between two operands. The golden model gains `last`, the from-the-end pair the fixture had no case for, which is what holds the new arms of the walk to output someone has read. --- docs/reference/notation.md | 25 +++++++- src/math_spec/typeset/__init__.py | 1 + src/math_spec/typeset/format.py | 9 +++ src/math_spec/typeset/latex.py | 3 + src/math_spec/typeset/markdown.py | 3 + src/math_spec/typeset/typst.py | 3 + src/math_spec/typeset/walk.py | 98 ++++++++++++++++++++++++++++--- tests/typeset/golden/latex.out | 11 +++- tests/typeset/golden/markdown.out | 14 ++++- tests/typeset/golden/model.yaml | 4 ++ tests/typeset/golden/typst.out | 11 +++- tests/typeset/test_typeset.py | 69 ++++++++++++++++++++++ 12 files changed, 233 insertions(+), 18 deletions(-) diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 7f06d76a..36df7332 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -73,7 +73,7 @@ parameters: | Symbol | Meaning | |---|---| -| $\mathcal{T}$ | index $t$ — `snapshot` with $\mathrm{season\_of}: \mathcal{T} \to \mathcal{S}$ | +| $\mathcal{T}$ | index $t$ — `snapshot` (`int` coordinates) with $\mathrm{season\_of}: \mathcal{T} \to \mathcal{S}$ | | $\mathcal{G}$ | index $g$ — `generator` with $\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B},\enspace \mathrm{gen\_tech}: \mathcal{G} \to \mathcal{E}$ carrying label $\mathrm{tech}$ | | $\mathcal{B}$ | index $b$ — `bus` with $\mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\enspace \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z}$ | | $\mathcal{Z}$ | index $z$ — `zone` | @@ -121,6 +121,12 @@ $t \boxminus_{v} k$ denotes translation with $v$ standing where index $t-k$ leav $t \ominus^{\mathrm{lookup}(t)} k$ denotes a translation counted inside the group a lookup puts $t$ in (`shift(by=lookup)`), so a term never crosses out of its own group. The two modifiers take different slots — the group above, the fill below — so $t \boxminus_{v}^{\mathrm{lookup}(t)} k$ is both at once. +$\mathrm{pos}(t)$ denotes where index $t$ sits along its dimension's own order — the order `shift` walks, 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. + +$\mathrm{pos}_{\mathrm{lookup}(t)}(t)$ counts within the group a lookup puts $t$ in: the subscript names the map, $\mathcal{T}_{\mathrm{lookup}(t)}$ is the group it lands in, and that group has a first position of its own. + +$\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$. + ### The objective #### `objective` @@ -382,7 +388,20 @@ first: expression: on == 1 ``` -$$\mathit{on}_{t,g} = 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G} \thinspace:\thinspace \left( \mathrm{pos}(t) = 0 \vee \mathrm{pos}(t, \mathrm{season\_of}(t)) = 0 \right)$$ +$$\mathit{on}_{t,g} = 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G} \thinspace:\thinspace \left( \mathrm{pos}(t) = 0 \vee \mathrm{pos}_{\mathrm{season\_of}(t)}(t) = 0 \right)$$ + +#### `last` + +the same two counted from the end, which print against a size rather than as themselves + +```yaml +last: + foreach: [snapshot, generator] + where: "position(snapshot) == -1 OR position(snapshot, by=season_of) == -1" + expression: on == 0 +``` + +$$\mathit{on}_{t,g} = 0 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G} \thinspace:\thinspace \left( \mathrm{pos}(t) = \lvert \mathcal{T} \rvert - 1 \vee \mathrm{pos}_{\mathrm{season\_of}(t)}(t) = \lvert \mathcal{T}_{\mathrm{season\_of}(t)} \rvert - 1 \right)$$ #### `northern` @@ -719,7 +738,7 @@ $$\mathit{op\_cost}_{t,g} \cdot \left( \mathrm{x}_{g,b} - \mathrm{x}_{g,b \boxmi $$p_{t,g} \ge \mathrm{x}_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace \mathrm{pos}(b) = 0$$ -$$p_{t,g} \le \mathrm{x}_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace \mathrm{pos}(b) = -1$$ +$$p_{t,g} \le \mathrm{x}_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace \mathrm{pos}(b) = \lvert \mathcal{B} \rvert - 1$$ ### Sets carried to the solver diff --git a/src/math_spec/typeset/__init__.py b/src/math_spec/typeset/__init__.py index bff51c9e..0e4b7d68 100644 --- a/src/math_spec/typeset/__init__.py +++ b/src/math_spec/typeset/__init__.py @@ -114,6 +114,7 @@ def typeset( blocks += [fmt.glossary(group.title, group.entries) for group in walk.glossaries()] blocks += [fmt.note(text) for text in walk.convention_notes()] blocks += [fmt.note(text) for text in walk.translation_notes()] + blocks += [fmt.note(text) for text in walk.position_notes()] return fmt.document([*blocks, *rendered], standalone=standalone) diff --git a/src/math_spec/typeset/format.py b/src/math_spec/typeset/format.py index 0e079e96..48358e53 100644 --- a/src/math_spec/typeset/format.py +++ b/src/math_spec/typeset/format.py @@ -189,6 +189,15 @@ def superscript(self, base: str, tail: str) -> str: ... def parenthesise(self, inner: str) -> str: ... + def cardinality(self, inner: str) -> str: + """How many members a set has: ``|T|``. + + A fence rather than an entry in :data:`OPERATOR_NAMES`, which is a + vocabulary of *infix* spellings — ``tests/typeset/test_typeset.py`` + compiles every one of them between two operands. + """ + ... + def fraction(self, numerator: str, denominator: str) -> str: ... def summation(self, domain: str, body: str) -> str: ... diff --git a/src/math_spec/typeset/latex.py b/src/math_spec/typeset/latex.py index b78ca414..e523df4d 100644 --- a/src/math_spec/typeset/latex.py +++ b/src/math_spec/typeset/latex.py @@ -122,6 +122,9 @@ def superscript(self, base: str, tail: str) -> str: def parenthesise(self, inner: str) -> str: return rf'\left( {inner} \right)' + def cardinality(self, inner: str) -> str: + return rf'\lvert {inner} \rvert' + def fraction(self, numerator: str, denominator: str) -> str: return rf'\frac{{{numerator}}}{{{denominator}}}' diff --git a/src/math_spec/typeset/markdown.py b/src/math_spec/typeset/markdown.py index 1bb6a8cf..0a9924f0 100644 --- a/src/math_spec/typeset/markdown.py +++ b/src/math_spec/typeset/markdown.py @@ -83,6 +83,9 @@ def superscript(self, base: str, tail: str) -> str: def parenthesise(self, inner: str) -> str: return _LATEX.parenthesise(inner) + def cardinality(self, inner: str) -> str: + return _LATEX.cardinality(inner) + def fraction(self, numerator: str, denominator: str) -> str: return _LATEX.fraction(numerator, denominator) diff --git a/src/math_spec/typeset/typst.py b/src/math_spec/typeset/typst.py index d27b9d45..a7a87b4b 100644 --- a/src/math_spec/typeset/typst.py +++ b/src/math_spec/typeset/typst.py @@ -132,6 +132,9 @@ def superscript(self, base: str, tail: str) -> str: def parenthesise(self, inner: str) -> str: return f'({inner})' + def cardinality(self, inner: str) -> str: + return f'abs({inner})' + def fraction(self, numerator: str, denominator: str) -> str: return f'frac({numerator}, {denominator})' diff --git a/src/math_spec/typeset/walk.py b/src/math_spec/typeset/walk.py index fa55ef2f..e71f164e 100644 --- a/src/math_spec/typeset/walk.py +++ b/src/math_spec/typeset/walk.py @@ -213,9 +213,11 @@ def indexed(self, symbol: str, dims: list[str]) -> str: class Walk: """Walks a validated schema, emitting :class:`Line`s in one format. - Stateful only in what it has *noticed* — which edge policies appeared and - whether a translation was counted inside a group, which the legend needs - to explain the symbols they print. + Stateful only in what it has *noticed* — which edge policies appeared, + whether a translation was counted inside a group, which positional forms + printed, and which dimensions were compared against a coordinate that is a + number; every one of them something the legend has to explain once the + equations print it. """ def __init__(self, schema: Buildable, namespace: Namespace, symbols: Symbols, fmt: Format) -> None: @@ -225,6 +227,8 @@ def __init__(self, schema: Buildable, namespace: Namespace, symbols: Symbols, fm self.format = fmt self.policies: set[str] = set() self.grouped = False + self.positions: set[str] = set() + self.numeric_coordinates: set[str] = set() def op(self, name: str) -> str: return self.format.operators[name] @@ -478,6 +482,11 @@ def _where(self, node: WhereNode, ctx: _Context) -> tuple[str, int]: return f'{left} {self.op(_PREDICATES[node.op])} {self.literal(node.value)}', 2 if isinstance(node, DimensionComparisonNode): + if isinstance(node.value, (int, float)): + # a label that is a number is the one the legend has to place: + # every other coordinate prints as prose and cannot be read as + # a position in the first place + self.numeric_coordinates.add(node.name) return f'{ctx.subscript(node.name)} {self.op(_PREDICATES[node.op])} {self.literal(node.value)}', 2 if isinstance(node, DimensionPositionNode): @@ -485,7 +494,8 @@ def _where(self, node: WhereNode, ctx: _Context) -> tuple[str, int]: None if node.by is None else self.format.apply(self.format.upright(node.by), ctx.subscript(node.name)) ) place = self.position(ctx.subscript(node.name), grouping) - return f'{place} {self.op(_PREDICATES[node.op])} {self.number(node.position)}', 2 + ordinal = self.ordinal(node.name, node.position, grouping) + return f'{place} {self.op(_PREDICATES[node.op])} {ordinal}', 2 if isinstance(node, LookupComparisonNode): applied = self.format.apply(self.format.upright(node.name), ctx.subscript(node.over)) @@ -526,12 +536,36 @@ def position(self, index: str, grouping: str | None = None) -> str: Applied to the *row* rather than to the set, because that is what it converts: a coordinate to where that coordinate sits. *grouping* is the - lookup already applied to the row, and prints as a second argument so - the group a position is counted within is visible where the position - is. + lookup already applied to the row, and rides as a **subscript** rather + than as a second argument — a modifier saying which order is being + counted, the way an edge fill rides its translation. As an argument it + sat where the first one's integer sits, and read as a second position. """ - inner = index if grouping is None else f'{index}, {grouping}' - return self.format.apply(self.op('position'), inner) + self.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]) + return self.format.apply(symbol, index) + + def ordinal(self, dimension: str, at: int, grouping: str | None = None) -> str: + """The position compared against, counted from the end where it is negative. + + A position is a place in the order, and the legend runs that order + from ``0`` to the size less one — so ``-1`` printed as itself asserts a + position the page has just said cannot exist. What it counts back from + is that size, and the *group's* size where the count is grouped, which + is the set those positions are positions in. + + ``0`` prints as ``0``: the file is 0-based and so is the page, so a + clause can be read off one and written into the other. + """ + if at >= 0: + return self.number(at) + self.positions.add('from_end') + size = self.symbols.set[dimension] + if grouping is not None: + size = self.format.subscript(size, [grouping]) + return f'{self.format.cardinality(size)} {self.op("minus")} {self.number(-at)}' def conjoined(self, ctx: _Context, *nodes: WhereNode | None) -> str: r"""The mask on a quantifier, as one condition. @@ -706,6 +740,11 @@ def _coords(self, dim: str) -> str: targeted = self.schema.targeted_of(dim) labels = self.schema.labels_of(dim) clauses = [] + if dim in self.numeric_coordinates: + # only where an equation compared this index against a number, + # which is the one place "position 3" and "the coordinate 3" are + # both readings of the same line + clauses.append(f' ({self.format.mono(self.schema.dimensions[dim].dtype)} coordinates)') if targeted: maps = self.format.joined( [ @@ -785,3 +824,44 @@ def translation_notes(self) -> list[str]: ) notes.append(note) return notes + + def position_notes(self) -> list[str]: + """A sentence for each positional symbol the model actually printed. + + Gated the way the translation notes are, and the first of them is what + the page cannot go without. A reader arrives from papers where the + index *is* the ordinal — sets are written as ``{1, …, T}`` there, so + nothing is marked because nothing needs to be — and this language + indexes by coordinates instead. A page printing both + ``pos(t) = 0`` and ``t >= 3`` therefore has to say once which of the + two is the position, or the reader recovers a different model from the + one the file holds. + """ + notes = [] + if self.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")} walks, 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 self.positions: + applied = self.format.apply(self.format.upright('lookup'), '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 lookup 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 self.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/typeset/golden/latex.out b/tests/typeset/golden/latex.out index 2f7cd228..e4840a01 100644 --- a/tests/typeset/golden/latex.out +++ b/tests/typeset/golden/latex.out @@ -9,7 +9,7 @@ \paragraph{Sets} \begin{description} -\item[$\mathcal{T}$] index $t$ --- \texttt{snapshot} with $\mathrm{season\_of}: \mathcal{T} \to \mathcal{S}$ +\item[$\mathcal{T}$] index $t$ --- \texttt{snapshot} (\texttt{int} coordinates) with $\mathrm{season\_of}: \mathcal{T} \to \mathcal{S}$ \item[$\mathcal{G}$] index $g$ --- \texttt{generator} with $\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B},\ \mathrm{gen\_tech}: \mathcal{G} \to \mathcal{E}$ carrying label $\mathrm{tech}$ \item[$\mathcal{B}$] index $b$ --- \texttt{bus} with $\mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z}$ \item[$\mathcal{Z}$] index $z$ --- \texttt{zone} @@ -56,6 +56,12 @@ \noindent $t \ominus^{\mathrm{lookup}(t)} k$ denotes a translation counted inside the group a lookup puts $t$ in (\texttt{shift(by=lookup)}), so a term never crosses out of its own group. The two modifiers take different slots --- the group above, the fill below --- so $t \boxminus_{v}^{\mathrm{lookup}(t)} k$ is both at once. +\noindent $\mathrm{pos}(t)$ denotes where index $t$ sits along its dimension's own order --- the order \texttt{shift} walks, 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. + +\noindent $\mathrm{pos}_{\mathrm{lookup}(t)}(t)$ counts within the group a lookup puts $t$ in: the subscript names the map, $\mathcal{T}_{\mathrm{lookup}(t)}$ is the group it lands in, and that group has a first position of its own. + +\noindent $\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$. + \paragraph{Objective} \begin{align} && \max & \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} \cdot p_{t,g} \cdot \mathrm{cost}_{g} + \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} \cdot \mathrm{growth}^{\mathrm{lead}_{g}} + \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{p}^{\mathrm{max}}_{g} - \mathit{reserve} - \mathit{headroom} @@ -82,7 +88,8 @@ \text{total} && \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} & \le \mathrm{budget} \\ \text{scalar} && \mathit{units}_{g} & \le \mathrm{budget} && \forall\, g \in \mathcal{G} \,:\, \mathrm{cost}_{g} \text{ is defined} \\ \text{running} && \theta_{b} & \le \mathrm{load}_{t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \theta_{b} \text{ exists} \wedge t \ge 3 \\ -\text{first} && \mathit{on}_{t,g} & = 1 && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{pos}(t) = 0 \vee \mathrm{pos}(t, \mathrm{season\_of}(t)) = 0 \right) \\ +\text{first} && \mathit{on}_{t,g} & = 1 && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{pos}(t) = 0 \vee \mathrm{pos}_{\mathrm{season\_of}(t)}(t) = 0 \right) \\ +\text{last} && \mathit{on}_{t,g} & = 0 && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{pos}(t) = \lvert \mathcal{T} \rvert - 1 \vee \mathrm{pos}_{\mathrm{season\_of}(t)}(t) = \lvert \mathcal{T}_{\mathrm{season\_of}(t)} \rvert - 1 \right) \\ \text{northern} && \mathit{slack}_{t} & \le \mathrm{load}_{t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{zone\_of}(b) = \text{north} \wedge \mathrm{zone\_of}(b) \neq \mathrm{area\_of}(b) \wedge \mathrm{zone\_of}(b) \text{ is defined} \\ \text{efficiency} && p_{t,g} & \le \mathrm{eta}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{always} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \\ diff --git a/tests/typeset/golden/markdown.out b/tests/typeset/golden/markdown.out index e64d0575..746cea9d 100644 --- a/tests/typeset/golden/markdown.out +++ b/tests/typeset/golden/markdown.out @@ -6,7 +6,7 @@ every character a notation escapes, set as text: link_to, 100% & #1 costs $5 {ne | Symbol | Meaning | |---|---| -| $\mathcal{T}$ | index $t$ — `snapshot` with $\mathrm{season\_of}: \mathcal{T} \to \mathcal{S}$ | +| $\mathcal{T}$ | index $t$ — `snapshot` (`int` coordinates) with $\mathrm{season\_of}: \mathcal{T} \to \mathcal{S}$ | | $\mathcal{G}$ | index $g$ — `generator` with $\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B},\enspace \mathrm{gen\_tech}: \mathcal{G} \to \mathcal{E}$ carrying label $\mathrm{tech}$ | | $\mathcal{B}$ | index $b$ — `bus` with $\mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\enspace \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z}$ | | $\mathcal{Z}$ | index $z$ — `zone` | @@ -54,6 +54,12 @@ $t \boxminus_{v} k$ denotes translation with $v$ standing where index $t-k$ leav $t \ominus^{\mathrm{lookup}(t)} k$ denotes a translation counted inside the group a lookup puts $t$ in (`shift(by=lookup)`), so a term never crosses out of its own group. The two modifiers take different slots — the group above, the fill below — so $t \boxminus_{v}^{\mathrm{lookup}(t)} k$ is both at once. +$\mathrm{pos}(t)$ denotes where index $t$ sits along its dimension's own order — the order `shift` walks, 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. + +$\mathrm{pos}_{\mathrm{lookup}(t)}(t)$ counts within the group a lookup puts $t$ in: the subscript names the map, $\mathcal{T}_{\mathrm{lookup}(t)}$ is the group it lands in, and that group has a first position of its own. + +$\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 $$\max \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot p_{t,g} \cdot \mathrm{cost}_{g} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} \cdot \mathrm{growth}^{\mathrm{lead}_{g}} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathrm{p}^{\mathrm{max}}_{g} - \mathit{reserve} - \mathit{headroom}$$ @@ -138,7 +144,11 @@ $$\theta_{b} \le \mathrm{load}_{t,b} \qquad \forall\thinspace t \in \mathcal{T}, **`first`** -$$\mathit{on}_{t,g} = 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G} \thinspace:\thinspace \left( \mathrm{pos}(t) = 0 \vee \mathrm{pos}(t, \mathrm{season\_of}(t)) = 0 \right)$$ +$$\mathit{on}_{t,g} = 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G} \thinspace:\thinspace \left( \mathrm{pos}(t) = 0 \vee \mathrm{pos}_{\mathrm{season\_of}(t)}(t) = 0 \right)$$ + +**`last`** + +$$\mathit{on}_{t,g} = 0 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G} \thinspace:\thinspace \left( \mathrm{pos}(t) = \lvert \mathcal{T} \rvert - 1 \vee \mathrm{pos}_{\mathrm{season\_of}(t)}(t) = \lvert \mathcal{T}_{\mathrm{season\_of}(t)} \rvert - 1 \right)$$ **`northern`** diff --git a/tests/typeset/golden/model.yaml b/tests/typeset/golden/model.yaml index 902b0f0a..35113da2 100644 --- a/tests/typeset/golden/model.yaml +++ b/tests/typeset/golden/model.yaml @@ -154,6 +154,10 @@ constraints: foreach: [snapshot, generator] where: "position(snapshot) == 0 OR position(snapshot, by=season_of) == 0" expression: on == 1 + last: # the same two counted from the end, which print against a size rather than as themselves + foreach: [snapshot, generator] + where: "position(snapshot) == -1 OR position(snapshot, by=season_of) == -1" + expression: on == 0 northern: # a lookup compared to a label, to another lookup, and to nothing foreach: [snapshot, bus] where: "zone_of == 'north' AND zone_of != area_of AND zone_of" diff --git a/tests/typeset/golden/typst.out b/tests/typeset/golden/typst.out index 2a612e26..d5336a96 100644 --- a/tests/typeset/golden/typst.out +++ b/tests/typeset/golden/typst.out @@ -4,7 +4,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 {net} \~ ^ \\ \*star\* \@ref \ == Sets -/ $cal(T)$: index $t$ --- `snapshot` with $upright("season_of"): cal(T) arrow.r cal(S)$ +/ $cal(T)$: index $t$ --- `snapshot` (`int` coordinates) with $upright("season_of"): cal(T) arrow.r cal(S)$ / $cal(G)$: index $g$ --- `generator` with $upright("gen_bus"): cal(G) arrow.r cal(B), upright("gen_tech"): cal(G) arrow.r cal(E)$ carrying label $upright("tech")$ / $cal(B)$: index $b$ --- `bus` with $upright("zone_of"): cal(B) arrow.r cal(Z), upright("area_of"): cal(B) arrow.r cal(Z)$ / $cal(Z)$: index $z$ --- `zone` @@ -46,6 +46,12 @@ $t minus.square_(v) k$ denotes translation with $v$ standing where index $t-k$ l $t minus.o^(upright("lookup")(t)) k$ denotes a translation counted inside the group a lookup puts $t$ in (`shift(by=lookup)`), so a term never crosses out of its own group. The two modifiers take different slots --- the group above, the fill below --- so $t minus.square_(v)^(upright("lookup")(t)) k$ is both at once. +$upright("pos")(t)$ denotes where index $t$ sits along its dimension's own order --- the order `shift` walks, not the order labels sort in --- counted from $0$. The index itself stays the coordinate, so $t$ compares against labels and $upright("pos")(t)$ against positions. + +$upright("pos")_(upright("lookup")(t))(t)$ counts within the group a lookup puts $t$ in: the subscript names the map, $cal(T)_(upright("lookup")(t))$ is the group it lands in, and that group has a first position of its own. + +$abs(cal(T))$ denotes the size of the set being counted along, and a position counted from the end prints against it --- $abs(cal(T)) - 1$ is the last position, one less than the size because the first is $0$. + == Objective #set math.equation(numbering: "(1)") $ & max & 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) dot p_(t,g) dot upright("cost")_(g) + sum_(t in cal(T), g in cal(G)) p_(t,g) dot upright("cost")_(g) dot upright("growth")^(upright("lead")_(g)) + sum_(t in cal(T), g in cal(G)) p_(t,g) dot upright("p")^(upright("max"))_(g) - italic("reserve") - italic("headroom") $ @@ -71,7 +77,8 @@ $ upright("balance") & sum_(g in cal(G) colon upright("gen_bus")(g) = b) p_(t,g) upright("total") & sum_(t in cal(T), g in cal(G)) p_(t,g) & <= upright("budget") \ upright("scalar") & italic("units")_(g) & <= upright("budget") & forall g in cal(G) colon upright("cost")_(g) upright(" is defined") \ upright("running") & theta_(b) & <= upright("load")_(t,b) & forall t in cal(T), b in cal(B) colon theta_(b) upright(" exists") and t >= 3 \ - upright("first") & italic("on")_(t,g) & = 1 & forall t in cal(T), g in cal(G) colon (upright("pos")(t) = 0 or upright("pos")(t, upright("season_of")(t)) = 0) \ + upright("first") & italic("on")_(t,g) & = 1 & forall t in cal(T), g in cal(G) colon (upright("pos")(t) = 0 or upright("pos")_(upright("season_of")(t))(t) = 0) \ + upright("last") & italic("on")_(t,g) & = 0 & forall t in cal(T), g in cal(G) colon (upright("pos")(t) = abs(cal(T)) - 1 or upright("pos")_(upright("season_of")(t))(t) = abs(cal(T)_(upright("season_of")(t))) - 1) \ upright("northern") & italic("slack")_(t) & <= upright("load")_(t,b) & forall t in cal(T), b in cal(B) colon upright("zone_of")(b) = upright("north") and upright("zone_of")(b) != upright("area_of")(b) and upright("zone_of")(b) upright(" is defined") \ upright("efficiency") & p_(t,g) & <= upright("eta")_(g) dot upright("p")^(upright("max"))_(g) & forall t in cal(T), g in cal(G) \ upright("always") & italic("spill")_(t) & >= 0 & forall t in cal(T) \ diff --git a/tests/typeset/test_typeset.py b/tests/typeset/test_typeset.py index 1172580b..adf0aa46 100644 --- a/tests/typeset/test_typeset.py +++ b/tests/typeset/test_typeset.py @@ -382,6 +382,75 @@ def test_the_legend_explains_wraparound_only_when_it_is_used(fmt: Format): assert 'cyclic translation' not in typeset(DISPATCH, fmt) +def _selected(mask: str) -> dict[str, Any]: + """One constraint carrying *mask*, over a dimension a lookup groups.""" + return { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'season': {'dtype': 'str'}}, + 'lookups': {'season_of': {'over': 'snapshot', 'into': 'season'}}, + 'variables': {'soc': {'foreach': ['snapshot'], 'bounds': {'lower': 0}}}, + 'constraints': {'seed': {'foreach': ['snapshot'], 'where': mask, 'expression': 'soc == 0'}}, + } + + +@EVERY_FORMAT +def test_a_position_from_the_end_prints_against_the_size(fmt: Format): + """``-1`` is not a position, and the page has already said so. + + The legend gives the order ``0`` to one end and the size less one to the + other, so an equation asserting a position *is* ``-1`` has no solution + under the only reading offered for it — Python's index sugar, read as + math. The sign is known where it prints, so the page can say what the file + means instead. + """ + text = typeset(_selected('position(snapshot) == -1'), fmt) + assert f'{fmt.cardinality(fmt.script("T"))} {fmt.operators["minus"]} 1' in text + assert f'{fmt.operators["equal"]} -1' not in text + + +@EVERY_FORMAT +def test_a_grouped_position_rides_a_subscript_rather_than_a_second_argument(fmt: Format): + """The group is a modifier — which order is counted — not another position. + + As ``pos(t, season_of(t))`` the second argument sits where a reader of the + first one expects an integer, and nothing says it means "within". + """ + text = typeset(_selected('position(snapshot, by=season_of) == 0'), fmt) + applied = fmt.apply(fmt.upright('season_of'), 't') + assert fmt.apply(fmt.subscript(fmt.operators['position'], [applied]), 't') in text + + +@EVERY_FORMAT +def test_the_legend_explains_a_position_only_where_one_prints(fmt: Format): + """Each positional symbol is introduced where it is used, and nowhere else. + + The first note is the one the page cannot go without: a reader arrives + from papers whose index *is* the ordinal, so a page printing both + ``pos(t) = 0`` and ``t >= 3`` has to say once which of the two is the + coordinate. + """ + plain = typeset(_selected('position(snapshot) == 0'), fmt) + assert 'against positions' in plain + assert 'against positions' not in typeset(DISPATCH, fmt) + assert 'counts within the group' not in plain, 'no grouped position printed' + assert 'counted from the end' not in plain, 'no position counted from the end printed' + assert 'counts within the group' in typeset(_selected('position(snapshot, by=season_of) == 0'), fmt) + assert 'counted from the end' in typeset(_selected('position(snapshot) == -1'), fmt) + + +@EVERY_FORMAT +def test_a_dimension_compared_against_a_number_says_what_its_coordinates_are(fmt: Format): + """``t >= 3`` is the line the convention this notation inverts reads wrong. + + A comparison against a label that is a *number* is the one that can be + taken for a position, so the legend places it: every other coordinate + prints as prose and could not be read as one. + """ + text = typeset(_selected('snapshot >= 3'), fmt) + assert f'({fmt.mono("int")} coordinates)' in text + assert f'({fmt.mono("str")} coordinates)' not in text, 'season is compared against nothing' + assert 'coordinates)' not in typeset(_selected('position(snapshot) == 0'), fmt) + + @EVERY_FORMAT def test_a_description_is_joined_to_its_name_by_a_dash_the_format_renders(fmt: Format): """``---`` is TeX's em-dash ligature and Typst's, and nothing in Markdown.