mathspec 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mathspec/dimensions.py ADDED
@@ -0,0 +1,377 @@
1
+ # SPDX-FileCopyrightText: mathspec Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """Static dim-set checking — a type system whose type is a set of dim names.
6
+
7
+ Every node's dim set is computable before any data is attached, so this pass runs
8
+ at load on the resolved tree. The per-node rules are the "Dim algebra" table in
9
+ ``docs/reference/language/expressions.md``; a constraint's two sides together
10
+ must equal its ``dims``, and a where or a bound may not exceed the frame.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TYPE_CHECKING, assert_never
16
+
17
+ from mathspec.errors import DimensionError, case_context
18
+ from mathspec.operators import AMOUNTS
19
+ from mathspec.program import (
20
+ Add,
21
+ Cases,
22
+ Constant,
23
+ CountComparison,
24
+ DimensionComparison,
25
+ DimensionPosition,
26
+ Direction,
27
+ Divide,
28
+ Dual,
29
+ Expression,
30
+ ExpressionComparison,
31
+ GroupSum,
32
+ Mask,
33
+ Multiply,
34
+ Named,
35
+ Negate,
36
+ Parameter,
37
+ ParameterComparison,
38
+ ParameterDefined,
39
+ Partition,
40
+ Power,
41
+ Pullback,
42
+ PulledBackPredicate,
43
+ RelationComparison,
44
+ RelationDefined,
45
+ RelationPairComparison,
46
+ Sum,
47
+ Translate,
48
+ TranslatedPredicate,
49
+ Variable,
50
+ VariableDefined,
51
+ WindowSum,
52
+ children,
53
+ )
54
+
55
+ if TYPE_CHECKING:
56
+ from mathspec.model import Spec
57
+ from mathspec.program import Program
58
+
59
+
60
+ def dims_of(node: Expression, schema: Spec, context: str) -> frozenset[str]:
61
+ """The dim set of a resolved expression, checking every rule on the way.
62
+
63
+ Raises:
64
+ DimensionError: On the first rule broken.
65
+ """
66
+ if isinstance(node, Constant):
67
+ return frozenset()
68
+
69
+ if isinstance(node, Parameter):
70
+ return frozenset(schema.parameters[node.name].dims)
71
+
72
+ if isinstance(node, Variable):
73
+ return frozenset(schema.variables[node.name].dims)
74
+
75
+ if isinstance(node, Dual):
76
+ return frozenset(schema.constraints[node.constraint].dims)
77
+
78
+ if isinstance(node, Named):
79
+ return _named_dims(node, schema, context)
80
+
81
+ if isinstance(node, Cases):
82
+ return frozenset().union(*(dims_of(region.value, schema, context) for region in node.regions))
83
+
84
+ if isinstance(node, Negate | Add | Multiply | Power | Divide):
85
+ return frozenset().union(*(dims_of(child, schema, context) for child in children(node)))
86
+
87
+ inner = dims_of(node.operand, schema, context)
88
+ if isinstance(node, Sum):
89
+ return _sum_dims(node, inner, context)
90
+ if isinstance(node, GroupSum):
91
+ return _group_sum_dims(node, inner, context)
92
+ if isinstance(node, Pullback):
93
+ return _at_dims(node, inner, context)
94
+ if isinstance(node, Translate | WindowSum):
95
+ return _translation_dims(node, inner, schema, context)
96
+
97
+ assert_never(node)
98
+
99
+
100
+ def _named_dims(node: Named, schema: Spec, context: str) -> frozenset[str]:
101
+ """A cased entry's declared frame rather than the union of its arms — a narrower arm broadcasts — and a plain entry's body."""
102
+ declared = schema.expressions[node.name].dims
103
+ if declared is not None:
104
+ return frozenset(declared)
105
+ return dims_of(node.body, schema, context)
106
+
107
+
108
+ def _not_carried(context: str, call: str, inner: frozenset[str], rewrite: str) -> str:
109
+ """The refusal for an operator reaching a dim its operand does not carry; *rewrite* is the operator's own."""
110
+ return (
111
+ f'{context}: {call} but the expression has dims {sorted(inner)}. An operator over a dim the '
112
+ f'operand does not carry is a no-op that builds and solves wrong — {rewrite}.'
113
+ )
114
+
115
+
116
+ def _sum_dims(node: Sum, inner: frozenset[str], context: str) -> frozenset[str]:
117
+ """``sum`` reduces each named dim away, so the operand carries every one."""
118
+ for consumed in node.over:
119
+ if consumed not in inner:
120
+ raise DimensionError(_not_carried(context, f'sum(over={consumed})', inner, 'drop the sum, or fix the dim'))
121
+ return inner - frozenset(node.over)
122
+
123
+
124
+ def _group_sum_dims(node: GroupSum, inner: frozenset[str], context: str) -> frozenset[str]:
125
+ """``sum`` through a relation: the consumed dim goes, the produced dims arrive, the joined stay."""
126
+ direction = node.direction
127
+ if missing := sorted(set(direction.consumed_dims) - inner):
128
+ raise DimensionError(
129
+ _not_carried(
130
+ context,
131
+ f'sum(by={direction.name}) consumes {missing}, the dims it reads from,',
132
+ inner,
133
+ 'drop the sum, or fix the dim',
134
+ )
135
+ )
136
+ return _read_dims(f'sum(by={direction.name})', direction, inner, context)
137
+
138
+
139
+ def _at_dims(node: Pullback, inner: frozenset[str], context: str) -> frozenset[str]:
140
+ """``at`` is the adjoint of ``sum(by=)``: it consumes the dims a sum produces and produces the ones it consumes."""
141
+ return pulled_back_dims(node.direction, inner, context, 'the expression')
142
+
143
+
144
+ def pulled_back_dims(direction: Direction, inner: frozenset[str], context: str, operand: str) -> frozenset[str]:
145
+ """The dims *inner* has once ``at`` reads it through *direction*, an expression's or a predicate's alike.
146
+
147
+ Raises:
148
+ DimensionError: *operand* does not carry a dim the read consumes or
149
+ joins on, or already carries one it lands on.
150
+ """
151
+ if absent := sorted(set(direction.consumed_dims) - inner):
152
+ raise DimensionError(
153
+ f'{context}: at(by={direction.name}) reads through '
154
+ f'{absent}, which {operand} does not carry (dims '
155
+ f'{sorted(inner)}). A pullback needs the coarse dims to read *from* — '
156
+ f'sum is the direction that produces them.'
157
+ )
158
+ return _read_dims(f'at(by={direction.name})', direction, inner, context)
159
+
160
+
161
+ #: The verb a file writes each translation with, which its refusals quote.
162
+ _VERBS: dict[type[Translate | WindowSum], str] = {Translate: 'shift', WindowSum: 'sum_back'}
163
+
164
+
165
+ def _translation_dims(node: Translate | WindowSum, inner: frozenset[str], schema: Spec, context: str) -> frozenset[str]:
166
+ """``shift`` and ``sum_back`` keep every dim, and a named amount and a partition are checked here."""
167
+ verb = _VERBS[type(node)]
168
+ if node.along not in inner:
169
+ raise DimensionError(
170
+ _not_carried(
171
+ context,
172
+ f'{verb}(along={node.along})',
173
+ inner,
174
+ f'name a dim the operand carries, or drop the {verb}',
175
+ )
176
+ )
177
+ _check_named_amount(node, verb, inner, schema, context)
178
+ if node.partition is not None:
179
+ _check_joined(f'{verb}(along={node.along}, by={node.partition.name})', node.partition, inner, context)
180
+ return inner
181
+
182
+
183
+ def _read_dims(call: str, direction: Direction, inner: frozenset[str], context: str) -> frozenset[str]:
184
+ """The dims after a relation is read in *direction*: the consumed go, the produced arrive, the joined stay.
185
+
186
+ The dims a call lands on are its own to bring, so the operand does not
187
+ already carry one. Where it does, the call would tie the operand's axis to
188
+ the one it produces rather than adding it, and it reads the same either
189
+ way. A relation into its own dimension is not that case: there the dim
190
+ landed on is the dim just consumed, so every factor is read at the
191
+ coordinate the sum runs over, and nothing is tied.
192
+ """
193
+ consumed, produced = set(direction.consumed_dims), set(direction.produced_dims)
194
+ if clash := sorted((produced & inner) - consumed):
195
+ raise DimensionError(
196
+ f'{context}: {call} lands on {clash}, which the expression already carries.\n'
197
+ f'A call brings the dims it lands on, so that reading it tells you what it '
198
+ f'adds. Move the factor carrying {clash} outside the operator, or read to a column '
199
+ f'over another dimension.'
200
+ )
201
+ _check_joined(call, direction, inner, context)
202
+ return (inner - consumed) | produced
203
+
204
+
205
+ def _check_joined(call: str, use: Direction | Partition, inner: frozenset[str], context: str) -> None:
206
+ """The columns a call joins on are read at their dimensions, so the operand carries every one, each once.
207
+
208
+ A joined dimension the call also consumes is the same ambiguity as two
209
+ joined columns over one dimension: the operand's one coordinate would
210
+ have to be read as both. A partition consumes nothing.
211
+ """
212
+ dims = use.joined_dims
213
+ if missing := sorted(set(dims) - inner):
214
+ raise DimensionError(
215
+ f'{context}: {call} joins on {missing} (columns {[r for r in use.joined if use.dim(r) in missing]} '
216
+ f"of '{use.name}'), which the expression does not carry (dims {sorted(inner)}). A relation is "
217
+ f'read between two of its columns and joined at the others — index the operand by them, or '
218
+ f'read it between different columns.'
219
+ )
220
+ consumed = use.consumed_dims if isinstance(use, Direction) else ()
221
+ if twice := sorted({d for d in dims if dims.count(d) > 1 or d in consumed}):
222
+ raise DimensionError(
223
+ f"{context}: {call} joins '{use.name}' on {twice} through more than one column, and the operand "
224
+ f'carries each dimension once. Read between different columns, or use a relation whose joined '
225
+ f'columns are over distinct dimensions.'
226
+ )
227
+
228
+
229
+ def _check_named_amount(
230
+ node: Translate | WindowSum, verb: str, inner: frozenset[str], schema: Spec, context: str
231
+ ) -> None:
232
+ """The two rules of an ``offset=`` or ``window=`` naming a parameter that need the operand's dims; resolution holds it to its dtype."""
233
+ kwarg, amount = ('offset', node.offset) if isinstance(node, Translate) else ('window', node.width)
234
+ if not isinstance(amount, str):
235
+ return
236
+ words = AMOUNTS[verb]
237
+ declared = schema.parameters[amount]
238
+ if node.along in declared.dims:
239
+ raise DimensionError(
240
+ f'{context}: {verb}({kwarg}={amount}) steps along '
241
+ f"'{node.along}', but '{amount}' is declared over {sorted(declared.dims)}, which "
242
+ f'carries it. A named {words.noun} that varies over the axis it steps along is {words.varies} '
243
+ f"— declare '{amount}' over dims '{node.along}' is not one of."
244
+ )
245
+ groups = (
246
+ frozenset(node.partition.dim(v) for v in node.partition.group) if node.partition is not None else frozenset()
247
+ )
248
+ if stray := sorted(frozenset(declared.dims) - inner - groups):
249
+ raise DimensionError(
250
+ f'{context}: {verb}({kwarg}={amount}) reads its {words.noun} at the coordinate it '
251
+ f"steps from, but '{amount}' varies over {stray}, which that coordinate does not carry "
252
+ f'(dims {sorted(inner)}). A dim the coordinate does not have is no coordinate at all — '
253
+ f"declare '{amount}' over dims the expression carries, or group by a relation into "
254
+ f'one of {stray}, so that each group is reached by its own {words.noun}.'
255
+ )
256
+
257
+
258
+ # ---------------------------------------------------------------------------
259
+ # declaration-level rules
260
+ # ---------------------------------------------------------------------------
261
+
262
+
263
+ def check_schema(schema: Spec, program: Program) -> None:
264
+ """Check every declaration's dim rules, on the trees *program* holds for *schema*.
265
+
266
+ Raises:
267
+ DimensionError: On the first declaration that breaks one.
268
+ """
269
+ for vname, vdef in schema.variables.items():
270
+ frame = frozenset(vdef.dims)
271
+ context = f"Variable '{vname}'"
272
+ _check_where_dims(program.variables[vname].where, frame, context)
273
+ for side in ('lower', 'upper'):
274
+ bound = getattr(vdef.bounds, side)
275
+ if isinstance(bound, str):
276
+ bdims = frozenset(schema.parameters[bound].dims)
277
+ if not bdims <= frame:
278
+ raise DimensionError(
279
+ f"{context}: bounds.{side} parameter '{bound}' has dims "
280
+ f"{sorted(bdims - frame)} outside the variable's dims "
281
+ f'{sorted(frame)}.'
282
+ )
283
+
284
+ for ename, entry in program.expressions.items():
285
+ if not isinstance(entry.expression, Cases):
286
+ continue
287
+ block = schema.expressions[ename]
288
+ frame = frozenset(block.dims or [])
289
+ for region, label in zip(entry.expression.regions, [*block.cases, None], strict=True):
290
+ context = case_context(ename, label)
291
+ if label is not None:
292
+ _check_where_dims(region.when, frame, context)
293
+ _check_value_dims(region.value, schema, frame, context)
294
+
295
+ for cname, constraint in program.constraints.items():
296
+ frame = frozenset(constraint.dims)
297
+ context = f"Constraint '{cname}'"
298
+ _check_where_dims(constraint.where, frame, context)
299
+ got = dims_of(constraint.lhs, schema, context) | dims_of(constraint.rhs, schema, context)
300
+ if got != frame:
301
+ stray, missing = sorted(got - frame), sorted(frame - got)
302
+ detail = (
303
+ f'carries dims {stray} that are not in its dims: {sorted(frame)} — every '
304
+ f'stray dim multiplies the rows this constraint builds; add it to '
305
+ f'dims: if that is intended, or sum it out'
306
+ if stray
307
+ else f'does not carry {missing}, which its dims: declares — the same row '
308
+ f'would be repeated across {missing}; drop it from dims:, or use it '
309
+ f'in the expression'
310
+ )
311
+ raise DimensionError(f'{context}: the expression {detail}.')
312
+
313
+ if program.objective is not None:
314
+ context = 'The objective'
315
+ got = dims_of(program.objective.expression, schema, context)
316
+ if got:
317
+ raise DimensionError(
318
+ f'{context}: the expression carries dims {sorted(got)}, and an objective is one '
319
+ f'number. Wrap each additive term in its own sum(): '
320
+ f'`sum(p * cost) + sum(p_nom * capex)`.'
321
+ )
322
+
323
+
324
+ def _check_value_dims(node: Expression, schema: Spec, frame: frozenset[str], context: str) -> None:
325
+ """A region's value may only carry dims the frame does — the ``otherwise:`` included.
326
+
327
+ A wider one would give the quantity dims its declaration does not, which is
328
+ the second answer a ``dims:`` exists to avoid.
329
+ """
330
+ got = dims_of(node, schema, context)
331
+ if not got <= frame:
332
+ raise DimensionError(
333
+ f'{context}: the value carries dims {sorted(got - frame)} outside the dims: '
334
+ f'{sorted(frame)}. A case is a value within the frame — it cannot widen it.'
335
+ )
336
+
337
+
338
+ def _check_where_dims(
339
+ mask: Mask | None,
340
+ frame: frozenset[str],
341
+ context: str,
342
+ ) -> None:
343
+ """A predicate may only test dims the frame carries; reducing an outside dim to fit would fail open.
344
+
345
+ The refusal names the leaf that left the frame, reading its dims as
346
+ :attr:`~mathspec.program.Mask.dims` does.
347
+ """
348
+ if mask is None:
349
+ return
350
+
351
+ for atom in mask.atoms:
352
+ if not (outside := sorted(Mask(atom).dims - frame)):
353
+ continue
354
+ match atom:
355
+ case ParameterDefined() | ParameterComparison():
356
+ leaf = f"where-parameter '{atom.name}'"
357
+ case VariableDefined():
358
+ leaf = f"where-variable '{atom.name}'"
359
+ case DimensionComparison() | DimensionPosition():
360
+ leaf = f"where-dimension '{atom.name}'"
361
+ case RelationComparison() | RelationPairComparison() | RelationDefined():
362
+ leaf = f"where-relation '{atom.name}'"
363
+ case ExpressionComparison():
364
+ leaf = 'a where-comparison of expressions'
365
+ case CountComparison():
366
+ leaf = f"a where-count over '{atom.over}'"
367
+ case TranslatedPredicate():
368
+ leaf = f"a where-predicate translated along '{atom.along}'"
369
+ case PulledBackPredicate():
370
+ leaf = f"a where-predicate read through '{atom.direction.name}'"
371
+ case _:
372
+ assert_never(atom)
373
+ raise DimensionError(
374
+ f'{context}: {leaf} reads dims {outside} outside the frame {sorted(frame)}. '
375
+ f'Reducing a mask over an unlisted dim would silently widen it — add the dim to dims:, '
376
+ f'or test a name the frame carries.'
377
+ )
mathspec/errors.py ADDED
@@ -0,0 +1,105 @@
1
+ # SPDX-FileCopyrightText: mathspec Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """What the language says back about a file: the errors it raises, and the advice it gives."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import difflib
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING, Literal
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Iterable
15
+
16
+ from pydantic import ValidationError
17
+
18
+
19
+ #: Which pass an :class:`Advice` comes from. Closed, like the operator set: a
20
+ #: consumer filtering on it can enumerate every value.
21
+ AdviceKind = Literal['never-an-axis', 'unbounded']
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Advice:
26
+ """One thing the language advises about a file it accepts.
27
+
28
+ Never an error: each is what a half-written model looks like too. A
29
+ consumer prints it, or filters on ``kind`` and ``subject``; the text is the
30
+ language's, so no consumer writes its own.
31
+
32
+ Attributes:
33
+ kind: The pass that said it.
34
+ subject: The declaration it is about — a dimension name, a variable name.
35
+ text: The sentence, naming the rewrite.
36
+ """
37
+
38
+ kind: AdviceKind
39
+ subject: str
40
+ text: str
41
+
42
+ def __str__(self) -> str:
43
+ return self.text
44
+
45
+
46
+ class MathSpecError(ValueError):
47
+ """Base class for every error this package raises on purpose."""
48
+
49
+
50
+ class LanguageError(MathSpecError):
51
+ """The model is not sayable in the language, or does not obey its rules."""
52
+
53
+
54
+ class SchemaError(LanguageError):
55
+ """What a load refuses: an unknown key, a bad ``dtype``, a duplicate YAML key, an unparseable or unresolvable expression."""
56
+
57
+
58
+ class DimensionError(LanguageError):
59
+ """A dim-set rule was violated. Raised at load time, before any data."""
60
+
61
+
62
+ def did_you_mean(name: str, known: Iterable[str], *, label: str = 'Declared') -> str:
63
+ """The repair clause for an unrecognised name: the near miss, or the set."""
64
+ candidates = sorted(known)
65
+ near = difflib.get_close_matches(name, candidates, n=1, cutoff=0.6)
66
+ if near:
67
+ return f"Did you mean '{near[0]}'?"
68
+ return f'{label}: {", ".join(candidates) or "nothing"}.'
69
+
70
+
71
+ def schema_error(exc: ValidationError) -> LanguageError:
72
+ """A pydantic ``ValidationError`` as one of ours.
73
+
74
+ Returns the original :class:`LanguageError` subclass where exactly one
75
+ error carries one, and a :class:`SchemaError` otherwise.
76
+ """
77
+ errors = exc.errors()
78
+ lines = []
79
+ for error in errors:
80
+ message = error.get('msg', '').removeprefix('Value error, ')
81
+ where = '.'.join(str(part) for part in error.get('loc', ()))
82
+ lines.append(f'{where}: {message}' if where else message)
83
+ text = '\n'.join(lines) or str(exc)
84
+
85
+ if len(errors) == 1:
86
+ original = errors[0].get('ctx', {}).get('error')
87
+ if isinstance(original, LanguageError):
88
+ return type(original)(text)
89
+ return SchemaError(text)
90
+
91
+
92
+ def prefixed(context: str, e: ValueError) -> str:
93
+ """*e* under *context*, once — an expansion error already carries it."""
94
+ return str(e) if str(e).startswith(context) else f'{context}: {e}'
95
+
96
+
97
+ def case_context(name: str, label: str | None) -> str:
98
+ """The context an error inside one arm of a cased expression is reported under.
99
+
100
+ Args:
101
+ name: The named expression the arm belongs to.
102
+ label: The case's name, or ``None`` for the block's ``otherwise:``.
103
+ """
104
+ where = 'otherwise' if label is None else f"case '{label}'"
105
+ return f"Named expression '{name}', {where}"