diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f82fabe..79b9a7ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version +- feat(program): each expression node states the dimensions it is read over ([#899](https://github.com/energy-models/mathspec/pull/899)) - fix(language): two files that declare their dimensions in different orders load to the same program by sorting dimension lists by name ([#911](https://github.com/energy-models/mathspec/pull/911)) - refactor(language)!: a file names a condition under `conditions:` rather than `masks:` ([#907](https://github.com/energy-models/mathspec/pull/907)) - refactor(api)!: the docs, the error messages and the program classes call a true-or-false `where` expression a condition rather than a predicate ([#904](https://github.com/energy-models/mathspec/pull/904)) diff --git a/docs/reference/language/errors.md b/docs/reference/language/errors.md index aeacab11..6c29a8ad 100644 --- a/docs/reference/language/errors.md +++ b/docs/reference/language/errors.md @@ -57,6 +57,13 @@ variable with no constraint row. | `SchemaError` | `LanguageError` | Something in the file: an unknown key, a malformed entry, or a bad symbol table | | `DimensionError` | `LanguageError` | Dimensions that disagree, such as a constraint whose expression does not equal its `dims` | +A load reads every expression and `where:` in the file, and then lists each +fault it found, one to a line. The error is a `DimensionError` when every fault +breaks a dimension rule, and a `SchemaError` when any fault is of another kind. +After that, the load checks the rules on whole entries, such as a constraint +whose expression is not indexed over exactly its `dims:`. That `DimensionError` names the +first entry that breaks a rule. + You can reproduce each of these errors from the YAML alone. ## What the language will not express diff --git a/docs/reference/reading.md b/docs/reference/reading.md index 1a046f34..20883a7d 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -147,6 +147,19 @@ entry's expression, the same object that `program.expressions[name].expression` holds, and its value is the body's value. `children()` steps into the body, so a walk reads through it. +Every expression node has `dims`: the dimensions over which it is indexed, +sorted by name. A sum has the dimensions its operand has, +less the ones it sums over. A `Join` under a sum through a relation has the +dimensions of that sum. A `Join` also opens one `Axis` for each column it +drops, but an axis is not a dimension and is not in `dims`. An +`ExpressionReference` has the dimensions of its body. + +A node's `dims` are always sorted by name. The order of `dimensions:` and the +order of an entry's `dims:` do not change them. An entry that declares +`dims: [snapshot, generator]` has +`program.expressions[name].dims == ('snapshot', 'generator')`, and each +`ExpressionReference` to it has `dims == ('generator', 'snapshot')`. + A `ConditionReference` stands where a `conditions:` entry is read. Its `body` is the condition of the entry, the root of `program.conditions[name].condition`, and it is true where the body is true. `condition_children()` steps into the body, so `.atoms`, diff --git a/src/mathspec/_condition_resolver.py b/src/mathspec/_condition_resolver.py index c8d5ffa8..6ebce76d 100644 --- a/src/mathspec/_condition_resolver.py +++ b/src/mathspec/_condition_resolver.py @@ -25,7 +25,7 @@ UnresolvedConditionNode, UnresolvedCountNode, ) -from mathspec._dimensions import dims_of, join_dims +from mathspec._dimensions import join_dims from mathspec._expansion import expand from mathspec._expression_parser import ( ArithmeticNode, @@ -266,7 +266,7 @@ def _joined(self, node: UnresolvedConditionCallNode, mask: Mask) -> Condition | try: dims = join_dims(by, mask.dims, context, 'the condition') except DimensionError as refusal: - self.errors.append(str(refusal)) + self.errors.append(self.ns.refused_dims(refusal)) return node return JoinedCondition(mask, by, tuple(sorted(dims))) @@ -408,7 +408,7 @@ def _expression_comparison(self, node: UnresolvedComparisonNode) -> ExpressionCo else: try: degree.check_expression(side, context) - dims |= dims_of(side, ns.spec, context) + dims |= set(side.dims) except LanguageError as e: self.errors.append(str(e)) if len(self.errors) > found: diff --git a/src/mathspec/_dimensions.py b/src/mathspec/_dimensions.py index b9a1a3e5..ea43df1e 100644 --- a/src/mathspec/_dimensions.py +++ b/src/mathspec/_dimensions.py @@ -4,10 +4,12 @@ """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 -``docs/reference/language/expressions.md``; a constraint's two sides together -must equal its ``dims``, and a condition or a bound may not exceed the frame. +Every node states its dims, so every rule is decided at load. Resolution holds +each node to the rule of its operator as it builds the node ([`check_node`][]); +the rules are the "Dim algebra" table in +``docs/reference/language/expressions.md``. [`check_spec`][] holds each entry +to its own: a constraint's two sides together are indexed over exactly its +``dims``, and a where or a bound is indexed over no dim the entry is not. """ from __future__ import annotations @@ -16,28 +18,19 @@ from mathspec.errors import DimensionError, case_context from mathspec.program import ( - Add, Cases, - Constant, CountComparison, DimensionComparison, DimensionPosition, - Divide, - Dual, Expression, ExpressionComparison, - ExpressionReference, Join, JoinColumns, JoinedCondition, Mask, - Multiply, - Negate, ParameterComparison, ParameterDefined, - ParameterReference, Partition, - Power, RelationComparison, RelationDefined, RelationPairComparison, @@ -45,62 +38,32 @@ Translate, TranslatedCondition, VariableDefined, - VariableReference, WindowSum, - children, ) if TYPE_CHECKING: + from collections.abc import Iterable + from mathspec.program import Program from mathspec.spec import Spec -def dims_of(node: Expression, spec: Spec, context: str) -> frozenset[str]: - """The dim set of a resolved expression, checking every rule on the way. +def check_node(node: Sum | Join | Translate | WindowSum, spec: Spec, context: str) -> None: + """The dim rule *node*'s own operator holds it to, on the dims its operand states. + + Resolution calls it on each node it builds, so a tree is checked once, + bottom up, and no pass walks it again. Raises: - DimensionError: On the first rule broken. + DimensionError: *node* breaks the rule. """ - if isinstance(node, Constant): - return frozenset() - - if isinstance(node, ParameterReference): - return frozenset({**spec.parameters, **spec.given.parameters}[node.name].dims) - - if isinstance(node, VariableReference): - columns = {**spec.variables, **spec.given.variables, **spec.given.expressions} - return frozenset(columns[node.name].dims or ()) - - if isinstance(node, Dual): - return frozenset({**spec.constraints, **spec.given.constraints}[node.constraint].dims) - - if isinstance(node, ExpressionReference): - return _named_dims(node, spec, context) - - if isinstance(node, Cases): - return frozenset().union(*(dims_of(region.value, spec, context) for region in node.regions)) - - if isinstance(node, Negate | Add | Multiply | Power | Divide): - return frozenset().union(*(dims_of(child, spec, context) for child in children(node))) - + inner = frozenset(node.operand.dims) if isinstance(node, Sum): - return _sum_dims(node, spec, context) - inner = dims_of(node.operand, spec, 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, spec, context) - - assert_never(node) - - -def _named_dims(node: ExpressionReference, spec: Spec, context: str) -> frozenset[str]: - """The dims an entry declares where it declares them, else those of its expression.""" - declared = spec.expressions[node.name].dims - if declared is not None: - return frozenset(declared) - return dims_of(node.body, spec, context) + _check_sum(node, inner, context) + elif isinstance(node, Join): + join_dims(node.columns, inner, context, 'the expression') + else: + _check_translation(node, inner, spec, context) def _not_carried(context: str, call: str, inner: frozenset[str], rewrite: str) -> str: @@ -108,23 +71,20 @@ def _not_carried(context: str, call: str, inner: frozenset[str], rewrite: str) - return f'{context}: {call} but the expression has only the dims {sorted(inner)}. {rewrite}.' -def _sum_dims(node: Sum, spec: Spec, context: str) -> frozenset[str]: +def _check_sum(node: Sum, inner: frozenset[str], context: str) -> None: """``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. + every one, and the join's own rules are the sum's. """ 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, spec, context), context, 'the expression') - inner = dims_of(node.operand, spec, context) + assert node.over == node.operand.columns.axes, 'a sum through a relation closes exactly the axes its join opens' + return for summed in node.over: if summed.dimension not in inner: raise DimensionError( _not_carried(context, f'sum(over={summed})', inner, 'Remove the sum, or correct the dimension') ) - return inner - {axis.dimension for axis in node.over} def join_dims(columns: JoinColumns, inner: frozenset[str], context: str, operand: str) -> frozenset[str]: @@ -160,14 +120,24 @@ def join_dims(columns: JoinColumns, inner: frozenset[str], context: str, operand f'dimension.' ) _check_joined(call, columns, inner, context) - return (inner - set(columns.joined_dims)) | set(columns.grouped_dims) + return joined(columns, inner) + + +def sorted_dims(*dims: Iterable[str]) -> tuple[str, ...]: + """Every dim in *dims*, once, sorted by name: what a node states as ``dims``.""" + return tuple(sorted(set().union(*dims))) + + +def joined(columns: JoinColumns, inner: Iterable[str]) -> frozenset[str]: + """The dims a join on *columns* leaves of *inner*: the dims joined on go, and the dims grouped by arrive.""" + return (frozenset(inner) - set(columns.joined_dims)) | set(columns.grouped_dims) #: The operator name a file writes for each translation, which its refusals quote. _VERBS: dict[type[Translate | WindowSum], str] = {Translate: 'shift', WindowSum: 'sum_back'} -def _translation_dims(node: Translate | WindowSum, inner: frozenset[str], spec: Spec, context: str) -> frozenset[str]: +def _check_translation(node: Translate | WindowSum, inner: frozenset[str], spec: Spec, context: str) -> None: """``shift`` and ``sum_back`` keep every dim, and a named amount and a partition are checked here.""" verb = _VERBS[type(node)] if node.along not in inner: @@ -183,7 +153,6 @@ def _translation_dims(node: Translate | WindowSum, inner: frozenset[str], spec: if node.partition is not None: within = f'{node.partition.name}[{", ".join(node.partition.grouped)}]' _check_joined(f'{verb}(along={node.along}, within={within})', node.partition, inner, context) - return inner def _check_joined(call: str, use: JoinColumns | Partition, inner: frozenset[str], context: str) -> None: @@ -264,7 +233,7 @@ def check_spec(spec: Spec, program: Program) -> None: continue frame = frozenset(declared.dims) if not isinstance(entry.expression, Cases): - indexed = _check_body_dims(entry.expression, spec, frame, f"Named expression '{ename}'") + indexed = _check_body_dims(entry.expression, frame, f"Named expression '{ename}'") _check_dims_are_indexed(ename, frame, indexed, 'the expression is not') continue indexed = frozenset[str]() @@ -272,7 +241,7 @@ def check_spec(spec: Spec, program: Program) -> None: context = case_context(ename, label) if label is not None: _check_condition_dims(region.when, frame, context) - indexed |= _check_value_dims(region.value, spec, frame, context) | region.when.dims + indexed |= _check_value_dims(region.value, frame, context) | region.when.dims _check_dims_are_indexed(ename, frame, indexed, 'no case value and no `when:` is') for ename, entry in program.expressions.items(): @@ -289,7 +258,7 @@ def check_spec(spec: Spec, program: Program) -> None: frame = frozenset(constraint.dims) context = f"Constraint '{cname}'" _check_condition_dims(constraint.where, frame, context) - got = dims_of(constraint.lhs, spec, context) | dims_of(constraint.rhs, spec, context) + got = frozenset((*constraint.lhs.dims, *constraint.rhs.dims)) if got != frame: stray, missing = sorted(got - frame), sorted(frame - got) detail = ( @@ -302,7 +271,7 @@ def check_spec(spec: Spec, program: Program) -> None: if program.objective is not None: context = 'The objective' - got = dims_of(program.objective.expression, spec, context) + got = program.objective.expression.dims if got: raise DimensionError( f'{context}: the expression carries dims {sorted(got)}, and an objective is one ' @@ -311,13 +280,13 @@ def check_spec(spec: Spec, program: Program) -> None: ) -def _check_value_dims(node: Expression, spec: Spec, frame: frozenset[str], context: str) -> frozenset[str]: +def _check_value_dims(node: Expression, frame: frozenset[str], context: str) -> frozenset[str]: """A region's value may only carry dims the frame does — the ``otherwise:`` included. A wider one would give the quantity dims its entry does not, which is the second answer a ``dims:`` exists to avoid. """ - got = dims_of(node, spec, context) + got = frozenset(node.dims) if not got <= frame: raise DimensionError( f'{context}: the value carries dims {sorted(got - frame)} outside the dims: ' @@ -326,9 +295,9 @@ def _check_value_dims(node: Expression, spec: Spec, frame: frozenset[str], conte return got -def _check_body_dims(node: Expression, spec: Spec, frame: frozenset[str], context: str) -> frozenset[str]: +def _check_body_dims(node: Expression, frame: frozenset[str], context: str) -> frozenset[str]: """A plain entry's expression is indexed only over dims its ``dims:`` gives; the dims it is indexed over come back.""" - got = dims_of(node, spec, context) + got = frozenset(node.dims) if not got <= frame: raise DimensionError( f'{context}: the body carries dims {sorted(got - frame)} outside the dims: {sorted(frame)}. ' diff --git a/src/mathspec/_expression_resolver.py b/src/mathspec/_expression_resolver.py index 61822624..6d2edb27 100644 --- a/src/mathspec/_expression_resolver.py +++ b/src/mathspec/_expression_resolver.py @@ -15,7 +15,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, assert_never, cast -from mathspec._dimensions import dims_of +from mathspec._dimensions import check_node, joined, sorted_dims from mathspec._expression_parser import ( MAX_DEPTH, ArithmeticNode, @@ -127,14 +127,14 @@ def arith(self, node: ArithmeticNode) -> Expression | None: formal bound to one. """ if isinstance(node, NumberNode): - return Constant(node.value) + return Constant(node.value, dims=()) if isinstance(node, NameNode): return self._name(node) if isinstance(node, UnaryOperatorNode): operand = self.arith(node.operand) if operand is None: return None - return Negate(operand) if node.op == '-' else operand + return Negate(operand, dims=operand.dims) if node.op == '-' else operand if isinstance(node, BinaryOperatorNode): return self._binary(node) if isinstance(node, FunctionCallNode): @@ -164,17 +164,18 @@ def _binary(self, node: BinaryOperatorNode) -> Expression | None: left, right = self.arith(node.left), self.arith(node.right) if left is None or right is None: return None + dims = sorted_dims(left.dims, right.dims) match node.op: case '+': - return Add(left, right) + return Add(left, right, dims=dims) case '-': - return Add(left, Negate(right)) + return Add(left, Negate(right, dims=right.dims), dims=dims) case '*': - return Multiply(left, right) + return Multiply(left, right, dims=dims) case '/': - return Divide(left, right) + return Divide(left, right, dims=dims) case '**': - return Power(left, right) + return Power(left, right, dims=dims) case _: assert_never(node.op) @@ -199,13 +200,13 @@ def _name(self, node: NameNode) -> Expression | None: return None match self.ns.kind(node.name): case 'variable': - return VariableReference(node.name) + return VariableReference(node.name, dims=sorted_dims(self.ns.leaf_dims[node.name])) case 'parameter': dtype = self.ns.dtypes.get(node.name) if dtype is not None and dtype not in NUMERIC_DTYPES: self.errors.append(not_a_number(node.name, dtype, self.context)) return None - return ParameterReference(node.name) + return ParameterReference(node.name, dims=sorted_dims(self.ns.leaf_dims[node.name])) case 'dimension': self.errors.append( f"{self.context}: '{node.name}' is a dimension, and a dimension is " @@ -290,24 +291,39 @@ def _sum(self, operand: Expression, over: tuple[str, ...] | None, columns: Colum if columns is None: if over is None: return self._bare_sum(operand) - return Sum(operand, tuple(Axis(d) for d in over)) + return self._summed(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, tuple(Axis(d) for d in plain)) if plain else grouped + if (through := self._joined(operand, join)) is None: + return None + grouped = self._summed(through, join.axes) + return self._summed(grouped, tuple(Axis(d) for d in plain)) if plain and grouped is not None 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.""" + def _summed(self, operand: Expression, over: tuple[Axis, ...]) -> Sum | None: + """A [`Sum`][mathspec.program.Sum] over *over*, which removes the dims its plain axes name; a join's axes are no dims.""" + closed = {axis.dimension for axis in over if axis.column is None} + return self._checked(Sum(operand, over, dims=tuple(d for d in operand.dims if d not in closed))) + + def _joined(self, operand: Expression, columns: JoinColumns) -> Join | None: + """A [`Join`][mathspec.program.Join] of *operand* on *columns*, over the dims the join leaves.""" + return self._checked(Join(operand, columns, dims=sorted_dims(joined(columns, operand.dims)))) + + def _checked[N: Sum | Join | Translate | WindowSum](self, node: N) -> N | None: + """*node*, or ``None`` with its refusal appended where it breaks the dim rule of its operator.""" try: - inner = dims_of(operand, self.ns.spec, self.context) + check_node(node, self.ns.spec, self.context) except DimensionError as e: - self.errors.append(str(e)) + self.errors.append(self.ns.refused_dims(e)) return None - join = self.lookup(columns, inner) - return None if join is None else Join(operand, join) + return node + + 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.""" + join = self.lookup(columns, frozenset(operand.dims)) + return None if join is None else self._joined(operand, join) def _translation( self, @@ -328,7 +344,9 @@ def _translation( assert offset is not None if not self._edge_fits(operand, offset, wrap=wrap, fill=fill): return None - return Translate(operand, along, offset, wrap=wrap, fill=fill, partition=partition) + return self._checked( + Translate(operand, along, offset, wrap=wrap, fill=fill, partition=partition, dims=operand.dims) + ) if fill is not None: self.errors.append( f"{self.context}: sum_back(edge=...) takes 'wrap' or nothing. To add a constant, " @@ -337,22 +355,18 @@ def _translation( return None width = amounts['window'] assert width is not None - return WindowSum(operand, along, width, wrap=wrap, partition=partition) + return self._checked(WindowSum(operand, along, width, wrap=wrap, partition=partition, dims=operand.dims)) def _bare_sum(self, operand: Expression) -> Expression | None: """``sum(x)`` with no ``over=`` or ``by=`` reduces every dim the operand carries, which it has to carry some of.""" - try: - inner = dims_of(operand, self.ns.spec, self.context) - except DimensionError as e: - self.errors.append(str(e)) - return None + inner = operand.dims if not inner: self.errors.append( f'{self.context}: sum() has no over= or by=, and its operand has no dimensions to sum over. ' f'Remove the sum.' ) return None - return Sum(operand, tuple(Axis(d) for d in sorted(inner))) + return self._summed(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. @@ -477,7 +491,8 @@ def _dual(self, node: FunctionCallNode) -> Dual | None: if value.name not in self.ns.constraints: self.errors.append(self.ns.unknown_constraint(value.name, self.context, formals=self.formals)) return None - return Dual(value.name) + declared = {**self.ns.spec.constraints, **self.ns.spec.given.constraints}[value.name] + return Dual(value.name, dims=sorted_dims(declared.dims)) def _over(self, value: ArithmeticNode, operator: str, *, relation: bool) -> tuple[str, ...] | None: """``over=``: a dimension or a list of them, and beside ``by=`` the columns of the relation too, each a bare name. diff --git a/src/mathspec/_lowering.py b/src/mathspec/_lowering.py index 07b280bd..4229c98d 100644 --- a/src/mathspec/_lowering.py +++ b/src/mathspec/_lowering.py @@ -17,7 +17,7 @@ from typing import TYPE_CHECKING -from mathspec._dimensions import check_spec, dims_of +from mathspec._dimensions import check_spec, sorted_dims from mathspec._expansion import expand, parse_template from mathspec._piecewise import assumptions_of, curve_frame, lp_domain_refusal, refused_under_points, resolve_links from mathspec._resolution import ( @@ -97,8 +97,9 @@ def lower(spec: Spec) -> Program: SchemaError: Listing every problem found, one per line. A name a set or curve writes that the file declares is listed once every other problem is gone, since it is read off the curve as lowered. - DimensionError: The first dim rule an entry breaks, once every - name resolves. + DimensionError: Listing every dim rule an operator breaks, where + nothing else is wrong; else the first rule an entry breaks, once + every name resolves. """ errors = reference_errors(spec) if errors: @@ -142,9 +143,9 @@ def lower(spec: Spec) -> Program: if where is not None and (refusal := self_existence(where, vname, f"Variable '{vname}'")) is not None: errors.append(refusal) if vdef.domain == 'binary': - lower_bound, upper_bound = Constant(0.0), Constant(1.0) + lower_bound, upper_bound = Constant(0.0, dims=()), Constant(1.0, dims=()) else: - lower_bound, upper_bound = _bound(vdef.bounds.lower), _bound(vdef.bounds.upper) + lower_bound, upper_bound = _bound(vdef.bounds.lower, ns), _bound(vdef.bounds.upper, ns) variables[vname] = Variable( tuple(vdef.dims), where=mask_of(where), @@ -186,7 +187,7 @@ def lower(spec: Spec) -> Program: curves[pname] = links if errors: - raise SchemaError('\n'.join(errors)) + raise ns.error(errors) roots = [side for c in constraints.values() for side in (c.lhs, c.rhs)] if objective is not None: @@ -340,17 +341,15 @@ def _loop(target: str, entry: ExpressionReference, sums: Mapping[str, list[Expre def _frame_of(name: str, entry: ExpressionReference, spec: Spec) -> tuple[str, ...]: """The dims an entry is read over: the ``dims:`` it declares, as written, else the body's, sorted by name.""" declared = spec.expressions[name].dims - if declared is not None: - return tuple(declared) - return tuple(sorted(dims_of(entry.body, spec, f"Named expression '{name}'"))) + return entry.dims if declared is None else tuple(declared) -def _bound(value: float | str | None) -> Constant | ParameterReference | None: +def _bound(value: float | str | None, ns: Namespace) -> Constant | ParameterReference | None: if value is None: return None if isinstance(value, str): - return ParameterReference(value) - return Constant(value) + return ParameterReference(value, dims=sorted_dims(ns.leaf_dims[value])) + return Constant(value, dims=()) def _assumption(name: str, entry: AssumptionSpec, ns: Namespace, errors: list[str]) -> Assumption | None: diff --git a/src/mathspec/_piecewise.py b/src/mathspec/_piecewise.py index 1d9bbe91..732dcff6 100644 --- a/src/mathspec/_piecewise.py +++ b/src/mathspec/_piecewise.py @@ -19,7 +19,6 @@ from typing import TYPE_CHECKING, Literal import mathspec._sos as sos -from mathspec._dimensions import dims_of from mathspec._resolution import resolve_expression_text from mathspec.errors import DimensionError from mathspec.program import Piecewise, PiecewiseMethod, Variable, carries_variable @@ -317,7 +316,7 @@ def curve_frame(spec: Spec, name: str, pw: PiecewiseSpec, links: Iterable[Expres expression carries. """ context = f"piecewise '{name}'" - carried = [(f'link {i} expression', dims_of(node, spec, f'{context} link {i}')) for i, node in enumerate(links)] + carried = [(f'link {i} expression', frozenset(node.dims)) for i, node in enumerate(links)] if pw.activity is not None: carried.append(('activity', frozenset(spec.variables[pw.activity].dims))) for what, found in carried: diff --git a/src/mathspec/_resolution.py b/src/mathspec/_resolution.py index c02c606b..66de83b4 100644 --- a/src/mathspec/_resolution.py +++ b/src/mathspec/_resolution.py @@ -25,6 +25,7 @@ parse_condition, ) from mathspec._condition_resolver import ConditionResolver +from mathspec._dimensions import sorted_dims from mathspec._exclusivity import overlapping from mathspec._expansion import expand, parse_and_expand from mathspec._expression_parser import ( @@ -34,7 +35,7 @@ nodes, ) from mathspec._expression_resolver import ExpressionResolver -from mathspec.errors import LanguageError, SchemaError, case_context, prefixed +from mathspec.errors import DimensionError, LanguageError, SchemaError, case_context, prefixed from mathspec.program import ( BooleanLiteral, Cases, @@ -78,8 +79,10 @@ class Namespace: 'conditions', 'constraints', 'defaults', + 'dimension_refusals', 'dimensions', 'dtypes', + 'follow_ons', 'given_conditions', 'leaf_dims', 'parameters', @@ -141,6 +144,29 @@ def __init__(self, spec: Spec) -> None: #: mask -> its resolved node, or ``None``, and its refusals; filled #: the first time anything reads the name. self._conditions: dict[str, tuple[ConditionReference | None, tuple[str, ...]]] = {} + #: The refusals a dim rule wrote, which [`error`][] reports as a + #: ``DimensionError`` when nothing else is wrong. + self.dimension_refusals: set[str] = set() + #: The refusals that only point at an entry's own, which leave the + #: class of the error to that entry's. + self.follow_ons: set[str] = set() + + def refused_dims(self, refusal: DimensionError) -> str: + """*refusal*'s text, recorded as a dim rule's.""" + self.dimension_refusals.add(text := str(refusal)) + return text + + def error(self, errors: list[str]) -> SchemaError | DimensionError: + """The one error that lists every refusal in *errors*. + + It is a ``DimensionError`` where every fault is a dim rule's, so the + class says what is wrong wherever resolution met it, and a + ``SchemaError`` where anything else is. + """ + faults = [e for e in errors if e not in self.follow_ons] + if faults and all(e in self.dimension_refusals for e in faults): + return DimensionError('\n'.join(errors)) + return SchemaError('\n'.join(errors)) def named(self, name: str, context: str) -> ExpressionReference: """The ``expressions:`` entry *name* as the node that stands where its name is written. @@ -156,6 +182,7 @@ def named(self, name: str, context: str) -> ExpressionReference: node, _ = self.named_entry(name) if node is None: msg = f"{context}: named expression '{name}' does not load. Fix the error listed for '{name}' first." + self.follow_ons.add(msg) raise SchemaError(msg) return node @@ -180,6 +207,7 @@ def condition(self, name: str, context: str) -> ConditionReference: node, _ = self.condition_entry(name) if node is None: msg = f"{context}: condition '{name}' does not load. Its refusal is listed with it." + self.follow_ons.add(msg) raise SchemaError(msg) return node @@ -492,7 +520,7 @@ def _named(name: str, entry: ExpressionSpec, ns: Namespace, errors: list[str]) - if not entry.cases: assert entry.expression is not None body = resolve_expression_text(entry.expression, ns, context, errors, ceiling=None) - return None if body is None else ExpressionReference(name, body) + return None if body is None else ExpressionReference(name, body, dims=body.dims) found = len(errors) regions: list[Region] = [] @@ -515,7 +543,9 @@ def _named(name: str, entry: ExpressionSpec, ns: Namespace, errors: list[str]) - if len(errors) > found: return None left_over = Region(remainder(region.when for region in regions), fallback) - return ExpressionReference(name, Cases((*regions, left_over))) + every = (*regions, left_over) + cases = Cases(every, dims=sorted_dims(*(r.value.dims for r in every), *(r.when.dims for r in every))) + return ExpressionReference(name, cases, dims=cases.dims) def _named_condition(name: str, entry: ConditionSpec, ns: Namespace, errors: list[str]) -> ConditionReference | None: diff --git a/src/mathspec/_typesetting/walk.py b/src/mathspec/_typesetting/walk.py index d5f25e87..89c1e97b 100644 --- a/src/mathspec/_typesetting/walk.py +++ b/src/mathspec/_typesetting/walk.py @@ -209,7 +209,11 @@ def _unsigned(node: Expression) -> Expression | None: if isinstance(node, Multiply | Divide): first, second = (node.left, node.right) if isinstance(node, Multiply) else (node.numerator, node.divisor) if (head := _unsigned(first)) is not None: - return Multiply(head, second) if isinstance(node, Multiply) else Divide(head, second) + return ( + Multiply(head, second, dims=node.dims) + if isinstance(node, Multiply) + else Divide(head, second, dims=node.dims) + ) return None @@ -385,7 +389,9 @@ def _binary(self, node: Add | Multiply | Divide | Power, ctx: _Context) -> tuple precedence = _PRECEDENCE[op] operand = self._substituted(node.right) if op == '+' and isinstance(operand, Add): - return self._binary(Add(Add(node.left, operand.left), operand.right), ctx) + head = {*node.left.dims, *operand.left.dims} + joined = Add(node.left, operand.left, dims=tuple(d for d in node.dims if d in head)) + return self._binary(Add(joined, operand.right, dims=node.dims), ctx) left = self._expression(node.left, ctx, need=precedence) if op == '+': while (unsigned := _unsigned(operand)) is not None: diff --git a/src/mathspec/program.py b/src/mathspec/program.py index d68b56ef..d2f26961 100644 --- a/src/mathspec/program.py +++ b/src/mathspec/program.py @@ -22,7 +22,7 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass, fields, replace +from dataclasses import dataclass, field, fields, replace from functools import cached_property from typing import TYPE_CHECKING, Literal, assert_never @@ -188,6 +188,7 @@ class Constant: """A scalar constant.""" value: float + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) @@ -195,6 +196,7 @@ class ParameterReference: """A parameter reference — contributes to the constant part.""" name: str + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) @@ -202,6 +204,7 @@ class VariableReference: """A variable reference — one term per existing variable row.""" name: str + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) @@ -217,17 +220,20 @@ class Dual: """ constraint: str + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) class Negate: operand: Expression + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) class Add: left: Expression right: Expression + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) @@ -240,6 +246,7 @@ class Multiply: left: Expression right: Expression + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) @@ -253,6 +260,7 @@ class Power: base: Expression exponent: Expression + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) @@ -261,6 +269,7 @@ class Divide: numerator: Expression divisor: Expression + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) @@ -306,6 +315,7 @@ class Sum: operand: Expression over: tuple[Axis, ...] + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) @@ -323,10 +333,15 @@ class Join: join opens no axis, and the bare ``Join`` is ``at(x, by=relation[a])``: one value per row of the result, fanned out where several key tuples share the values joined on. + + An axis is never a dim: a join that opens axes states the ``dims`` of the + sum that closes them, and that sum removes only the dims its plain axes + name. """ operand: Expression columns: JoinColumns + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) @@ -354,6 +369,7 @@ class Translate: wrap: bool fill: float | None = None partition: Partition | None = None + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) @@ -382,6 +398,7 @@ class WindowSum: width: int | str wrap: bool partition: Partition | None = None + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) @@ -406,6 +423,7 @@ class Cases: """ regions: tuple[Region, ...] + dims: tuple[str, ...] = field(kw_only=True) @dataclass(frozen=True) @@ -421,6 +439,7 @@ class ExpressionReference: name: str body: Expression + dims: tuple[str, ...] = field(kw_only=True) #: Every expression node, as one type — what a walk takes. The set is @@ -432,7 +451,10 @@ class ExpressionReference: #: that is ``in_math`` — affine but where a [`QuadraticPosition`][] admits a #: [`Multiply`][] of two variable-carrying operands. A #: [`NamedExpression`][] the math never reads is held to none of them. -#: No node records which tree it stands in. +#: No node records which tree it stands in. Every node states ``dims``, the +#: dimensions over which it is indexed, sorted by name, so a consumer reads a +#: node's dims rather than deriving them. An +#: [`ExpressionReference`][] states its body's dims. Expression = ( Constant | ParameterReference diff --git a/tests/fixtures.py b/tests/fixtures.py index bde3407d..0f95c104 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -15,7 +15,6 @@ from mathspec._resolution import Namespace, mask_of, resolve_condition_text, resolve_expression from mathspec._validation import to_spec from mathspec._yaml import parse_yaml, read_yaml -from mathspec.errors import SchemaError from mathspec.spec import Spec if TYPE_CHECKING: @@ -131,7 +130,7 @@ def expression_of(text: str, ns: Namespace, context: str) -> Expression: assert not isinstance(ast, ComparisonNode), 'a comparison is a constraint, which comparison_of reads' resolved = resolve_expression(ast, ns, context, errors) if errors: - raise SchemaError('\n'.join(errors)) + raise ns.error(errors) assert resolved is not None return resolved @@ -143,7 +142,7 @@ def comparison_of(text: str, ns: Namespace, context: str) -> tuple[Expression, s assert isinstance(ast, ComparisonNode), 'a value is an expression, which expression_of reads' left, right = (resolve_expression(side, ns, context, errors) for side in (ast.left, ast.right)) if errors: - raise SchemaError('\n'.join(errors)) + raise ns.error(errors) assert left is not None and right is not None return left, ast.op, right @@ -153,5 +152,5 @@ def where_of(text: str | None, ns: Namespace, context: str, self_variable: str | errors: list[str] = [] resolved = resolve_condition_text(text, ns, context, errors, self_variable) if errors: - raise SchemaError('\n'.join(errors)) + raise ns.error(errors) return mask_of(resolved) diff --git a/tests/test_dimensions.py b/tests/test_dimensions.py index 5c1a2052..ad039784 100644 --- a/tests/test_dimensions.py +++ b/tests/test_dimensions.py @@ -7,17 +7,40 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, Any, ClassVar, cast +from collections.abc import Iterator, Mapping +from dataclasses import fields, is_dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, ClassVar, cast, get_args import pytest -from mathspec._dimensions import DimensionError, _check_condition_dims, dims_of +from mathspec._dimensions import DimensionError, _check_condition_dims from mathspec._resolution import Namespace from mathspec._validation import to_spec from mathspec.errors import SchemaError -from mathspec.program import Axis, Column, Join, Mask, RelationPairComparison, Sum +from mathspec.program import ( + Axis, + Cases, + Column, + Constant, + Dual, + Expression, + ExpressionReference, + Join, + Mask, + ParameterReference, + RelationPairComparison, + Sum, + Translate, + VariableReference, + WindowSum, + children, +) from tests.fixtures import DISPATCH_MODEL, EXAMPLES, SPECS, expression_of, spec_of, varied, where_of +#: Every class an expression node is an instance of. +EXPRESSION_NODES = get_args(Expression) + if TYPE_CHECKING: from mathspec.spec import Spec @@ -70,7 +93,7 @@ def _spec(**overrides) -> Spec: def _dims(expr: str) -> frozenset[str]: s = _spec() - return dims_of(expression_of(expr, Namespace(s), 't'), s, 't') + return frozenset(expression_of(expr, Namespace(s), 't').dims) @pytest.fixture @@ -202,7 +225,7 @@ def test_dim_inference(expr, expected): def _dims_with(expr: str, **overrides) -> frozenset[str]: s = _spec(**overrides) - return dims_of(expression_of(expr, Namespace(s), 't'), s, 't') + return frozenset(expression_of(expr, Namespace(s), 't').dims) @pytest.mark.parametrize( @@ -262,7 +285,7 @@ def test_a_join_opens_an_axis_for_the_column_it_drops_and_the_sum_over_it_closes 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 == (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' + assert set(node.dims) == {'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(): @@ -306,15 +329,15 @@ def test_a_bare_name_reaches_the_variable_a_dual_the_same_named_constraint(): ), pytest.param( 'sum(sum(p, over=bus))', - SchemaError, + DimensionError, r'sum\(over=bus\) but the expression has only the dims', - id='a-dim-fault-under-a-bare-sum-is-met-while-the-sum-is-built', + id='a-dim-fault-under-a-bare-sum', ), pytest.param( 'sum(sum(p, over=bus), over=generator)', DimensionError, r'sum\(over=bus\) but the expression has only the dims', - id='and-the-same-fault-under-a-sum-over-a-named-dim-is-the-dim-rules', + id='and-the-same-fault-under-a-sum-over-a-named-dim', ), pytest.param( 'sum(load, over=generator, by=gen_bus[bus])', @@ -385,11 +408,11 @@ def test_a_bare_name_reaches_the_variable_a_dual_the_same_named_constraint(): ], ) def test_an_ill_dimensioned_expression_is_rejected(expr, error, match): - """The class says which pass refused: what resolution needs to build a node is a `SchemaError`, and a rule on a built tree's dims a `DimensionError`. + """The class says what is wrong: a dim rule broken is a `DimensionError`, and anything else a `SchemaError`. - A dim fault under a bare `sum()` is met while the sum is built, since the - dims it reduces are the operand's, and the same fault under a sum over a - named dim is met by the dim rules. + A dim fault under a bare `sum()` was a `SchemaError`, because the bare sum + met it while it was built, and the same fault under a sum over a named dim + was a `DimensionError`. """ with pytest.raises(error, match=match): _dims(expr) @@ -729,6 +752,118 @@ def test_a_row_over_more_dims_than_the_entry_it_reads_is_refused(): to_spec(varied(DISPATCH_MODEL, **row, **{'expressions.cap': 'sum(p, over=snapshot) - p_max'})) +# --------------------------------------------------------------------------- +# every expression node states its dims +# --------------------------------------------------------------------------- + +#: Every spec in the repository, before and after each expansion pair. +EVERY_SPEC = [ + *SPECS, + *sorted((Path(__file__).parent / 'expand').glob('*/*.yaml')), + Path(__file__).parent / 'typesetting' / 'golden' / 'model.yaml', +] + + +def _derived(node: Expression, spec: Spec) -> frozenset[str]: + """The dims of *node* by the rules of the language reference, derived from the spec without reading ``dims``.""" + if isinstance(node, Constant): + return frozenset() + if isinstance(node, ParameterReference): + return frozenset({**spec.parameters, **spec.given.parameters}[node.name].dims) + if isinstance(node, VariableReference): + return frozenset({**spec.variables, **spec.given.variables, **spec.given.expressions}[node.name].dims or ()) + if isinstance(node, Dual): + return frozenset({**spec.constraints, **spec.given.constraints}[node.constraint].dims) + if isinstance(node, ExpressionReference): + return _derived(node.body, spec) + if isinstance(node, Cases): + return frozenset().union(*(_derived(r.value, spec) | r.when.dims for r in node.regions)) + if isinstance(node, Join): + joined = _derived(node.operand, spec) - set(node.columns.joined_dims) + return joined | set(node.columns.grouped_dims) + if isinstance(node, Sum): + return _derived(node.operand, spec) - {axis.dimension for axis in node.over if axis.column is None} + if isinstance(node, Translate | WindowSum): + return _derived(node.operand, spec) + return frozenset().union(*(_derived(child, spec) for child in children(node))) + + +def _expressions(root: object) -> Iterator[Expression]: + """Every expression node reachable from *root*, a program or any part of one, each once.""" + seen: set[int] = set() + stack = [root] + while stack: + item = stack.pop() + if id(item) in seen or isinstance(item, str | int | float | bool | None): + continue + seen.add(id(item)) + if isinstance(item, EXPRESSION_NODES): + yield cast('Expression', item) + if isinstance(item, Mapping): + stack.extend(item.values()) + elif isinstance(item, tuple | list | frozenset): + stack.extend(item) + elif is_dataclass(item): + stack.extend(getattr(item, f.name) for f in fields(item)) + + +@pytest.mark.parametrize('path', EVERY_SPEC, ids=lambda path: str(path.relative_to(Path(__file__).parent.parent))) +def test_every_expression_node_states_its_dims_sorted_by_name(path): + """A consumer read a node's dims by deriving them again, as the loader did to check them and then threw away.""" + spec = spec_of(path) + for loaded in (spec, spec.expand()): + nodes = list(_expressions(loaded.program)) + assert nodes, f'{path.name} lowers to no expression node' + for node in nodes: + derived = _derived(node, loaded) + assert node.dims == tuple(sorted(derived)), ( + f'{type(node).__name__} in {path.name} states {node.dims}, and the rules give {sorted(derived)}, ' + f'sorted by name' + ) + + +#: Two operator faults, each in an entry of its own. +TWO_DIM_FAULTS = { + 'constraints.over_bus': {'dims': [], 'expression': 'sum(sum(p, over=bus), over=generator) <= 1'}, + 'constraints.along_bus': { + 'dims': ['generator'], + 'expression': "shift(cost, along=snapshot, offset=1, edge='wrap') <= 1", + }, +} + + +@pytest.mark.parametrize( + ('patch', 'error', 'listed'), + [ + pytest.param(TWO_DIM_FAULTS, DimensionError, ['sum(over=bus)', 'shift(along=snapshot)'], id='two-dim-faults'), + pytest.param( + {**TWO_DIM_FAULTS, 'constraints.typo': {'dims': [], 'expression': 'sum(nothing) <= 1'}}, + SchemaError, + ['sum(over=bus)', 'shift(along=snapshot)', "'nothing'"], + id='and-a-name-nothing-declares', + ), + pytest.param( + { + 'constraints.read': { + 'dims': ['generator'], + 'where': 'at(cost > 0, by=gen_bus[bus])', + 'expression': 'sum(p, over=snapshot) <= 1', + } + }, + DimensionError, + ["joins on ['bus'], which the condition does not carry"], + id='a-condition-read-through-a-dim-it-lacks', + ), + ], +) +def test_every_fault_resolution_meets_is_listed_under_the_class_of_what_is_wrong(patch, error, listed): + """A dim fault met while a node was built was a `SchemaError`, and the same fault met later a `DimensionError`.""" + with pytest.raises(error) as raised: + _spec(**patch) + missing = [text for text in listed if text not in str(raised.value)] + assert not missing, f'every fault is listed, not the first: {missing} are absent' + + @pytest.mark.parametrize('path', SPECS, ids=lambda path: str(path.relative_to(EXAMPLES))) def test_the_order_of_dimensions_reaches_no_value_of_the_program(path): """The dims of a named condition, of a named expression that declares none, of a piecewise curve and of a diff --git a/tests/test_expansion.py b/tests/test_expansion.py index a44755d5..4d9a7330 100644 --- a/tests/test_expansion.py +++ b/tests/test_expansion.py @@ -141,9 +141,10 @@ 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(spec(expressions={'gen_cost': 'p * cost'})) resolved = expression_of('sum(gen_cost, over=generator)', ns, 'e') + frame = ('generator', 'snapshot') + body = Multiply(VariableReference('p', dims=frame), ParameterReference('cost', dims=('generator',)), dims=frame) assert resolved == Sum( - ExpressionReference('gen_cost', Multiply(VariableReference('p'), ParameterReference('cost'))), - (Axis('generator'),), + ExpressionReference('gen_cost', body, dims=frame), (Axis('generator'),), dims=('snapshot',) ), 'the body is inlined resolved and the name kept, for the typesetter to define it once' assert isinstance(resolved, Sum) assert resolved.operand is expression_of('gen_cost', ns, 'another use'), 'every use reads the one node' diff --git a/tests/test_lowering.py b/tests/test_lowering.py index bccd72a9..7fc709e4 100644 --- a/tests/test_lowering.py +++ b/tests/test_lowering.py @@ -88,7 +88,7 @@ LK_JOIN = JoinColumns('lk', LK, ('g',), ('h',)) AT_BUS = Relation((('g', 'g'), ('bus', 'bus')), ('g',)) -#: `fixtures.SMALL_MODEL` plus a second relation and a per-entity +#: `fixtures.SMALL_MODEL` plus a second relation and an integer #: offset. Which node a construct becomes is mostly a claim about the dim it #: joins on and the dim it groups by, and stating that needs a third dimension #: and two relations over one of them. @@ -97,7 +97,7 @@ **{ 'dimensions.z': {'dtype': 'str'}, 'relations.lk2': {'key': 'g', 'values': 'z'}, - 'parameters.lead': {'dims': ['g'], 'dtype': 'int'}, + 'parameters.lead': {'dims': [], 'dtype': 'int'}, }, ) @@ -137,18 +137,26 @@ def test_program_structure(dispatch_program): assert vname == 'dispatch' assert v.dims == ('snapshot', 'generator'), 'the frame is the dims, in the order the file wrote it' assert v.where == Mask(CAPACITY_POSITIVE) - assert v.upper == ParameterReference('capacity') + assert v.upper == ParameterReference('capacity', dims=('generator',)) ((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(VariableReference('dispatch'), (Axis('generator'),)) + assert c.lhs == Sum( + VariableReference('dispatch', dims=('generator', 'snapshot')), (Axis('generator'),), dims=('snapshot',) + ) assert c.sense == '==', "the comparison crosses as the file's own operator, untranslated" - assert c.rhs == ParameterReference('load') + assert c.rhs == ParameterReference('load', dims=('snapshot',)) assert dispatch_program.objective.sense == 'minimize', "the program carries the language's spelling, untranslated" assert dispatch_program.objective.expression == Sum( - Multiply(VariableReference('dispatch'), ParameterReference('cost')), (Axis('generator'), Axis('snapshot')) + Multiply( + VariableReference('dispatch', dims=('generator', 'snapshot')), + ParameterReference('cost', dims=('generator',)), + dims=('generator', 'snapshot'), + ), + (Axis('generator'), Axis('snapshot')), + dims=(), ), 'the objective carries the sum the file wrote, over the dims it named none of' @@ -414,7 +422,10 @@ def test_a_comparison_of_expressions_lowers_to_program_expressions_on_both_sides where = program.variables['p'].where assert where is not None assert where.root == ExpressionComparison( - ParameterReference('c'), '<=', Multiply(Constant(0.5), ParameterReference('k')), ('g',) + ParameterReference('c', dims=('g',)), + '<=', + Multiply(Constant(0.5, dims=()), ParameterReference('k', dims=()), dims=()), + ('g',), ), 'the sides are lowered as a constraint side is, and the dims are what either side carries' mask = program.constraints['w'].where assert mask is not None and isinstance(mask.root, ExpressionComparison) @@ -435,7 +446,10 @@ def test_a_condition_a_leaf_carries_is_lowered_like_any_other_mask(): mask = program.constraints['w'].where assert mask is not None and isinstance(mask.root, CountComparison) assert mask.root.condition.root == ExpressionComparison( - ParameterReference('c'), '<=', Multiply(Constant(0.5), ParameterReference('k')), ('g',) + ParameterReference('c', dims=('g',)), + '<=', + Multiply(Constant(0.5, dims=()), ParameterReference('k', dims=()), dims=()), + ('g',), ), 'the counted condition is rebuilt, not handed through with the resolved comparison still in it' assert mask.names_read == frozenset({'c', 'k'}), 'what the counted condition reads is data the consumer attaches' @@ -478,7 +492,10 @@ def test_a_condition_read_through_a_relation_is_lowered_and_keeps_the_relation_i mask = program.constraints['w'].where assert mask is not None and isinstance(mask.root, JoinedCondition) assert mask.root.operand.root == ExpressionComparison( - ParameterReference('zcap'), '<=', Multiply(Constant(0.5), ParameterReference('k')), ('z',) + ParameterReference('zcap', dims=('z',)), + '<=', + Multiply(Constant(0.5, dims=()), ParameterReference('k', dims=()), dims=()), + ('z',), ), 'the read condition is rebuilt, not handed through with the resolved comparison still in it' assert mask.names_read == frozenset({'zcap', 'k', 'lk2'}) assert sorted(mask.dims) == ['g'], 'z is read at lk2(g), so the mask is over g alone' @@ -512,7 +529,10 @@ def test_an_assumption_lowers_both_of_its_masks(): assert assumption == Assumption( Mask( ExpressionComparison( - ParameterReference('c'), '<=', Multiply(Constant(0.5), ParameterReference('k')), ('g',) + ParameterReference('c', dims=('g',)), + '<=', + Multiply(Constant(0.5, dims=()), ParameterReference('k', dims=()), dims=()), + ('g',), ) ), Mask(ParameterDefined('flag', ('g',))), @@ -576,72 +596,80 @@ def test_a_power_resolves_to_a_node_of_its_own(dispatch_spec): [ pytest.param( 'sum(q)', - Sum(VariableReference('q'), (Axis('g'), Axis('h'))), + Sum(VariableReference('q', dims=('g', 'h')), (Axis('g'), Axis('h')), dims=()), id='a-bare-sum-sums-away-every-dim-the-operand-carries', ), pytest.param( - 'sum(q, over=h)', Sum(VariableReference('q'), (Axis('h'),)), id='an-over-sums-away-the-dim-it-names' + 'sum(q, over=h)', + Sum(VariableReference('q', dims=('g', 'h')), (Axis('h'),), dims=('g',)), + id='an-over-sums-away-the-dim-it-names', ), pytest.param( 'sum(p, over=g, by=lk[h])', - Sum(Join(VariableReference('p'), LK_JOIN), (Axis('g', Column('lk', 'g')),)), + Sum( + Join(VariableReference('p', dims=('g',)), LK_JOIN, dims=('h',)), + (Axis('g', Column('lk', 'g')),), + dims=('h',), + ), id='a-grouped-sum-is-a-sum-over-the-axis-its-join-opens', ), pytest.param( 'at(r, by=lk[h])', - Join(VariableReference('r'), JoinColumns('lk', LK, ('h',), ('g',))), + Join(VariableReference('r', dims=('h',)), JoinColumns('lk', LK, ('h',), ('g',)), dims=('g',)), id='an-at-is-the-same-join-the-other-way-with-no-sum-over-it', ), pytest.param( "shift(p, along=g, offset=1, edge='wrap')", - Translate(VariableReference('p'), 'g', offset=1, wrap=True, fill=None), + Translate(VariableReference('p', dims=('g',)), 'g', offset=1, wrap=True, fill=None, dims=('g',)), id='a-wrapping-translation-fills-nothing', ), pytest.param( 'shift(p, along=g, offset=-2, edge=0)', - Translate(VariableReference('p'), 'g', offset=-2, wrap=False, fill=0.0), + Translate(VariableReference('p', dims=('g',)), 'g', offset=-2, wrap=False, fill=0.0, dims=('g',)), id='a-lead-is-a-negative-offset-and-the-edge-is-what-it-fills-with', ), pytest.param( 'shift(p, along=g, offset=lead, edge=0)', - Translate(VariableReference('p'), 'g', offset='lead', wrap=False, fill=0.0), + Translate(VariableReference('p', dims=('g',)), 'g', offset='lead', wrap=False, fill=0.0, dims=('g',)), id='a-named-offset-crosses-as-the-parameter-name', ), pytest.param( "shift(p, along=g, offset=+lead, edge='wrap')", - Translate(VariableReference('p'), 'g', offset='lead', wrap=True, fill=None), + Translate(VariableReference('p', dims=('g',)), 'g', offset='lead', wrap=True, fill=None, dims=('g',)), id='a-named-offset-written-with-a-plus-is-the-parameter', ), pytest.param( 'shift(p, along=g, offset=1, within=lk[h], edge=0)', Translate( - VariableReference('p'), + VariableReference('p', dims=('g',)), 'g', offset=1, wrap=False, fill=0.0, partition=Partition('lk', LK, 'g', ('h',), ()), + dims=('g',), ), id='a-translation-stops-at-the-edges-of-the-relation-it-names', ), pytest.param( 'sum_back(p, along=g, window=3)', - WindowSum(VariableReference('p'), 'g', width=3, wrap=False), + WindowSum(VariableReference('p', dims=('g',)), 'g', width=3, wrap=False, dims=('g',)), id='a-window-is-one-node-rather-than-a-fold-of-translations', ), pytest.param( 'sum_back(p, along=g, window=lead)', - WindowSum(VariableReference('p'), 'g', width='lead', wrap=False), + WindowSum(VariableReference('p', dims=('g',)), 'g', width='lead', wrap=False, dims=('g',)), id='a-named-width-crosses-as-the-parameter-name', ), pytest.param( 'sum_back(p, along=g, window=2, within=lk[h])', WindowSum( - VariableReference('p'), + VariableReference('p', dims=('g',)), 'g', width=2, wrap=False, partition=Partition('lk', LK, 'g', ('h',), ()), + dims=('g',), ), id='a-window-stops-at-the-edges-of-the-relation-it-names', ), @@ -723,7 +751,11 @@ 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(VariableReference('p'), columns), (Axis('generator', Column('zone_of', 'generator')),)), ( + assert zonal == Sum( + Join(VariableReference('p', dims=('generator', 'snapshot')), columns, dims=('snapshot', 'zone')), + (Axis('generator', Column('zone_of', 'generator')),), + dims=('snapshot', 'zone'), + ), ( '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' @@ -739,13 +771,18 @@ def test_a_relation_lowers_with_the_join_each_call_names(): ) assert program.constraints['history'].lhs == Sum( Join( - VariableReference('p'), JoinColumns('zone_of', declared, ('snapshot', 'generator'), ('zone', 'generator')) + VariableReference('p', dims=('generator', 'snapshot')), + JoinColumns('zone_of', declared, ('snapshot', 'generator'), ('zone', 'generator')), + dims=('generator', 'zone'), ), (Axis('snapshot', Column('zone_of', 'snapshot')),), + dims=('generator', 'zone'), ), 'the same table joined on its other key column' priced = program.constraints['priced'].rhs assert priced == Join( - ParameterReference('price'), JoinColumns('zone_of', declared, ('zone', 'snapshot'), ('generator', 'snapshot')) + ParameterReference('price', dims=('snapshot', 'zone')), + JoinColumns('zone_of', declared, ('zone', 'snapshot'), ('generator', 'snapshot')), + dims=('generator', 'snapshot'), ), 'and an at is the bare join, on the value column, grouped by the key columns' assert isinstance(priced, Join) assert (priced.columns.dropped_dims, priced.columns.added_dims, priced.columns.kept) == ( @@ -771,33 +808,46 @@ def test_a_binary_variable_lowers_to_a_binary_domain(): def test_a_divisor_under_a_join_is_still_named(): """`children` has to descend through every node, or a refusal loses its name.""" - quotient = Divide(VariableReference('x'), ParameterReference('rate')) + quotient = Divide( + VariableReference('x', dims=('component',)), + ParameterReference('rate', dims=('component',)), + dims=('component',), + ) component_of = Relation((('flow', 'flow'), ('component', 'component')), ('flow',)) - looked_up = Join(quotient, JoinColumns('component_of', component_of, ('component',), ('flow',))) + looked_up = Join(quotient, JoinColumns('component_of', component_of, ('component',), ('flow',)), dims=('flow',)) assert parameters_of(looked_up) == frozenset({'rate'}), 'the walk descends through `Join`' - assert parameters_of(Sum(looked_up, (Axis('flow'),))) == frozenset({'rate'}), 'and through a `Sum` over it' + assert parameters_of(Sum(looked_up, (Axis('flow'),), dims=())) == frozenset({'rate'}), 'and through a `Sum` over it' def test_a_divisor_under_a_power_is_still_named(): """`children` had no branch for `Power`, so every walk stopped at it and a divisor written `d ** 2` was reported with no parameter at all (#403).""" - quotient = Divide(VariableReference('x'), Power(ParameterReference('d'), Constant(2.0))) + d, two = ParameterReference('d', dims=()), Constant(2.0, dims=()) + quotient = Divide(VariableReference('x', dims=()), Power(d, two, dims=()), dims=()) - assert children(quotient.divisor) == (ParameterReference('d'), Constant(2.0)), 'the base first, then the exponent' + assert children(quotient.divisor) == (d, two), 'the base first, then the exponent' assert parameters_of(quotient) == frozenset({'d'}), 'the walk descends through `Power`' OUTER = Mask(ParameterDefined('committable', ('g',))) INNER = Mask(ParameterDefined('flag', ('g',))) +X, P, ZERO, Q = ( + VariableReference('x', dims=('g',)), + VariableReference('p', dims=('g',)), + Constant(0.0, dims=()), + ParameterReference('q', dims=('g',)), +) NESTED = Add( - VariableReference('x'), + X, Cases( ( - Region(OUTER, Cases((Region(INNER, VariableReference('p')), Region(~INNER, Constant(0.0))))), - Region(~OUTER, ParameterReference('q')), - ) + Region(OUTER, Cases((Region(INNER, P), Region(~INNER, ZERO)), dims=('g',))), + Region(~OUTER, Q), + ), + dims=('g',), ), + dims=('g',), ) @@ -806,12 +856,12 @@ def test_walk_regions_carries_the_regions_a_node_stands_under(): and every consumer recursed for it on its own (#473).""" assert list(walk_regions(NESTED)) == [ (NESTED, ()), - (VariableReference('x'), ()), + (X, ()), (NESTED.right, ()), (NESTED.right.regions[0].value, (OUTER,)), - (VariableReference('p'), (OUTER, INNER)), - (Constant(0.0), (OUTER, ~INNER)), - (ParameterReference('q'), (~OUTER,)), + (P, (OUTER, INNER)), + (ZERO, (OUTER, ~INNER)), + (Q, (~OUTER,)), ], ( 'parents first; a node outside any `cases:` carries nothing; a `Cases` carries only the regions ' 'above it; a value under two `cases:` carries both, the outer one first' diff --git a/tests/test_piecewise.py b/tests/test_piecewise.py index b8a23234..26f4907d 100644 --- a/tests/test_piecewise.py +++ b/tests/test_piecewise.py @@ -167,7 +167,7 @@ def test_a_program_mirrors_the_model_it_was_lowered_from(): assert [link.values for link in curve.links] == ['bp_x', 'bp_y'] and curve.frame == ('snapshot',), ( 'the curve as the file states it, with its links typed and its frame decided' ) - assert curve.links[0].expression == VariableReference('p'), 'a link is the tree the file wrote' + assert curve.links[0].expression == VariableReference('p', dims=('snapshot',)), 'a link is the tree the file wrote' assert 'cost_curve_lam' not in program.variables, 'the rows are on the expansion' assert not rows.piecewise and {'cost_curve_lam', 'p', 'op_cost'} <= set(rows.variables), ( 'the expansion carries the rows and no curve' diff --git a/tests/test_validation.py b/tests/test_validation.py index ab668fde..4ff88073 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -2341,10 +2341,10 @@ def record(*args, **kwargs): ) def test_a_plain_entry_that_breaks_a_dim_rule_is_refused_at_load_under_its_own_name(constraints): """An entry nothing read loaded and failed only when printed, and one a constraint read was - refused under the constraint's name. The program reads an entry's frame off its body at - load, so the fault is the entry's, wherever it is read.""" + refused under the constraint's name. Resolution checks each node as it builds the entry's + body, so the fault is the entry's, wherever it is read.""" model = varied(SMALL_MODEL, expressions={'bad': {'expression': 'sum(k, over=g)'}}, constraints=constraints) - with pytest.raises(DimensionError, match=r"^Named expression 'bad': sum\(over=g\)"): + with pytest.raises(DimensionError, match=r"(?m)^Named expression 'bad': sum\(over=g\)"): to_spec(model)