From 4421a79ac487b6d2264478b24e3cbaccf4b42f7b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 22:42:21 +0000 Subject: [PATCH 1/3] feat(program): a frame holds axes, and the axis a join opens names the relation column it stands for Axis(dimension, column) is a position in a frame: the dimension whose labels it runs over, and the Column(relation, name) a join opened it for, or None for a dimension's own axis. Sum.over and JoinColumns.axes hold axes, and dims_of returns a frame of them. A declaration keeps its dims as strings, and frame_of is where the two meet. The dotted name a join axis had is gone, and so is the side table separability kept to map an axis back to its dimension. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PHNF149r8B4tGmGUKmAdUd --- docs/about/relations-as-linear-maps.md | 7 +- docs/contributing.md | 2 +- src/math_spec/_expression_resolver.py | 9 +-- src/math_spec/_where_resolver.py | 11 ++-- src/math_spec/dimensions.py | 89 +++++++++++++++++--------- src/math_spec/lowering.py | 3 +- src/math_spec/piecewise.py | 8 +-- src/math_spec/program.py | 66 ++++++++++++++----- src/math_spec/separability.py | 15 ++--- src/math_spec/typesetting/walk.py | 2 +- tests/test_dimensions.py | 44 +++++++------ tests/test_expansion.py | 4 +- tests/test_lowering.py | 36 +++++++---- tests/typesetting/test_golden.py | 6 +- 14 files changed, 190 insertions(+), 112 deletions(-) diff --git a/docs/about/relations-as-linear-maps.md b/docs/about/relations-as-linear-maps.md index 40cbc9c7..16c68604 100644 --- a/docs/about/relations-as-linear-maps.md +++ b/docs/about/relations-as-linear-maps.md @@ -133,9 +133,10 @@ $`x`$ over generators and $`y`$ over buses, which is why the program lowers `at` to a `Join` node and `sum(by=)` to the same `Join` under a `Sum`. The join keeps the column it sums away as an axis -named for the relation's column, `gen_zone.generator`, and the `Sum` stands -over that axis. So a map into its own dimension, which drops and adds one -dimension, still has two axes between the join and the sum. A bare relation has +of its own, `Axis('generator', Column('gen_zone', 'generator'))`: it runs over +`generator` and stands for the relation's column. The `Sum` stands over that +axis. So a map into its own dimension, which drops and adds one dimension, +still has two axes between the join and the sum. A bare relation has the same matrix without the functional claim. A column of $`M`$ may hold several ones, so the sum fans out and no group is one row. That is why `at` through a bare relation is refused. diff --git a/docs/contributing.md b/docs/contributing.md index 56f33171..f9013b99 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -109,7 +109,7 @@ to two nodes, so the file's spelling cannot decide the name. | File verb | Node | What the node names | | ------------------ | ------------------- | ---------------------------------------------------- | -| `sum(over=)` | `Sum` | dims removed from the result | +| `sum(over=)` | `Sum` | axes removed from the result | | `sum(by=)` | `Sum` over a `Join` | a join, and the sum over the axes it opens | | `at(by=)` | `Join` | a join whose groups are one row, with no sum over it | | `shift(along=)` | `Translate` | a re-index along one dimension | diff --git a/src/math_spec/_expression_resolver.py b/src/math_spec/_expression_resolver.py index a6be8467..a1f051c3 100644 --- a/src/math_spec/_expression_resolver.py +++ b/src/math_spec/_expression_resolver.py @@ -44,6 +44,7 @@ ) from math_spec.program import ( Add, + Axis, Constant, Divide, Dual, @@ -295,14 +296,14 @@ def _sum( if over is None: return self._bare_sum(operand) assert not isinstance(over, ColumnsNode), 'over=relation[column] is read only beside by=' - return Sum(operand, over) + return Sum(operand, tuple(Axis(d) for d in over)) assert over is not None, 'the call shape requires over= beside by=' grouping = self._grouping(columns, over) if grouping is None: return None join, plain = grouping grouped = Sum(Join(operand, join), join.axes) - return Sum(grouped, plain) if plain else grouped + return Sum(grouped, tuple(Axis(d) for d in plain)) if plain else grouped def _at(self, operand: Expression, columns: ColumnsNode) -> Expression | None: """``at(x, by=relation[column])``: *operand* read at the value of *columns* each row of the relation holds.""" @@ -311,7 +312,7 @@ def _at(self, operand: Expression, columns: ColumnsNode) -> Expression | None: except DimensionError as e: self.errors.append(str(e)) return None - join = self.lookup(columns, inner) + join = self.lookup(columns, frozenset(axis.dimension for axis in inner)) return None if join is None else Join(operand, join) def _translation( @@ -356,7 +357,7 @@ def _bare_sum(self, operand: Expression) -> Expression | None: f'scalar. Drop the sum.' ) return None - return Sum(operand, tuple(sorted(inner))) + return Sum(operand, tuple(sorted(inner, key=str))) def _edge_fits(self, operand: Expression, offset: int | str, *, wrap: bool, fill: float | None) -> bool: """What a ``shift``'s ``edge=`` may say, and where saying nothing is an answer. diff --git a/src/math_spec/_where_resolver.py b/src/math_spec/_where_resolver.py index c03c74ff..2b74b9c5 100644 --- a/src/math_spec/_where_resolver.py +++ b/src/math_spec/_where_resolver.py @@ -34,12 +34,13 @@ UnresolvedPredicateCallNode, UnresolvedWhereNode, ) -from math_spec.dimensions import dims_of, join_dims +from math_spec.dimensions import dims_of, frame_of, join_dims from math_spec.errors import DimensionError, LanguageError, did_you_mean, prefixed from math_spec.expansion import expand from math_spec.program import ( Add, And, + Axis, BooleanLiteral, Constant, CountComparison, @@ -231,11 +232,11 @@ def _pulled_back(self, node: UnresolvedPredicateCallNode, mask: Mask) -> Predica if by is None: return node try: - dims = join_dims(by, mask.dims, context, 'the predicate') + dims = join_dims(by, frame_of(mask.dims), context, 'the predicate') except DimensionError as refusal: self.errors.append(str(refusal)) return node - return PulledBackPredicate(mask, by, tuple(sorted(dims))) + return PulledBackPredicate(mask, by, tuple(sorted(axis.dimension for axis in dims))) def _count(self, node: UnresolvedCountNode) -> Predicate | UnresolvedWhereNode: """``count(, over=) `` — how many coordinates the predicate admits. @@ -352,7 +353,7 @@ def _expression_comparison(self, node: UnresolvedComparisonNode) -> ExpressionCo if len(self.errors) > found: return node assert len(sides) == 2, 'a side of a where builds or refuses, since a where holds no formal' - dims: set[str] = set() + dims: set[Axis] = set() for side in sides: if carries_variable(side): self.errors.append( @@ -380,7 +381,7 @@ def _expression_comparison(self, node: UnresolvedComparisonNode) -> ExpressionCo f'the comparison.' ) return node - return ExpressionComparison(left, node.op, right, tuple(d for d in ns.schema.dimensions if d in dims)) + return ExpressionComparison(left, node.op, right, tuple(d for d in ns.schema.dimensions if Axis(d) in dims)) def _position( self, call: FunctionCallNode, node: UnresolvedComparisonNode diff --git a/src/math_spec/dimensions.py b/src/math_spec/dimensions.py index 247f8e65..2458b906 100644 --- a/src/math_spec/dimensions.py +++ b/src/math_spec/dimensions.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -"""Static dim-set checking — a type system whose type is a set of dim names. +"""Static dim-set checking — a type system whose type is a set of axes. Every node's dim set is computable before any data is bound, so this pass runs at load on the resolved tree. The per-node rules are the "Dim algebra" table in @@ -18,6 +18,7 @@ from math_spec.operators import AMOUNTS from math_spec.program import ( Add, + Axis, Cases, Constant, CountComparison, @@ -52,12 +53,33 @@ ) if TYPE_CHECKING: + from collections.abc import Iterable + from math_spec.model import Spec from math_spec.program import Program -def dims_of(node: Expression, schema: Spec, context: str) -> frozenset[str]: - """The dim set of a resolved expression, checking every rule on the way. +def frame_of(dims: Iterable[str]) -> frozenset[Axis]: + """The axes of a declared frame: each dimension's own, and no join's.""" + return frozenset(Axis(d) for d in dims) + + +def _names(frame: Iterable[Axis]) -> list[str]: + """A frame as a refusal prints it: each axis as the file writes it, sorted.""" + return sorted(str(axis) for axis in frame) + + +def _dimensions(frame: frozenset[Axis]) -> frozenset[str]: + """The dimensions whose own axes *frame* holds, which is what a join matches the operand on.""" + return frozenset(axis.dimension for axis in frame if axis.column is None) + + +def dims_of(node: Expression, schema: Spec, context: str) -> frozenset[Axis]: + """The frame of a resolved expression, checking every rule on the way. + + A declaration's own frame holds each dimension's axis. A frame inside a + sum through a relation also holds the axes its join opens, which that sum + takes away. Raises: DimensionError: On the first rule broken. @@ -66,13 +88,13 @@ def dims_of(node: Expression, schema: Spec, context: str) -> frozenset[str]: return frozenset() if isinstance(node, Parameter): - return frozenset(schema.parameters[node.name].dims) + return frame_of(schema.parameters[node.name].dims) if isinstance(node, Variable): - return frozenset(schema.variables[node.name].dims) + return frame_of(schema.variables[node.name].dims) if isinstance(node, Dual): - return frozenset(schema.constraints[node.constraint].dims) + return frame_of(schema.constraints[node.constraint].dims) if isinstance(node, Named): return _named_dims(node, schema, context) @@ -94,32 +116,32 @@ def dims_of(node: Expression, schema: Spec, context: str) -> frozenset[str]: assert_never(node) -def _named_dims(node: Named, schema: Spec, context: str) -> frozenset[str]: +def _named_dims(node: Named, schema: Spec, context: str) -> frozenset[Axis]: """A cased entry's declared frame rather than the union of its arms — a narrower arm broadcasts — and a plain entry's body.""" declared = schema.expressions[node.name].dims if declared is not None: - return frozenset(declared) + return frame_of(declared) return dims_of(node.body, schema, context) -def _not_carried(context: str, call: str, inner: frozenset[str], rewrite: str) -> str: +def _not_carried(context: str, call: str, inner: frozenset[Axis], rewrite: str) -> str: """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'{context}: {call} but the expression has dims {_names(inner)}. An operator over a dim the ' f'operand does not carry is a no-op that builds and solves wrong — {rewrite}.' ) -def _sum_dims(node: Sum, inner: frozenset[str], context: str) -> frozenset[str]: - """``sum`` reduces each named dim away, so the operand carries every one.""" +def _sum_dims(node: Sum, inner: frozenset[Axis], context: str) -> frozenset[Axis]: + """``sum`` reduces each axis in ``over`` away, so the operand carries every one.""" for summed in node.over: if summed not in inner: raise DimensionError(_not_carried(context, f'sum(over={summed})', inner, 'drop the sum, or fix the dim')) return inner - frozenset(node.over) -def join_dims(columns: JoinColumns, inner: frozenset[str], context: str, operand: str) -> frozenset[str]: - """The dims *inner* has once *columns* joins it, an expression's or a predicate's alike. +def join_dims(columns: JoinColumns, inner: frozenset[Axis], context: str, operand: str) -> frozenset[Axis]: + """The frame *inner* has once *columns* joins it, an expression's or a predicate's alike. The dims joined on go, the dims grouped by arrive, and each column joined on and not grouped by opens its own axis (:attr:`JoinColumns.axes`), which @@ -134,18 +156,19 @@ def join_dims(columns: JoinColumns, inner: frozenset[str], context: str, operand lookup = columns.one_row_per_group named = columns.dropped if lookup else columns.added call = f'{"at" if lookup else "sum"}(by={columns.name}[{", ".join(named)}])' - if missing := sorted(set(columns.dropped_dims) - inner): + carried = _dimensions(inner) + if missing := sorted(set(columns.dropped_dims) - carried): if lookup: raise DimensionError( f'{context}: {call} joins on {missing}, which {operand} does not carry (dims ' - f'{sorted(inner)}). A lookup joins the operand on the columns it reads at — ' + f'{_names(inner)}). A lookup joins the operand on the columns it reads at — ' f'sum is the call that groups by them.' ) raise DimensionError( _not_carried(context, f'{call} joins on {missing} to sum it away,', inner, 'drop the sum, or fix the dim') ) added, dropped = set(columns.added_dims), set(columns.dropped_dims) - if clash := sorted((added & inner) - dropped): + if clash := sorted((added & carried) - dropped): raise DimensionError( f'{context}: {call} groups by {clash}, which the expression already carries.\n' f'A join on a column the operand carries matches it rather than grouping by it, so a ' @@ -153,17 +176,19 @@ def join_dims(columns: JoinColumns, inner: frozenset[str], context: str, operand f'or group by a column over another dimension.' ) _check_joined(call, columns, inner, context) - return (inner - set(columns.joined_dims)) | set(columns.grouped_dims) | set(columns.axes) + return (inner - frame_of(columns.joined_dims)) | frame_of(columns.grouped_dims) | frozenset(columns.axes) #: The verb a file writes each translation with, which its refusals quote. _VERBS: dict[type[Translate | WindowSum], str] = {Translate: 'shift', WindowSum: 'sum_back'} -def _translation_dims(node: Translate | WindowSum, inner: frozenset[str], schema: Spec, context: str) -> frozenset[str]: - """``shift`` and ``sum_back`` keep every dim, and a named amount and a partition are checked here.""" +def _translation_dims( + node: Translate | WindowSum, inner: frozenset[Axis], schema: Spec, context: str +) -> frozenset[Axis]: + """``shift`` and ``sum_back`` keep every axis, and a named amount and a partition are checked here.""" verb = _VERBS[type(node)] - if node.along not in inner: + if Axis(node.along) not in inner: raise DimensionError( _not_carried( context, @@ -179,7 +204,7 @@ def _translation_dims(node: Translate | WindowSum, inner: frozenset[str], schema return inner -def _check_joined(call: str, use: JoinColumns | Partition, inner: frozenset[str], context: str) -> None: +def _check_joined(call: str, use: JoinColumns | Partition, inner: frozenset[Axis], context: str) -> None: """The columns a call joins on are matched at their dimensions, so the operand carries every one, each once. Two joined columns over one dimension would match the operand's one @@ -188,10 +213,10 @@ def _check_joined(call: str, use: JoinColumns | Partition, inner: frozenset[str] step along. """ dims = use.joined_dims - if missing := sorted(set(dims) - inner): + if missing := sorted(set(dims) - _dimensions(inner)): raise DimensionError( f'{context}: {call} joins on {missing} (columns {[r for r in use.joined if use.dim(r) in missing]} ' - f"of '{use.name}'), which the expression does not carry (dims {sorted(inner)}). A join matches " + f"of '{use.name}'), which the expression does not carry (dims {_names(inner)}). A join matches " f'the operand on every key column the call does not name — index the operand by them, or ' f'name them in the call.' ) @@ -204,7 +229,7 @@ def _check_joined(call: str, use: JoinColumns | Partition, inner: frozenset[str] def _check_named_amount( - node: Translate | WindowSum, verb: str, inner: frozenset[str], schema: Spec, context: str + node: Translate | WindowSum, verb: str, inner: frozenset[Axis], schema: Spec, context: str ) -> None: """The two rules of an ``offset=`` or ``window=`` naming a parameter that need the operand's dims; resolution holds it to its dtype.""" kwarg, amount = ('offset', node.offset) if isinstance(node, Translate) else ('window', node.width) @@ -222,11 +247,11 @@ def _check_named_amount( groups = ( frozenset(node.partition.dim(v) for v in node.partition.grouped) if node.partition is not None else frozenset() ) - if stray := sorted(frozenset(declared.dims) - inner - groups): + if stray := sorted(frozenset(declared.dims) - _dimensions(inner) - groups): raise DimensionError( f'{context}: {verb}({kwarg}={amount}) reads its {words.noun} at the coordinate it ' f"steps from, but '{amount}' 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'(dims {_names(inner)}). A dim the coordinate does not have is no coordinate at all — ' f"declare '{amount}' 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}.' ) @@ -274,8 +299,8 @@ def check_schema(schema: Spec, program: Program) -> None: context = f"Constraint '{cname}'" _check_where_dims(constraint.where, frame, context) got = dims_of(constraint.lhs, schema, context) | dims_of(constraint.rhs, schema, context) - if got != frame: - stray, missing = sorted(got - frame), sorted(frame - got) + if got != frame_of(frame): + stray, missing = _names(got - frame_of(frame)), _names(frame_of(frame) - got) detail = ( f'carries dims {stray} that are not in its dims: {sorted(frame)} — every ' f'stray dim multiplies the rows this constraint builds; add it to ' @@ -292,7 +317,7 @@ def check_schema(schema: Spec, program: Program) -> None: got = dims_of(program.objective.expression, schema, context) if got: raise DimensionError( - f'{context}: the expression carries dims {sorted(got)}, and an objective is one ' + f'{context}: the expression carries dims {_names(got)}, and an objective is one ' f'number. Wrap each additive term in its own sum(): ' f'`sum(p * cost) + sum(p_nom * capex)`.' ) @@ -305,9 +330,9 @@ def _check_value_dims(node: Expression, schema: Spec, frame: frozenset[str], con the second answer a ``dims:`` exists to avoid. """ got = dims_of(node, schema, context) - if not got <= frame: + if not got <= frame_of(frame): raise DimensionError( - f'{context}: the value carries dims {sorted(got - frame)} outside the dims: ' + f'{context}: the value carries dims {_names(got - frame_of(frame))} outside the dims: ' f'{sorted(frame)}. A case is a value within the frame — it cannot widen it.' ) diff --git a/src/math_spec/lowering.py b/src/math_spec/lowering.py index d637b712..10b042a9 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -23,6 +23,7 @@ from math_spec.piecewise import assumptions_of, curve_frame, lp_domain_refusal, resolve_links from math_spec.program import ( Assumption, + Axis, BooleanLiteral, Cases, Constant, @@ -232,7 +233,7 @@ def _frame_of(name: str, entry: Named, schema: Spec) -> tuple[str, ...]: if isinstance(entry.body, Cases): return tuple(schema.expressions[name].dims or ()) carried = dims_of(entry.body, schema, f"Named expression '{name}'") - return tuple(d for d in schema.dimensions if d in carried) + return tuple(d for d in schema.dimensions if Axis(d) in carried) def _bound(value: float | str) -> Constant | Parameter: diff --git a/src/math_spec/piecewise.py b/src/math_spec/piecewise.py index 7bd122b9..8991a683 100644 --- a/src/math_spec/piecewise.py +++ b/src/math_spec/piecewise.py @@ -18,10 +18,10 @@ from typing import TYPE_CHECKING, Literal import math_spec.sos as sos -from math_spec.dimensions import dims_of +from math_spec.dimensions import dims_of, frame_of from math_spec.errors import DimensionError from math_spec.model import AssumptionBlock, Curvature, PiecewiseBlock, Spec -from math_spec.program import PiecewiseDeclaration, PiecewiseMethod, carries_variable +from math_spec.program import Axis, PiecewiseDeclaration, PiecewiseMethod, carries_variable from math_spec.resolution import resolve_expression_text if TYPE_CHECKING: @@ -299,10 +299,10 @@ def curve_frame(schema: Spec, name: str, pw: PiecewiseBlock, links: Iterable[Exp context = f"piecewise '{name}'" carried = [(f'link {i} expression', dims_of(node, schema, f'{context} link {i}')) for i, node in enumerate(links)] if pw.activity is not None: - carried.append(('activity', frozenset(schema.variables[pw.activity].dims))) + carried.append(('activity', frame_of(schema.variables[pw.activity].dims))) frame: list[str] = [] for what, found in carried: - for d in (d for d in schema.dimensions if d in found): + for d in (d for d in schema.dimensions if Axis(d) in found): if d == pw.over: raise DimensionError(f"{context}: {what} already carries the breakpoint dim '{pw.over}'") if d not in frame: diff --git a/src/math_spec/program.py b/src/math_spec/program.py index c57db92e..3accf49f 100644 --- a/src/math_spec/program.py +++ b/src/math_spec/program.py @@ -41,8 +41,10 @@ 'Add', 'And', 'Assumption', + 'Axis', 'BooleanLiteral', 'Cases', + 'Column', 'Connective', 'Constant', 'ConstraintDeclaration', @@ -257,19 +259,50 @@ class Divide: divisor: Expression +@dataclass(frozen=True) +class Column: + """One column of a relation: the relation's name, and the column's name in its declaration.""" + + relation: str + name: str + + def __str__(self) -> str: + """The column as a file writes it, ``relation[column]``.""" + return f'{self.relation}[{self.name}]' + + +@dataclass(frozen=True) +class Axis: + """A position in a frame: the dimension whose labels it runs over, and the relation column it stands for. + + A declared dimension is the axis ``Axis(dimension)``. A :class:`Join` + opens ``Axis(dimension, column)`` for each column it drops, so a column + dropped and a column added over one dimension are two axes of one frame. + Two axes are equal where both fields are, so a frame holds a dimension's + own axis at most once however many join axes run over the same labels. + """ + + dimension: str + column: Column | None = None + + def __str__(self) -> str: + """The dimension's name, or the column's ``relation[column]`` for a join's axis.""" + return self.dimension if self.column is None else str(self.column) + + @dataclass(frozen=True) class Sum: - """Sum ``operand`` over the named dims, removing them from the result. + """Sum ``operand`` over the axes in ``over``, removing them from the result. - A name in ``over`` is a dimension, or one of the axes a :class:`Join` - under it opens (:attr:`JoinColumns.axes`): ``sum(x, over=a, - by=relation[b])`` lowers to a ``Sum`` over a ``Join``, over the axis of - each column the join does not group by. The join is the join, and this node is the - group-by that follows it. + An axis in ``over`` is a dimension's own, or one a :class:`Join` under it + opens (:attr:`JoinColumns.axes`): ``sum(x, over=a, by=relation[b])`` + lowers to a ``Sum`` over a ``Join``, over the axis of each column the join + does not group by. The join is the join, and this node is the group-by + that follows it. """ operand: Expression - over: tuple[str, ...] + over: tuple[Axis, ...] @dataclass(frozen=True) @@ -278,9 +311,9 @@ class Join: The operand carries every dim joined on. The result has the operand's dims, less the dims joined on, plus the dims grouped by, plus one axis - per column joined on and not grouped by (:attr:`JoinColumns.axes`), named - for the relation's column and not for its dimension. So a column dropped - and a column added over one dimension stay two axes. A :class:`Sum` over + per column joined on and not grouped by (:attr:`JoinColumns.axes`), which + stands for the relation's column and runs over its dimension. So a column + dropped and a column added over one dimension stay two axes. A :class:`Sum` over those axes is the group-by that follows the join, which is how ``sum(x, over=a, by=relation[b])`` lowers. Where the grouped columns hold the relation's whole key, they determine every other column, the @@ -573,16 +606,17 @@ def one_row_per_group(self) -> bool: return set(self.relation.key) <= set(self.grouped) @property - def axes(self) -> tuple[str, ...]: - """The axis the join opens for each column it drops, ``relation.column``, empty where each group is one row. + def axes(self) -> tuple[Axis, ...]: + """The axis the join opens for each column it drops, empty where each group is one row. - A dimension's name holds no dot, so an axis never meets one. A column - the grouped columns determine opens none: in a lookup they hold the - key, and the key determines every column. + Each runs over the column's dimension and stands for the column, so it + never equals that dimension's own axis. A column the grouped columns + determine opens none: in a lookup they hold the key, and the key + determines every column. """ if self.one_row_per_group: return () - return tuple(f'{self.name}.{role}' for role in self.dropped) + return tuple(Axis(self.dim(role), Column(self.name, role)) for role in self.dropped) @dataclass(frozen=True) diff --git a/src/math_spec/separability.py b/src/math_spec/separability.py index 672aec4b..fa1d0537 100644 --- a/src/math_spec/separability.py +++ b/src/math_spec/separability.py @@ -97,22 +97,21 @@ def waits_on(dimension: str, label: str, name: str, kind: Literal['offset', 'par if isinstance(node, Cases): masks.extend(region.when for region in node.regions) elif isinstance(node, Sum): - join = node.operand.columns if isinstance(node.operand, Join) and node.operand.columns.axes else None - grouped = dict(zip(join.axes, join.dropped_dims, strict=True)) if join is not None else {} - for name in node.over: - if join is not None and name in grouped: + for axis in node.over: + if axis.column is not None: + assert isinstance(node.operand, Join), 'a join axis is opened by the join the sum stands over' report( 'coupled', - grouped[name], + axis.dimension, label, - f'groups {grouped[name]} into {", ".join(join.added_dims)} — window that dimension instead, or cut only at the group edges', + f'groups {axis.dimension} into {", ".join(node.operand.columns.added_dims)} — window that dimension instead, or cut only at the group edges', ) elif reductions_couple: report( 'coupled', - name, + axis.dimension, label, - f'sums over {name} — a rolling sum_back(window=n) windows, a total over the horizon does not', + f'sums over {axis.dimension} — a rolling sum_back(window=n) windows, a total over the horizon does not', ) elif isinstance(node, Join) and not node.columns.axes: for dimension in node.columns.dropped_dims: diff --git a/src/math_spec/typesetting/walk.py b/src/math_spec/typesetting/walk.py index 30509dd4..3f90620c 100644 --- a/src/math_spec/typesetting/walk.py +++ b/src/math_spec/typesetting/walk.py @@ -403,7 +403,7 @@ def _sum(self, node: Sum, ctx: _Context) -> tuple[str, int]: return self._grouped_sum(node.operand, ctx) memberships = [] inner = ctx - for d in self._sorted(frozenset(node.over)): + for d in self._sorted(frozenset(axis.dimension for axis in node.over)): dummy, inner = inner.reducing(d) memberships.append(self._membership(d, dummy)) domain = self.format.joined(memberships, '') diff --git a/tests/test_dimensions.py b/tests/test_dimensions.py index 867d4947..65847414 100644 --- a/tests/test_dimensions.py +++ b/tests/test_dimensions.py @@ -10,9 +10,9 @@ import pytest -from math_spec.dimensions import DimensionError, _check_where_dims, dims_of +from math_spec.dimensions import DimensionError, _check_where_dims, dims_of, frame_of from math_spec.errors import SchemaError -from math_spec.program import Join, Mask, RelationPairComparison, Sum +from math_spec.program import Axis, Column, Join, Mask, RelationPairComparison, Sum from math_spec.resolution import Namespace from math_spec.validation import to_spec from tests.fixtures import expression_of, override, schema_of, where_of @@ -67,7 +67,7 @@ def _schema(**overrides) -> Spec: return schema_of(BASE, **overrides) -def _dims(expr: str) -> frozenset[str]: +def _dims(expr: str) -> frozenset[Axis]: s = _schema() return dims_of(expression_of(expr, Namespace(s), 't'), s, 't') @@ -194,10 +194,10 @@ def namespace() -> Namespace: ], ) def test_dim_inference(expr, expected): - assert _dims(expr) == expected + assert _dims(expr) == frame_of(expected) -def _dims_with(expr: str, **overrides) -> frozenset[str]: +def _dims_with(expr: str, **overrides) -> frozenset[Axis]: s = _schema(**overrides) return dims_of(expression_of(expr, Namespace(s), 't'), s, 't') @@ -219,7 +219,7 @@ def _dims_with(expr: str, **overrides) -> frozenset[str]: ) def test_a_bare_relation_is_summed_between_its_key_columns(expr, expected): """A bare relation holds no value column, so the column a sum groups by is a key column.""" - assert _dims_with(expr, **{'relations.connection': {'key': ['generator', 'bus']}}) == expected + assert _dims_with(expr, **{'relations.connection': {'key': ['generator', 'bus']}}) == frame_of(expected) def test_a_lookup_carries_the_whole_key_and_what_the_operand_brings_beside_it(): @@ -229,42 +229,50 @@ def test_a_lookup_carries_the_whole_key_and_what_the_operand_brings_beside_it(): operand's `snapshot` is neither joined on nor part of the key, and the result keeps it. """ - assert _dims('at(zone_load, by=gen_bz[zone])') == {'generator', 'snapshot'} + assert _dims('at(zone_load, by=gen_bz[zone])') == frame_of({'generator', 'snapshot'}) def test_a_join_opens_an_axis_for_the_column_it_drops_and_the_sum_over_it_closes_it(): """A map into its own dimension drops and adds one dimension, so the join names the dropped column for the relation. Named for its dimension, the column the join drops and the column it - groups by are one name, and the sum over the join takes away the dim the - row keeps. + groups by were one name, and the sum over the join took away the dim the + row keeps. The dropped column's axis runs over `snapshot` and stands for + `rep_of[snapshot]`, so it is not the dimension's own axis. """ s = _schema() node = expression_of('sum(p, over=snapshot, by=rep_of[rep])', Namespace(s), 't') assert isinstance(node, Sum) and isinstance(node.operand, Join) - assert node.over == ('rep_of.snapshot',), 'the sum stands over the axis the join opens' - assert dims_of(node.operand, s, 't') == {'generator', 'snapshot', 'rep_of.snapshot'}, ( + opened = Axis('snapshot', Column('rep_of', 'snapshot')) + assert node.over == (opened,), 'the sum stands over the axis the join opens' + assert dims_of(node.operand, s, 't') == frame_of({'generator', 'snapshot'}) | {opened}, ( 'the join keeps the dropped column beside the dimension it groups by' ) - assert dims_of(node, s, 't') == {'generator', 'snapshot'}, 'and the sum over it leaves the frame the row keeps' + assert dims_of(node, s, 't') == frame_of({'generator', 'snapshot'}), ( + 'and the sum over it leaves the frame the row keeps' + ) def test_a_sum_joins_on_a_key_column_and_a_value_column_together(): """The columns a sum joins on and sums away are not one kind: it needs one key column, and may name a value column beside it.""" - assert _dims('sum(p * load, over=[generator, bus], by=gen_bz[zone])') == {'snapshot', 'zone'} + assert _dims('sum(p * load, over=[generator, bus], by=gen_bz[zone])') == frame_of({'snapshot', 'zone'}) def test_a_dual_carries_the_constraints_own_frame(): """`dual(c)` is a row dual at every coordinate of the constraint's declared `dims`.""" s = _schema() - assert _dims_with('dual(balance)') == frozenset(s.constraints['balance'].dims) == {'snapshot', 'bus'} + assert _dims_with('dual(balance)') == frame_of(s.constraints['balance'].dims) == frame_of({'snapshot', 'bus'}) def test_a_bare_name_reaches_the_variable_a_dual_the_same_named_constraint(): """Constraints sit outside the flat namespace, so only `dual()` reads the constraint store — a bare name never does, even one a constraint shares (#74).""" shadowing = {'variables.balance': {'dims': ['snapshot'], 'bounds': {'lower': 0}}} - assert _dims_with('balance', **shadowing) == {'snapshot'}, 'a bare name resolves to the variable of that name' - assert _dims_with('dual(balance)', **shadowing) == {'snapshot', 'bus'}, 'dual() alone reaches the constraint' + assert _dims_with('balance', **shadowing) == frame_of({'snapshot'}), ( + 'a bare name resolves to the variable of that name' + ) + assert _dims_with('dual(balance)', **shadowing) == frame_of({'snapshot', 'bus'}), ( + 'dual() alone reaches the constraint' + ) @pytest.mark.parametrize( @@ -414,7 +422,7 @@ def test_a_lookup_joins_on_the_key_columns_the_operand_carries_and_the_read_does call names only the column it reads now, so the key's `snapshot` arrives and the result is `load` read at `rep(t, g)` for every `(t, g)`. """ - assert _dims_with(expr, **{'relations.gen_zone': relation}) == frozenset(expected), ( + assert _dims_with(expr, **{'relations.gen_zone': relation}) == frame_of(expected), ( 'the frame is the operand less the dim read, plus the key' ) @@ -424,7 +432,7 @@ def test_an_outer_product_is_legal_and_carries_both_dim_sets(): piecewise epigraph, which multiplies a per-segment slope by a per-snapshot variable on purpose. The guard is the constraint rule below: the *frame* has to declare the result.""" - assert _dims('cost + load') == {'generator', 'snapshot', 'bus'}, ( + assert _dims('cost + load') == frame_of({'generator', 'snapshot', 'bus'}), ( 'a binary operator unions its two sides rather than requiring one to contain the other' ) diff --git a/tests/test_expansion.py b/tests/test_expansion.py index bd6f889d..c4af2539 100644 --- a/tests/test_expansion.py +++ b/tests/test_expansion.py @@ -14,7 +14,7 @@ from math_spec import to_spec from math_spec.errors import LanguageError from math_spec.expansion import parse_and_expand -from math_spec.program import Multiply, Named, Parameter, Sum, Translate, Variable +from math_spec.program import Axis, Multiply, Named, Parameter, Sum, Translate, Variable from math_spec.resolution import Namespace from tests.fixtures import DISPATCH_MODEL, SMALL_MODEL, comparison_of, expression_of, schema_of @@ -127,7 +127,7 @@ def test_a_call_expands_to_core_ast(expressions, macros, call, want): def test_a_named_expression_arrives_under_the_node_carrying_its_name(): ns = Namespace(schema(expressions={'gen_cost': 'p * cost'})) resolved = expression_of('sum(gen_cost, over=generator)', ns, 'e') - assert resolved == Sum(Named('gen_cost', Multiply(Variable('p'), Parameter('cost'))), ('generator',)), ( + assert resolved == Sum(Named('gen_cost', Multiply(Variable('p'), Parameter('cost'))), (Axis('generator'),)), ( 'the body is inlined resolved and the name kept, for the typesetter to define it once' ) assert isinstance(resolved, Sum) diff --git a/tests/test_lowering.py b/tests/test_lowering.py index 95d06912..cf274326 100644 --- a/tests/test_lowering.py +++ b/tests/test_lowering.py @@ -23,8 +23,10 @@ Add, And, Assumption, + Axis, BooleanLiteral, Cases, + Column, Constant, CountComparison, DimensionComparison, @@ -143,13 +145,13 @@ def test_program_structure(dispatch_program): ((cname, c),) = dispatch_program.constraints.items() assert cname == 'power_balance' assert c.dims == ('snapshot',), 'the frame is the dims, in the order the file wrote it' - assert c.lhs == Sum(Variable('dispatch'), ('generator',)) + assert c.lhs == Sum(Variable('dispatch'), (Axis('generator'),)) assert c.sense == '==', "the comparison crosses as the file's own operator, untranslated" assert c.rhs == Parameter('load') assert dispatch_program.objective.sense == 'minimize', "the program carries the language's spelling, untranslated" assert dispatch_program.objective.expression == Sum( - Multiply(Variable('dispatch'), Parameter('cost')), ('generator', 'snapshot') + Multiply(Variable('dispatch'), Parameter('cost')), (Axis('generator'), Axis('snapshot')) ), 'the objective carries the sum the file wrote, over the dims it named none of' @@ -571,11 +573,15 @@ def test_a_power_resolves_to_a_node_of_its_own(dispatch_schema): @pytest.mark.parametrize( ('expression', 'expected'), [ - pytest.param('sum(q)', Sum(Variable('q'), ('g', 'h')), id='a-bare-sum-sums-away-every-dim-the-operand-carries'), - pytest.param('sum(q, over=h)', Sum(Variable('q'), ('h',)), id='an-over-sums-away-the-dim-it-names'), + pytest.param( + 'sum(q)', + Sum(Variable('q'), (Axis('g'), Axis('h'))), + id='a-bare-sum-sums-away-every-dim-the-operand-carries', + ), + pytest.param('sum(q, over=h)', Sum(Variable('q'), (Axis('h'),)), id='an-over-sums-away-the-dim-it-names'), pytest.param( 'sum(p, over=g, by=lk[h])', - Sum(Join(Variable('p'), LK_JOIN), ('lk.g',)), + Sum(Join(Variable('p'), LK_JOIN), (Axis('g', Column('lk', 'g')),)), id='a-grouped-sum-is-a-sum-over-the-axis-its-join-opens', ), pytest.param( @@ -714,7 +720,7 @@ def test_a_relation_lowers_with_the_join_each_call_names(): assert program.relations == {'zone_of': declared}, 'the relation sits once in the program, under its name' zonal = program.constraints['zonal'].lhs columns = JoinColumns('zone_of', declared, ('generator', 'snapshot'), ('zone', 'snapshot')) - assert zonal == Sum(Join(Variable('p'), columns), ('zone_of.generator',)), ( + assert zonal == Sum(Join(Variable('p'), columns), (Axis('generator', Column('zone_of', 'generator')),)), ( 'a grouped sum is a sum over a join: the join names the column over the over= dim and the unnamed key ' 'column as joined on, the by= column and that key column as grouped by, and the sum stands over the axis ' 'the join opens for the column it drops' @@ -730,7 +736,7 @@ def test_a_relation_lowers_with_the_join_each_call_names(): ) assert program.constraints['history'].lhs == Sum( Join(Variable('p'), JoinColumns('zone_of', declared, ('snapshot', 'generator'), ('zone', 'generator'))), - ('zone_of.snapshot',), + (Axis('snapshot', Column('zone_of', 'snapshot')),), ), 'the same table joined on its other key column' priced = program.constraints['priced'].rhs assert priced == Join( @@ -767,7 +773,7 @@ def test_a_divisor_under_a_join_is_still_named(): looked_up = Join(quotient, JoinColumns('component_of', component_of, ('component',), ('flow',))) assert divisor_parameters(looked_up) == frozenset({'rate'}), 'the walk descends through `Join`' - assert divisor_parameters(Sum(looked_up, ('flow',))) == frozenset({'rate'}), 'and through a `Sum` over it' + assert divisor_parameters(Sum(looked_up, (Axis('flow'),))) == frozenset({'rate'}), 'and through a `Sum` over it' def test_a_divisor_under_a_power_is_still_named(): @@ -784,12 +790,12 @@ def test_a_quotient_is_found_whole_so_its_two_halves_stay_paired(): left = Divide(Variable('x'), Parameter('rate')) right = Divide(Variable('y'), Parameter('loss')) - found = quotients(Sum(Add(left, right), ('flow',))) + found = quotients(Sum(Add(left, right), (Axis('flow'),))) assert [(variables_of(q.numerator), q.divisor) for q in found] == [ (frozenset({'x'}), Parameter('rate')), (frozenset({'y'}), Parameter('loss')), ], 'each quotient keeps its own numerator, in the order the expression writes them' - assert divisor_parameters(Sum(Add(left, right), ('flow',))) == frozenset({'rate', 'loss'}), ( + assert divisor_parameters(Sum(Add(left, right), (Axis('flow'),))) == frozenset({'rate', 'loss'}), ( 'the flat answer is still the union of the same walk' ) @@ -838,14 +844,16 @@ def test_walk_is_the_node_column_of_walk_regions(): Multiply(Variable('p'), Parameter('c')): 'one-to-one', Power(Parameter('c'), Constant(2.0)): 'one-to-one', Divide(Variable('p'), Parameter('c')): 'one-to-one', - Sum(Variable('p'), ('g',)): 'many-to-one', - Sum(Join(Variable('p'), JoinColumns('at_bus', AT_BUS, ('g',), ('bus',))), ('at_bus.g',)): 'many-to-one', + Sum(Variable('p'), (Axis('g'),)): 'many-to-one', + Sum( + Join(Variable('p'), JoinColumns('at_bus', AT_BUS, ('g',), ('bus',))), (Axis('g', Column('at_bus', 'g')),) + ): 'many-to-one', Join(Variable('p'), JoinColumns('at_bus', AT_BUS, ('bus',), ('g',))): 'one-to-one', Translate(Variable('p'), 't', offset=1, wrap=False, fill=0.0): 'one-to-one', WindowSum(Variable('p'), 't', width=2, wrap=False): 'one-to-many', Cases((Region(Mask(ParameterDefined('c', ('g',))), Variable('p')),)): 'one-to-one', Dual('balance'): 'one-to-one', - Named('total', Sum(Variable('p'), ('g',))): 'many-to-one', + Named('total', Sum(Variable('p'), (Axis('g'),))): 'many-to-one', } @@ -864,7 +872,7 @@ def test_a_node_answers_its_fan_in(node, expected): def test_fan_in_reads_through_a_named_expression(): """`fan_in` on a tree holding a `Named` ended in `assert_never`.""" - named = Named('total', Sum(Variable('p'), ('g',))) + named = Named('total', Sum(Variable('p'), (Axis('g'),))) assert fan_in(named) == 'many-to-one', 'a use of an entry fans in as the entry does' diff --git a/tests/typesetting/test_golden.py b/tests/typesetting/test_golden.py index dab7d07b..ffb9ed8d 100644 --- a/tests/typesetting/test_golden.py +++ b/tests/typesetting/test_golden.py @@ -148,11 +148,11 @@ def _rendered_trees() -> Iterator[object]: #: A dataclass the walk steps *through* rather than renders: a region has no #: branch of its own — its ``when`` and ``value`` do — the columns a join -#: names and the relation it reads are the facts a node carries rather than -#: nodes, and a ``Mask`` is the wrapper a leaf carries a predicate in. None is a +#: names, the axes a sum is over and the relation it reads are the facts a +#: node carries rather than nodes, and a ``Mask`` is the wrapper a leaf carries a predicate in. None is a #: member of any node union, so they are subtracted from what the tree walk #: finds rather than added to what the vocabulary declares. -CARRIERS = {'Region', 'JoinColumns', 'Mask', 'Partition', 'RelationDeclaration'} +CARRIERS = {'Axis', 'Column', 'Region', 'JoinColumns', 'Mask', 'Partition', 'RelationDeclaration'} def test_the_golden_model_carries_every_node_kind_the_walk_renders(): From 8aed330773a40fed30623934668134b365188f92 Mon Sep 17 00:00:00 2001 From: Felix <117816358+FBumann@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:28:48 +0200 Subject: [PATCH 2/3] chore(program): a frame stays a set of dimension names, and only the sum over a join reads the axes it opens (#799) The axes a join opens are closed by the sum over that join, which the resolver always builds with exactly `join.axes`. So `dims_of` reads a sum over a join as one rule, and no frame holds a join's axis. `Axis` and `Column` stay in `Sum.over` and `JoinColumns.axes`, where consumers read them. `frame_of`, `_names`, `_dimensions` and each `Axis(d) in frame` membership test go. Claude-Session: https://claude.ai/code/session_01CPwrjirBZhVS4CcndN6f4H Co-authored-by: Claude --- src/mathspec/_expression_resolver.py | 4 +- src/mathspec/_where_resolver.py | 11 ++- src/mathspec/composition.py | 2 +- src/mathspec/dimensions.py | 122 ++++++++++++--------------- src/mathspec/lowering.py | 3 +- src/mathspec/piecewise.py | 8 +- tests/test_dimensions.py | 47 ++++------- 7 files changed, 83 insertions(+), 114 deletions(-) diff --git a/src/mathspec/_expression_resolver.py b/src/mathspec/_expression_resolver.py index 17325f26..560776d8 100644 --- a/src/mathspec/_expression_resolver.py +++ b/src/mathspec/_expression_resolver.py @@ -312,7 +312,7 @@ def _at(self, operand: Expression, columns: ColumnsNode) -> Expression | None: except DimensionError as e: self.errors.append(str(e)) return None - join = self.lookup(columns, frozenset(axis.dimension for axis in inner)) + join = self.lookup(columns, inner) return None if join is None else Join(operand, join) def _translation( @@ -357,7 +357,7 @@ def _bare_sum(self, operand: Expression) -> Expression | None: f'scalar. Drop the sum.' ) return None - return Sum(operand, tuple(sorted(inner, key=str))) + return Sum(operand, tuple(Axis(d) for d in sorted(inner))) def _edge_fits(self, operand: Expression, offset: int | str, *, wrap: bool, fill: float | None) -> bool: """What a ``shift``'s ``edge=`` may say, and where saying nothing is an answer. diff --git a/src/mathspec/_where_resolver.py b/src/mathspec/_where_resolver.py index 4c709c9a..c9903667 100644 --- a/src/mathspec/_where_resolver.py +++ b/src/mathspec/_where_resolver.py @@ -34,13 +34,12 @@ UnresolvedPredicateCallNode, UnresolvedWhereNode, ) -from mathspec.dimensions import dims_of, frame_of, join_dims +from mathspec.dimensions import dims_of, join_dims from mathspec.errors import DimensionError, LanguageError, did_you_mean, prefixed from mathspec.expansion import expand from mathspec.program import ( Add, And, - Axis, BooleanLiteral, Constant, CountComparison, @@ -232,11 +231,11 @@ def _pulled_back(self, node: UnresolvedPredicateCallNode, mask: Mask) -> Predica if by is None: return node try: - dims = join_dims(by, frame_of(mask.dims), context, 'the predicate') + dims = join_dims(by, mask.dims, context, 'the predicate') except DimensionError as refusal: self.errors.append(str(refusal)) return node - return PulledBackPredicate(mask, by, tuple(sorted(axis.dimension for axis in dims))) + return PulledBackPredicate(mask, by, tuple(sorted(dims))) def _count(self, node: UnresolvedCountNode) -> Predicate | UnresolvedWhereNode: """``count(, over=) `` — how many coordinates the predicate admits. @@ -353,7 +352,7 @@ def _expression_comparison(self, node: UnresolvedComparisonNode) -> ExpressionCo if len(self.errors) > found: return node assert len(sides) == 2, 'a side of a where builds or refuses, since a where holds no formal' - dims: set[Axis] = set() + dims: set[str] = set() for side in sides: if carries_variable(side): self.errors.append( @@ -381,7 +380,7 @@ def _expression_comparison(self, node: UnresolvedComparisonNode) -> ExpressionCo f'the comparison.' ) return node - return ExpressionComparison(left, node.op, right, tuple(d for d in ns.schema.dimensions if Axis(d) in dims)) + return ExpressionComparison(left, node.op, right, tuple(d for d in ns.schema.dimensions if d in dims)) def _position( self, call: FunctionCallNode, node: UnresolvedComparisonNode diff --git a/src/mathspec/composition.py b/src/mathspec/composition.py index e06fdca4..26c341ca 100644 --- a/src/mathspec/composition.py +++ b/src/mathspec/composition.py @@ -502,7 +502,7 @@ def _definer_frame(loaded: Mapping[str, Spec], key: str) -> frozenset[str]: frame |= set(spec.program.expressions[key].dims) given = spec.program.given.expressions.get(key) if given is not None and given.term is not None: - frame |= {str(axis) for axis in dims_of(given.term, spec, f"Given expression '{key}'")} + frame |= dims_of(given.term, spec, f"Given expression '{key}'") return frozenset(frame) diff --git a/src/mathspec/dimensions.py b/src/mathspec/dimensions.py index 31f893fb..64b176f3 100644 --- a/src/mathspec/dimensions.py +++ b/src/mathspec/dimensions.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -"""Static dim-set checking — a type system whose type is a set of axes. +"""Static dim-set checking — a type system whose type is a set of dim names. Every node's dim set is computable before any data is attached, so this pass runs at load on the resolved tree. The per-node rules are the "Dim algebra" table in @@ -18,7 +18,6 @@ from mathspec.operators import AMOUNTS from mathspec.program import ( Add, - Axis, Cases, Constant, CountComparison, @@ -54,33 +53,12 @@ from mathspec.spec import empty_sums if TYPE_CHECKING: - from collections.abc import Iterable - from mathspec.program import Program from mathspec.spec import Spec -def frame_of(dims: Iterable[str]) -> frozenset[Axis]: - """The axes of a declared frame: each dimension's own, and no join's.""" - return frozenset(Axis(d) for d in dims) - - -def _names(frame: Iterable[Axis]) -> list[str]: - """A frame as a refusal prints it: each axis as the file writes it, sorted.""" - return sorted(str(axis) for axis in frame) - - -def _dimensions(frame: frozenset[Axis]) -> frozenset[str]: - """The dimensions whose own axes *frame* holds, which is what a join matches the operand on.""" - return frozenset(axis.dimension for axis in frame if axis.column is None) - - -def dims_of(node: Expression, schema: Spec, context: str) -> frozenset[Axis]: - """The frame of a resolved expression, checking every rule on the way. - - A declaration's own frame holds each dimension's axis. A frame inside a - sum through a relation also holds the axes its join opens, which that sum - takes away. +def dims_of(node: Expression, schema: Spec, context: str) -> frozenset[str]: + """The dim set of a resolved expression, checking every rule on the way. Raises: DimensionError: On the first rule broken. @@ -89,14 +67,14 @@ def dims_of(node: Expression, schema: Spec, context: str) -> frozenset[Axis]: return frozenset() if isinstance(node, Parameter): - return frame_of({**schema.parameters, **schema.given.parameters}[node.name].dims) + return frozenset({**schema.parameters, **schema.given.parameters}[node.name].dims) if isinstance(node, Variable): columns = {**schema.variables, **schema.given.variables, **schema.given.expressions, **empty_sums(schema)} - return frame_of(columns[node.name].dims or ()) + return frozenset(columns[node.name].dims or ()) if isinstance(node, Dual): - return frame_of({**schema.constraints, **schema.given.constraints}[node.constraint].dims) + return frozenset({**schema.constraints, **schema.given.constraints}[node.constraint].dims) if isinstance(node, Named): return _named_dims(node, schema, context) @@ -107,10 +85,11 @@ def dims_of(node: Expression, schema: Spec, context: str) -> frozenset[Axis]: if isinstance(node, Negate | Add | Multiply | Power | Divide): return frozenset().union(*(dims_of(child, schema, context) for child in children(node))) - inner = dims_of(node.operand, schema, context) if isinstance(node, Sum): - return _sum_dims(node, inner, context) + return _sum_dims(node, schema, context) + inner = dims_of(node.operand, schema, context) if isinstance(node, Join): + assert not node.columns.axes, 'a join that opens axes is read only by the sum that closes them' return join_dims(node.columns, inner, context, 'the expression') if isinstance(node, Translate | WindowSum): return _translation_dims(node, inner, schema, context) @@ -118,37 +97,44 @@ def dims_of(node: Expression, schema: Spec, context: str) -> frozenset[Axis]: assert_never(node) -def _named_dims(node: Named, schema: Spec, context: str) -> frozenset[Axis]: +def _named_dims(node: Named, schema: Spec, context: str) -> frozenset[str]: """An entry's declared frame where it has one — a narrower arm or body broadcasts along the rest — else its body's.""" declared = schema.expressions[node.name].dims if declared is not None: - return frame_of(declared) + return frozenset(declared) return dims_of(node.body, schema, context) -def _not_carried(context: str, call: str, inner: frozenset[Axis], rewrite: str) -> str: +def _not_carried(context: str, call: str, inner: frozenset[str], rewrite: str) -> str: """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 {_names(inner)}. An operator over a dim the ' + 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}.' ) -def _sum_dims(node: Sum, inner: frozenset[Axis], context: str) -> frozenset[Axis]: - """``sum`` reduces each axis in ``over`` away, so the operand carries every one.""" +def _sum_dims(node: Sum, schema: Spec, context: str) -> frozenset[str]: + """``sum`` reduces each dim in ``over`` away, so the operand carries every one. + + The axes a join opens never reach a frame: the sum over the join closes + every one, so its frame is the join's frame less them. + """ + if isinstance(node.operand, Join) and node.operand.columns.axes: + join = node.operand + assert node.over == join.columns.axes, 'a sum through a relation closes exactly the axes its join opens' + return join_dims(join.columns, dims_of(join.operand, schema, context), context, 'the expression') + inner = dims_of(node.operand, schema, context) for summed in node.over: - if summed not in inner: + if summed.dimension not in inner: raise DimensionError(_not_carried(context, f'sum(over={summed})', inner, 'drop the sum, or fix the dim')) - return inner - frozenset(node.over) + return inner - {axis.dimension for axis in node.over} -def join_dims(columns: JoinColumns, inner: frozenset[Axis], context: str, operand: str) -> frozenset[Axis]: - """The frame *inner* has once *columns* joins it, an expression's or a predicate's alike. +def join_dims(columns: JoinColumns, inner: frozenset[str], context: str, operand: str) -> frozenset[str]: + """The dims *inner* has once *columns* joins it, an expression's or a predicate's alike. - The dims joined on go, the dims grouped by arrive, and each column joined - on and not grouped by opens its own axis ([`JoinColumns.axes`][mathspec.program.JoinColumns.axes]), which - the [`Sum`][mathspec.program.Sum] over the join takes away. A column - both joined on and grouped by keeps its dim. The call a refusal quotes is + The dims joined on go and the dims grouped by arrive. A column both + joined on and grouped by keeps its dim. The call a refusal quotes is ``at`` where each group is one row, and ``sum`` otherwise. Raises: @@ -158,19 +144,18 @@ def join_dims(columns: JoinColumns, inner: frozenset[Axis], context: str, operan lookup = columns.one_row_per_group named = columns.dropped if lookup else columns.added call = f'{"at" if lookup else "sum"}(by={columns.name}[{", ".join(named)}])' - carried = _dimensions(inner) - if missing := sorted(set(columns.dropped_dims) - carried): + if missing := sorted(set(columns.dropped_dims) - inner): if lookup: raise DimensionError( f'{context}: {call} joins on {missing}, which {operand} does not carry (dims ' - f'{_names(inner)}). A lookup joins the operand on the columns it reads at — ' + f'{sorted(inner)}). A lookup joins the operand on the columns it reads at — ' f'sum is the call that groups by them.' ) raise DimensionError( _not_carried(context, f'{call} joins on {missing} to sum it away,', inner, 'drop the sum, or fix the dim') ) added, dropped = set(columns.added_dims), set(columns.dropped_dims) - if clash := sorted((added & carried) - dropped): + if clash := sorted((added & inner) - dropped): raise DimensionError( f'{context}: {call} groups by {clash}, which the expression already carries.\n' f'A join on a column the operand carries matches it rather than grouping by it, so a ' @@ -178,19 +163,17 @@ def join_dims(columns: JoinColumns, inner: frozenset[Axis], context: str, operan f'or group by a column over another dimension.' ) _check_joined(call, columns, inner, context) - return (inner - frame_of(columns.joined_dims)) | frame_of(columns.grouped_dims) | frozenset(columns.axes) + return (inner - set(columns.joined_dims)) | set(columns.grouped_dims) #: The verb a file writes each translation with, which its refusals quote. _VERBS: dict[type[Translate | WindowSum], str] = {Translate: 'shift', WindowSum: 'sum_back'} -def _translation_dims( - node: Translate | WindowSum, inner: frozenset[Axis], schema: Spec, context: str -) -> frozenset[Axis]: - """``shift`` and ``sum_back`` keep every axis, and a named amount and a partition are checked here.""" +def _translation_dims(node: Translate | WindowSum, inner: frozenset[str], schema: Spec, context: str) -> frozenset[str]: + """``shift`` and ``sum_back`` keep every dim, and a named amount and a partition are checked here.""" verb = _VERBS[type(node)] - if Axis(node.along) not in inner: + if node.along not in inner: raise DimensionError( _not_carried( context, @@ -206,7 +189,7 @@ def _translation_dims( return inner -def _check_joined(call: str, use: JoinColumns | Partition, inner: frozenset[Axis], context: str) -> None: +def _check_joined(call: str, use: JoinColumns | Partition, inner: frozenset[str], context: str) -> None: """The columns a call joins on are matched at their dimensions, so the operand carries every one, each once. Two joined columns over one dimension would match the operand's one @@ -215,10 +198,10 @@ def _check_joined(call: str, use: JoinColumns | Partition, inner: frozenset[Axis step along. """ dims = use.joined_dims - if missing := sorted(set(dims) - _dimensions(inner)): + if missing := sorted(set(dims) - inner): raise DimensionError( f'{context}: {call} joins on {missing} (columns {[r for r in use.joined if use.dim(r) in missing]} ' - f"of '{use.name}'), which the expression does not carry (dims {_names(inner)}). A join matches " + f"of '{use.name}'), which the expression does not carry (dims {sorted(inner)}). A join matches " f'the operand on every key column the call does not name — index the operand by them, or ' f'name them in the call.' ) @@ -231,7 +214,7 @@ def _check_joined(call: str, use: JoinColumns | Partition, inner: frozenset[Axis def _check_named_amount( - node: Translate | WindowSum, verb: str, inner: frozenset[Axis], schema: Spec, context: str + node: Translate | WindowSum, verb: str, inner: frozenset[str], schema: Spec, context: str ) -> None: """The two rules of an ``offset=`` or ``window=`` naming a parameter that need the operand's dims; resolution holds it to its dtype.""" kwarg, amount = ('offset', node.offset) if isinstance(node, Translate) else ('window', node.width) @@ -249,11 +232,11 @@ def _check_named_amount( groups = ( frozenset(node.partition.dim(v) for v in node.partition.grouped) if node.partition is not None else frozenset() ) - if stray := sorted(frozenset(declared.dims) - _dimensions(inner) - groups): + if stray := sorted(frozenset(declared.dims) - inner - groups): raise DimensionError( f'{context}: {verb}({kwarg}={amount}) reads its {words.noun} at the coordinate it ' f"steps from, but '{amount}' varies over {stray}, which that coordinate does not carry " - f'(dims {_names(inner)}). A dim the coordinate does not have is no coordinate at all — ' + f'(dims {sorted(inner)}). A dim the coordinate does not have is no coordinate at all — ' f"declare '{amount}' 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}.' ) @@ -303,7 +286,7 @@ def check_schema(schema: Spec, program: Program) -> None: if given.term is None: continue context = f"Given expression '{gname}'" - if extra := _names(dims_of(given.term, schema, context) - frame_of(given.dims)): + if extra := sorted(dims_of(given.term, schema, context) - set(given.dims)): raise DimensionError( f'{context}: its term carries {extra}, which its dims {list(given.dims)} do not. A term is read ' f"over the frame the entry states: add {extra} to the entry's dims, or leave them out of the term." @@ -314,8 +297,8 @@ def check_schema(schema: Spec, program: Program) -> None: context = f"Constraint '{cname}'" _check_where_dims(constraint.where, frame, context) got = dims_of(constraint.lhs, schema, context) | dims_of(constraint.rhs, schema, context) - if got != frame_of(frame): - stray, missing = _names(got - frame_of(frame)), _names(frame_of(frame) - got) + if got != frame: + stray, missing = sorted(got - frame), sorted(frame - got) detail = ( f'carries dims {stray} that are not in its dims: {sorted(frame)} — every ' f'stray dim multiplies the rows this constraint builds; add it to ' @@ -332,7 +315,7 @@ def check_schema(schema: Spec, program: Program) -> None: got = dims_of(program.objective.expression, schema, context) if got: raise DimensionError( - f'{context}: the expression carries dims {_names(got)}, and an objective is one ' + f'{context}: the expression carries dims {sorted(got)}, and an objective is one ' f'number. Wrap each additive term in its own sum(): ' f'`sum(p * cost) + sum(p_nom * capex)`.' ) @@ -345,9 +328,9 @@ def _check_value_dims(node: Expression, schema: Spec, frame: frozenset[str], con the second answer a ``dims:`` exists to avoid. """ got = dims_of(node, schema, context) - if not got <= frame_of(frame): + if not got <= frame: raise DimensionError( - f'{context}: the value carries dims {_names(got - frame_of(frame))} outside the dims: ' + f'{context}: the value carries dims {sorted(got - frame)} outside the dims: ' f'{sorted(frame)}. A case is a value within the frame — it cannot widen it.' ) @@ -355,12 +338,11 @@ def _check_value_dims(node: Expression, schema: Spec, frame: frozenset[str], con def _check_body_dims(node: Expression, schema: Spec, frame: frozenset[str], context: str) -> None: """A plain entry's body may only carry dims its declared frame does; fewer is constant along the rest.""" got = dims_of(node, schema, context) - if not got <= frame_of(frame): - outside = _names(got - frame_of(frame)) + if not got <= frame: raise DimensionError( - f'{context}: the body carries dims {outside} outside the dims: {sorted(frame)}. ' + f'{context}: the body carries dims {sorted(got - frame)} outside the dims: {sorted(frame)}. ' f'The dims: are the frame the quantity is read over, and the body cannot widen it: add ' - f'{outside} to dims:, or take them out of the body.' + f'{sorted(got - frame)} to dims:, or take them out of the body.' ) diff --git a/src/mathspec/lowering.py b/src/mathspec/lowering.py index 9e59f38c..a826a297 100644 --- a/src/mathspec/lowering.py +++ b/src/mathspec/lowering.py @@ -23,7 +23,6 @@ from mathspec.piecewise import assumptions_of, curve_frame, lp_domain_refusal, resolve_links from mathspec.program import ( Assumption, - Axis, BooleanLiteral, Constant, ConstraintDeclaration, @@ -313,7 +312,7 @@ def _frame_of(name: str, entry: Named, schema: Spec) -> tuple[str, ...]: if declared is not None: return tuple(declared) carried = dims_of(entry.body, schema, f"Named expression '{name}'") - return tuple(d for d in schema.dimensions if Axis(d) in carried) + return tuple(d for d in schema.dimensions if d in carried) def _bound(value: float | str | None) -> Constant | Parameter | None: diff --git a/src/mathspec/piecewise.py b/src/mathspec/piecewise.py index dde9b137..e4dafb83 100644 --- a/src/mathspec/piecewise.py +++ b/src/mathspec/piecewise.py @@ -19,9 +19,9 @@ from typing import TYPE_CHECKING, Literal import mathspec.sos as sos -from mathspec.dimensions import dims_of, frame_of +from mathspec.dimensions import dims_of from mathspec.errors import DimensionError -from mathspec.program import Axis, PiecewiseDeclaration, PiecewiseMethod, VariableDeclaration, carries_variable +from mathspec.program import PiecewiseDeclaration, PiecewiseMethod, VariableDeclaration, carries_variable from mathspec.resolution import resolve_expression_text from mathspec.spec import AssumptionBlock, Curvature, PiecewiseBlock, Spec, VariableBlock @@ -322,10 +322,10 @@ def curve_frame(schema: Spec, name: str, pw: PiecewiseBlock, links: Iterable[Exp context = f"piecewise '{name}'" carried = [(f'link {i} expression', dims_of(node, schema, f'{context} link {i}')) for i, node in enumerate(links)] if pw.activity is not None: - carried.append(('activity', frame_of(schema.variables[pw.activity].dims))) + carried.append(('activity', frozenset(schema.variables[pw.activity].dims))) frame: list[str] = [] for what, found in carried: - for d in (d for d in schema.dimensions if Axis(d) in found): + for d in (d for d in schema.dimensions if d in found): if d == pw.over: raise DimensionError(f"{context}: {what} already carries the breakpoint dim '{pw.over}'") if d not in frame: diff --git a/tests/test_dimensions.py b/tests/test_dimensions.py index f6c6d8d0..96c8ab21 100644 --- a/tests/test_dimensions.py +++ b/tests/test_dimensions.py @@ -10,7 +10,7 @@ import pytest -from mathspec.dimensions import DimensionError, _check_where_dims, dims_of, frame_of +from mathspec.dimensions import DimensionError, _check_where_dims, dims_of from mathspec.errors import SchemaError from mathspec.program import Axis, Column, Join, Mask, RelationPairComparison, Sum from mathspec.resolution import Namespace @@ -67,7 +67,7 @@ def _schema(**overrides) -> Spec: return schema_of(BASE, **overrides) -def _dims(expr: str) -> frozenset[Axis]: +def _dims(expr: str) -> frozenset[str]: s = _schema() return dims_of(expression_of(expr, Namespace(s), 't'), s, 't') @@ -196,10 +196,10 @@ def namespace() -> Namespace: ], ) def test_dim_inference(expr, expected): - assert _dims(expr) == frame_of(expected) + assert _dims(expr) == expected -def _dims_with(expr: str, **overrides) -> frozenset[Axis]: +def _dims_with(expr: str, **overrides) -> frozenset[str]: s = _schema(**overrides) return dims_of(expression_of(expr, Namespace(s), 't'), s, 't') @@ -221,7 +221,7 @@ def _dims_with(expr: str, **overrides) -> frozenset[Axis]: ) def test_a_bare_relation_is_summed_between_its_key_columns(expr, expected): """A bare relation holds no value column, so the column a sum groups by is a key column.""" - assert _dims_with(expr, **{'relations.connection': {'key': ['generator', 'bus']}}) == frame_of(expected) + assert _dims_with(expr, **{'relations.connection': {'key': ['generator', 'bus']}}) == expected def test_a_lookup_carries_the_whole_key_and_what_the_operand_brings_beside_it(): @@ -231,50 +231,39 @@ def test_a_lookup_carries_the_whole_key_and_what_the_operand_brings_beside_it(): operand's `snapshot` is neither joined on nor part of the key, and the result keeps it. """ - assert _dims('at(zone_load, by=gen_bz[zone])') == frame_of({'generator', 'snapshot'}) + assert _dims('at(zone_load, by=gen_bz[zone])') == {'generator', 'snapshot'} def test_a_join_opens_an_axis_for_the_column_it_drops_and_the_sum_over_it_closes_it(): - """A map into its own dimension drops and adds one dimension, so the join names the dropped column for the relation. + """A map into its own dimension drops and adds one dimension, so the axis the join opens stands for the column. - Named for its dimension, the column the join drops and the column it - groups by were one name, and the sum over the join took away the dim the - row keeps. The dropped column's axis runs over `snapshot` and stands for - `rep_of[snapshot]`, so it is not the dimension's own axis. + The dropped column's axis runs over `snapshot` and stands for + `rep_of[snapshot]`, so it is not the dimension's own axis. The sum over + the join closes it, and the frame keeps the `snapshot` the row groups by. """ s = _schema() node = expression_of('sum(p, over=snapshot, by=rep_of[rep])', Namespace(s), 't') assert isinstance(node, Sum) and isinstance(node.operand, Join) - opened = Axis('snapshot', Column('rep_of', 'snapshot')) - assert node.over == (opened,), 'the sum stands over the axis the join opens' - assert dims_of(node.operand, s, 't') == frame_of({'generator', 'snapshot'}) | {opened}, ( - 'the join keeps the dropped column beside the dimension it groups by' - ) - assert dims_of(node, s, 't') == frame_of({'generator', 'snapshot'}), ( - 'and the sum over it leaves the frame the row keeps' - ) + assert node.over == (Axis('snapshot', Column('rep_of', 'snapshot')),), 'the sum stands over the axis the join opens' + assert dims_of(node, s, 't') == {'generator', 'snapshot'}, 'and the sum over it leaves the frame the row keeps' def test_a_sum_joins_on_a_key_column_and_a_value_column_together(): """The columns a sum joins on and sums away are not one kind: it needs one key column, and may name a value column beside it.""" - assert _dims('sum(p * load, over=[generator, bus], by=gen_bz[zone])') == frame_of({'snapshot', 'zone'}) + assert _dims('sum(p * load, over=[generator, bus], by=gen_bz[zone])') == {'snapshot', 'zone'} def test_a_dual_carries_the_constraints_own_frame(): """`dual(c)` is a row dual at every coordinate of the constraint's declared `dims`.""" s = _schema() - assert _dims_with('dual(balance)') == frame_of(s.constraints['balance'].dims) == frame_of({'snapshot', 'bus'}) + assert _dims_with('dual(balance)') == frozenset(s.constraints['balance'].dims) == {'snapshot', 'bus'} def test_a_bare_name_reaches_the_variable_a_dual_the_same_named_constraint(): """Constraints sit outside the flat namespace, so only `dual()` reads the constraint store — a bare name never does, even one a constraint shares (#74).""" shadowing = {'variables.balance': {'dims': ['snapshot'], 'bounds': {'lower': 0}}} - assert _dims_with('balance', **shadowing) == frame_of({'snapshot'}), ( - 'a bare name resolves to the variable of that name' - ) - assert _dims_with('dual(balance)', **shadowing) == frame_of({'snapshot', 'bus'}), ( - 'dual() alone reaches the constraint' - ) + assert _dims_with('balance', **shadowing) == {'snapshot'}, 'a bare name resolves to the variable of that name' + assert _dims_with('dual(balance)', **shadowing) == {'snapshot', 'bus'}, 'dual() alone reaches the constraint' @pytest.mark.parametrize( @@ -430,7 +419,7 @@ def test_a_lookup_joins_on_the_key_columns_the_operand_carries_and_the_read_does call names only the column it reads now, so the key's `snapshot` arrives and the result is `load` read at `rep(t, g)` for every `(t, g)`. """ - assert _dims_with(expr, **{'relations.gen_zone': relation}) == frame_of(expected), ( + assert _dims_with(expr, **{'relations.gen_zone': relation}) == frozenset(expected), ( 'the frame is the operand less the dim read, plus the key' ) @@ -440,7 +429,7 @@ def test_an_outer_product_is_legal_and_carries_both_dim_sets(): piecewise epigraph, which multiplies a per-segment slope by a per-snapshot variable on purpose. The guard is the constraint rule below: the *frame* has to declare the result.""" - assert _dims('cost + load') == frame_of({'generator', 'snapshot', 'bus'}), ( + assert _dims('cost + load') == {'generator', 'snapshot', 'bus'}, ( 'a binary operator unions its two sides rather than requiring one to contain the other' ) From 3dfafa6b88f97f3f2634e3983c8ef93f1480b0cd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 15:29:31 +0000 Subject: [PATCH 3/3] chore: the changelog line for #665 drops the frame, which #799 kept as dimension names Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CPwrjirBZhVS4CcndN6f4H --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32d660fc..ad5c7285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version -- feat(program): a frame holds axes, and the axis a join opens names the relation column it stands for ([#665](https://github.com/energy-models/mathspec/pull/665)) +- feat(program): the axis a join opens names the relation column it stands for ([#665](https://github.com/energy-models/mathspec/pull/665)) - feat(language): a call names the columns of a relation as relation[column], and a sum through a relation is a sum over the axes its join opens ([#664](https://github.com/energy-models/mathspec/pull/664)) - feat(language): a parameter value that is null or NaN is refused when the data is attached ([#788](https://github.com/energy-models/mathspec/pull/788)) - fix(language): a where string names several columns in at's over= and into=, as an expression does ([#782](https://github.com/energy-models/mathspec/pull/782))