From 6bcc68b89bc917979fcb890d8b966e008f12ae24 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 12:19:05 +0000 Subject: [PATCH 1/2] refactor(language): a relation is read in a direction rather than walked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `program.Walk` becomes `program.Direction`, and the field both nodes hold becomes `direction`. `_Resolver._walk` and `_partition_walk` become `_direction` and `_partition_direction`, and nine refusals say what a call does to a relation instead of describing a traversal. A `Direction` is one relation and one join — which columns are consumed, which produced, which joined on. It is never a sequence and carries no accumulator, so "walk" promised a fold that is not there. The language now says so itself: since #533 one call reads one relation, so there is not even a list to compose. The word stays where the thing is a traversal: `program.walk`, `walk_regions`, `_expression_parser.nodes`, and the typesetter's own `Walk`, which is a fold with `_Context` as its accumulator. That module imported `Walk as RelationWalk` to hold both meanings at once; it imports `Direction` now and the alias is gone. The axis sense stays too — `shift`, `sum_back` and `position` step along an ordered dimension. The refusals that changed, in full: ...names 2 relations, and one call reads one table ...this sum lands on the key ['g'], so each coordinate has one term A call names both of its ends, so that a relation may gain a value column... A relation is read between two of its columns and joined at the others... A call brings the dims it lands on, so that reading it tells you what it adds ...and a partition steps along a key column over the dimension it groups `dimensions.md` renames its `### Walks` section to `### Directions`, and the two links into it follow. This renames an exported name: `math_spec.program.Walk` no longer exists and `Direction` is in `program.__all__` in its place. lpspec reads the old name and needs a follow-up once this tags; it pins math-spec by tag, so nothing breaks before then. pytest: 1294 passed, 6 skipped. ruff check, ruff format, typos, prettier and `mkdocs build --strict` clean — the strict build is what proves the renamed anchor has no dead link left. pyrefly reports the 3 pre-existing missing `yaml` stubs. The five generators were re-run: only `notation.md` moved, and only where it quotes the golden model's comments. The golden `.out` files did not move, so the typeset math is unchanged. `pixi` cannot be installed in this environment, so the gates ran from a pip environment on Python 3.13; `compile-tex` is CI's to run. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NUhbXWGiyZF46wHp3fVnr2 --- docs/reference/language/dimensions.md | 63 ++++++++++++------------ docs/reference/language/expressions.md | 2 +- docs/reference/language/operators.md | 12 ++--- docs/reference/notation.md | 16 +++---- src/math_spec/_expression_parser.py | 6 +-- src/math_spec/dimensions.py | 42 ++++++++-------- src/math_spec/lowering.py | 10 ++-- src/math_spec/operators.py | 20 ++++---- src/math_spec/program.py | 66 +++++++++++++------------- src/math_spec/resolution.py | 66 +++++++++++++------------- src/math_spec/typesetting/walk.py | 58 +++++++++++----------- tests/test_dimensions.py | 2 +- tests/test_lowering.py | 24 +++++----- tests/test_validation.py | 4 +- tests/typesetting/golden/model.yaml | 16 +++---- 15 files changed, 206 insertions(+), 201 deletions(-) diff --git a/docs/reference/language/dimensions.md b/docs/reference/language/dimensions.md index 5a138483..c9c358df 100644 --- a/docs/reference/language/dimensions.md +++ b/docs/reference/language/dimensions.md @@ -67,8 +67,8 @@ generator's bus, a snapshot's period, or the buses a generator may connect to. It is what makes topology data, so no adjacency matrix and no hand-written join appears anywhere. `key:` names the columns that identify a row, and `values:` the columns that key determines. Every relation is keyed: with no `values:`, the -key is every column. The declaration fixes no direction. The operator that -walks the table says which column it consumes and which it produces. +key is every column. The declaration fixes no direction: the call says which +column it consumes and which it produces. ```yaml dimensions: @@ -131,15 +131,16 @@ Each cardinality is one declaration: | one-to-one | not a claim the language has: a key is one set of columns, and nothing checks the other side | | A bare relation is a set of rows: no row twice, and nothing else claimed. -`sum` walks it with both ends named, and a bare `where` tests it. That is what +`sum` reads it with both ends named, and a bare `where` tests it. That is what a many-to-many relation can say, and all it can say. -### Walks +### Directions -A walk consumes one or more columns of a relation, produces one or more, and -joins on every other key column. The call writes both ends. The operand carries -every dimension the walk consumes and every one it joins on. The result is what -the operand had, less the consumed dimensions, plus the produced ones. +A call reads a relation in one **direction**: it consumes one or more columns, +produces one or more, and joins on every other key column. The call writes both +ends. The operand carries every dimension consumed and every one joined on. The +result is what the operand had, less the consumed dimensions, plus the produced +ones. `sum` consumes key columns and produces value columns. `at` consumes value columns and produces the key. @@ -172,7 +173,7 @@ constraints: The typesetter prints it as $`\mathrm{price}_{\mathrm{zone\_of}(g,\ e),e}`$, and the joined `period` is the second subscript. -- **A walk names both of its ends, always.** `sum(p, by=gen_bus)` is refused, +- **A call names both of its ends, always.** `sum(p, by=gen_bus)` is refused, even where `gen_bus` has one key column and one value column. What a call leaves out, an edit to the relation can change: a table that gained a value column would silently start producing it. @@ -189,9 +190,9 @@ and the joined `period` is the second subscript. product `bus x technology` in one join. `sum(p, by=zone_of, over=[generator, period], into=zone)` consumes both key columns at once, so nothing is joined on and `period` leaves with `generator`. -- **A walk brings the dimensions it lands on.** Producing one the operand - already carries is refused. The walk would tie the operand's axis to the one - it produces rather than adding it, and the call reads the same either way. +- **A call brings the dimensions it lands on.** Producing one the operand + already carries is refused. It would tie the operand's axis to the one it + produces rather than adding it, and the call reads the same either way. Write the factor carrying that dimension outside the operator: `load * sum(p, by=gen_bus, over=generator, into=bus)`, not `sum(load * p, by=gen_bus, over=generator, into=bus)`. A relation into its own @@ -199,24 +200,24 @@ and the joined `period` is the second subscript. just consumed. - **`into=` needs a `by=`**, because a column belongs to a table. `over=` without a `by=` names a dimension, as in `sum(p, over=period)`. -- **One call walks one table.** `by=` names a single relation. To land on +- **One call reads one table.** `by=` names a single relation. To land on columns of two tables at once, declare one relation holding the columns of - both; to walk them in turn, write one call each. + both; to read them in turn, write one call each. Five refusals draw the line, and each message names the rewrite: | refused | message | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| a walk with an end unsaid | `sum() through a relation leaves into= unsaid. A walk names both of its ends, so that a relation may gain a value column without changing what this call means. Write: sum(), sum(, over=) or sum(, by=, over=, into=)` | -| landing on a dimension already carried | `sum(by=gen_bus) lands on ['bus'], which the expression already carries. A walk brings the dims it lands on, so that reading the call tells you what it adds. Move the factor carrying ['bus'] outside the operator, or walk to a column over another dimension.` | +| a call with an end unsaid | `sum() through a relation leaves into= unsaid. A call names both of its ends, so that a relation may gain a value column without changing what this call means. Write: sum(), sum(, over=) or sum(, by=, over=, into=)` | +| landing on a dimension already carried | `sum(by=gen_bus) lands on ['bus'], which the expression already carries. A call brings the dims it lands on, so that reading it tells you what it adds. Move the factor carrying ['bus'] outside the operator, or read to a column over another dimension.` | | `at` on a bare relation | `at(by=connection): at reads one value per coordinate, and 'connection' is not single-valued in ['bus'] at the columns the operand fixes (['generator']) — its key is ['generator', 'bus']. Key the table by columns the read fixes, or read the other way.` | -| a `sum` that consumes no key column | `sum(by=zone_of): this sum walks to the key ['generator', 'period'], so each coordinate has one term and nothing is added up — that is a read, which is at()'s. Write at(..., by=zone_of, over=['generator'], into=['zone']), or sum toward a value column.` | -| an operand missing a joined dimension | `at(by=zone_of) joins on ['period'] (columns ['period'] of 'zone_of'), which the expression does not carry (dims ['zone']). A relation is walked between two of its columns and read at the others — index the operand by them, or walk between different columns.` | +| a `sum` that consumes no key column | `sum(by=zone_of): this sum lands on the key ['generator', 'period'], so each coordinate has one term and nothing is added up — that is a read, which is at()'s. Write at(..., by=zone_of, over=['generator'], into=['zone']), or sum toward a value column.` | +| an operand missing a joined dimension | `at(by=zone_of) joins on ['period'] (columns ['period'] of 'zone_of'), which the expression does not carry (dims ['zone']). A relation is read between two of its columns and joined at the others — index the operand by them, or read it between different columns.` | ### Partitions `shift(x, along=d, by=l)`, `sum_back(x, along=d, by=l)` and `position(d, by=l)` -walk the one key column over `d`, join on the other key columns, and group by +step along the one key column over `d`, join on the other key columns, and group by the value columns. The frame does not change: the group says which rows are neighbours, and nothing lands anywhere. @@ -250,7 +251,7 @@ one bus. `rep_of` relates a dimension to itself. That is how a clustered year runs on a few typical days: every snapshot names the snapshot that stands for it. The rules are the same. `sum` consumes `snapshot` and produces `rep`, both over -`snapshot`, so the frame is `[snapshot]` before the walk and after it: +`snapshot`, so the frame is `[snapshot]` before the read and after it: ```yaml constraints: @@ -267,7 +268,7 @@ constraints: a representative the snapshots it stands for. Two steps along the map are two nested calls. Put both columns under `key:`, as `{key: {from: snapshot, to: snapshot}}`, and the table is a neighbour table -instead, which `sum` walks either way and nothing reads. The rows where a +instead, which `sum` reads either way and nothing reads back. The rows where a snapshot is its own representative cannot be selected with a `where`, because a value column is never compared to the frame's own coordinate. Declare a `bool` parameter for @@ -301,16 +302,16 @@ other extra columns, but this one would be a map read by accident. Every column of data is one of the three. What decides which is what the math does with the column, not what the column holds: -| The column… | is declared as | because | -| ------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| is an axis: something is indexed by it, or an aggregation lands terms on it | a `dimension` | its members are the coordinate set every table over it is reindexed onto | -| has one value per member of a dimension, or per tuple of several — a generator's bus, a line's two ends, a generator's zone by period | a `relation` with that `key` | it is a map every operator walks, and its values are checked against the dimensions they name | -| relates members of two dimensions many-to-many, with nothing to weigh — which buses a generator may connect to | a bare `relation`, with no `values:` | `sum` walks it with both ends named, and a bare `where` tests it. Nothing reads it, because there is no one value to read | -| relates members of two dimensions many-to-many, with a weight per pair — a link's efficiency to each bus, a cycle's lines | a `parameter` over both | the weight is the data, its row set is the relation, and the aggregation is `sum(w * x, over=a)` | -| is a label set the model only selects on or counts within — a period, a season, a zone | a `dimension`, and a `relation` onto it | its labels are checked, at the cost of one line and one table | -| scales terms — a coefficient, a bound, an offset | a `parameter` (`float` or `int`) | arithmetic is over numbers ([dtype](declarations.md#parameters)) | -| is a per-row attribute the math only selects on — a fuel, a constraint's sense | a `str` parameter | it names rows rather than scaling them, and no set is declared to check its values against | -| is a mask | a `bool` parameter | a bare name in a `where` is its own answer | +| The column… | is declared as | because | +| ------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| is an axis: something is indexed by it, or an aggregation lands terms on it | a `dimension` | its members are the coordinate set every table over it is reindexed onto | +| has one value per member of a dimension, or per tuple of several — a generator's bus, a line's two ends, a generator's zone by period | a `relation` with that `key` | it is a map every operator reads, and its values are checked against the dimensions they name | +| relates members of two dimensions many-to-many, with nothing to weigh — which buses a generator may connect to | a bare `relation`, with no `values:` | `sum` reads it with both ends named, and a bare `where` tests it. Nothing reads it back, because there is no one value to read | +| relates members of two dimensions many-to-many, with a weight per pair — a link's efficiency to each bus, a cycle's lines | a `parameter` over both | the weight is the data, its row set is the relation, and the aggregation is `sum(w * x, over=a)` | +| is a label set the model only selects on or counts within — a period, a season, a zone | a `dimension`, and a `relation` onto it | its labels are checked, at the cost of one line and one table | +| scales terms — a coefficient, a bound, an offset | a `parameter` (`float` or `int`) | arithmetic is over numbers ([dtype](declarations.md#parameters)) | +| is a per-row attribute the math only selects on — a fuel, a constraint's sense | a `str` parameter | it names rows rather than scaling them, and no set is declared to check its values against | +| is a mask | a `bool` parameter | a bare name in a `where` is its own answer | Two rules follow. If `b` has one value per `a`, declare `b` as a **relation** keyed by `a`, not as a dimension. Two dimensions that depend on each other, diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index ae3a0ed2..67b39c37 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -128,7 +128,7 @@ before any data binds**: | `a + b`, `a * b`, `a / b` | `dims(a) ∪ dims(b)` | | | `sum(x)` | `{}` | error if `dims(x)` is already empty | | `sum(x, over=d)` | `dims(x) − {d}` | error if `d ∉ dims(x)` | -| `sum(x, by=l, over=a, into=b)` | `(dims(x) − from(l)) ∪ into(l)` | error if `from(l) ⊄ dims(x)`, if a joined column's dimension is not in `dims(x)`, or if `l`'s key lies inside the columns `into=` names and the joined columns — that walk is a read, which is `at`'s | +| `sum(x, by=l, over=a, into=b)` | `(dims(x) − from(l)) ∪ into(l)` | error if `from(l) ⊄ dims(x)`, if a joined column's dimension is not in `dims(x)`, or if `l`'s key lies inside the columns `into=` names and the joined columns — that call is a read, which is `at`'s | | `at(x, by=l, over=a, into=b)` | `(dims(x) − from(l)) ∪ into(l)` | error if `from(l) ⊄ dims(x)`, if a joined column's dimension is not, or if `l` has no key inside the columns `into=` names | | `shift(x, along=d, offset=n)` | `dims(x)` | error if `d ∉ dims(x)` | | `sum_back(x, along=d, window=n)` | `dims(x)` | error if `d ∉ dims(x)` | diff --git a/docs/reference/language/operators.md b/docs/reference/language/operators.md index 691c9026..66acd40d 100644 --- a/docs/reference/language/operators.md +++ b/docs/reference/language/operators.md @@ -22,7 +22,7 @@ model can never depend on what a caller registered. A composition of them goes i | `shift(array, along=dim, offset=n, edge='wrap')` | The value `n` positions earlier, counted cyclically, so nothing is vacated | | `shift(array, along=dim, offset=n, edge=v)` | The value `n` positions earlier, with the number `v` standing where the edge was vacated | | `shift(array, along=dim, offset=p, edge=…)` | `p` is an integer parameter, so each entity is reached by its own offset. Declared over what a `by=` groups into, it gives one lag per group | -| `shift(array, along=dim, offset=n, by=relation[, within=c])` | The translation walks inside each group that the relation makes. Neighbours, edges and a wrap all belong to that group | +| `shift(array, along=dim, offset=n, by=relation[, within=c])` | The translation steps inside each group that the relation makes. Neighbours, edges and a wrap all belong to that group | | `sum_back(array, along=dim, window=n)` | The sum of the last `n` positions along `dim`, ending at the position being written | | `sum_back(array, along=dim, window=p)` | `p` is an integer parameter, so each entity gets its own window length | | `sum_back(array, along=dim, window=p, edge='wrap')` | The window reaches around the axis, instead of stopping short at its start | @@ -76,7 +76,7 @@ The same `f` is summed twice through two relations, once as inflow and once as outflow, with no adjacency matrix and no join written by hand. `sum(by=)` consumes a key column and produces a value column. `over=` and -`into=` name them, on every call ([walks](dimensions.md#walks)), +`into=` name them, on every call ([directions](dimensions.md#directions)), and every other key column is joined on, so each group is one coordinate of it. A bare relation, one with no `values:`, is summed with both ends named. @@ -88,11 +88,11 @@ coordinate the data never covered is refused. See [absence](absence.md). ## `at` -`at(x, by=l, over=a, into=b)` walks the same relation the other way. It consumes +`at(x, by=l, over=a, into=b)` reads the same relation the other way. It consumes a value column and produces the key, so it reads one coarse value once for each fine label that points at it, and a bare relation is never read by `at`. `over=` and `into=` name the columns on every call, and every other key column is read at -the row's own coordinate ([walks](dimensions.md#walks)). +the row's own coordinate ([directions](dimensions.md#directions)). `at` reads a variable as readily as a parameter. One decision taken per bus, read once by every line that touches the bus, is `at(decision, by=line_bus, over=bus, into=line)`. @@ -207,7 +207,7 @@ previous snapshot's duration, without a pre-shifted copy of the table. ### A translation that stops at each group's edge -`by=` partitions the axis the operator walks, so the neighbour of a coordinate +`by=` partitions the axis the operator steps along, so the neighbour of a coordinate is the coordinate before it in its own group. A group can be a season, an investment period or a representative day: @@ -233,7 +233,7 @@ coordinate of each group is vacated and its row drops. `edge='wrap'` closes each group onto its own last coordinate, which a store that returns to its starting level every period asks for. `edge=v` puts `v` at the edge of each group. -`by=` takes a relation with a key column over the dimension being walked, and the +`by=` takes a relation with a key column over the dimension being stepped along, and the group is the value columns: all of them, or the ones `within=` names, so one calendar table serves `within=day` and `within=week` alike. The group columns are what a named `offset=` may vary over, so each group is reached by its own offset. diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 62355a39..456cb380 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -51,10 +51,10 @@ relations: zone_of: { key: bus, values: zone } area_of: { key: bus, values: zone } # a second map into the same set, to compare against season_of: { key: snapshot, values: season } - gen_zone: { key: [generator, snapshot], values: zone } # a map keyed by two dimensions: a call walks one and joins on the other + gen_zone: { key: [generator, snapshot], values: zone } # a map keyed by two dimensions: a call consumes one and joins on the other rep_of: { key: snapshot, values: { rep: snapshot } } # a map into its own dimension: the representative snapshot - connection: { key: [generator, bus] } # a bare relation, with no value columns: many-to-many, walked only by sum with both ends named - gen_bt: { key: generator, values: [bus, technology] } # one table with two value columns, walked to both at once + connection: { key: [generator, bus] } # a bare relation, with no value columns: many-to-many, read only by sum with both ends named + gen_bt: { key: generator, values: [bus, technology] } # one table with two value columns, read to both at once parameters: p_max: { dims: [generator] } @@ -382,7 +382,7 @@ pullback: #### `grouped_once` -one table walked to two value columns: the domain carries a condition per column +one table read to two value columns: the domain carries a condition per column ```yaml grouped_once: @@ -454,7 +454,7 @@ p_{t,g} \le \mathrm{load}_{t,b} \qquad \forall\, t \in \mathcal{T},\ g \in \math #### `representative` -a map into its own dimension, walked both ways: the frame is unchanged and the index is primed +a map into its own dimension, read both ways: the frame is unchanged and the index is primed ```yaml representative: @@ -468,7 +468,7 @@ representative: #### `zonal` -a grouping through a two-key map, walked along one key: the condition reads the other, and the row keeps it +a grouping through a two-key map, consuming one key: the condition reads the other, and the row keeps it ```yaml zonal: @@ -482,7 +482,7 @@ zonal: #### `zonal_history` -the same table walked along its other key +the same table consuming its other key ```yaml zonal_history: @@ -496,7 +496,7 @@ zonal_history: #### `zonal_membership` -the same table walked between its two key columns: no value column is read, so the domain asks only that the row is there +the same table read between its two key columns: no value column is read, so the domain asks only that the row is there ```yaml zonal_membership: diff --git a/src/math_spec/_expression_parser.py b/src/math_spec/_expression_parser.py index 67dc87d6..1bd0dec9 100644 --- a/src/math_spec/_expression_parser.py +++ b/src/math_spec/_expression_parser.py @@ -22,7 +22,7 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterator, Mapping - from math_spec.program import Walk, WhereNode + from math_spec.program import Direction, WhereNode #: The relation a comparison may carry — the three an expression may be #: written with, which is what a constraint's sense is read off. @@ -115,7 +115,7 @@ def shown(self) -> str: @dataclass(frozen=True) class RelationNode: - """A resolved ``by=`` — the relation, and the walk the call takes through it. + """A resolved ``by=`` — the relation, and the direction the call reads it in. ``dimensions`` is the fine side — what ``sum`` consumes and ``at`` produces — and ``into`` the coarse dims, which ``sum`` produces and ``at`` @@ -126,7 +126,7 @@ class RelationNode: name: str dimensions: tuple[str, ...] into: tuple[str, ...] - walk: Walk + direction: Direction @property def shown(self) -> str: diff --git a/src/math_spec/dimensions.py b/src/math_spec/dimensions.py index 29d1b514..ca931156 100644 --- a/src/math_spec/dimensions.py +++ b/src/math_spec/dimensions.py @@ -120,7 +120,7 @@ def _cases_dims(node: CasesNode, schema: Spec) -> frozenset[str]: def _not_carried(context: str, call: str, inner: frozenset[str], rewrite: str) -> str: - """The refusal for an operator walking a dim its operand does not carry; *rewrite* is the operator's own.""" + """The refusal for an operator reaching a dim its operand does not carry; *rewrite* is the operator's own.""" return ( f'{context}: {call} but the expression has dims {sorted(inner)}. An operator over a dim the ' f'operand does not carry is a no-op that builds and solves wrong — {rewrite}.' @@ -134,7 +134,7 @@ def _dims_call(node: FunctionCallNode, schema: Spec, context: str) -> frozenset[ def _sum_dims(node: FunctionCallNode, inner: frozenset[str], schema: Spec, context: str) -> frozenset[str]: - """``sum`` reduces a dim away, or walks relations: the consumed dim goes, the produced dims arrive, the joined stay.""" + """``sum`` reduces a dim away, or reads a relation: the consumed dim goes, the produced dims arrive, the joined stay.""" by = node.kwargs.get('by') if by is None and 'over' not in node.kwargs: if not inner: @@ -158,7 +158,7 @@ def _sum_dims(node: FunctionCallNode, inner: frozenset[str], schema: Spec, conte raise DimensionError( _not_carried( context, - f'sum(by={by.shown}) consumes {missing}, the dims it walks from,', + f'sum(by={by.shown}) consumes {missing}, the dims it reads from,', inner, 'drop the sum, or fix the dim', ) @@ -169,7 +169,7 @@ def _sum_dims(node: FunctionCallNode, inner: frozenset[str], schema: Spec, conte def _at_dims(node: FunctionCallNode, inner: frozenset[str], schema: Spec, context: str) -> frozenset[str]: - """``at`` is the adjoint of ``sum(by=)``: it consumes the dims the walks produce and produces the one they consume.""" + """``at`` is the adjoint of ``sum(by=)``: it consumes the dims a sum produces and produces the ones it consumes.""" by = node.kwargs['by'] assert isinstance(by, RelationNode) absent = sorted(set(by.into) - inner) @@ -195,7 +195,7 @@ def _translation_dims(node: FunctionCallNode, inner: frozenset[str], schema: Spe context, f'{node.name}(along={over.name})', inner, - f'walk a dim the operand carries, or drop the {node.name}', + f'name a dim the operand carries, or drop the {node.name}', ) ) _check_named_amount(node, over.name, inner, schema, context) @@ -209,38 +209,38 @@ def _translation_dims(node: FunctionCallNode, inner: frozenset[str], schema: Spe def _check_lands_clear(call: str, produced: set[str], consumed: set[str], inner: frozenset[str], context: str) -> None: - """The dims a walk lands on are its own to bring, so the operand does not already carry one. + """The dims a call lands on are its own to bring, so the operand does not already carry one. - Where it does, the walk would tie the operand's axis to the one it - produces rather than adding it, and the call reads the same either way. + Where it does, the call would tie the operand's axis to the one it + produces rather than adding it, and it reads the same either way. A relation into its own dimension is not that case: there the dim landed on is the dim just consumed, so every factor is read at the coordinate - the walk sums over, and nothing is tied. + the sum runs over, and nothing is tied. """ if clash := sorted((produced & inner) - consumed): raise DimensionError( f'{context}: {call} lands on {clash}, which the expression already carries.\n' - f'A walk brings the dims it lands on, so that reading the call tells you what it ' - f'adds. Move the factor carrying {clash} outside the operator, or walk to a column ' + f'A call brings the dims it lands on, so that reading it tells you what it ' + f'adds. Move the factor carrying {clash} outside the operator, or read to a column ' f'over another dimension.' ) def _check_joined(call: str, by: RelationNode, inner: frozenset[str], context: str) -> None: - """The columns a walk joins on are read at their dimensions, so the operand carries every one, each once.""" - walk = by.walk - dims = walk.joined_dims + """The columns a call joins on are read at their dimensions, so the operand carries every one, each once.""" + direction = by.direction + dims = direction.joined_dims if missing := sorted(set(dims) - inner): raise DimensionError( - f'{context}: {call} joins on {missing} (columns {[r for r in walk.joined if walk.dim(r) in missing]} ' - f"of '{walk.name}'), which the expression does not carry (dims {sorted(inner)}). A relation is " - f'walked between two of its columns and read at the others — index the operand by them, or ' - f'walk between different columns.' + f'{context}: {call} joins on {missing} (columns {[r for r in direction.joined if direction.dim(r) in missing]} ' + f"of '{direction.name}'), which the expression does not carry (dims {sorted(inner)}). A relation is " + f'read between two of its columns and joined at the others — index the operand by them, or ' + f'read it between different columns.' ) if twice := sorted({d for d in dims if dims.count(d) > 1 or d in by.dimensions}): raise DimensionError( - f"{context}: {call} joins '{walk.name}' on {twice} through more than one column, and the operand " - f'carries each dimension once. Give the walk different columns, or a relation whose joined ' + f"{context}: {call} joins '{direction.name}' on {twice} through more than one column, and the operand " + f'carries each dimension once. Read between different columns, or use a relation whose joined ' f'columns are over distinct dimensions.' ) @@ -422,7 +422,7 @@ def _check_named_amount(node: FunctionCallNode, over: str, inner: frozenset[str] ) partition = node.kwargs.get('by') groups = ( - frozenset(partition.walk.dim(v) for v in partition.walk.produced) + frozenset(partition.direction.dim(v) for v in partition.direction.produced) if isinstance(partition, RelationNode) else frozenset() ) diff --git a/src/math_spec/lowering.py b/src/math_spec/lowering.py index 6cca5857..b6f82bb9 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -274,13 +274,13 @@ def sum(self, node: FunctionCallNode) -> program.ExpressionNode: assert isinstance(consumed, DimensionNode), 'resolution refuses a over= that is not a dimension' return program.Sum(operand, (consumed.name,)) assert isinstance(by_node, RelationNode), 'resolution refuses a by= that is not a relation' - return program.GroupSum(operand, walk=by_node.walk) + return program.GroupSum(operand, direction=by_node.direction) def at(self, node: FunctionCallNode) -> program.ExpressionNode: """``at(x, by=relation)`` — the adjoint of :meth:`sum`'s ``by=`` form.""" by_node = node.kwargs['by'] assert isinstance(by_node, RelationNode), 'resolution refuses a by= that is not a relation' - return program.At(self.expr(node.args[0]), walk=by_node.walk) + return program.At(self.expr(node.args[0]), direction=by_node.direction) def sum_back(self, node: FunctionCallNode) -> program.ExpressionNode: """``sum_back(x, along=d, window=w)`` — a trailing window along one dimension. @@ -342,8 +342,8 @@ def shift(self, node: FunctionCallNode) -> program.ExpressionNode: } -def _partition_of(node: FunctionCallNode) -> program.Walk | None: - """The walk a translation partitions by, if the call names a relation. +def _partition_of(node: FunctionCallNode) -> program.Direction | None: + """The direction a translation partitions by, if the call names a relation. That it is a *single* relation, walked *along the translated dimension*, is checked with the other dim rules (``math_spec.dimensions``), where a model @@ -353,7 +353,7 @@ def _partition_of(node: FunctionCallNode) -> program.Walk | None: if by_node is None: return None assert isinstance(by_node, RelationNode) - return by_node.walk + return by_node.direction def _bound_expression(value: float | str) -> program.ExpressionNode: diff --git a/src/math_spec/operators.py b/src/math_spec/operators.py index 05a756f5..39b7f8be 100644 --- a/src/math_spec/operators.py +++ b/src/math_spec/operators.py @@ -39,11 +39,11 @@ class Builtin: dimension_kwargs: tuple[str, ...] = () relation_kwargs: tuple[str, ...] = () #: Kwargs naming a column of the relation ``by=`` names — ``over=`` and - #: ``into=`` — which resolution folds into the relation's walk. + #: ``into=`` — which resolution folds into the direction it is read in. role_kwargs: tuple[str, ...] = () #: Kwargs naming a dimension on their own and a column of the relation where #: ``by=`` names one. ``sum(x, over=generator)`` reduces the dimension - #: away; ``sum(x, by=l, over=c)`` names the column the walk consumes. + #: away; ``sum(x, by=l, over=c)`` names the column the call consumes. #: One meaning — what leaves the frame — read in the namespace ``by=`` #: decides. dimension_or_role_kwargs: tuple[str, ...] = () @@ -52,7 +52,7 @@ class Builtin: #: Kwargs the call may omit. Their *kind* still comes from the tuples #: above — this says only that the operator has an answer without them. optional_kwargs: tuple[str, ...] = () - #: Kwargs required exactly when the call addresses a relation. A walk + #: Kwargs required exactly when the call addresses a relation. A call #: through a relation names both of its ends, so that adding a value #: column to the relation cannot change what an existing call means. with_relation: tuple[str, ...] = () @@ -90,7 +90,7 @@ def kind_of( #: The closed operator set. ``by=`` is the one keyword that addresses a relation, #: and a relation carries its own dimensions, so no sibling kwarg restates them. -#: On ``shift`` and ``sum_back`` it partitions the axis the operator walks: it +#: On ``shift`` and ``sum_back`` it partitions the axis the operator steps along: it #: says which rows are neighbours, not which group a term lands in, and #: ``within=`` names the columns whose values that group is read from. BUILTINS: dict[str, Builtin] = { @@ -151,20 +151,20 @@ def call_shape_error(name: str, positional: int, kwargs: Iterable[str]) -> str | builtin = BUILTINS[name] keys = set(kwargs) optional = {*builtin.edge_kwargs, *builtin.optional_kwargs} - walks = bool(keys & set(builtin.relation_kwargs)) - required = builtin.required | frozenset(builtin.with_relation) if walks else builtin.required - optional |= set() if walks else set(builtin.with_relation) - if walks and (unsaid := sorted(frozenset(builtin.with_relation) - keys)): + reads = bool(keys & set(builtin.relation_kwargs)) + required = builtin.required | frozenset(builtin.with_relation) if reads else builtin.required + optional |= set() if reads else set(builtin.with_relation) + if reads and (unsaid := sorted(frozenset(builtin.with_relation) - keys)): return unsaid_ends_error(name, unsaid) fits = positional == 1 and keys - optional == required return None if fits else f'{name}() expects {builtin.usage}' def unsaid_ends_error(name: str, unsaid: list[str]) -> str: - """Why a walk through a relation has to write both of its ends.""" + """Why a call through a relation has to write both of its ends.""" return ( f'{name}() through a relation leaves {", ".join(f"{k}=" for k in unsaid)} unsaid.\n' - f'A walk names both of its ends, so that a relation may gain a value ' + f'A call names both of its ends, so that a relation may gain a value ' f'column without changing what this call means.\n' f'Write: {BUILTINS[name].usage}' ) diff --git a/src/math_spec/program.py b/src/math_spec/program.py index 504e9de9..6e12fbcf 100644 --- a/src/math_spec/program.py +++ b/src/math_spec/program.py @@ -56,6 +56,7 @@ 'DimensionDeclaration', 'DimensionDtype', 'DimensionPositionNode', + 'Direction', 'Divide', 'Dual', 'Expression', @@ -101,7 +102,6 @@ 'VariableDeclaration', 'VariableDefinedNode', 'VariableDomain', - 'Walk', 'WhereNode', 'Window', 'carries_variable', @@ -267,8 +267,8 @@ class Sum(Expression): class GroupSum(Expression): """Sum ``operand`` through a relation, consuming the dims ``over`` and producing ``into``. - ``walk`` says which columns are consumed, which produced and which joined - on, and is the one fact the node holds: ``over`` is the dims the walk + ``direction`` says which columns are consumed, which produced and which + joined on, and is the one fact the node holds: ``over`` is the dims it consumes, ``into`` the dims it produces, and ``joined`` the dims it joins on. The result replaces every dim in ``over`` with every dim in ``into`` and keeps every dim in ``joined``. The join keys on the consumed columns @@ -276,58 +276,58 @@ class GroupSum(Expression): """ operand: ExpressionNode - walk: Walk + direction: Direction @property def relation(self) -> str: - return self.walk.name + return self.direction.name @property def over(self) -> tuple[str, ...]: - return self.walk.consumed_dims + return self.direction.consumed_dims @property def into(self) -> tuple[str, ...]: - return self.walk.produced_dims + return self.direction.produced_dims @property def joined(self) -> tuple[str, ...]: - """The dims the walk joins on — the key columns neither consumed nor produced, which the operand carries.""" - return self.walk.joined_dims + """The dims it joins on — the key columns neither consumed nor produced, which the operand carries.""" + return self.direction.joined_dims @dataclass(frozen=True) class At(Expression): """Read ``operand`` through a relation — the adjoint of :class:`GroupSum`. - The same table, walked the other way: this consumes the dims in ``into`` + The same table read the other way: this consumes the dims in ``into`` and produces the dims in ``over``, one value per coordinate because the - walk reads value columns at a key the operand fixes - (``Walk.is_function_read``). The join fans out, many ``over`` tuples + read takes value columns at a key the operand fixes + (``Direction.is_function_read``). The join fans out, many ``over`` tuples sharing one ``into`` tuple — at each coordinate of the joined columns, which the operand carries and the result keeps. As on - :class:`GroupSum`, ``walk`` is the fact and the rest are read off it. + :class:`GroupSum`, ``direction`` is the fact and the rest are read off it. """ operand: ExpressionNode - walk: Walk + direction: Direction @property def relation(self) -> str: - return self.walk.name + return self.direction.name @property def over(self) -> tuple[str, ...]: - return self.walk.produced_dims + return self.direction.produced_dims @property def into(self) -> tuple[str, ...]: - return self.walk.consumed_dims + return self.direction.consumed_dims @property def joined(self) -> tuple[str, ...]: - """The dims the walk joins on — the key columns neither consumed nor produced, which the operand carries.""" - return self.walk.joined_dims + """The dims it joins on — the key columns neither consumed nor produced, which the operand carries.""" + return self.direction.joined_dims @dataclass(frozen=True) @@ -355,7 +355,7 @@ class Translate(Expression): offset: int | str wrap: bool fill: float | None = None - partition: Walk | None = None + partition: Direction | None = None @dataclass(frozen=True) @@ -383,7 +383,7 @@ class Window(Expression): dimension: str width: int | str wrap: bool - partition: Walk | None = None + partition: Direction | None = None @dataclass(frozen=True) @@ -511,16 +511,18 @@ def dim(self, role: str) -> str: return dict(self.columns)[role] -class Walk(NamedTuple): - """One relation as an operator walks it — which columns are consumed, which produced, which joined on. +class Direction(NamedTuple): + """One relation as one call reads it — which columns are consumed, which produced, which joined on. - ``consumed``, ``produced`` and ``joined`` are *roles* — column names of - ``relation``, which binds every role to its dimension and names the key. - ``joined`` is the key roles not walked (every role, for a bare relation): - the join keys on them, and a value role not walked is not read. For a - partition (``shift``, ``sum_back``, ``position``) ``consumed`` is the key - role over the dimension walked and ``produced`` the value roles that make - the group — every value role unless the call named some with ``within=``. + The declaration fixes no direction; the call does, and this is the one it + named. ``consumed``, ``produced`` and ``joined`` are *roles* — column names + of ``relation``, which binds every role to its dimension and names the key. + ``joined`` is the key roles the call did not name (every role, for a bare + relation): the join keys on them, and a value role left unnamed is not + read. For a partition (``shift``, ``sum_back``, ``position``) ``consumed`` + is the key role over the dimension walked and ``produced`` the value roles + that make the group — every value role unless the call named some with + ``within=``. """ relation: RelationDeclaration @@ -562,7 +564,7 @@ def joined_dims(self) -> tuple[str, ...]: @property def is_function_read(self) -> bool: - """Whether the walk reads one value per coordinate: the key lies inside what is fixed.""" + """Whether the read is one value per coordinate: the key lies inside what is fixed.""" return bool(self.key) and set(self.key) <= {*self.joined, *self.produced} @@ -1220,7 +1222,7 @@ class DimensionPositionNode: name: str op: PredicateOperator position: int - partition: Walk | None = None + partition: Direction | None = None @dataclass(frozen=True) diff --git a/src/math_spec/resolution.py b/src/math_spec/resolution.py index 06565af5..c28e7f3f 100644 --- a/src/math_spec/resolution.py +++ b/src/math_spec/resolution.py @@ -65,6 +65,7 @@ BooleanLiteralNode, DimensionComparisonNode, DimensionPositionNode, + Direction, Mask, NotNode, OrNode, @@ -77,7 +78,6 @@ RelationPairComparisonNode, TypedPredicateNode, VariableDefinedNode, - Walk, WhereNode, ) @@ -575,11 +575,11 @@ def _relation_ref( roles: Mapping[str, ArithmeticNode], over: ArithmeticNode | None, ) -> ArithmeticNode: - """An operator's ``by=``, with the ``over=`` and ``into=`` that say how the relation is walked. + """An operator's ``by=``, with the ``over=`` and ``into=`` that say which direction it is read in. A relation carries its own dimensions, so the call names columns rather than dims: ``over=`` the column consumed, ``into=`` the column - produced, every other key column joined on. A value column not walked + produced, every other key column joined on. A value column not named is not read, and a bare relation's columns are all key. One call addresses one table, so several columns of one table are a list and several tables are not. @@ -591,7 +591,7 @@ def _relation_ref( if len(names) > 1: self.errors.append( f'{self.context}: {operator}({key}={shown(names)}) names {len(names)} relations, and one call ' - f'walks one table. Declare one relation with the columns of all of them, or walk them in turn, ' + f'reads one table. Declare one relation with the columns of all of them, or read them in turn, ' f'one call each.' ) return value @@ -606,20 +606,20 @@ def _relation_ref( named = {k: r for k, r in read.items() if r is not None} if operator in ('shift', 'sum_back'): over_dim = over.name if isinstance(over, NameNode | DimensionNode) else None - walk = self._partition_walk(name, operator, over_dim, named.get('within')) + direction = self._partition_direction(name, operator, over_dim, named.get('within')) else: if not ({'over', 'into'} <= set(named)): return value # the call shape refused it already, with the wording that names the rewrite - walk = self._walk(name, operator, named['over'], named['into']) - if walk is None: + direction = self._direction(name, operator, named['over'], named['into']) + if direction is None: return value - fine = walk.produced_dims if operator == 'at' else walk.consumed_dims + fine = direction.produced_dims if operator == 'at' else direction.consumed_dims if operator in ('shift', 'sum_back'): coarse: tuple[str, ...] = () else: - coarse = walk.consumed_dims if operator == 'at' else walk.produced_dims - return RelationNode(name, dimensions=fine, into=coarse, walk=walk) + coarse = direction.consumed_dims if operator == 'at' else direction.produced_dims + return RelationNode(name, dimensions=fine, into=coarse, direction=direction) def _role_name(self, value: ArithmeticNode, operator: str, key: str) -> tuple[str, ...] | None: """``over=`` or ``into=`` as the column names it must be — one bare name, or a bracketed list of them.""" @@ -632,19 +632,19 @@ def _role_name(self, value: ArithmeticNode, operator: str, key: str) -> tuple[st ) return None - def _walk( + def _direction( self, name: str, operator: str, from_roles: tuple[str, ...], into_roles: tuple[str, ...], - ) -> Walk | None: - """How ``sum`` or ``at`` walks relation *name*, between the columns the call named. + ) -> Direction | None: + """Which direction ``sum`` or ``at`` reads relation *name* in, between the columns the call named. - Both ends arrive written: the call shape refuses a walk that leaves + Both ends arrive written: the call shape refuses a call that leaves one unsaid, so that a relation may gain a value column without - changing what this call means. ``at`` needs the walk single-valued - and ``sum`` needs it not: a sum that walks to the key has one term + changing what this call means. ``at`` needs the read single-valued + and ``sum`` needs it not: a sum that lands on the key has one term per coordinate and adds up nothing, which is a read, so it is refused toward ``at``. """ @@ -659,7 +659,7 @@ def _walk( forward = operator == 'sum' if both := sorted(set(from_roles) & set(into_roles)): self.errors.append( - f'{context}: {call}: over= and into= both name {both}, and a walk goes between two sets of columns.' + f'{context}: {call}: over= and into= both name {both}, and a call reads between two sets of columns.' ) return None for kwarg, roles in (('over', from_roles), ('into', into_roles)): @@ -667,28 +667,28 @@ def _walk( if shared := sorted({d for d in dims if dims.count(d) > 1}): self.errors.append( f'{context}: {call}: {kwarg}={list(roles)} names two columns over {shared}, and the operand ' - f'carries each dimension once, so nothing says which column its coordinate is read at. Walk ' + f'carries each dimension once, so nothing says which column its coordinate is read at. Read ' f'between columns over distinct dimensions.' ) return None joined = tuple(r for r in (shape.key or shape.roles) if r not in from_roles and r not in into_roles) - walk = Walk(shape, from_roles, into_roles, joined) - if not forward and not walk.is_function_read: + direction = Direction(shape, from_roles, into_roles, joined) + if not forward and not direction.is_function_read: self.errors.append( f"{context}: {call}: at reads one value per coordinate, and '{name}' is not single-valued in " f'{list(from_roles)} at the columns the operand fixes ({[*into_roles, *joined]}) — its key is ' f'{list(shape.key)}. Key the table by columns the read fixes, or read the other way.' ) return None - if forward and walk.is_function_read: + if forward and direction.is_function_read: self.errors.append( - f'{context}: {call}: this sum walks to the key {list(shape.key)}, so each coordinate has one ' + f'{context}: {call}: this sum lands on the key {list(shape.key)}, so each coordinate has one ' f"term and nothing is added up — that is a read, which is at()'s. Write " f'at(..., by={name}, over={list(into_roles)}, into={list(from_roles)}), or sum toward ' f'a value column.' ) return None - return walk + return direction def _known_roles(self, name: str, call: str, roles: tuple[str, ...] | None, kwarg: str) -> bool: """Whether every role *kwarg* names is a column of relation *name*, each once; the refusal otherwise.""" @@ -705,12 +705,12 @@ def _known_roles(self, name: str, call: str, roles: tuple[str, ...] | None, kwar return False return True - def _partition_walk( + def _partition_direction( self, name: str, operator: str, walked_dim: str | None, within_roles: tuple[str, ...] | None - ) -> Walk | None: - """How a partition (``shift``, ``sum_back``, ``position``) walks relation *name* along *walked_dim*. + ) -> Direction | None: + """Which direction a partition (``shift``, ``sum_back``, ``position``) reads relation *name* in along *walked_dim*. - It walks the one key column over that dimension (a key has one column + It takes the one key column over that dimension (a key has one column per dimension), joins on the other key columns and groups by the value columns *within_roles* names — every value column where the call names none. ``None`` where the dimension is not one (already refused), the @@ -726,14 +726,14 @@ def _partition_walk( self.errors.append( f"{context}: {call}: '{name}' is a bare relation — every column is in its key — so it makes no " f'groups and no coordinate is in exactly one. Move the columns the group is made of under ' - f'values:, leaving key: the column {operator} walks.' + f'values:, leaving key: the column {operator} steps along.' ) return None over_keys = [r for r in shape.key if shape.dim(r) == walked_dim] if not over_keys: self.errors.append( f"{context}: {call}: '{name}' has no key column over '{walked_dim}' — its key is " - f'{list(shape.key)} — and a partition walks a key column over the dimension it groups.' + f'{list(shape.key)} — and a partition steps along a key column over the dimension it groups.' ) return None if keyed := [r for r in within_roles or () if r in shape.key]: @@ -744,7 +744,7 @@ def _partition_walk( return None (walked,) = over_keys joined = tuple(r for r in shape.key if r != walked) - return Walk(shape, (walked,), shape.values if within_roles is None else within_roles, joined) + return Direction(shape, (walked,), shape.values if within_roles is None else within_roles, joined) def _not_a_relation(self, name: str, operator: str, key: str) -> str | None: """Why *name* is not a relation; ``None`` where it is one.""" @@ -851,10 +851,10 @@ def _position(self, node: UnresolvedPositionNode) -> DimensionPositionNode | Unr f'{did_you_mean(node.by, ns.relations, label="Relations")}' ) return node - walk = self._partition_walk(node.by, 'position', node.dimension, node.into) - if walk is None: + direction = self._partition_direction(node.by, 'position', node.dimension, node.into) + if direction is None: return node - return DimensionPositionNode(node.dimension, node.op, node.position, walk) + return DimensionPositionNode(node.dimension, node.op, node.position, direction) def _comparison(self, node: UnresolvedComparisonNode) -> WhereNode | UnresolvedWhereNode: """``name literal``, or the one structural form ``relation relation``.""" diff --git a/src/math_spec/typesetting/walk.py b/src/math_spec/typesetting/walk.py index bedc65e1..a877f218 100644 --- a/src/math_spec/typesetting/walk.py +++ b/src/math_spec/typesetting/walk.py @@ -58,7 +58,7 @@ from collections.abc import Iterable, Mapping from math_spec.model import RelationBlock, SosBlock, _ExpandedSpec - from math_spec.program import Walk as RelationWalk + from math_spec.program import Direction from math_spec.typesetting.format import Format from math_spec.typesetting.symbols import Symbols @@ -274,15 +274,15 @@ def _translation(self, step: _Step) -> str: self.noticed.grouped = True return self.format.superscript(operator, step.within) - def _relation_read(self, walk: RelationWalk, at: Mapping[str, str], read: str) -> str: + def _relation_read(self, direction: Direction, at: Mapping[str, str], read: str) -> str: """A relation's column *read* as a function at the columns *at* fixes: ``bus(g)``, ``zone_of(g, p)`` or ``ends.bus0(l)``. *at* maps each key role to the index it is read at. The function is named after the relation alone where the key determines one column, and after the column read otherwise. """ - name = walk.name if len(walk.values) == 1 else f'{walk.name}.{read}' - return self.format.apply(self.format.upright(name), self.format.joined([at[k] for k in walk.key], '')) + name = direction.name if len(direction.values) == 1 else f'{direction.name}.{read}' + return self.format.apply(self.format.upright(name), self.format.joined([at[k] for k in direction.key], '')) def _relation_row(self, name: str, key: list[str]) -> str: """That relation *name* has a row at *key*, its key columns' indices in declared order. @@ -307,10 +307,12 @@ def _value_read(self, name: str, column: str, ctx: _Context) -> str: def _position_group(self, node: DimensionPositionNode, ctx: _Context) -> str: """The group a grouped position counts within: the relation's group columns read at the row's key.""" assert node.partition is not None - walk = node.partition - keyed = self.format.joined([ctx.subscript(walk.dim(k)) for k in walk.key], '') - single = len(walk.values) == 1 - reads = [self.format.apply(self._column(walk.name, column, single), keyed) for column in walk.produced] + direction = node.partition + keyed = self.format.joined([ctx.subscript(direction.dim(k)) for k in direction.key], '') + single = len(direction.values) == 1 + reads = [ + self.format.apply(self._column(direction.name, column, single), keyed) for column in direction.produced + ] return self._tuple(reads) def _tuple(self, reads: list[str]) -> str: @@ -457,10 +459,10 @@ def _call(self, node: FunctionCallNode, ctx: _Context) -> tuple[str, int]: by = node.kwargs['by'] assert isinstance(by, RelationNode) outer = ctx - walk = by.walk - at = {r: outer.subscript(walk.dim(r)) for r in (*walk.produced, *walk.joined)} - for read in walk.consumed: - ctx = ctx.pulled_back(walk.dim(read), self._relation_read(walk, at, read)) + direction = by.direction + at = {r: outer.subscript(direction.dim(r)) for r in (*direction.produced, *direction.joined)} + for read in direction.consumed: + ctx = ctx.pulled_back(direction.dim(read), self._relation_read(direction, at, read)) return self._arithmetic(node.args[0], ctx) if (by := node.kwargs.get('by')) is not None: @@ -469,7 +471,7 @@ def _call(self, node: FunctionCallNode, ctx: _Context) -> tuple[str, int]: inner = ctx for d in by.dimensions: dummies[d], inner = inner.reducing(d) - conditions = list(self._grouping(by.walk, dummies, ctx)) + conditions = list(self._grouping(by.direction, dummies, ctx)) domain = ( f'{self.format.joined([self._membership(d, dummies[d]) for d in by.dimensions], "")} ' f'{self._op("such_that")} {self.format.joined(conditions, self._op("and"))}' @@ -487,23 +489,23 @@ def _call(self, node: FunctionCallNode, ctx: _Context) -> tuple[str, int]: domain = self.format.joined(memberships, '') return self.format.summation(domain, self._reduction_body(node.args[0], inner)), _PRECEDENCE['+'] - def _grouping(self, walk: RelationWalk, dummies: Mapping[str, str], ctx: _Context) -> list[str]: - """The conditions a grouped sum's domain carries for one walk: what the walk fixes of the row it joins on. + def _grouping(self, direction: Direction, dummies: Mapping[str, str], ctx: _Context) -> list[str]: + """The conditions a grouped sum's domain carries for one direction: what it fixes of the row it joins on. - A walk fixes its relation's key either way, so each value column it - fixes — consumed or produced, one lookup at the key either way — is - that column read there. A walk that fixes none of them asks only that - the row is there, because a value column the walk does not touch is - not read, and a bare relation has no value column to read at all. + A direction fixes its relation's key either way, so each value column + it fixes — consumed or produced, one lookup at the key either way — is + that column read there. One that fixes none of them asks only that the + row is there, because a value column it does not touch is not read, + and a bare relation has no value column to read at all. """ at = { - **{r: dummies[walk.dim(r)] for r in walk.consumed}, - **{r: ctx.subscript(walk.dim(r)) for r in (*walk.joined, *walk.produced)}, + **{r: dummies[direction.dim(r)] for r in direction.consumed}, + **{r: ctx.subscript(direction.dim(r)) for r in (*direction.joined, *direction.produced)}, } - fixed = [r for r in walk.values if r in at] + fixed = [r for r in direction.values if r in at] if not fixed: - return [self._relation_row(walk.name, [at[k] for k in walk.key])] - return [f'{self._relation_read(walk, at, r)} {self._op("equal")} {at[r]}' for r in fixed] + return [self._relation_row(direction.name, [at[k] for k in direction.key])] + return [f'{self._relation_read(direction, at, r)} {self._op("equal")} {at[r]}' for r in fixed] def _group(self, by: ArithmeticNode | None, dim: str) -> str: """A ``by=`` as the superscript its translation operator carries. @@ -515,9 +517,9 @@ def _group(self, by: ArithmeticNode | None, dim: str) -> str: if by is None: return '' assert isinstance(by, RelationNode) - walk = by.walk - at = {r: self.symbols.index[walk.dim(r)] for r in (*walk.consumed, *walk.joined)} - return self._tuple([self._relation_read(walk, at, r) for r in walk.produced]) + direction = by.direction + at = {r: self.symbols.index[direction.dim(r)] for r in (*direction.consumed, *direction.joined)} + return self._tuple([self._relation_read(direction, at, r) for r in direction.produced]) def _width(self, node: ArithmeticNode) -> str: """``sum_back``'s ``window=``: a number, or a parameter's own symbol. diff --git a/tests/test_dimensions.py b/tests/test_dimensions.py index cffdd45f..d632ba23 100644 --- a/tests/test_dimensions.py +++ b/tests/test_dimensions.py @@ -225,7 +225,7 @@ def test_a_bare_name_reaches_the_variable_a_dual_the_same_named_constraint(): ), pytest.param( 'sum(load, by=gen_bus, over=generator, into=bus)', - r"sum\(by=gen_bus\) consumes \['generator'\], the dims it walks from", + r"sum\(by=gen_bus\) consumes \['generator'\], the dims it reads from", id='sum-requires-the-grouped-dim', ), pytest.param( diff --git a/tests/test_lowering.py b/tests/test_lowering.py index c8a033a6..13705f09 100644 --- a/tests/test_lowering.py +++ b/tests/test_lowering.py @@ -31,6 +31,7 @@ Constant, DimensionComparisonNode, DimensionDeclaration, + Direction, Divide, Dual, ExpressionNode, @@ -51,7 +52,6 @@ Sum, Translate, Variable, - Walk, Window, children, divisor_parameters, @@ -86,7 +86,7 @@ #: `lk` as `sum` walks it: key consumed, value produced, nothing joined. LK = RelationDeclaration('lk', (('g', 'g'), ('h', 'h')), ('g',)) LK2 = RelationDeclaration('lk2', (('g', 'g'), ('z', 'z')), ('g',)) -LK_WALK = Walk(LK, ('g',), ('h',), ()) +LK_DIRECTION = Direction(LK, ('g',), ('h',), ()) AT_BUS = RelationDeclaration('at_bus', (('g', 'g'), ('bus', 'bus')), ('g',)) #: `fixtures.SMALL_MODEL` plus a second relation and a per-entity @@ -412,12 +412,12 @@ def test_a_power_lowers_to_a_node_of_its_own(dispatch_schema): pytest.param('sum(q, over=h)', Sum(Variable('q'), ('h',)), id='an-over-consumes-the-dim-it-names'), pytest.param( 'sum(p, by=lk, over=g, into=h)', - GroupSum(Variable('p'), walk=LK_WALK), + GroupSum(Variable('p'), direction=LK_DIRECTION), id='a-grouped-sum-names-the-dim-it-consumes-and-the-one-it-lands-on', ), pytest.param( 'at(r, by=lk, over=h, into=g)', - At(Variable('r'), walk=Walk(LK, ('h',), ('g',), ())), + At(Variable('r'), direction=Direction(LK, ('h',), ('g',), ())), id='a-pullback-walks-the-same-table-back', ), pytest.param( @@ -443,7 +443,7 @@ def test_a_power_lowers_to_a_node_of_its_own(dispatch_schema): offset=1, wrap=False, fill=0.0, - partition=Walk(LK, ('g',), ('h',), ()), + partition=Direction(LK, ('g',), ('h',), ()), ), id='a-translation-stops-at-the-edges-of-the-relation-it-names', ), @@ -464,7 +464,7 @@ def test_a_power_lowers_to_a_node_of_its_own(dispatch_schema): 'g', width=2, wrap=False, - partition=Walk(LK, ('g',), ('h',), ()), + partition=Direction(LK, ('g',), ('h',), ()), ), id='a-window-stops-at-the-edges-of-the-relation-it-names', ), @@ -510,7 +510,7 @@ def test_a_relation_lowers_with_the_walk_each_call_takes(): assert program.dimension('zone').relations == (declared,), 'and under its last' assert program.relations == {'zone_of': declared}, 'and once in the program' zonal = program.constraints['zonal'].lhs - assert zonal == GroupSum(Variable('p'), walk=Walk(declared, ('generator',), ('zone',), ('snapshot',))), ( + assert zonal == GroupSum(Variable('p'), direction=Direction(declared, ('generator',), ('zone',), ('snapshot',))), ( 'a grouped sum names the column it consumes, the one it produces and the one it joins on' ) assert isinstance(zonal, GroupSum) @@ -521,10 +521,10 @@ def test_a_relation_lowers_with_the_walk_each_call_takes(): 'zone_of', ), 'the dims a consumer reads are read off the walk' assert program.constraints['history'].lhs == GroupSum( - Variable('p'), walk=Walk(declared, ('snapshot',), ('zone',), ('generator',)) + Variable('p'), direction=Direction(declared, ('snapshot',), ('zone',), ('generator',)) ), 'the same table walked from its other key column' priced = program.constraints['priced'].rhs - assert priced == At(Parameter('price'), walk=Walk(declared, ('zone',), ('generator',), ('snapshot',))), ( + assert priced == At(Parameter('price'), direction=Direction(declared, ('zone',), ('generator',), ('snapshot',))), ( 'and its adjoint consumes the value column and produces the key column' ) assert isinstance(priced, At) @@ -553,7 +553,7 @@ def test_a_divisor_under_a_pullback_is_still_named(): """`children` has to descend through every node, or a refusal loses its name.""" quotient = Divide(Variable('x'), Parameter('rate')) component_of = RelationDeclaration('component_of', (('flow', 'flow'), ('component', 'component')), ('flow',)) - pulled = At(quotient, walk=Walk(component_of, ('component',), ('flow',), ())) + pulled = At(quotient, direction=Direction(component_of, ('component',), ('flow',), ())) assert divisor_parameters(pulled) == frozenset({'rate'}), 'the walk descends through `At`' assert divisor_parameters(Sum(pulled, ('flow',))) == frozenset({'rate'}), 'and through a `Sum` over it' @@ -628,8 +628,8 @@ def test_walk_is_the_node_column_of_walk_regions(): Power(Parameter('c'), Constant(2.0)): 'one-to-one', Divide(Variable('p'), Parameter('c')): 'one-to-one', Sum(Variable('p'), ('g',)): 'many-to-one', - GroupSum(Variable('p'), walk=Walk(AT_BUS, ('g',), ('bus',), ())): 'many-to-one', - At(Variable('p'), walk=Walk(AT_BUS, ('bus',), ('g',), ())): 'one-to-one', + GroupSum(Variable('p'), direction=Direction(AT_BUS, ('g',), ('bus',), ())): 'many-to-one', + At(Variable('p'), direction=Direction(AT_BUS, ('bus',), ('g',), ())): 'one-to-one', Translate(Variable('p'), 't', offset=1, wrap=False, fill=0.0): 'one-to-one', Window(Variable('p'), 't', width=2, wrap=False): 'one-to-many', Cases((Region(Mask(ParameterDefinedNode('c', ('g',))), Variable('p')),)): 'one-to-one', diff --git a/tests/test_validation.py b/tests/test_validation.py index 2206a9d5..1548d609 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -766,7 +766,7 @@ class TestRulesDecidedWithoutData: ), pytest.param( {'objective': {'expression': 'sum(sum(q, by=lk, over=h, into=g))'}}, - ("this sum walks to the key ['g']", 'that is a read, which is', 'at(..., by=lk'), + ("this sum lands on the key ['g']", 'that is a read, which is', 'at(..., by=lk'), id='a-sum-that-walks-to-the-key-is-a-read', ), pytest.param( @@ -934,7 +934,7 @@ class TestRulesDecidedWithoutData: 'dimensions.z': {}, 'objective': {'expression': 'sum(sum(p, by=[lk, lk2], over=g, into=[h, z]))'}, }, - ('names 2 relations, and one call walks one table',), + ('names 2 relations, and one call reads one table',), id='several-relations-in-one-by', ), pytest.param( diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index 6de5434d..d98e4512 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -25,10 +25,10 @@ relations: zone_of: { key: bus, values: zone } area_of: { key: bus, values: zone } # a second map into the same set, to compare against season_of: { key: snapshot, values: season } - gen_zone: { key: [generator, snapshot], values: zone } # a map keyed by two dimensions: a call walks one and joins on the other + gen_zone: { key: [generator, snapshot], values: zone } # a map keyed by two dimensions: a call consumes one and joins on the other rep_of: { key: snapshot, values: { rep: snapshot } } # a map into its own dimension: the representative snapshot - connection: { key: [generator, bus] } # a bare relation, with no value columns: many-to-many, walked only by sum with both ends named - gen_bt: { key: generator, values: [bus, technology] } # one table with two value columns, walked to both at once + connection: { key: [generator, bus] } # a bare relation, with no value columns: many-to-many, read only by sum with both ends named + gen_bt: { key: generator, values: [bus, technology] } # one table with two value columns, read to both at once parameters: p_max: { dims: [generator] } @@ -149,7 +149,7 @@ constraints: pullback: # at(), which re-indexes through a relation instead of an offset dims: [snapshot, bus] expression: spill <= at(zone_cap, by=zone_of, over=zone, into=bus) - grouped_once: # one table walked to two value columns: the domain carries a condition per column + grouped_once: # one table read to two value columns: the domain carries a condition per column dims: [snapshot, bus, technology] expression: sum(p, by=gen_bt, into=[bus, technology], over=generator) <= tech_cap pulled_back_once: # its adjoint, reading one slot through two columns of one table @@ -166,16 +166,16 @@ constraints: dims: [snapshot, generator, bus] where: "connection" expression: p <= load - representative: # a map into its own dimension, walked both ways: the frame is unchanged and the index is primed + representative: # a map into its own dimension, read both ways: the frame is unchanged and the index is primed dims: [snapshot] expression: sum(spill, by=rep_of, over=snapshot, into=rep) <= at(spill, by=rep_of, over=rep, into=snapshot) - zonal: # a grouping through a two-key map, walked along one key: the condition reads the other, and the row keeps it + zonal: # a grouping through a two-key map, consuming one key: the condition reads the other, and the row keeps it dims: [snapshot, zone] expression: sum(p, by=gen_zone, over=generator, into=zone) <= zone_cap - zonal_history: # the same table walked along its other key + zonal_history: # the same table consuming its other key dims: [generator, zone] expression: sum(p, by=gen_zone, over=snapshot, into=zone) <= zone_cap - zonal_membership: # the same table walked between its two key columns: no value column is read, so the domain asks only that the row is there + zonal_membership: # the same table read between its two key columns: no value column is read, so the domain asks only that the row is there dims: [snapshot] expression: sum(units, by=gen_zone, over=generator, into=snapshot) <= budget zonal_pullback: # its adjoint, reading the slot the row's own snapshot puts the generator in From 6526f76fb69358d455c3f7fd8079328895062088 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 19:43:54 +0000 Subject: [PATCH 2/2] refactor(language): the sweep reaches every docstring, example and test that still walked a relation The relation docstring that the schema prints, the Direction class's own docstring, three node docstrings, three example descriptions and the test names and ids all said "walk" for a relation. They say "read" now. The axis sense was half renamed: four places said "steps along" and six still said "walks". It is "steps along" everywhere, which reaches the typesetter's position note and so the golden outputs and the generated pages. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UnbTmmYp7auaiCwSgkKKsB --- docs/examples/commitment.md | 2 +- docs/examples/operators.md | 6 +++--- docs/examples/pypsa.md | 2 +- docs/examples/pypsa_linearized_uc.md | 2 +- docs/reference/language/expressions.md | 2 +- docs/reference/notation.md | 2 +- examples/operators/sum_by.yaml | 2 +- examples/operators/sum_by_column_lists.yaml | 2 +- examples/operators/sum_by_columns.yaml | 2 +- schema/math-spec.schema.json | 2 +- src/math_spec/dimensions.py | 12 ++++++------ src/math_spec/lowering.py | 4 ++-- src/math_spec/model.py | 2 +- src/math_spec/program.py | 6 +++--- src/math_spec/resolution.py | 16 ++++++++-------- src/math_spec/typesetting/walk.py | 2 +- tests/test_dimensions.py | 14 +++++++------- tests/test_lowering.py | 12 ++++++------ tests/test_validation.py | 4 ++-- tests/typesetting/golden/latex.out | 2 +- tests/typesetting/golden/markdown.out | 2 +- tests/typesetting/golden/typst.out | 2 +- tests/typesetting/test_walk.py | 20 ++++++++++---------- 23 files changed, 61 insertions(+), 61 deletions(-) diff --git a/docs/examples/commitment.md b/docs/examples/commitment.md index f7b81791..901182f9 100644 --- a/docs/examples/commitment.md +++ b/docs/examples/commitment.md @@ -123,7 +123,7 @@ Upright is what the model is given — a parameter such as $`\mathrm{committable $`t \boxminus_{v} k`$ denotes translation with $`v`$ standing where index $`t-k`$ leaves the dimension (`shift(edge=v)`), so the row at that boundary is built and carries $`v`$ rather than being dropped. -$`\mathrm{pos}(t)`$ denotes where index $`t`$ sits along its dimension's own order — the order `shift` 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}(t)`$ denotes where index $`t`$ sits along its dimension's own order — the order `shift` steps along, not the order labels sort in — counted from $`0`$. The index itself stays the coordinate, so $`t`$ compares against labels and $`\mathrm{pos}(t)`$ against positions. #### Objective diff --git a/docs/examples/operators.md b/docs/examples/operators.md index eefbf74d..1679d0fa 100644 --- a/docs/examples/operators.md +++ b/docs/examples/operators.md @@ -76,7 +76,7 @@ $`\sum_{g \in \mathcal{G}} p_{t,g} \le \mathrm{limit}_{t} \qquad \forall\, t \in ```yaml description: >- The membership reduction — `sum(array, by=relation, over=a, into=b)` lands the result on the - column the relation is walked to, which is what makes topology data rather than + column the relation is read to, which is what makes topology data rather than structure. dimensions: @@ -111,7 +111,7 @@ $`\sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} p_{t,g} \le \mathrm{li ```yaml description: >- - A walk that names its ends — `sum(array, by=relation, over=a, into=b)` + A call that names its ends — `sum(array, by=relation, over=a, into=b)` consumes column `a` and lands on column `b`, and the other key column is joined on, so each zone's total is taken per period. @@ -147,7 +147,7 @@ $`\sum_{g \in \mathcal{G} \,:\, \mathrm{zone\_of}(g,\ e) = z} p_{g,e} \ge \mathr ```yaml description: >- - A walk with several columns at each end — `sum(array, by=relation, over=[a, …], into=[b, …])` + A call with several columns at each end — `sum(array, by=relation, over=[a, …], into=[b, …])` consumes both key columns at once and lands on the product of both value columns in one join. diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 60700420..921b046f 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -1385,7 +1385,7 @@ $`t \ominus k`$ denotes cyclic translation: index $`t-k`$ taken modulo the size $`t \boxminus_{v} k`$ denotes translation with $`v`$ standing where index $`t-k`$ leaves the dimension (`shift(edge=v)`), so the row at that boundary is built and carries $`v`$ rather than being dropped. -$`\mathrm{pos}(t)`$ denotes where index $`t`$ sits along its dimension's own order — the order `shift` 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}(t)`$ denotes where index $`t`$ sits along its dimension's own order — the order `shift` steps along, not the order labels sort in — counted from $`0`$. The index itself stays the coordinate, so $`t`$ compares against labels and $`\mathrm{pos}(t)`$ against positions. ### Objective diff --git a/docs/examples/pypsa_linearized_uc.md b/docs/examples/pypsa_linearized_uc.md index 7b410b6e..6ef90674 100644 --- a/docs/examples/pypsa_linearized_uc.md +++ b/docs/examples/pypsa_linearized_uc.md @@ -150,7 +150,7 @@ The relaxed class of a plain `n.optimize()`: `linearized_unit_commitment`, state | $`\overleftarrow{u}`$ | `Generator_previous_status` over $`\mathcal{T} \times \mathcal{G}`$ — the commitment state a generator carries into a snapshot — the state it brought into the horizon at the first, the previous snapshot's after that | | $`\overleftarrow{p}`$ | `Generator_previous_p` over $`\mathcal{T} \times \mathcal{G}`$ — the output a generator carries into a snapshot — nothing at the start of the horizon, which is why a unit that came in running carries no ramp row there | -$`\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}(t)`$ denotes where index $`t`$ sits along its dimension's own order — the order `shift` steps along, not the order labels sort in — counted from $`0`$. The index itself stays the coordinate, so $`t`$ compares against labels and $`\mathrm{pos}(t)`$ against positions. ### Objective diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index 45734288..15bf21e9 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -167,7 +167,7 @@ a boolean parameter instead. ### `position()` `position(dim)` is where the row sits along the dimension's own order, which is -the order `shift` walks. A boundary written with it survives a relabelling of +the order `shift` steps along. A boundary written with it survives a relabelling of the index: ```yaml diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 18583db5..9ccdb9ff 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -131,7 +131,7 @@ $`t \boxminus_{v} k`$ denotes translation with $`v`$ standing where index $`t-k` $`t \ominus^{\mathrm{relation}(t)} k`$ denotes a translation counted inside the group a relation puts $`t`$ in (`shift(by=relation)`), 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{relation}(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}(t)`$ denotes where index $`t`$ sits along its dimension's own order — the order `shift` steps along, not the order labels sort in — counted from $`0`$. The index itself stays the coordinate, so $`t`$ compares against labels and $`\mathrm{pos}(t)`$ against positions. $`\mathrm{pos}_{\mathrm{relation}(t)}(t)`$ counts within the group a relation puts $`t`$ in: the subscript names the map, $`\mathcal{T}_{\mathrm{relation}(t)}`$ is the group it lands in, and that group has a first position of its own. diff --git a/examples/operators/sum_by.yaml b/examples/operators/sum_by.yaml index 258e1466..e02cf26c 100644 --- a/examples/operators/sum_by.yaml +++ b/examples/operators/sum_by.yaml @@ -4,7 +4,7 @@ description: >- The membership reduction — `sum(array, by=relation, over=a, into=b)` lands the result on the - column the relation is walked to, which is what makes topology data rather than + column the relation is read to, which is what makes topology data rather than structure. dimensions: diff --git a/examples/operators/sum_by_column_lists.yaml b/examples/operators/sum_by_column_lists.yaml index bb17afac..98f70b7f 100644 --- a/examples/operators/sum_by_column_lists.yaml +++ b/examples/operators/sum_by_column_lists.yaml @@ -3,7 +3,7 @@ # SPDX-License-Identifier: MIT description: >- - A walk with several columns at each end — `sum(array, by=relation, over=[a, …], into=[b, …])` + A call with several columns at each end — `sum(array, by=relation, over=[a, …], into=[b, …])` consumes both key columns at once and lands on the product of both value columns in one join. diff --git a/examples/operators/sum_by_columns.yaml b/examples/operators/sum_by_columns.yaml index 8ed05f0d..683fcd3c 100644 --- a/examples/operators/sum_by_columns.yaml +++ b/examples/operators/sum_by_columns.yaml @@ -3,7 +3,7 @@ # SPDX-License-Identifier: MIT description: >- - A walk that names its ends — `sum(array, by=relation, over=a, into=b)` + A call that names its ends — `sum(array, by=relation, over=a, into=b)` consumes column `a` and lands on column `b`, and the other key column is joined on, so each zone's total is taken per period. diff --git a/schema/math-spec.schema.json b/schema/math-spec.schema.json index 2cdc05fa..450866fc 100644 --- a/schema/math-spec.schema.json +++ b/schema/math-spec.schema.json @@ -450,7 +450,7 @@ }, "RelationBlock": { "additionalProperties": false, - "description": "A named relation between dimensions: the columns a row is keyed by, and the columns that key determines.\n\nEach side is a dimension, a list of them, or a mapping of column name to\ndimension where two columns share one. ``key:`` is the claim the language\nchecks at bind: one row per key tuple, so every ``values:`` column is a\nfunction of it. A relation with no ``values:`` is **bare** \u2014 every column is\nin its key, a row is its own identity, and nothing reads it::\n\n relations:\n gen_bus: {key: generator, values: bus}\n gen_bt: {key: [generator], values: [bus, technology]}\n zone_of: {key: [generator, period], values: zone}\n ends: {key: line, values: {bus0: bus, bus1: bus}}\n connection: {key: [generator, bus]}\n\nAn operator walks the table in the direction the call names\n(``over=``, ``into=``), joining on the other key columns; the\ndeclaration fixes no direction. The map itself is data, and arrives at bind\ntime under the relation's name, one column per role.", + "description": "A named relation between dimensions: the columns a row is keyed by, and the columns that key determines.\n\nEach side is a dimension, a list of them, or a mapping of column name to\ndimension where two columns share one. ``key:`` is the claim the language\nchecks at bind: one row per key tuple, so every ``values:`` column is a\nfunction of it. A relation with no ``values:`` is **bare** \u2014 every column is\nin its key, a row is its own identity, and nothing reads it::\n\n relations:\n gen_bus: {key: generator, values: bus}\n gen_bt: {key: [generator], values: [bus, technology]}\n zone_of: {key: [generator, period], values: zone}\n ends: {key: line, values: {bus0: bus, bus1: bus}}\n connection: {key: [generator, bus]}\n\nAn operator reads the table in the direction the call names\n(``over=``, ``into=``), joining on the other key columns; the\ndeclaration fixes no direction. The map itself is data, and arrives at bind\ntime under the relation's name, one column per role.", "properties": { "description": { "anyOf": [ diff --git a/src/math_spec/dimensions.py b/src/math_spec/dimensions.py index ca931156..064c830c 100644 --- a/src/math_spec/dimensions.py +++ b/src/math_spec/dimensions.py @@ -255,13 +255,13 @@ def _check_joined(call: str, by: RelationNode, inner: frozenset[str], context: s class _Amount(NamedTuple): - """What an axis-walking operator's errors say about the amount it takes.""" + """What the errors of an operator that steps along an axis say about the amount it takes.""" #: The word for the amount. noun: str #: Why negating a named one at the call site is not what the caller means. negated: str - #: What a named one that varies along the axis it walks becomes. + #: What a named one that varies over the axis it steps along becomes. varies: str #: The least whole number a literal may be. minimum: float @@ -292,7 +292,7 @@ class _Amount(NamedTuple): def _amount_of(node: FunctionCallNode) -> tuple[str, ArithmeticNode]: - """The kwarg an axis-walking operator takes its amount through, and the value written there.""" + """The kwarg an operator that steps along an axis takes its amount through, and the value written there.""" (kwarg,) = BUILTINS[node.name].required_value_kwargs return kwarg, node.kwargs[kwarg] @@ -415,9 +415,9 @@ def _check_named_amount(node: FunctionCallNode, over: str, inner: frozenset[str] ) if over in declared.dims: raise DimensionError( - f'{context}: {node.name}({kwarg}={amount.name}) walks ' + f'{context}: {node.name}({kwarg}={amount.name}) steps along ' f"'{over}', but '{amount.name}' is declared over {sorted(declared.dims)}, which " - f'carries it. A named {words.noun} that varies along the axis it walks is {words.varies} ' + f'carries it. A named {words.noun} that varies over the axis it steps along is {words.varies} ' f"— declare '{amount.name}' over dims '{over}' is not one of." ) partition = node.kwargs.get('by') @@ -429,7 +429,7 @@ def _check_named_amount(node: FunctionCallNode, over: str, inner: frozenset[str] if stray := sorted(frozenset(declared.dims) - inner - groups): raise DimensionError( f'{context}: {node.name}({kwarg}={amount.name}) reads its {words.noun} at the coordinate it ' - f"walks, but '{amount.name}' varies over {stray}, which that coordinate does not carry " + f"steps from, but '{amount.name}' varies over {stray}, which that coordinate does not carry " f'(dims {sorted(inner)}). A dim the coordinate does not have is no coordinate at all — ' f"declare '{amount.name}' over dims the expression carries, or group by a relation into " f'one of {stray}, so that each group is reached by its own {words.noun}.' diff --git a/src/math_spec/lowering.py b/src/math_spec/lowering.py index b6f82bb9..6f931266 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -291,7 +291,7 @@ def sum_back(self, node: FunctionCallNode) -> program.ExpressionNode: ``by=`` names the relation the window stops at the edges of, and rides on the node the way it rides on a translation — the dim rules have already - held it to one relation over the walked dimension. + held it to one relation over the dimension stepped along. """ over_node = node.kwargs['along'] assert isinstance(over_node, DimensionNode), 'resolution refuses an along= that is not a dimension' @@ -345,7 +345,7 @@ def shift(self, node: FunctionCallNode) -> program.ExpressionNode: def _partition_of(node: FunctionCallNode) -> program.Direction | None: """The direction a translation partitions by, if the call names a relation. - That it is a *single* relation, walked *along the translated dimension*, is + That it is a *single* relation, stepped *along the translated dimension*, is checked with the other dim rules (``math_spec.dimensions``), where a model is refused before any data is read. """ diff --git a/src/math_spec/model.py b/src/math_spec/model.py index 54a8845d..71e6b21d 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -182,7 +182,7 @@ class RelationBlock(_StrictBlock): ends: {key: line, values: {bus0: bus, bus1: bus}} connection: {key: [generator, bus]} - An operator walks the table in the direction the call names + An operator reads the table in the direction the call names (``over=``, ``into=``), joining on the other key columns; the declaration fixes no direction. The map itself is data, and arrives at bind time under the relation's name, one column per role. diff --git a/src/math_spec/program.py b/src/math_spec/program.py index 5601e02e..6d76a5c3 100644 --- a/src/math_spec/program.py +++ b/src/math_spec/program.py @@ -342,7 +342,7 @@ class Translate(Expression): ``offset`` is an integer, or the name of an integer parameter that does not depend on ``dimension`` and carries its sign in the values. - ``partition`` is a relation walked along ``dimension`` — its consumed + ``partition`` is a relation stepped along ``dimension`` — its consumed column is a key over that dimension, its produced columns are the group — and the translation then happens inside each group: the neighbour is the one before in the same group, the edge is the group's, and a wrap closes @@ -520,7 +520,7 @@ class Direction(NamedTuple): ``joined`` is the key roles the call did not name (every role, for a bare relation): the join keys on them, and a value role left unnamed is not read. For a partition (``shift``, ``sum_back``, ``position``) ``consumed`` - is the key role over the dimension walked and ``produced`` the value roles + is the key role over the dimension stepped along and ``produced`` the value roles that make the group, which are the ones ``within=`` named. """ @@ -1213,7 +1213,7 @@ class DimensionPositionNode: Both sides are integers, negative counting from the end. With a ``partition`` the position is counted within each group the relation makes, - walked as :class:`Translate` walks one: its consumed column is the key + read as :class:`Translate` reads one: its consumed column is the key column over ``name``, the group is its produced columns, and its joined columns are the other key columns, whose dimensions the frame carries. """ diff --git a/src/math_spec/resolution.py b/src/math_spec/resolution.py index 26aea6c2..741bca02 100644 --- a/src/math_spec/resolution.py +++ b/src/math_spec/resolution.py @@ -709,9 +709,9 @@ def _known_roles(self, name: str, call: str, roles: tuple[str, ...] | None, kwar return True def _partition_direction( - self, name: str, operator: str, walked_dim: str | None, within_roles: tuple[str, ...] + self, name: str, operator: str, along_dim: str | None, within_roles: tuple[str, ...] ) -> Direction | None: - """Which direction a partition (``shift``, ``sum_back``, ``position``) reads relation *name* in along *walked_dim*. + """Which direction a partition (``shift``, ``sum_back``, ``position``) reads relation *name* in along *along_dim*. It takes the one key column over that dimension (a key has one column per dimension), joins on the other key columns and groups by the value @@ -722,7 +722,7 @@ def _partition_direction( context = self.context shape = self.ns.shape_of(name) call = f'{operator}(by={name})' - if walked_dim is None or not self._known_roles(name, call, within_roles, 'within'): + if along_dim is None or not self._known_roles(name, call, within_roles, 'within'): return None if not shape.values: self.errors.append( @@ -731,10 +731,10 @@ def _partition_direction( f'values:, leaving key: the column {operator} steps along.' ) return None - over_keys = [r for r in shape.key if shape.dim(r) == walked_dim] + over_keys = [r for r in shape.key if shape.dim(r) == along_dim] if not over_keys: self.errors.append( - f"{context}: {call}: '{name}' has no key column over '{walked_dim}' — its key is " + f"{context}: {call}: '{name}' has no key column over '{along_dim}' — its key is " f'{list(shape.key)} — and a partition steps along a key column over the dimension it groups.' ) return None @@ -744,9 +744,9 @@ def _partition_direction( f'value columns — its value columns are {list(shape.values)}.' ) return None - (walked,) = over_keys - joined = tuple(r for r in shape.key if r != walked) - return Direction(shape, (walked,), within_roles, joined) + (along,) = over_keys + joined = tuple(r for r in shape.key if r != along) + return Direction(shape, (along,), within_roles, joined) def _not_a_relation(self, name: str, operator: str, key: str) -> str | None: """Why *name* is not a relation; ``None`` where it is one.""" diff --git a/src/math_spec/typesetting/walk.py b/src/math_spec/typesetting/walk.py index a877f218..b2e40177 100644 --- a/src/math_spec/typesetting/walk.py +++ b/src/math_spec/typesetting/walk.py @@ -984,7 +984,7 @@ def position_notes(self, noticed: Noticed) -> list[str]: 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.mono("shift")} steps along, not the order labels sort in {dash} counted from ' f'{self.format.math("0")}. The index itself stays the coordinate, so {index} compares against ' f'labels and {place} against positions.' ) diff --git a/tests/test_dimensions.py b/tests/test_dimensions.py index 252e2d5f..e4dd5130 100644 --- a/tests/test_dimensions.py +++ b/tests/test_dimensions.py @@ -22,8 +22,8 @@ #: `fixtures.DISPATCH_MODEL` plus buses: a dim rule is mostly about an #: expression carrying a dim its frame does not, which needs three dims to #: state. `snap_bus` is over `snapshot` so it can partition the axis the -#: translations walk; `spinup` and `horizon` are the named amount that obeys -#: the position rules and the one that spans the axis walked; `bus_lead` is +#: translations step along; `spinup` and `horizon` are the named amount that +#: obeys the position rules and the one that spans that axis; `bus_lead` is #: over a dim `p` does not carry, so it is readable only through a `by=`. BASE = { 'dimensions': { @@ -111,12 +111,12 @@ def namespace() -> Namespace: pytest.param( 'sum(p, by=gen_zone, over=generator, into=zone)', {'snapshot', 'zone'}, - id='a-two-key-relation-consumes-the-key-it-walks-and-keeps-the-other', + id='a-two-key-relation-consumes-the-key-it-names-and-keeps-the-other', ), pytest.param( 'sum(p, by=gen_zone, over=snapshot, into=zone)', {'generator', 'zone'}, - id='the-same-table-walked-along-its-other-key', + id='the-same-table-read-along-its-other-key', ), pytest.param( 'at(zone_load, by=gen_zone, into=generator, over=zone)', @@ -166,7 +166,7 @@ def namespace() -> Namespace: pytest.param( 'sum(p, by=gen_bz, into=bus, over=generator)', {'snapshot', 'bus'}, - id='a-value-column-not-walked-is-not-read', + id='a-value-column-not-named-is-not-read', ), pytest.param( 'sum(p, by=gen_bz, over=generator, into=bus)', @@ -240,8 +240,8 @@ def test_a_bare_name_reaches_the_variable_a_dual_the_same_named_constraint(): ), pytest.param( "shift(p, along=snapshot, offset=horizon, edge='wrap')", - r'varies along the axis it walks is a permutation rather than a lag', - id='a-named-offset-does-not-span-the-axis-it-walks', + r'varies over the axis it steps along is a permutation rather than a lag', + id='a-named-offset-does-not-span-the-axis-it-steps-along', ), pytest.param( 'sum_back(p, along=snapshot, window=cost)', diff --git a/tests/test_lowering.py b/tests/test_lowering.py index 39ff79c0..58f85e3f 100644 --- a/tests/test_lowering.py +++ b/tests/test_lowering.py @@ -83,7 +83,7 @@ 'constraints': {'c': {'dims': [], 'expression': 'sum(p, over=g) >= 1'}}, } -#: `lk` as `sum` walks it: key consumed, value produced, nothing joined. +#: `lk` as `sum` reads it: key consumed, value produced, nothing joined. LK = RelationDeclaration('lk', (('g', 'g'), ('h', 'h')), ('g',)) LK2 = RelationDeclaration('lk2', (('g', 'g'), ('z', 'z')), ('g',)) LK_DIRECTION = Direction(LK, ('g',), ('h',), ()) @@ -418,7 +418,7 @@ def test_a_power_lowers_to_a_node_of_its_own(dispatch_schema): pytest.param( 'at(r, by=lk, over=h, into=g)', At(Variable('r'), direction=Direction(LK, ('h',), ('g',), ())), - id='a-pullback-walks-the-same-table-back', + id='a-pullback-reads-the-same-table-back', ), pytest.param( "shift(p, along=g, offset=1, edge='wrap')", @@ -511,8 +511,8 @@ def _partition_of(row): return partition -def test_a_relation_lowers_with_the_walk_each_call_takes(): - """Every node reading a relation carries its columns, its key and the walk, so a consumer joins on the right columns.""" +def test_a_relation_lowers_with_the_direction_each_call_names(): + """Every node reading a relation carries its columns, its key and the direction, so a consumer joins on the right columns.""" program = to_program( { 'dimensions': {'snapshot': {'dtype': 'int'}, 'generator': {}, 'zone': {}}, @@ -557,10 +557,10 @@ def test_a_relation_lowers_with_the_walk_each_call_takes(): ('zone',), ('snapshot',), 'zone_of', - ), 'the dims a consumer reads are read off the walk' + ), 'the dims a consumer reads are read off the direction' assert program.constraints['history'].lhs == GroupSum( Variable('p'), direction=Direction(declared, ('snapshot',), ('zone',), ('generator',)) - ), 'the same table walked from its other key column' + ), 'the same table read from its other key column' priced = program.constraints['priced'].rhs assert priced == At(Parameter('price'), direction=Direction(declared, ('zone',), ('generator',), ('snapshot',))), ( 'and its adjoint consumes the value column and produces the key column' diff --git a/tests/test_validation.py b/tests/test_validation.py index 9dc1b166..3af61dc5 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -793,7 +793,7 @@ class TestRulesDecidedWithoutData: pytest.param( {'objective': {'expression': 'sum(sum(q, by=lk, over=h, into=g))'}}, ("this sum lands on the key ['g']", 'that is a read, which is', 'at(..., by=lk'), - id='a-sum-that-walks-to-the-key-is-a-read', + id='a-sum-that-lands-on-the-key-is-a-read', ), pytest.param( { @@ -977,7 +977,7 @@ class TestRulesDecidedWithoutData: 'objective': {'expression': 'sum(sum(q, by=bare, over=k, into=m))'}, }, ("joins 'bare' on ['h'] through more than one column",), - id='a-walk-joining-one-dimension-through-two-columns', + id='a-call-joining-one-dimension-through-two-columns', ), pytest.param( { diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index feb37d75..abeef120 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -63,7 +63,7 @@ \noindent $t \ominus^{\mathrm{relation}(t)} k$ denotes a translation counted inside the group a relation puts $t$ in (\texttt{shift(by=relation)}), 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{relation}(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}(t)$ denotes where index $t$ sits along its dimension's own order --- the order \texttt{shift} steps along, not the order labels sort in --- counted from $0$. The index itself stays the coordinate, so $t$ compares against labels and $\mathrm{pos}(t)$ against positions. \noindent $\mathrm{pos}_{\mathrm{relation}(t)}(t)$ counts within the group a relation puts $t$ in: the subscript names the map, $\mathcal{T}_{\mathrm{relation}(t)}$ is the group it lands in, and that group has a first position of its own. diff --git a/tests/typesetting/golden/markdown.out b/tests/typesetting/golden/markdown.out index 09439aee..c4759172 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -62,7 +62,7 @@ $`t \boxminus_{v} k`$ denotes translation with $`v`$ standing where index $`t-k` $`t \ominus^{\mathrm{relation}(t)} k`$ denotes a translation counted inside the group a relation puts $`t`$ in (`shift(by=relation)`), 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{relation}(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}(t)`$ denotes where index $`t`$ sits along its dimension's own order — the order `shift` steps along, not the order labels sort in — counted from $`0`$. The index itself stays the coordinate, so $`t`$ compares against labels and $`\mathrm{pos}(t)`$ against positions. $`\mathrm{pos}_{\mathrm{relation}(t)}(t)`$ counts within the group a relation puts $`t`$ in: the subscript names the map, $`\mathcal{T}_{\mathrm{relation}(t)}`$ is the group it lands in, and that group has a first position of its own. diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index 3a437390..ef848e20 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -51,7 +51,7 @@ $t minus.square_(v) k$ denotes translation with $v$ standing where index $t-k$ l $t minus.o^(upright("relation")(t)) k$ denotes a translation counted inside the group a relation puts $t$ in (`shift(by=relation)`), 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("relation")(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")(t)$ denotes where index $t$ sits along its dimension's own order --- the order `shift` steps along, not the order labels sort in --- counted from $0$. The index itself stays the coordinate, so $t$ compares against labels and $upright("pos")(t)$ against positions. $upright("pos")_(upright("relation")(t))(t)$ counts within the group a relation puts $t$ in: the subscript names the map, $cal(T)_(upright("relation")(t))$ is the group it lands in, and that group has a first position of its own. diff --git a/tests/typesetting/test_walk.py b/tests/typesetting/test_walk.py index 1fa637a9..c1c4cc34 100644 --- a/tests/typesetting/test_walk.py +++ b/tests/typesetting/test_walk.py @@ -632,12 +632,12 @@ def test_every_operator_probe_renders(path, name: FormatName, fmt: Format): # --------------------------------------------------------------------------- -# a grouped sum's domain — every column the walk fixes, and no column it does not +# a grouped sum's domain — every column the direction fixes, and no column it does not # --------------------------------------------------------------------------- -#: Two tables a walk can leave a value column unread. A walk between -#: `gen_zone`'s two key columns reads no value column at all, and a walk that +#: Two tables a call can leave a value column unread. A sum between +#: `gen_zone`'s two key columns reads no value column at all, and a sum that #: consumes one of `gen_bt`'s two value columns leaves the other one. UNREAD = { 'dimensions': { @@ -655,7 +655,7 @@ def test_every_operator_probe_renders(path, name: FormatName, fmt: Format): 'variables': { 'p': {'dims': ['snapshot', 'generator']}, 'f': {'dims': ['generator', 'bus']}, - # indexed by the key column the walk consumes alone, so the column it lands on is one it brings + # indexed by the key column the sum consumes alone, so the column it lands on is one it brings 'u': {'dims': ['generator']}, }, } @@ -667,13 +667,13 @@ def _grouped(dims: list[str], expression: str) -> str: return next(line for line in to_latex(model, legend=False).splitlines() if line.startswith(r'\text{c}')) -def test_a_walk_that_reads_no_value_column_asks_only_that_the_key_has_a_row(): +def test_a_sum_that_reads_no_value_column_asks_only_that_the_key_has_a_row(): """`sum(u, by=gen_zone, over=generator, into=snapshot)` died with `KeyError: 'zone'`. The domain was written as a whole row of the table, which needs an index - for every column, and this walk goes between the two key columns: it reads + for every column, and this sum goes between the two key columns: it reads no value column, so there is no index to write in `zone`'s place. What the - walk asks of the table is that the key it walks between has a row at all. + sum asks of the table is that the key it reads between has a row at all. """ row = _grouped(['snapshot'], 'sum(u, by=gen_zone, over=generator, into=snapshot) <= cap') assert r'\sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_zone}(g,\ t) \text{ is defined}}' in row, ( @@ -681,12 +681,12 @@ def test_a_walk_that_reads_no_value_column_asks_only_that_the_key_has_a_row(): ) -def test_a_value_column_the_walk_consumes_is_a_condition_like_a_produced_one(): +def test_a_value_column_the_call_consumes_is_a_condition_like_a_produced_one(): """`sum(f, by=gen_bt, over=[generator, bus], into=technology)` bound `b` and then said nothing about it, so the sum ran over every bus rather than over the one the table puts each generator on. - The conditions were written per *produced* column. A walk fixes a value + The conditions were written per *produced* column. A call fixes a value column by consuming it too, and either way the column is one lookup at the key. """ @@ -694,7 +694,7 @@ def test_a_value_column_the_walk_consumes_is_a_condition_like_a_produced_one(): assert ( r'\sum_{g \in \mathcal{G},\ b \in \mathcal{B} \,:\, ' r'\mathrm{gen\_bt.bus}(g) = b \wedge \mathrm{gen\_bt.technology}(g) = e}' - ) in row, 'both columns the walk touches are read, in the order the table declares them' + ) in row, 'both columns the call touches are read, in the order the table declares them' # ---------------------------------------------------------------------------