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
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: mathspec Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""The expression walk of name resolution: an arithmetic syntax tree into the program's expression nodes.
|
|
6
|
+
|
|
7
|
+
Every operator call is read here — its shape against :data:`~mathspec.operators.BUILTINS`,
|
|
8
|
+
its dimension and relation arguments against the namespace — and the node it
|
|
9
|
+
stands for is built. :mod:`mathspec.resolution` holds the namespace and the
|
|
10
|
+
doors that call this.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import TYPE_CHECKING, assert_never, cast
|
|
17
|
+
|
|
18
|
+
from mathspec._expression_parser import (
|
|
19
|
+
MAX_DEPTH,
|
|
20
|
+
ArithmeticNode,
|
|
21
|
+
BinaryOperatorNode,
|
|
22
|
+
FunctionCallNode,
|
|
23
|
+
KeywordNode,
|
|
24
|
+
NameListNode,
|
|
25
|
+
NameNode,
|
|
26
|
+
NumberNode,
|
|
27
|
+
UnaryOperatorNode,
|
|
28
|
+
depth,
|
|
29
|
+
literal_number,
|
|
30
|
+
names_in,
|
|
31
|
+
shown,
|
|
32
|
+
)
|
|
33
|
+
from mathspec.dimensions import dims_of
|
|
34
|
+
from mathspec.errors import DimensionError, SchemaError, did_you_mean
|
|
35
|
+
from mathspec.model import NUMERIC_DTYPES
|
|
36
|
+
from mathspec.operators import (
|
|
37
|
+
AMOUNTS,
|
|
38
|
+
BUILTINS,
|
|
39
|
+
EDGE_WRAP,
|
|
40
|
+
call_shape_error,
|
|
41
|
+
edge_error,
|
|
42
|
+
unknown_operator_message,
|
|
43
|
+
)
|
|
44
|
+
from mathspec.program import (
|
|
45
|
+
Add,
|
|
46
|
+
Constant,
|
|
47
|
+
Direction,
|
|
48
|
+
Divide,
|
|
49
|
+
Dual,
|
|
50
|
+
Expression,
|
|
51
|
+
GroupSum,
|
|
52
|
+
Multiply,
|
|
53
|
+
Negate,
|
|
54
|
+
Parameter,
|
|
55
|
+
Partition,
|
|
56
|
+
Power,
|
|
57
|
+
Pullback,
|
|
58
|
+
Sum,
|
|
59
|
+
Translate,
|
|
60
|
+
Variable,
|
|
61
|
+
WindowSum,
|
|
62
|
+
carries_variable,
|
|
63
|
+
children,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if TYPE_CHECKING:
|
|
67
|
+
from collections.abc import Mapping
|
|
68
|
+
|
|
69
|
+
from mathspec.resolution import Namespace
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
#: An ``edge=`` as a translation carries it: whether it wraps, and the number
|
|
73
|
+
#: the vacated positions contribute where it does not.
|
|
74
|
+
_Edge = tuple[bool, float | None]
|
|
75
|
+
|
|
76
|
+
#: How deep a resolved tree may be with every named expression it reads
|
|
77
|
+
#: written in — the tree every pass after resolution recurses over. Three
|
|
78
|
+
#: times what one text may nest, since a text reads other texts: a chain of
|
|
79
|
+
#: 200 entries survived every pass on a default stack and 250 did not (#643).
|
|
80
|
+
MAX_RESOLVED_DEPTH = 3 * MAX_DEPTH
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class ExpressionResolver:
|
|
85
|
+
"""One resolution walk over an expression, and the three things every step of it reads.
|
|
86
|
+
|
|
87
|
+
A node that cannot be built comes back as ``None`` with its refusal
|
|
88
|
+
appended to ``errors``; every sibling is still read, so a declaration
|
|
89
|
+
with two faults reports both. ``formals`` are a macro template's formals:
|
|
90
|
+
a formal has no kind until a call site binds it, so a node one stands
|
|
91
|
+
under is ``None`` with nothing appended.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
ns: Namespace
|
|
95
|
+
context: str
|
|
96
|
+
errors: list[str]
|
|
97
|
+
formals: frozenset[str] = frozenset()
|
|
98
|
+
|
|
99
|
+
def _formal(self, value: ArithmeticNode) -> bool:
|
|
100
|
+
"""Whether *value* is a formal, left for the call site to bind."""
|
|
101
|
+
return isinstance(value, NameNode) and value.name in self.formals
|
|
102
|
+
|
|
103
|
+
def build(self, node: ArithmeticNode) -> Expression | None:
|
|
104
|
+
"""The program tree *node* stands for, held to :data:`MAX_RESOLVED_DEPTH` before anything walks it.
|
|
105
|
+
|
|
106
|
+
The depth is measured with every named expression written in, since
|
|
107
|
+
that is the tree every later pass recurses over, and measured with an
|
|
108
|
+
explicit stack, since a recursion would be the crash it prevents.
|
|
109
|
+
"""
|
|
110
|
+
resolved = self.arith(node)
|
|
111
|
+
if resolved is None or (found := depth(resolved, children)) <= MAX_RESOLVED_DEPTH:
|
|
112
|
+
return resolved
|
|
113
|
+
self.errors.append(
|
|
114
|
+
f'{self.context}: the expression nests {found} deep with every named expression it reads written in, '
|
|
115
|
+
f'past the {MAX_RESOLVED_DEPTH} levels the language admits. Reduce over a dimension with sum() rather '
|
|
116
|
+
f'than writing the terms out, or precompute the deepest part as a parameter — a named expression '
|
|
117
|
+
f'stands inline where it is read, so naming a part does not make the tree shallower.'
|
|
118
|
+
)
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
def arith(self, node: ArithmeticNode) -> Expression | None:
|
|
122
|
+
"""The program node *node* stands for, or ``None``.
|
|
123
|
+
|
|
124
|
+
A quoted keyword or a name list in arithmetic arrives through a macro
|
|
125
|
+
formal bound to one.
|
|
126
|
+
"""
|
|
127
|
+
if isinstance(node, NumberNode):
|
|
128
|
+
return Constant(node.value)
|
|
129
|
+
if isinstance(node, NameNode):
|
|
130
|
+
return self._name(node)
|
|
131
|
+
if isinstance(node, UnaryOperatorNode):
|
|
132
|
+
operand = self.arith(node.operand)
|
|
133
|
+
if operand is None:
|
|
134
|
+
return None
|
|
135
|
+
return Negate(operand) if node.op == '-' else operand
|
|
136
|
+
if isinstance(node, BinaryOperatorNode):
|
|
137
|
+
return self._binary(node)
|
|
138
|
+
if isinstance(node, FunctionCallNode):
|
|
139
|
+
return self._call(node)
|
|
140
|
+
if isinstance(node, KeywordNode):
|
|
141
|
+
self.errors.append(
|
|
142
|
+
f'{self.context}: {node.value!r} is a quoted keyword, which is only legal as a '
|
|
143
|
+
f"operator kwarg value such as shift(..., edge='wrap'). In an expression, quote "
|
|
144
|
+
f'nothing — names resolve and numbers are written bare.'
|
|
145
|
+
)
|
|
146
|
+
return None
|
|
147
|
+
if isinstance(node, NameListNode):
|
|
148
|
+
self.errors.append(
|
|
149
|
+
f'{self.context}: {node} is a list of names, which is only legal as an operator '
|
|
150
|
+
f'kwarg value such as sum(x, by=[gen_bus, gen_tech]). In an expression, write the '
|
|
151
|
+
f'terms out and add them.'
|
|
152
|
+
)
|
|
153
|
+
return None
|
|
154
|
+
assert_never(node)
|
|
155
|
+
|
|
156
|
+
def _binary(self, node: BinaryOperatorNode) -> Expression | None:
|
|
157
|
+
"""A subtraction is an addition of the negation, so a program has one additive node."""
|
|
158
|
+
left, right = self.arith(node.left), self.arith(node.right)
|
|
159
|
+
if left is None or right is None:
|
|
160
|
+
return None
|
|
161
|
+
match node.op:
|
|
162
|
+
case '+':
|
|
163
|
+
return Add(left, right)
|
|
164
|
+
case '-':
|
|
165
|
+
return Add(left, Negate(right))
|
|
166
|
+
case '*':
|
|
167
|
+
return Multiply(left, right)
|
|
168
|
+
case '/':
|
|
169
|
+
return Divide(left, right)
|
|
170
|
+
case '**':
|
|
171
|
+
return Power(left, right)
|
|
172
|
+
case _:
|
|
173
|
+
assert_never(node.op)
|
|
174
|
+
|
|
175
|
+
def _name(self, node: NameNode) -> Expression | None:
|
|
176
|
+
"""A bare name as the variable, parameter or named expression it declares; a dimension or relation is not a value.
|
|
177
|
+
|
|
178
|
+
A named expression arrives as the one node :meth:`Namespace.named`
|
|
179
|
+
built for it; the cast is the one place a
|
|
180
|
+
:class:`~mathspec.program.Named` enters a tree typed as a program's,
|
|
181
|
+
which lowering makes true.
|
|
182
|
+
"""
|
|
183
|
+
if node.name in self.formals:
|
|
184
|
+
return None
|
|
185
|
+
if node.name in self.ns.schema.expressions:
|
|
186
|
+
try:
|
|
187
|
+
return cast('Expression', self.ns.named(node.name, self.context))
|
|
188
|
+
except SchemaError as e:
|
|
189
|
+
self.errors.append(str(e))
|
|
190
|
+
return None
|
|
191
|
+
match self.ns.kind(node.name):
|
|
192
|
+
case 'variable':
|
|
193
|
+
return Variable(node.name)
|
|
194
|
+
case 'parameter':
|
|
195
|
+
dtype = self.ns.dtypes.get(node.name)
|
|
196
|
+
if dtype is not None and dtype not in NUMERIC_DTYPES:
|
|
197
|
+
self.errors.append(not_a_number(node.name, dtype, self.context))
|
|
198
|
+
return None
|
|
199
|
+
return Parameter(node.name)
|
|
200
|
+
case 'dimension':
|
|
201
|
+
self.errors.append(
|
|
202
|
+
f"{self.context}: '{node.name}' is a dimension, and a dimension is "
|
|
203
|
+
f'not a value in an expression. Dimensions appear in '
|
|
204
|
+
f"'dims:', in operator arguments (sum(x, over={node.name})), "
|
|
205
|
+
f'and in where-comparisons — to use its coordinates as data, '
|
|
206
|
+
f'declare a parameter over it.'
|
|
207
|
+
)
|
|
208
|
+
return None
|
|
209
|
+
case 'relation':
|
|
210
|
+
self.errors.append(
|
|
211
|
+
f"{self.context}: '{node.name}' is a relation, and a relation is structure "
|
|
212
|
+
f'rather than data, so it is not a value in an expression. A relation '
|
|
213
|
+
f'appears in a helper (sum(x, by={node.name})) and in a where — to '
|
|
214
|
+
f'carry numbers along this dimension, declare a parameter over it.'
|
|
215
|
+
)
|
|
216
|
+
return None
|
|
217
|
+
case _:
|
|
218
|
+
self.errors.append(self.ns.unknown(node.name, self.context, allow_dims=False, formals=self.formals))
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
def _call(self, node: FunctionCallNode) -> Expression | None:
|
|
222
|
+
"""An operator call as the node it is: its shape checked, and each kwarg read by the kind the operator declares for it.
|
|
223
|
+
|
|
224
|
+
Every argument is read even after one failed, so a call with two
|
|
225
|
+
faults reports both. A formal anywhere under the call builds nothing
|
|
226
|
+
and refuses nothing.
|
|
227
|
+
"""
|
|
228
|
+
if node.name not in BUILTINS:
|
|
229
|
+
self.errors.append(f'{self.context}: {unknown_operator_message(node.name)}')
|
|
230
|
+
return None
|
|
231
|
+
builtin = BUILTINS[node.name]
|
|
232
|
+
shape_error = call_shape_error(node.name, len(node.args), node.kwargs)
|
|
233
|
+
if shape_error is not None:
|
|
234
|
+
self.errors.append(f'{self.context}: {shape_error}')
|
|
235
|
+
if node.name == 'dual':
|
|
236
|
+
return None if shape_error is not None else self._dual(node)
|
|
237
|
+
args = [self.arith(a) for a in node.args]
|
|
238
|
+
with_relation = any(k in node.kwargs for k in builtin.relation_kwargs)
|
|
239
|
+
roles = {k: v for k, v in node.kwargs.items() if builtin.kind_of(k, with_relation=with_relation) == 'role'}
|
|
240
|
+
unrelated = bool(roles) and 'by' not in node.kwargs
|
|
241
|
+
if unrelated:
|
|
242
|
+
self.errors.append(
|
|
243
|
+
f'{self.context}: {node.name}({", ".join(f"{k}=" for k in roles)}) names a column of a relation, '
|
|
244
|
+
f'and no by= names the relation. Write {builtin.usage}'
|
|
245
|
+
)
|
|
246
|
+
dims: dict[str, str | None] = {}
|
|
247
|
+
amounts: dict[str, int | str | None] = {}
|
|
248
|
+
edge: _Edge | None = None
|
|
249
|
+
for key, value in node.kwargs.items():
|
|
250
|
+
match builtin.kind_of(key, with_relation=with_relation):
|
|
251
|
+
case 'edge':
|
|
252
|
+
edge = self._edge(value, node.name)
|
|
253
|
+
case 'dimension':
|
|
254
|
+
dims[key] = self._dim_ref(value, node.name, key)
|
|
255
|
+
case 'value':
|
|
256
|
+
amounts[key] = self._amount(value, node.name, key)
|
|
257
|
+
case 'relation' | 'role' | None:
|
|
258
|
+
pass
|
|
259
|
+
read = None
|
|
260
|
+
if 'by' in node.kwargs and builtin.kind_of('by') == 'relation':
|
|
261
|
+
read = self.relation_ref(node.kwargs['by'], node.name, 'by', roles, dims.get('along'))
|
|
262
|
+
unread = (
|
|
263
|
+
shape_error is not None
|
|
264
|
+
or unrelated
|
|
265
|
+
or not args
|
|
266
|
+
or args[0] is None
|
|
267
|
+
or None in dims.values()
|
|
268
|
+
or None in amounts.values()
|
|
269
|
+
or ('edge' in node.kwargs and edge is None)
|
|
270
|
+
or ('by' in node.kwargs and read is None)
|
|
271
|
+
)
|
|
272
|
+
if unread:
|
|
273
|
+
return None
|
|
274
|
+
return self._built(node.name, cast('Expression', args[0]), dims, amounts, edge, read)
|
|
275
|
+
|
|
276
|
+
def _built(
|
|
277
|
+
self,
|
|
278
|
+
operator: str,
|
|
279
|
+
operand: Expression,
|
|
280
|
+
dims: Mapping[str, str | None],
|
|
281
|
+
amounts: Mapping[str, int | str | None],
|
|
282
|
+
edge: _Edge | None,
|
|
283
|
+
read: Direction | Partition | None,
|
|
284
|
+
) -> Expression | None:
|
|
285
|
+
"""The node *operator* builds from its read arguments, or ``None`` with the refusal appended."""
|
|
286
|
+
if operator == 'sum':
|
|
287
|
+
if read is not None:
|
|
288
|
+
assert isinstance(read, Direction), 'a sum reads its relation in a direction'
|
|
289
|
+
return GroupSum(operand, read)
|
|
290
|
+
if (over := dims.get('over')) is not None:
|
|
291
|
+
return Sum(operand, (over,))
|
|
292
|
+
return self._bare_sum(operand)
|
|
293
|
+
if operator == 'at':
|
|
294
|
+
assert isinstance(read, Direction), 'at reads its relation in a direction'
|
|
295
|
+
return Pullback(operand, read)
|
|
296
|
+
assert read is None or isinstance(read, Partition), 'a translation reads its relation as a partition'
|
|
297
|
+
along = dims['along']
|
|
298
|
+
assert along is not None
|
|
299
|
+
wrap, fill = edge if edge is not None else (False, None)
|
|
300
|
+
if operator == 'shift':
|
|
301
|
+
offset = amounts['offset']
|
|
302
|
+
assert offset is not None
|
|
303
|
+
if not self._edge_fits(operand, offset, wrap=wrap, fill=fill):
|
|
304
|
+
return None
|
|
305
|
+
return Translate(operand, along, offset, wrap=wrap, fill=fill, partition=read)
|
|
306
|
+
if fill is not None:
|
|
307
|
+
self.errors.append(
|
|
308
|
+
f"{self.context}: sum_back(edge=...) takes 'wrap' or nothing. A window sums the terms "
|
|
309
|
+
f'it reaches, so a position before the first contributes nothing rather than a '
|
|
310
|
+
f'fill value; add the constant to the expression if you want one.'
|
|
311
|
+
)
|
|
312
|
+
return None
|
|
313
|
+
width = amounts['window']
|
|
314
|
+
assert width is not None
|
|
315
|
+
return WindowSum(operand, along, width, wrap=wrap, partition=read)
|
|
316
|
+
|
|
317
|
+
def _bare_sum(self, operand: Expression) -> Expression | None:
|
|
318
|
+
"""``sum(x)`` with no ``over=`` or ``by=`` reduces every dim the operand carries, which it has to carry some of."""
|
|
319
|
+
try:
|
|
320
|
+
inner = dims_of(operand, self.ns.schema, self.context)
|
|
321
|
+
except DimensionError as e:
|
|
322
|
+
self.errors.append(str(e))
|
|
323
|
+
return None
|
|
324
|
+
if not inner:
|
|
325
|
+
self.errors.append(
|
|
326
|
+
f'{self.context}: sum() with no over= or by= sums every dim the operand '
|
|
327
|
+
f'carries, and this one carries none — the expression is already a '
|
|
328
|
+
f'scalar. Drop the sum.'
|
|
329
|
+
)
|
|
330
|
+
return None
|
|
331
|
+
return Sum(operand, tuple(sorted(inner)))
|
|
332
|
+
|
|
333
|
+
def _edge_fits(self, operand: Expression, offset: int | str, *, wrap: bool, fill: float | None) -> bool:
|
|
334
|
+
"""What a ``shift``'s ``edge=`` may say, and where saying nothing is an answer.
|
|
335
|
+
|
|
336
|
+
Every rule here is decidable from the file — whether the operand
|
|
337
|
+
carries a variable, whether the offset is named, what the edge is
|
|
338
|
+
written as — so a file breaking one is refused at load rather than by
|
|
339
|
+
whoever lowers it.
|
|
340
|
+
"""
|
|
341
|
+
if wrap:
|
|
342
|
+
return True
|
|
343
|
+
has_var = carries_variable(operand)
|
|
344
|
+
if has_var and fill is not None and fill != 0:
|
|
345
|
+
self.errors.append(
|
|
346
|
+
f'{self.context}: shift(edge={fill:g}) over an expression containing a variable — only '
|
|
347
|
+
f'fill=0 is representable there, since a vacated slot contributes no term. A nonzero '
|
|
348
|
+
f'fill would be a constant standing where a term was; add that constant to the '
|
|
349
|
+
f'expression instead.'
|
|
350
|
+
)
|
|
351
|
+
return False
|
|
352
|
+
if fill is None and _vacates(offset) and not has_var:
|
|
353
|
+
self.errors.append(_shift_over_data_message(self.context))
|
|
354
|
+
return False
|
|
355
|
+
if fill is None and isinstance(offset, str):
|
|
356
|
+
self.errors.append(f'{self.context}: {_named_offset_edge_message(offset)}')
|
|
357
|
+
return False
|
|
358
|
+
return True
|
|
359
|
+
|
|
360
|
+
def _amount(self, value: ArithmeticNode, operator: str, key: str) -> int | str | None:
|
|
361
|
+
"""``offset=`` or ``window=``: a whole number in the operator's range, or the name of a parameter.
|
|
362
|
+
|
|
363
|
+
Closed so that :func:`mathspec.dimensions._check_named_amount` sees
|
|
364
|
+
every parameter an amount carries, and so that a program's
|
|
365
|
+
``offset`` and ``width`` are the ``int | str`` they say.
|
|
366
|
+
"""
|
|
367
|
+
words = AMOUNTS[operator]
|
|
368
|
+
if (literal := literal_number(value)) is not None:
|
|
369
|
+
if not (literal.value.is_integer() and literal.value >= words.minimum):
|
|
370
|
+
self.errors.append(f'{self.context}: {operator}({key}=...) {words.form}')
|
|
371
|
+
return None
|
|
372
|
+
return int(literal.value)
|
|
373
|
+
bare = _without_sign(value)
|
|
374
|
+
if not isinstance(bare, NameNode):
|
|
375
|
+
self.errors.append(
|
|
376
|
+
f'{self.context}: {operator}({key}=) takes a number or the name of an integer parameter. '
|
|
377
|
+
f'Precompute it as a parameter.'
|
|
378
|
+
)
|
|
379
|
+
return None
|
|
380
|
+
if self.ns.kind(bare.name) != 'parameter':
|
|
381
|
+
if self._name(bare) is not None:
|
|
382
|
+
self.errors.append(f'{self.context}: {operator}({key}=...) {words.form}')
|
|
383
|
+
return None
|
|
384
|
+
if (dtype := self.ns.dtypes[bare.name]) != 'int':
|
|
385
|
+
self.errors.append(
|
|
386
|
+
f"{self.context}: {operator}({key}={bare.name}) counts positions, but '{bare.name}' is declared "
|
|
387
|
+
f'dtype: {dtype}. A count of positions is integral — declare it dtype: int, which accepts only an '
|
|
388
|
+
f'integer column, so a fractional {words.noun} has nowhere to arrive from.'
|
|
389
|
+
)
|
|
390
|
+
return None
|
|
391
|
+
if isinstance(value, UnaryOperatorNode) and value.op == '-':
|
|
392
|
+
self.errors.append(
|
|
393
|
+
f'{self.context}: {operator}({key}=-{bare.name}) negates a named {words.noun}. {words.negated}'
|
|
394
|
+
)
|
|
395
|
+
return None
|
|
396
|
+
return bare.name
|
|
397
|
+
|
|
398
|
+
def _edge(self, value: ArithmeticNode, operator: str) -> _Edge | None:
|
|
399
|
+
"""``edge=``: the closed keyword ``wrap``, or a number to contribute; a name here is a typo."""
|
|
400
|
+
if self._formal(value):
|
|
401
|
+
return None
|
|
402
|
+
if isinstance(value, KeywordNode):
|
|
403
|
+
if value.value == EDGE_WRAP:
|
|
404
|
+
return True, None
|
|
405
|
+
self.errors.append(f'{self.context}: {edge_error(operator, repr(value.value))}')
|
|
406
|
+
return None
|
|
407
|
+
if isinstance(value, NameNode):
|
|
408
|
+
if value.name == EDGE_WRAP:
|
|
409
|
+
self.errors.append(
|
|
410
|
+
f'{self.context}: {operator}(edge={EDGE_WRAP}) is a bare name where a keyword belongs. '
|
|
411
|
+
f"Write edge='{EDGE_WRAP}', quoted."
|
|
412
|
+
)
|
|
413
|
+
return None
|
|
414
|
+
self.errors.append(f'{self.context}: {edge_error(operator, value.name)}')
|
|
415
|
+
return None
|
|
416
|
+
if (literal := literal_number(value)) is None:
|
|
417
|
+
self.errors.append(
|
|
418
|
+
f"{self.context}: {operator}(edge=) is an expression, and an edge is the keyword '{EDGE_WRAP}' "
|
|
419
|
+
f'or a number. Write the number itself.'
|
|
420
|
+
)
|
|
421
|
+
return None
|
|
422
|
+
return False, literal.value
|
|
423
|
+
|
|
424
|
+
def _dim_ref(self, value: ArithmeticNode, operator: str, key: str) -> str | None:
|
|
425
|
+
"""An operator kwarg whose *value* must name a declared dimension."""
|
|
426
|
+
if self._formal(value):
|
|
427
|
+
return None
|
|
428
|
+
if not isinstance(value, NameNode):
|
|
429
|
+
self.errors.append(f'{self.context}: {operator}({key}=...) must name a dimension.')
|
|
430
|
+
return None
|
|
431
|
+
if value.name not in self.ns.dimensions:
|
|
432
|
+
self.errors.append(
|
|
433
|
+
_undeclared_dim(self.context, operator, f'{key}={value.name}', value.name, self.ns, self.formals)
|
|
434
|
+
)
|
|
435
|
+
return None
|
|
436
|
+
return value.name
|
|
437
|
+
|
|
438
|
+
def _dual(self, node: FunctionCallNode) -> Dual | None:
|
|
439
|
+
"""``dual(c)`` as the leaf it is, its one argument the name of a declared constraint.
|
|
440
|
+
|
|
441
|
+
Constraints sit outside the flat namespace, so this store is consulted
|
|
442
|
+
only here — a bare name in arithmetic never reaches it. A dual standing
|
|
443
|
+
where the math is built is refused separately
|
|
444
|
+
(:mod:`mathspec.degree`); this pass only types the name.
|
|
445
|
+
"""
|
|
446
|
+
(value,) = node.args
|
|
447
|
+
if self._formal(value):
|
|
448
|
+
return None
|
|
449
|
+
if not isinstance(value, NameNode):
|
|
450
|
+
self.errors.append(
|
|
451
|
+
f'{self.context}: dual() takes the name of a declared constraint, written bare — '
|
|
452
|
+
f'dual(<constraint>). Name the constraint whose row dual you want.'
|
|
453
|
+
)
|
|
454
|
+
return None
|
|
455
|
+
if value.name not in self.ns.constraints:
|
|
456
|
+
self.errors.append(self.ns.unknown_constraint(value.name, self.context, formals=self.formals))
|
|
457
|
+
return None
|
|
458
|
+
return Dual(value.name)
|
|
459
|
+
|
|
460
|
+
def relation_ref(
|
|
461
|
+
self,
|
|
462
|
+
value: ArithmeticNode,
|
|
463
|
+
operator: str,
|
|
464
|
+
key: str,
|
|
465
|
+
roles: Mapping[str, ArithmeticNode],
|
|
466
|
+
along: str | None,
|
|
467
|
+
) -> Direction | Partition | None:
|
|
468
|
+
"""An operator's ``by=`` as the direction or the partition the call reads its relation in.
|
|
469
|
+
|
|
470
|
+
A relation carries its own dimensions, so the call names columns rather
|
|
471
|
+
than dims: ``over=`` the column consumed, ``into=`` the column
|
|
472
|
+
produced, every other key column joined on. A value column not named
|
|
473
|
+
is not read, and a bare relation's columns are all key. One call
|
|
474
|
+
addresses one table, so several columns of one table are a list and
|
|
475
|
+
several tables are not. *along* is the dimension a translation steps
|
|
476
|
+
along, already read, or ``None`` where it was refused.
|
|
477
|
+
"""
|
|
478
|
+
names = names_in(value)
|
|
479
|
+
if not names:
|
|
480
|
+
self.errors.append(f'{self.context}: {operator}({key}=...) must name a relation.')
|
|
481
|
+
return None
|
|
482
|
+
if len(names) > 1:
|
|
483
|
+
self.errors.append(
|
|
484
|
+
f'{self.context}: {operator}({key}={shown(names)}) names {len(names)} relations, and one call '
|
|
485
|
+
f'reads one table. Declare one relation with the columns of all of them, or read them in turn, '
|
|
486
|
+
f'one call each.'
|
|
487
|
+
)
|
|
488
|
+
return None
|
|
489
|
+
name = names[0]
|
|
490
|
+
if name in self.formals:
|
|
491
|
+
return None
|
|
492
|
+
if (problem := self.not_a_relation(name, operator, key)) is not None:
|
|
493
|
+
self.errors.append(problem)
|
|
494
|
+
return None
|
|
495
|
+
if any(n in self.formals for v in roles.values() for n in names_in(v)):
|
|
496
|
+
return None
|
|
497
|
+
read = {k: self._role_name(v, operator, k) for k, v in roles.items()}
|
|
498
|
+
named = {k: r for k, r in read.items() if r is not None}
|
|
499
|
+
if operator in ('shift', 'sum_back'):
|
|
500
|
+
if 'within' not in named:
|
|
501
|
+
return None # refused already, by the call shape or by the role that named no column
|
|
502
|
+
return self.partition(name, operator, along, named['within'])
|
|
503
|
+
if not ({'over', 'into'} <= set(named)):
|
|
504
|
+
return None # refused already, by the call shape or by the role that named no column
|
|
505
|
+
return self._direction(name, operator, named['over'], named['into'])
|
|
506
|
+
|
|
507
|
+
def _role_name(self, value: ArithmeticNode, operator: str, key: str) -> tuple[str, ...] | None:
|
|
508
|
+
"""``over=`` or ``into=`` as the column names it must be — one bare name, or a bracketed list of them."""
|
|
509
|
+
if names := names_in(value):
|
|
510
|
+
return names
|
|
511
|
+
self.errors.append(
|
|
512
|
+
f'{self.context}: {operator}({key}=...) names columns of the relation — a bare name, or a list of them.'
|
|
513
|
+
)
|
|
514
|
+
return None
|
|
515
|
+
|
|
516
|
+
def _direction(
|
|
517
|
+
self,
|
|
518
|
+
name: str,
|
|
519
|
+
operator: str,
|
|
520
|
+
from_roles: tuple[str, ...],
|
|
521
|
+
into_roles: tuple[str, ...],
|
|
522
|
+
) -> Direction | None:
|
|
523
|
+
"""Which direction ``sum`` or ``at`` reads relation *name* in, between the columns the call named.
|
|
524
|
+
|
|
525
|
+
Both ends arrive written: the call shape refuses a call that leaves
|
|
526
|
+
one unsaid, so that a relation may gain a value column without
|
|
527
|
+
changing what this call means. ``at`` needs the read single-valued
|
|
528
|
+
and ``sum`` needs it not: a sum that lands on the key has one term
|
|
529
|
+
per coordinate and adds up nothing, which is a read, so it is
|
|
530
|
+
refused toward ``at``. A read lands on key columns and nothing else,
|
|
531
|
+
because a column outside the key is one no coordinate of the read
|
|
532
|
+
fixes.
|
|
533
|
+
"""
|
|
534
|
+
ns, context = self.ns, self.context
|
|
535
|
+
shape = ns.relations[name]
|
|
536
|
+
call = f'{operator}(by={name})'
|
|
537
|
+
if not (
|
|
538
|
+
self._known_roles(name, call, from_roles, 'over') and self._known_roles(name, call, into_roles, 'into')
|
|
539
|
+
):
|
|
540
|
+
return None
|
|
541
|
+
|
|
542
|
+
forward = operator == 'sum'
|
|
543
|
+
if both := sorted(set(from_roles) & set(into_roles)):
|
|
544
|
+
self.errors.append(
|
|
545
|
+
f'{context}: {call}: over= and into= both name {both}, and a call reads between two sets of columns.'
|
|
546
|
+
)
|
|
547
|
+
return None
|
|
548
|
+
for kwarg, roles in (('over', from_roles), ('into', into_roles)):
|
|
549
|
+
dims = [shape.dim(r) for r in roles]
|
|
550
|
+
if shared := sorted({d for d in dims if dims.count(d) > 1}):
|
|
551
|
+
self.errors.append(
|
|
552
|
+
f'{context}: {call}: {kwarg}={list(roles)} names two columns over {shared}, and the operand '
|
|
553
|
+
f'carries each dimension once, so nothing says which column its coordinate is read at. Read '
|
|
554
|
+
f'between columns over distinct dimensions.'
|
|
555
|
+
)
|
|
556
|
+
return None
|
|
557
|
+
if not forward and (outside := [r for r in into_roles if r not in shape.key]):
|
|
558
|
+
self.errors.append(
|
|
559
|
+
f"{context}: {call}: into={list(into_roles)} names {outside}, which the key of '{name}' does not "
|
|
560
|
+
f'hold. A read lands on the key it reads at, {list(shape.key)}, and a column outside that key '
|
|
561
|
+
f'arrives as a dimension the read never fixes. Land on the key, or sum toward {outside}.'
|
|
562
|
+
)
|
|
563
|
+
return None
|
|
564
|
+
joined = tuple(r for r in shape.key if r not in from_roles and r not in into_roles)
|
|
565
|
+
single_valued = set(shape.key) <= {*into_roles, *joined}
|
|
566
|
+
direction = Direction(name, shape, from_roles, into_roles, joined)
|
|
567
|
+
if not forward and not single_valued:
|
|
568
|
+
self.errors.append(
|
|
569
|
+
f"{context}: {call}: at reads one value per coordinate, and '{name}' is not single-valued in "
|
|
570
|
+
f'{list(from_roles)} at the columns the call lands on ({[*into_roles, *joined]}) — its key is '
|
|
571
|
+
f'{list(shape.key)}. Key the table by the columns the call lands on, or read the other way.'
|
|
572
|
+
)
|
|
573
|
+
return None
|
|
574
|
+
if forward and single_valued:
|
|
575
|
+
self.errors.append(
|
|
576
|
+
f'{context}: {call}: this sum lands on the key {list(shape.key)}, so each coordinate has one '
|
|
577
|
+
f"term and nothing is added up — that is a read, which is at()'s. Write "
|
|
578
|
+
f'at(..., by={name}, over={list(from_roles)}, into={list(into_roles)}), or sum toward '
|
|
579
|
+
f'a value column.'
|
|
580
|
+
)
|
|
581
|
+
return None
|
|
582
|
+
return direction
|
|
583
|
+
|
|
584
|
+
def _known_roles(self, name: str, call: str, roles: tuple[str, ...], kwarg: str) -> bool:
|
|
585
|
+
"""Whether every role *kwarg* names is a column of relation *name*, each once; the refusal otherwise."""
|
|
586
|
+
shape = self.ns.relations[name]
|
|
587
|
+
for role in roles:
|
|
588
|
+
if role not in shape.roles:
|
|
589
|
+
self.errors.append(
|
|
590
|
+
f"{self.context}: {call}: {kwarg}={role} names no column of '{name}', whose columns are "
|
|
591
|
+
f'{list(shape.roles)}.'
|
|
592
|
+
)
|
|
593
|
+
return False
|
|
594
|
+
if len(set(roles)) < len(roles):
|
|
595
|
+
self.errors.append(f'{self.context}: {call}: {kwarg}={list(roles)} names a column twice.')
|
|
596
|
+
return False
|
|
597
|
+
return True
|
|
598
|
+
|
|
599
|
+
def partition(
|
|
600
|
+
self, name: str, operator: str, along_dim: str | None, within_roles: tuple[str, ...]
|
|
601
|
+
) -> Partition | None:
|
|
602
|
+
"""How a partition (``shift``, ``sum_back``, ``position``) steps along relation *name* over *along_dim*.
|
|
603
|
+
|
|
604
|
+
It steps along the one key column over that dimension (a key has one
|
|
605
|
+
column per dimension), joins on the other key columns and groups by the
|
|
606
|
+
value columns *within_roles* names. ``None`` where the dimension is not one
|
|
607
|
+
(already refused), the relation has no key column over it, or
|
|
608
|
+
``within=`` names a column that is not a value column.
|
|
609
|
+
"""
|
|
610
|
+
context = self.context
|
|
611
|
+
shape = self.ns.relations[name]
|
|
612
|
+
call = f'{operator}(by={name})'
|
|
613
|
+
if along_dim is None or not self._known_roles(name, call, within_roles, 'within'):
|
|
614
|
+
return None
|
|
615
|
+
if not shape.values:
|
|
616
|
+
self.errors.append(
|
|
617
|
+
f"{context}: {call}: '{name}' is a bare relation — every column is in its key — so it makes no "
|
|
618
|
+
f'groups and no coordinate is in exactly one. Move the columns the group is made of under '
|
|
619
|
+
f'values:, leaving key: the column {operator} steps along.'
|
|
620
|
+
)
|
|
621
|
+
return None
|
|
622
|
+
over_keys = [r for r in shape.key if shape.dim(r) == along_dim]
|
|
623
|
+
if not over_keys:
|
|
624
|
+
self.errors.append(
|
|
625
|
+
f"{context}: {call}: '{name}' has no key column over '{along_dim}' — its key is "
|
|
626
|
+
f'{list(shape.key)} — and a partition steps along a key column over the dimension it groups.'
|
|
627
|
+
)
|
|
628
|
+
return None
|
|
629
|
+
if keyed := [r for r in within_roles if r in shape.key]:
|
|
630
|
+
self.errors.append(
|
|
631
|
+
f"{context}: {call}: within={keyed} names a key column of '{name}', and a partition groups by "
|
|
632
|
+
f'value columns — its value columns are {list(shape.values)}.'
|
|
633
|
+
)
|
|
634
|
+
return None
|
|
635
|
+
(along,) = over_keys
|
|
636
|
+
joined = tuple(r for r in shape.key if r != along)
|
|
637
|
+
return Partition(name, shape, along, within_roles, joined)
|
|
638
|
+
|
|
639
|
+
def not_a_relation(self, name: str, operator: str, key: str) -> str | None:
|
|
640
|
+
"""Why *name* is not a relation; ``None`` where it is one."""
|
|
641
|
+
ns, context = self.ns, self.context
|
|
642
|
+
if name in ns.relations:
|
|
643
|
+
return None
|
|
644
|
+
if name in ns.dimensions:
|
|
645
|
+
over_here = sorted(n for n, shape in ns.relations.items() if name in dict(shape.columns).values())
|
|
646
|
+
hint = (
|
|
647
|
+
f" Relations with a column over '{name}': {over_here}"
|
|
648
|
+
if over_here
|
|
649
|
+
else f" No relation has a column over '{name}'."
|
|
650
|
+
)
|
|
651
|
+
return (
|
|
652
|
+
f"{context}: {operator}({key}={name}): '{name}' is a dimension, and "
|
|
653
|
+
f'{key}= takes a relation — the named map out of a dimension.\n{hint}'
|
|
654
|
+
)
|
|
655
|
+
return (
|
|
656
|
+
f'{context}: {operator}({key}={name}) does not name a relation{_or_a_formal(self.formals)}. '
|
|
657
|
+
f'{did_you_mean(name, ns.relations, label="Relations")}\n'
|
|
658
|
+
f"Declare it under 'relations:' — {name}: {{key: <the columns a row is identified by>, "
|
|
659
|
+
f'values: <the columns they determine>}}.'
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def not_a_number(name: str, dtype: str, context: str) -> str:
|
|
664
|
+
"""Why a ``str`` or ``bool`` parameter is refused where a value belongs; the rewrite is the dtype's own."""
|
|
665
|
+
if dtype == 'str':
|
|
666
|
+
instead = (
|
|
667
|
+
f'A label selects rather than scales: compare it in a where '
|
|
668
|
+
f'("{name} == \'some_label\'"), and carry the numbers it picks out in a '
|
|
669
|
+
f'parameter of its own.'
|
|
670
|
+
)
|
|
671
|
+
else:
|
|
672
|
+
instead = (
|
|
673
|
+
f'A flag masks rather than scales: name it in a where ("{name}", "NOT {name}"), '
|
|
674
|
+
f'which is what a mask is — or declare it dtype: int where the 0/1 is meant to '
|
|
675
|
+
f'arrive as data and be multiplied by.'
|
|
676
|
+
)
|
|
677
|
+
return (
|
|
678
|
+
f"{context}: '{name}' is declared dtype: {dtype}, and an expression is arithmetic — "
|
|
679
|
+
f'only dtype: float and dtype: int accept a column it can be done to. {instead}'
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _undeclared_dim(context: str, operator: str, call: str, name: str, ns: Namespace, formals: frozenset[str]) -> str:
|
|
684
|
+
return (
|
|
685
|
+
f'{context}: {operator}({call}) does not name a declared dimension{_or_a_formal(formals)}. '
|
|
686
|
+
f'{did_you_mean(name, ns.dimensions, label="Dimensions")}\n'
|
|
687
|
+
f"Declare '{name}' under 'dimensions:', or fix the typo — an unknown "
|
|
688
|
+
f'dimension makes {operator}() a silent no-op rather than an error.'
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _or_a_formal(formals: frozenset[str]) -> str:
|
|
693
|
+
"""The words a refusal inside a template adds, since a formal would have stood there too."""
|
|
694
|
+
return ' or a formal of this macro' if formals else ''
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _without_sign(value: ArithmeticNode) -> ArithmeticNode:
|
|
698
|
+
"""*value* under its sign, if it carries one."""
|
|
699
|
+
return value.operand if isinstance(value, UnaryOperatorNode) else value
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _vacates(offset: int | str) -> bool:
|
|
703
|
+
"""Whether a translation leaves anything behind.
|
|
704
|
+
|
|
705
|
+
A literal zero step reaches every coordinate from itself, so there is no
|
|
706
|
+
vacated position for an ``edge=`` to answer for and the refusal has
|
|
707
|
+
nothing to refuse. A *named* offset may be zero in the data and is not
|
|
708
|
+
known here, so it vacates until proved otherwise.
|
|
709
|
+
"""
|
|
710
|
+
return offset != 0
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _named_offset_edge_message(name: str) -> str:
|
|
714
|
+
"""Why a named offset must say what the vacated positions contribute.
|
|
715
|
+
|
|
716
|
+
The absent edge propagates through a presence frame keyed by the translated
|
|
717
|
+
dimension alone, and a per-entity offset vacates a different slot for each
|
|
718
|
+
entity — which that frame cannot say. Refused rather than answered wrongly
|
|
719
|
+
(#850); the two edges that write their own answer are allowed.
|
|
720
|
+
"""
|
|
721
|
+
return (
|
|
722
|
+
f'shift(offset={name}) leaves the vacated positions absent, which a '
|
|
723
|
+
f'per-entity offset cannot say yet.\n'
|
|
724
|
+
f"Add edge='wrap' for a cyclic translation, or edge=<number> for what the "
|
|
725
|
+
f'vacated positions contribute.'
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _shift_over_data_message(context: str) -> str:
|
|
730
|
+
"""The three ways out of a translation over data with no ``edge=``, the third being two things at once."""
|
|
731
|
+
return (
|
|
732
|
+
f'{context}: shift() over a variable-free expression leaves vacated positions with no '
|
|
733
|
+
f'value, and inventing one is what silently pinned a bound to zero. Say which you mean:\n'
|
|
734
|
+
f" shift(x, along=d, offset=n, edge='wrap') the dimension really is cyclic\n"
|
|
735
|
+
f' shift(x, along=d, offset=n, edge=0) the vacated positions contribute zero\n'
|
|
736
|
+
f' ...and a where: excluding them the vacated rows should not exist at all\n'
|
|
737
|
+
f'A where: alone does not lift this — it is decided on the expression, before any mask '
|
|
738
|
+
f'is read — and edge=0 alone leaves a row whose bound is that zero.'
|
|
739
|
+
)
|