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/__init__.py +59 -0
- mathspec/__main__.py +87 -0
- mathspec/_expression_parser.py +436 -0
- mathspec/_expression_resolver.py +739 -0
- mathspec/_sealed.py +62 -0
- mathspec/_where_parser.py +288 -0
- mathspec/_where_resolver.py +745 -0
- mathspec/_yaml.py +141 -0
- mathspec/advice.py +79 -0
- mathspec/boundedness.py +168 -0
- mathspec/degree.py +209 -0
- mathspec/dimensions.py +377 -0
- mathspec/errors.py +105 -0
- mathspec/exclusivity.py +587 -0
- mathspec/expansion.py +124 -0
- mathspec/lowering.py +302 -0
- mathspec/model.py +905 -0
- mathspec/operators.py +235 -0
- mathspec/piecewise.py +505 -0
- mathspec/program.py +1514 -0
- mathspec/py.typed +0 -0
- mathspec/resolution.py +475 -0
- mathspec/separability.py +185 -0
- mathspec/sos.py +159 -0
- mathspec/typesetting/README.md +110 -0
- mathspec/typesetting/__init__.py +223 -0
- mathspec/typesetting/format.py +273 -0
- mathspec/typesetting/latex.py +139 -0
- mathspec/typesetting/legend.py +286 -0
- mathspec/typesetting/markdown.py +105 -0
- mathspec/typesetting/symbols.py +310 -0
- mathspec/typesetting/typst.py +147 -0
- mathspec/typesetting/walk.py +941 -0
- mathspec/validation.py +338 -0
- mathspec-0.1.0.dist-info/METADATA +247 -0
- mathspec-0.1.0.dist-info/RECORD +38 -0
- mathspec-0.1.0.dist-info/WHEEL +4 -0
- mathspec-0.1.0.dist-info/licenses/LICENSE +21 -0
mathspec/py.typed
ADDED
|
File without changes
|
mathspec/resolution.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: mathspec Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""Name resolution — the pass that reads the syntax tree into the program vocabulary.
|
|
6
|
+
|
|
7
|
+
The grammars emit bare names and calls; resolution builds the
|
|
8
|
+
:mod:`mathspec.program` node each stands for, so every pass after — the dim
|
|
9
|
+
rules, the degree rules, the typesetter, lowering — reads one vocabulary. This
|
|
10
|
+
module holds the :class:`Namespace` a resolution reads names from and the doors
|
|
11
|
+
lowering calls, one per kind of text; the walks are
|
|
12
|
+
:class:`~mathspec._expression_resolver.ExpressionResolver` for arithmetic and
|
|
13
|
+
:class:`~mathspec._where_resolver.WhereResolver` for a where string. The rules
|
|
14
|
+
live in the language reference.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import TYPE_CHECKING, Literal, cast
|
|
20
|
+
|
|
21
|
+
import mathspec.degree as degree
|
|
22
|
+
from mathspec._expression_parser import (
|
|
23
|
+
ArithmeticNode,
|
|
24
|
+
ComparisonNode,
|
|
25
|
+
NameNode,
|
|
26
|
+
nodes,
|
|
27
|
+
)
|
|
28
|
+
from mathspec._expression_resolver import ExpressionResolver
|
|
29
|
+
from mathspec._where_parser import (
|
|
30
|
+
UnresolvedWhereNode,
|
|
31
|
+
nested,
|
|
32
|
+
parse_where,
|
|
33
|
+
)
|
|
34
|
+
from mathspec._where_resolver import WhereResolver
|
|
35
|
+
from mathspec.errors import LanguageError, SchemaError, case_context, prefixed
|
|
36
|
+
from mathspec.exclusivity import overlapping
|
|
37
|
+
from mathspec.expansion import expand, parse_and_expand
|
|
38
|
+
from mathspec.program import (
|
|
39
|
+
BooleanLiteral,
|
|
40
|
+
Cases,
|
|
41
|
+
Expression,
|
|
42
|
+
Mask,
|
|
43
|
+
Named,
|
|
44
|
+
Predicate,
|
|
45
|
+
Region,
|
|
46
|
+
RelationDeclaration,
|
|
47
|
+
carries_variable,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING:
|
|
51
|
+
from collections.abc import Iterable
|
|
52
|
+
|
|
53
|
+
from mathspec._expression_parser import ComparisonOperator, ParsedNode
|
|
54
|
+
from mathspec._where_parser import ParsedWhere
|
|
55
|
+
from mathspec.model import ExpressionBlock, Spec
|
|
56
|
+
from mathspec.program import DeclaredDtype
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
#: What a name a file may write turns out to be. Answered by
|
|
60
|
+
#: :meth:`Namespace.kind`, so a pass reading a name switches over this rather
|
|
61
|
+
#: than over the stores it would otherwise have to try in order.
|
|
62
|
+
DeclarationKind = Literal['variable', 'parameter', 'dimension', 'relation']
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Namespace:
|
|
66
|
+
"""The declared names of one schema, by kind — the whole of what a file may name, read once.
|
|
67
|
+
|
|
68
|
+
A name has one kind: validation.py refuses one declared under two sections.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
__slots__ = (
|
|
72
|
+
'_loading',
|
|
73
|
+
'_named',
|
|
74
|
+
'constraints',
|
|
75
|
+
'dimensions',
|
|
76
|
+
'dtypes',
|
|
77
|
+
'leaf_dims',
|
|
78
|
+
'parameters',
|
|
79
|
+
'relations',
|
|
80
|
+
'schema',
|
|
81
|
+
'variables',
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def __init__(self, schema: Spec) -> None:
|
|
85
|
+
#: The schema the names come from — what an expression is expanded and
|
|
86
|
+
#: dim-checked against, since macros, named expressions and the dim
|
|
87
|
+
#: rules read declarations the flat listing below does not carry.
|
|
88
|
+
self.schema = schema
|
|
89
|
+
self.variables = frozenset(schema.variables)
|
|
90
|
+
self.parameters = frozenset(schema.parameters)
|
|
91
|
+
self.dimensions = frozenset(schema.dimensions)
|
|
92
|
+
#: The declared constraint names, off the flat namespace: a bare name
|
|
93
|
+
#: never reaches them, so a model may name a constraint after a variable.
|
|
94
|
+
#: Consulted only in ``dual()``'s argument position.
|
|
95
|
+
self.constraints = frozenset(schema.constraints)
|
|
96
|
+
#: name -> declared dtype, for dimensions, parameters and relations alike;
|
|
97
|
+
#: what a where comparison checks its literal against.
|
|
98
|
+
self.dtypes: dict[str, DeclaredDtype] = {
|
|
99
|
+
**{p: pd.dtype for p, pd in schema.parameters.items()},
|
|
100
|
+
**{d: dd.dtype for d, dd in schema.dimensions.items()},
|
|
101
|
+
}
|
|
102
|
+
#: relation name -> its columns and key, as declared.
|
|
103
|
+
self.relations: dict[str, RelationDeclaration] = {
|
|
104
|
+
n: RelationDeclaration(lk.pairs, lk.key_roles, lk.description) for n, lk in schema.relations.items()
|
|
105
|
+
}
|
|
106
|
+
#: parameter or variable name -> the dims it is read through —
|
|
107
|
+
#: parameters by their ``dims``, variables by their frame. Stamped onto
|
|
108
|
+
#: each leaf a where names, the way a relation leaf carries ``over``.
|
|
109
|
+
self.leaf_dims: dict[str, tuple[str, ...]] = {
|
|
110
|
+
**{p: tuple(pd.dims) for p, pd in schema.parameters.items()},
|
|
111
|
+
**{v: tuple(vd.dims) for v, vd in schema.variables.items()},
|
|
112
|
+
}
|
|
113
|
+
#: named expression -> its resolved node, or ``None``, and its refusals;
|
|
114
|
+
#: filled the first time anything reads the name.
|
|
115
|
+
self._named: dict[str, tuple[Named | None, tuple[str, ...]]] = {}
|
|
116
|
+
#: The named expressions waiting to be resolved, the one asked for
|
|
117
|
+
#: first — each above the entries it reads, so it is a cycle's chain.
|
|
118
|
+
self._loading: list[str] = []
|
|
119
|
+
|
|
120
|
+
def named(self, name: str, context: str) -> Named:
|
|
121
|
+
"""The ``expressions:`` entry *name* as the node that stands where its name is written.
|
|
122
|
+
|
|
123
|
+
Resolved under the entry's own context the first time it is asked
|
|
124
|
+
for, and read from then on, so a fault in it is reported once.
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
SchemaError: The entry reads itself, or does not load.
|
|
128
|
+
"""
|
|
129
|
+
if (refusal := self.cycle(name, context)) is not None:
|
|
130
|
+
raise SchemaError(refusal)
|
|
131
|
+
node, _ = self.named_entry(name)
|
|
132
|
+
if node is None:
|
|
133
|
+
msg = f"{context}: named expression '{name}' does not load. Its refusal is listed with it."
|
|
134
|
+
raise SchemaError(msg)
|
|
135
|
+
return node
|
|
136
|
+
|
|
137
|
+
def cycle(self, name: str, context: str, through: Iterable[str] = ()) -> str | None:
|
|
138
|
+
"""The refusal for reading *name* while it is being resolved, or ``None``; *through* names the macros the read went through."""
|
|
139
|
+
if name not in self._loading:
|
|
140
|
+
return None
|
|
141
|
+
chain = ' -> '.join([*self._loading[self._loading.index(name) :], *through, name])
|
|
142
|
+
return f'{context}: circular expression reference: {chain}'
|
|
143
|
+
|
|
144
|
+
def named_entry(self, name: str) -> tuple[Named | None, tuple[str, ...]]:
|
|
145
|
+
"""The ``expressions:`` entry *name* resolved, or ``None``, with every refusal it earned.
|
|
146
|
+
|
|
147
|
+
The entries it reads are resolved before it, walked from a stack that
|
|
148
|
+
holds the path of reads from *name* rather than by recursing into
|
|
149
|
+
each, so a chain of entries however long costs no stack. An entry
|
|
150
|
+
that reads one on the path is a cycle, which :meth:`named` refuses
|
|
151
|
+
with that path when the resolution reaches the read.
|
|
152
|
+
"""
|
|
153
|
+
base = len(self._loading)
|
|
154
|
+
self._loading.append(name)
|
|
155
|
+
while len(self._loading) > base:
|
|
156
|
+
top = self._loading[-1]
|
|
157
|
+
if top in self._named:
|
|
158
|
+
self._loading.pop()
|
|
159
|
+
continue
|
|
160
|
+
waiting = [n for n in self._references(top) if n not in self._named and n not in self._loading]
|
|
161
|
+
if waiting:
|
|
162
|
+
self._loading.append(waiting[0])
|
|
163
|
+
continue
|
|
164
|
+
errors: list[str] = []
|
|
165
|
+
node = _named(top, self.schema.expressions[top], self, errors)
|
|
166
|
+
self._named[top] = (node, tuple(errors))
|
|
167
|
+
self._loading.pop()
|
|
168
|
+
return self._named[name]
|
|
169
|
+
|
|
170
|
+
def _references(self, name: str) -> tuple[str, ...]:
|
|
171
|
+
"""The ``expressions:`` entries the texts of entry *name* read, macros expanded, in first-mention order.
|
|
172
|
+
|
|
173
|
+
A text that does not parse or expand reads nothing here: the
|
|
174
|
+
resolution that follows reports it.
|
|
175
|
+
"""
|
|
176
|
+
block, context = self.schema.expressions[name], f"Named expression '{name}'"
|
|
177
|
+
arithmetic: list[ParsedNode] = []
|
|
178
|
+
for text in (block.expression, *(case.expression for case in (block.cases or {}).values()), block.otherwise):
|
|
179
|
+
if text is not None:
|
|
180
|
+
try:
|
|
181
|
+
arithmetic.append(parse_and_expand(text, self, context))
|
|
182
|
+
except ValueError:
|
|
183
|
+
continue
|
|
184
|
+
for case in (block.cases or {}).values():
|
|
185
|
+
try:
|
|
186
|
+
pending: list[ParsedWhere] = [parse_where(case.when)]
|
|
187
|
+
except ValueError:
|
|
188
|
+
continue
|
|
189
|
+
while pending:
|
|
190
|
+
node = pending.pop()
|
|
191
|
+
if isinstance(node, ArithmeticNode):
|
|
192
|
+
try:
|
|
193
|
+
arithmetic.append(expand(node, self, context))
|
|
194
|
+
except ValueError:
|
|
195
|
+
continue
|
|
196
|
+
else:
|
|
197
|
+
pending.extend(nested(node))
|
|
198
|
+
names = (n.name for n in nodes(*arithmetic) if isinstance(n, NameNode) and n.name in self.schema.expressions)
|
|
199
|
+
return tuple(dict.fromkeys(names))
|
|
200
|
+
|
|
201
|
+
def kind(self, name: str) -> DeclarationKind | None:
|
|
202
|
+
"""What *name* was declared as, or ``None`` where the file declares it nowhere."""
|
|
203
|
+
if name in self.variables:
|
|
204
|
+
return 'variable'
|
|
205
|
+
if name in self.parameters:
|
|
206
|
+
return 'parameter'
|
|
207
|
+
if name in self.dimensions:
|
|
208
|
+
return 'dimension'
|
|
209
|
+
if name in self.relations:
|
|
210
|
+
return 'relation'
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
def unknown(self, name: str, context: str, *, allow_dims: bool, formals: Iterable[str] = ()) -> str:
|
|
214
|
+
"""The refusal for a *name* declared nowhere, listing what it could have been.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
name: The name the file wrote.
|
|
218
|
+
context: The declaration it was found in.
|
|
219
|
+
allow_dims: Whether a dimension would have been accepted there. It marks a
|
|
220
|
+
where string, which reads a relation as readily as a parameter, so the
|
|
221
|
+
listing carries the relations too; an expression, where a relation is not a
|
|
222
|
+
value, lists the variables instead.
|
|
223
|
+
formals: A macro's formals, listed first when there are any.
|
|
224
|
+
"""
|
|
225
|
+
shown: list[tuple[str, Iterable[str]]] = [('Formals', formals)] if formals else []
|
|
226
|
+
shown += (
|
|
227
|
+
[('Parameters', self.parameters), ('Dimensions', self.dimensions), ('Relations', self.relations)]
|
|
228
|
+
if allow_dims
|
|
229
|
+
else [('Variables', self.variables), ('Parameters', self.parameters)]
|
|
230
|
+
)
|
|
231
|
+
listing = '\n'.join(f' {kind}: {sorted(names)}' for kind, names in shown)
|
|
232
|
+
return f"{context}: '{name}' not found.\n{listing}\nCheck for typos, or ensure '{name}' is declared."
|
|
233
|
+
|
|
234
|
+
def unknown_constraint(self, name: str, context: str, *, formals: Iterable[str] = ()) -> str:
|
|
235
|
+
"""The refusal for a ``dual(name)`` naming no constraint — nor, inside a template, a formal."""
|
|
236
|
+
also = ' or a formal of this macro' if formals else ''
|
|
237
|
+
return (
|
|
238
|
+
f"{context}: dual({name}): '{name}' is not a declared constraint{also}.\n"
|
|
239
|
+
f' Constraints: {sorted(self.constraints)}\n'
|
|
240
|
+
f"Check for typos, or declare '{name}' under 'constraints:'."
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
# the seam the rest of the package uses
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def mask_of(node: Predicate | None) -> Mask | None:
|
|
250
|
+
"""The mask a declaration carries for a resolved where: ``None`` where there is none, or where every row passes."""
|
|
251
|
+
if node is None or (isinstance(node, BooleanLiteral) and node.value):
|
|
252
|
+
return None
|
|
253
|
+
return Mask(node)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def remainder(masks: Iterable[Mask]) -> Mask:
|
|
257
|
+
"""The region left over: where not one of *masks* holds.
|
|
258
|
+
|
|
259
|
+
The ``otherwise`` arm's own mask, built rather than written. ``cases:``
|
|
260
|
+
carries at least one case, so there is no vacuous truth to spell.
|
|
261
|
+
"""
|
|
262
|
+
first, *rest = masks
|
|
263
|
+
left = ~first
|
|
264
|
+
for mask in rest:
|
|
265
|
+
left = left & ~mask
|
|
266
|
+
return left
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
# expressions
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def resolve_expression(
|
|
275
|
+
node: ArithmeticNode,
|
|
276
|
+
ns: Namespace,
|
|
277
|
+
context: str,
|
|
278
|
+
errors: list[str],
|
|
279
|
+
*,
|
|
280
|
+
formals: frozenset[str] = frozenset(),
|
|
281
|
+
) -> Expression | None:
|
|
282
|
+
"""Build the program tree *node* stands for, checking every name and operator call shape on the way.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
The tree, or ``None`` once anything failed — appending to *errors*
|
|
286
|
+
rather than raising, so a caller collecting problems across a whole
|
|
287
|
+
schema reports them together. Also ``None``, with nothing appended,
|
|
288
|
+
where a name in *formals* stands under *node*: a macro template is
|
|
289
|
+
checked by the rules a call site is before anything calls it, and
|
|
290
|
+
only the call site that binds its formals has a tree to build.
|
|
291
|
+
|
|
292
|
+
"""
|
|
293
|
+
before = len(errors)
|
|
294
|
+
resolved = ExpressionResolver(ns, context, errors, formals=formals).build(node)
|
|
295
|
+
return None if len(errors) > before else resolved
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def resolve_where(
|
|
299
|
+
node: Predicate | UnresolvedWhereNode,
|
|
300
|
+
ns: Namespace,
|
|
301
|
+
context: str,
|
|
302
|
+
errors: list[str],
|
|
303
|
+
self_variable: str | None = None,
|
|
304
|
+
) -> Predicate | None:
|
|
305
|
+
"""Rewrite a parsed where AST into typed predicates, folded as :class:`~mathspec.program.Mask` folds.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
The typed tree — a mask admitting every row or none comes back as the
|
|
309
|
+
one ``BooleanLiteral`` — or ``None`` once anything failed, with the
|
|
310
|
+
problems appended to *errors*.
|
|
311
|
+
"""
|
|
312
|
+
before = len(errors)
|
|
313
|
+
resolved = WhereResolver(ns, context, errors, self_variable).where(node)
|
|
314
|
+
return None if len(errors) > before else Mask(cast('Predicate', resolved)).root
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def resolve_where_text(
|
|
318
|
+
text: str | None,
|
|
319
|
+
ns: Namespace,
|
|
320
|
+
context: str,
|
|
321
|
+
errors: list[str],
|
|
322
|
+
self_variable: str | None = None,
|
|
323
|
+
) -> Predicate | None:
|
|
324
|
+
"""Parse and resolve one where string as :func:`resolve_where` does, a parse failure appended to *errors*.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
``None`` where there is no mask to read, and where reading it failed.
|
|
328
|
+
"""
|
|
329
|
+
if text is None:
|
|
330
|
+
return None
|
|
331
|
+
try:
|
|
332
|
+
node = parse_where(text)
|
|
333
|
+
except ValueError as e:
|
|
334
|
+
errors.append(f'{context}: {e}')
|
|
335
|
+
return None
|
|
336
|
+
return resolve_where(node, ns, context, errors, self_variable)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def resolve_expression_text(
|
|
340
|
+
text: str, ns: Namespace, context: str, errors: list[str], *, ceiling: int | None
|
|
341
|
+
) -> Expression | None:
|
|
342
|
+
"""Parse, expand, resolve and degree-check one expression string that stands for a value.
|
|
343
|
+
|
|
344
|
+
*ceiling* is the degree the position honours, and ``None`` for an
|
|
345
|
+
``expressions:`` entry's body: what the math admits
|
|
346
|
+
(:func:`~mathspec.degree.check_expression`) is a rule about the position
|
|
347
|
+
that *reads* it, so it fires on the expanded tree of every objective and
|
|
348
|
+
piecewise link, and not where an entry is declared. A constraint is
|
|
349
|
+
:func:`resolve_constraint_text`'s.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
The typed tree, or ``None`` once anything failed, the problem appended
|
|
353
|
+
to *errors*.
|
|
354
|
+
"""
|
|
355
|
+
ast = _parsed(text, ns, context, errors)
|
|
356
|
+
if ast is None:
|
|
357
|
+
return None
|
|
358
|
+
if isinstance(ast, ComparisonNode):
|
|
359
|
+
errors.append(f'{context}: expression must not contain a comparison operator.\nGot: {text!r}')
|
|
360
|
+
return None
|
|
361
|
+
resolved = resolve_expression(ast, ns, context, errors)
|
|
362
|
+
if resolved is None or ceiling is None:
|
|
363
|
+
return resolved
|
|
364
|
+
return None if _over_the_ceiling(resolved, context, errors, ceiling=ceiling) else resolved
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def resolve_constraint_text(
|
|
368
|
+
text: str, ns: Namespace, context: str, errors: list[str]
|
|
369
|
+
) -> tuple[Expression, ComparisonOperator, Expression] | None:
|
|
370
|
+
"""Parse, expand, resolve and degree-check one constraint string: exactly one comparison, a variable on a side (#1171).
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
The two sides and the sense between them, or ``None`` once anything
|
|
374
|
+
failed, the problem appended to *errors*.
|
|
375
|
+
"""
|
|
376
|
+
ast = _parsed(text, ns, context, errors)
|
|
377
|
+
if ast is None:
|
|
378
|
+
return None
|
|
379
|
+
if not isinstance(ast, ComparisonNode):
|
|
380
|
+
errors.append(
|
|
381
|
+
f'{context}: expression must contain exactly one comparison operator (<=, >=, ==).\nGot: {text!r}'
|
|
382
|
+
)
|
|
383
|
+
return None
|
|
384
|
+
found = len(errors)
|
|
385
|
+
resolver = ExpressionResolver(ns, context, errors)
|
|
386
|
+
left, right = resolver.build(ast.left), resolver.build(ast.right)
|
|
387
|
+
if len(errors) > found or left is None or right is None:
|
|
388
|
+
return None
|
|
389
|
+
if any(_over_the_ceiling(side, context, errors, ceiling=2) for side in (left, right)):
|
|
390
|
+
return None
|
|
391
|
+
if not (carries_variable(left) or carries_variable(right)):
|
|
392
|
+
errors.append(
|
|
393
|
+
f'{context}: neither side of the comparison carries a variable, so the row decides nothing.\n'
|
|
394
|
+
f'Got: {text!r}\n'
|
|
395
|
+
f'A constraint is a claim about a decision, and a comparison of numbers and parameters '
|
|
396
|
+
f'is settled before the solve — no consumer builds a row for it. Name the variable it should '
|
|
397
|
+
f'bound, or state the fact under `assumptions:`, where the consumer attaching the data checks it.'
|
|
398
|
+
)
|
|
399
|
+
return None
|
|
400
|
+
return left, ast.op, right
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _parsed(text: str, ns: Namespace, context: str, errors: list[str]) -> ComparisonNode | ArithmeticNode | None:
|
|
404
|
+
"""*text* parsed and its macros expanded, or ``None`` with the refusal appended."""
|
|
405
|
+
try:
|
|
406
|
+
return parse_and_expand(text, ns, context)
|
|
407
|
+
except ValueError as e:
|
|
408
|
+
errors.append(prefixed(context, e))
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _over_the_ceiling(node: Expression, context: str, errors: list[str], *, ceiling: int) -> bool:
|
|
413
|
+
"""Whether *node* breaks the degree rules at *ceiling*, the refusal appended."""
|
|
414
|
+
try:
|
|
415
|
+
degree.check_expression(node, context, ceiling=ceiling)
|
|
416
|
+
except LanguageError as e:
|
|
417
|
+
errors.append(str(e))
|
|
418
|
+
return True
|
|
419
|
+
return False
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _named(name: str, block: ExpressionBlock, ns: Namespace, errors: list[str]) -> Named | None:
|
|
423
|
+
"""One ``expressions:`` entry as the node every use of it holds, or ``None`` once anything in it failed.
|
|
424
|
+
|
|
425
|
+
A cased entry's arms are checked one by one, so every fault is collected
|
|
426
|
+
rather than the first, and proved apart only once all of them resolve.
|
|
427
|
+
The ``otherwise`` arm becomes the region left over, so a consumer adds
|
|
428
|
+
regions rather than working out which one is left; the language proved
|
|
429
|
+
the rest apart, so the regions are disjoint and total.
|
|
430
|
+
"""
|
|
431
|
+
context = f"Named expression '{name}'"
|
|
432
|
+
if not block.cases:
|
|
433
|
+
assert block.expression is not None
|
|
434
|
+
body = resolve_expression_text(block.expression, ns, context, errors, ceiling=None)
|
|
435
|
+
return None if body is None else Named(name, body)
|
|
436
|
+
|
|
437
|
+
found = len(errors)
|
|
438
|
+
regions: list[Region] = []
|
|
439
|
+
masks: dict[str, Predicate] = {}
|
|
440
|
+
for case_name, case in block.cases.items():
|
|
441
|
+
arm_context = case_context(name, case_name)
|
|
442
|
+
when = resolve_where_text(case.when, ns, arm_context, errors)
|
|
443
|
+
if isinstance(when, BooleanLiteral):
|
|
444
|
+
errors.append(_constant_arm(arm_context, value=when.value))
|
|
445
|
+
elif when is not None:
|
|
446
|
+
masks[case_name] = when
|
|
447
|
+
value = resolve_expression_text(case.expression, ns, arm_context, errors, ceiling=None)
|
|
448
|
+
if when is not None and value is not None:
|
|
449
|
+
regions.append(Region(Mask(when), value))
|
|
450
|
+
assert block.otherwise is not None
|
|
451
|
+
fallback = resolve_expression_text(block.otherwise, ns, case_context(name, None), errors, ceiling=None)
|
|
452
|
+
if len(errors) > found or fallback is None:
|
|
453
|
+
return None
|
|
454
|
+
errors.extend(f'{context}: {problem}' for problem in overlapping(masks, ns.dtypes))
|
|
455
|
+
if len(errors) > found:
|
|
456
|
+
return None
|
|
457
|
+
left_over = Region(remainder(region.when for region in regions), fallback)
|
|
458
|
+
return Named(name, Cases((*regions, left_over)))
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _constant_arm(context: str, *, value: bool) -> str:
|
|
462
|
+
"""The refusal for a case arm whose mask the connectives already decided.
|
|
463
|
+
|
|
464
|
+
Cases are proved apart rather than ranked, so an always-true arm is not
|
|
465
|
+
one that shadows the arms under it — it is one no other arm can be proved
|
|
466
|
+
apart from, and the ``otherwise`` it leaves is empty. An always-false arm
|
|
467
|
+
is the plainer half: nothing to apply to.
|
|
468
|
+
"""
|
|
469
|
+
if value:
|
|
470
|
+
return (
|
|
471
|
+
f'{context}: the mask admits every row, so no other arm can hold anywhere '
|
|
472
|
+
f'and `otherwise:` covers nothing. Write the expression without `cases:`, '
|
|
473
|
+
f'or narrow the `when`.'
|
|
474
|
+
)
|
|
475
|
+
return f'{context}: the mask admits no row, so this arm never applies. Delete the arm, or widen the `when`.'
|
mathspec/separability.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: mathspec Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""The walk behind :attr:`~mathspec.program.Program.separability` — every axis's verdict, in one pass over a program."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Literal, NamedTuple
|
|
10
|
+
|
|
11
|
+
from mathspec.program import (
|
|
12
|
+
Cases,
|
|
13
|
+
DimensionPosition,
|
|
14
|
+
GroupSum,
|
|
15
|
+
Mask,
|
|
16
|
+
Pullback,
|
|
17
|
+
Reach,
|
|
18
|
+
Separability,
|
|
19
|
+
Sum,
|
|
20
|
+
Translate,
|
|
21
|
+
WindowSum,
|
|
22
|
+
walk,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Iterator
|
|
27
|
+
|
|
28
|
+
from mathspec.program import Expression, Program
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _Block(NamedTuple):
|
|
32
|
+
"""One group of expressions the walk judges together, as the lowering's own messages label it.
|
|
33
|
+
|
|
34
|
+
``row`` is the constraint whose rows the block builds, and ``None`` where
|
|
35
|
+
it builds none — a bound narrows a column, and the objective is one row no
|
|
36
|
+
window cuts. A coupling reported against a block without a row is still a
|
|
37
|
+
coupling, and never a linking row.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
label: str
|
|
41
|
+
row: str | None
|
|
42
|
+
nodes: tuple[Expression, ...]
|
|
43
|
+
mask: Mask | None
|
|
44
|
+
reductions_couple: bool
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _built_blocks(program: Program) -> Iterator[_Block]:
|
|
48
|
+
"""Every block that builds rows.
|
|
49
|
+
|
|
50
|
+
A named expression is not one: its body stands under each use, so walking
|
|
51
|
+
the constraint sides reaches it, and walking it again would report one
|
|
52
|
+
coupling twice.
|
|
53
|
+
"""
|
|
54
|
+
for name, block in program.constraints.items():
|
|
55
|
+
yield _Block(f"constraint '{name}'", name, (block.lhs, block.rhs), block.where, True)
|
|
56
|
+
for name, variable in program.variables.items():
|
|
57
|
+
bounds = tuple(side for side in (variable.lower, variable.upper) if side is not None)
|
|
58
|
+
yield _Block(f"variable '{name}'", None, bounds, variable.where, True)
|
|
59
|
+
if program.objective is not None:
|
|
60
|
+
yield _Block('the objective', None, (program.objective.expression,), None, False)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def separabilities(program: Program) -> dict[str, Separability]:
|
|
64
|
+
"""Every axis's verdict, in one walk.
|
|
65
|
+
|
|
66
|
+
One traversal rather than one per axis, because every construct that ties an
|
|
67
|
+
axis together names the axis it ties: asking each node *which* dimension it
|
|
68
|
+
is about answers for all of them at what answering for one cost.
|
|
69
|
+
|
|
70
|
+
``reductions_couple`` is the position a block stands in rather than anything
|
|
71
|
+
about the block — a sum over the axis couples a constraint row to the whole
|
|
72
|
+
horizon and leaves an objective additively separable. A translation reads
|
|
73
|
+
ahead for a negative offset; what one reads behind is the window's edge,
|
|
74
|
+
which is not asked. Each coupling carries the one modelling change that
|
|
75
|
+
would lift it, after the dash.
|
|
76
|
+
|
|
77
|
+
The border a decomposition cuts along falls out of the same walk, which is
|
|
78
|
+
why it is taken here rather than in a pass of its own: a constraint the axis
|
|
79
|
+
does not index, and one a coupling names, are the rows no window holds.
|
|
80
|
+
"""
|
|
81
|
+
ahead = dict.fromkeys(program.dimensions, 0)
|
|
82
|
+
reasons: dict[str, dict[str, dict[str, list[str]]]] = {
|
|
83
|
+
kind: {dimension: {} for dimension in program.dimensions} for kind in ('coupled', 'restarts')
|
|
84
|
+
}
|
|
85
|
+
undecided: dict[str, dict[Reach, None]] = {dimension: {} for dimension in program.dimensions}
|
|
86
|
+
rows: dict[str, str] = {}
|
|
87
|
+
|
|
88
|
+
def report(kind: str, dimension: str, label: str, reason: str) -> None:
|
|
89
|
+
reasons[kind][dimension].setdefault(label, []).append(reason)
|
|
90
|
+
|
|
91
|
+
def waits_on(dimension: str, label: str, name: str, kind: Literal['offset', 'partition', 'coordinate']) -> None:
|
|
92
|
+
undecided[dimension][Reach(label, name, kind)] = None
|
|
93
|
+
|
|
94
|
+
for label, row, nodes, mask, reductions_couple in _built_blocks(program):
|
|
95
|
+
if row is not None:
|
|
96
|
+
rows[label] = row
|
|
97
|
+
masks: list[Mask | None] = [mask]
|
|
98
|
+
for node in walk(*nodes):
|
|
99
|
+
if isinstance(node, Cases):
|
|
100
|
+
masks.extend(region.when for region in node.regions)
|
|
101
|
+
elif isinstance(node, Sum):
|
|
102
|
+
if reductions_couple:
|
|
103
|
+
for dimension in node.over:
|
|
104
|
+
report(
|
|
105
|
+
'coupled',
|
|
106
|
+
dimension,
|
|
107
|
+
label,
|
|
108
|
+
f'sums over {dimension} — a rolling sum_back(window=n) windows, a total over the horizon does not',
|
|
109
|
+
)
|
|
110
|
+
elif isinstance(node, GroupSum):
|
|
111
|
+
for dimension in node.direction.consumed_dims:
|
|
112
|
+
report(
|
|
113
|
+
'coupled',
|
|
114
|
+
dimension,
|
|
115
|
+
label,
|
|
116
|
+
f'groups {dimension} into {", ".join(node.direction.produced_dims)} — window that dimension instead, or cut only at the group edges',
|
|
117
|
+
)
|
|
118
|
+
elif isinstance(node, Pullback):
|
|
119
|
+
for dimension in node.direction.consumed_dims:
|
|
120
|
+
waits_on(dimension, label, node.direction.name, 'coordinate')
|
|
121
|
+
elif isinstance(node, (Translate, WindowSum)):
|
|
122
|
+
dimension = node.along
|
|
123
|
+
if node.wrap:
|
|
124
|
+
report(
|
|
125
|
+
'coupled',
|
|
126
|
+
dimension,
|
|
127
|
+
label,
|
|
128
|
+
f'wraps around {dimension}, so its first row reads its last — an opening-state seed at '
|
|
129
|
+
f'position({dimension}) == 0 is what a rolling horizon replaces the wrap with',
|
|
130
|
+
)
|
|
131
|
+
continue
|
|
132
|
+
if node.partition is not None:
|
|
133
|
+
waits_on(dimension, label, node.partition.name, 'partition')
|
|
134
|
+
if isinstance(node, WindowSum):
|
|
135
|
+
continue
|
|
136
|
+
if isinstance(node.offset, str):
|
|
137
|
+
waits_on(dimension, label, node.offset, 'offset')
|
|
138
|
+
else:
|
|
139
|
+
ahead[dimension] = max(ahead[dimension], -node.offset)
|
|
140
|
+
for candidate in masks:
|
|
141
|
+
for atom in candidate.atoms if candidate is not None else ():
|
|
142
|
+
if isinstance(atom, DimensionPosition):
|
|
143
|
+
report('restarts', atom.name, label, f'counts a position along {atom.name}')
|
|
144
|
+
|
|
145
|
+
for name, block in program.sos.items():
|
|
146
|
+
report(
|
|
147
|
+
'coupled',
|
|
148
|
+
block.along,
|
|
149
|
+
f"set '{name}'",
|
|
150
|
+
f'is a set along {block.along}, which a window would cut — only a window holding every whole set keeps it',
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def joined(kind: str, dimension: str) -> dict[str, str]:
|
|
154
|
+
return {label: ', '.join(dict.fromkeys(found)) for label, found in reasons[kind][dimension].items()}
|
|
155
|
+
|
|
156
|
+
def linking_rows(dimension: str) -> tuple[str, ...]:
|
|
157
|
+
"""Each constraint no one window of *dimension* holds whole, in declaration order.
|
|
158
|
+
|
|
159
|
+
Two shapes reach the border by different routes, and a constraint that
|
|
160
|
+
takes both is still one name: a row the axis does not index stands in
|
|
161
|
+
every window, and a row a coupling names reads the whole axis. Only a
|
|
162
|
+
declaration that builds a row can put one here, which is what ``rows``
|
|
163
|
+
holds the coupled labels to.
|
|
164
|
+
"""
|
|
165
|
+
coupled = {rows[label] for label in reasons['coupled'][dimension] if label in rows}
|
|
166
|
+
return tuple(
|
|
167
|
+
name
|
|
168
|
+
for name, constraint in program.constraints.items()
|
|
169
|
+
if dimension not in constraint.dims or name in coupled
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
dimension: Separability(
|
|
174
|
+
dimension=dimension,
|
|
175
|
+
ahead=ahead[dimension],
|
|
176
|
+
coupled=joined('coupled', dimension),
|
|
177
|
+
undecided=tuple(undecided[dimension]),
|
|
178
|
+
restarts=joined('restarts', dimension),
|
|
179
|
+
linking_rows=linking_rows(dimension),
|
|
180
|
+
linking_columns=tuple(
|
|
181
|
+
name for name, variable in program.variables.items() if dimension not in variable.dims
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
for dimension in program.dimensions
|
|
185
|
+
}
|