Static dim-set checking — a type system whose type is a set of dim names.
Parameter dims are declared, variable foreach is declared, and operator
dimension arguments are name-checked, so every node's dim set is computable
before any data is bound. That is the whole basis of this pass: it runs at
load time, on the resolved core AST, so every consumer gets the same answer by
construction. The per-node rules are the "Dim algebra" table in
docs/reference/language/expressions.md; at the declaration level::
constraint -> the dims of both sides together must *equal* foreach
where -> the predicate's dims must not exceed the frame
bounds -> the bound parameter's dims must not exceed foreach
The direction that matters most is the stray dim: one the frame does not
declare broadcasts silently at build time, so the same YAML quietly builds a
bigger model than it reads as. The missing direction is checked too, a foreach
dim the equation never uses just repeating one row across it — nearly always a
typo.
check_schema(schema)
Check every declaration's dim rules.
| RAISES |
DESCRIPTION |
DimensionError
|
On the first declaration that breaks one.
|
Source code in src/math_spec/dimensions.py
| def check_schema(schema: Spec) -> None:
"""Check every declaration's dim rules.
Raises:
DimensionError: On the first declaration that breaks one.
"""
ns = Namespace.of(schema)
for vname, vdef in schema.variables.items():
frame = frozenset(vdef.foreach)
context = f"Variable '{vname}'"
_check_where_dims(where_of(vdef.where, ns, context), schema, frame, context)
for side in ('lower', 'upper'):
bound = getattr(vdef.bounds, side)
if isinstance(bound, str):
bdims = frozenset(schema.parameters[bound].dims)
if not bdims <= frame:
raise DimensionError(
f"{context}: bounds.{side} parameter '{bound}' has dims "
f"{sorted(bdims - frame)} outside the variable's foreach "
f'{sorted(frame)}.'
)
for cname, cdef in schema.constraints.items():
frame = frozenset(cdef.foreach)
context = f"Constraint '{cname}'"
_check_where_dims(where_of(cdef.where, ns, context), schema, frame, context)
got = dims_of(expression_of(cdef.expression, schema, ns, context), schema, context)
if got != frame:
stray, missing = sorted(got - frame), sorted(frame - got)
detail = (
f'carries dims {stray} that are not in foreach {sorted(frame)} — every '
f'stray dim multiplies the rows this constraint builds; add it to '
f'foreach if that is intended, or sum it out'
if stray
else f'does not carry {missing}, which foreach declares — the same row '
f'would be repeated across {missing}; drop it from foreach, or use it '
f'in the expression'
)
raise DimensionError(f'{context}: the expression {detail}.')
if schema.objective is not None:
context = 'The objective'
got = dims_of(expression_of(schema.objective.expression, schema, ns, context), schema, context)
if got:
raise DimensionError(
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)`.'
)
|
dims_of(node, schema, context)
The dim set of a resolved expression, checking every rule on the way.
Source code in src/math_spec/dimensions.py
| def dims_of(
node: ExpressionNode,
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.
"""
if isinstance(node, ComparisonNode):
return _dims(node.left, schema, context) | _dims(node.right, schema, context)
return _dims(node, schema, context)
|
where_dims(node, schema)
The dims a resolved predicate reads.
A where: is checked against a declared frame; a checks: block has
no frame of its own, so its rows are exactly the coordinates its names
span, and this is what says which those are.
Source code in src/math_spec/dimensions.py
| def where_dims(node: WhereNode | None, schema: Spec) -> frozenset[str]:
"""The dims a resolved predicate reads.
A ``where:`` is checked *against* a declared frame; a ``checks:`` block has
no frame of its own, so its rows are exactly the coordinates its names
span, and this is what says which those are.
"""
if node is None or isinstance(node, BooleanLiteralNode):
return frozenset()
if isinstance(node, (ParameterDefinedNode, ParameterComparisonNode)):
return frozenset(schema.parameters[node.name].dims)
if isinstance(node, VariableDefinedNode):
return frozenset(schema.variables[node.name].foreach)
if isinstance(node, (DimensionComparisonNode, DimensionPositionNode)):
return frozenset({node.name})
if isinstance(node, (LookupComparisonNode, LookupPairComparisonNode, LookupDefinedNode)):
return frozenset({node.over})
if isinstance(node, NotNode):
return where_dims(node.operand, schema)
if isinstance(node, (AndNode, OrNode)):
return where_dims(node.left, schema) | where_dims(node.right, schema)
msg = f'{type(node).__name__} reached the dim reader unresolved.'
raise AssertionError(msg)
|