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,745 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: mathspec Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""The where walk of name resolution: a parsed where string into the program's typed predicates.
|
|
6
|
+
|
|
7
|
+
A bare name, a comparison, a count and a predicate read through a relation
|
|
8
|
+
or along a dimension are each typed here against the namespace, and a
|
|
9
|
+
comparison of expressions hands its sides to the expression walk.
|
|
10
|
+
:mod:`mathspec.resolution` holds the namespace and the doors that call this.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import datetime
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import TYPE_CHECKING, NamedTuple, assert_never, cast
|
|
19
|
+
|
|
20
|
+
import mathspec.degree as degree
|
|
21
|
+
from mathspec._expression_parser import (
|
|
22
|
+
ArithmeticNode,
|
|
23
|
+
FunctionCallNode,
|
|
24
|
+
KeywordNode,
|
|
25
|
+
NameListNode,
|
|
26
|
+
NameNode,
|
|
27
|
+
literal_number,
|
|
28
|
+
names_in,
|
|
29
|
+
nodes,
|
|
30
|
+
)
|
|
31
|
+
from mathspec._expression_resolver import ExpressionResolver
|
|
32
|
+
from mathspec._where_parser import (
|
|
33
|
+
ColumnNode,
|
|
34
|
+
UnresolvedComparisonNode,
|
|
35
|
+
UnresolvedCountNode,
|
|
36
|
+
UnresolvedPredicateCallNode,
|
|
37
|
+
UnresolvedWhereNode,
|
|
38
|
+
)
|
|
39
|
+
from mathspec.dimensions import dims_of, pulled_back_dims
|
|
40
|
+
from mathspec.errors import DimensionError, LanguageError, did_you_mean, prefixed
|
|
41
|
+
from mathspec.expansion import expand
|
|
42
|
+
from mathspec.operators import (
|
|
43
|
+
PARTITION_NAMES_ITS_GROUP,
|
|
44
|
+
)
|
|
45
|
+
from mathspec.program import (
|
|
46
|
+
Add,
|
|
47
|
+
And,
|
|
48
|
+
BooleanLiteral,
|
|
49
|
+
Constant,
|
|
50
|
+
CountComparison,
|
|
51
|
+
DimensionComparison,
|
|
52
|
+
DimensionPosition,
|
|
53
|
+
Direction,
|
|
54
|
+
Divide,
|
|
55
|
+
Expression,
|
|
56
|
+
ExpressionComparison,
|
|
57
|
+
Mask,
|
|
58
|
+
Multiply,
|
|
59
|
+
Negate,
|
|
60
|
+
Not,
|
|
61
|
+
Or,
|
|
62
|
+
ParameterComparison,
|
|
63
|
+
ParameterDefined,
|
|
64
|
+
Power,
|
|
65
|
+
Predicate,
|
|
66
|
+
PredicateOperator,
|
|
67
|
+
PulledBackPredicate,
|
|
68
|
+
RelationComparison,
|
|
69
|
+
RelationDefined,
|
|
70
|
+
RelationPairComparison,
|
|
71
|
+
TranslatedPredicate,
|
|
72
|
+
TypedPredicate,
|
|
73
|
+
VariableDefined,
|
|
74
|
+
carries_variable,
|
|
75
|
+
walk,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if TYPE_CHECKING:
|
|
79
|
+
from collections.abc import Mapping
|
|
80
|
+
|
|
81
|
+
from mathspec.program import DeclaredDtype
|
|
82
|
+
from mathspec.resolution import Namespace
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class WhereResolver:
|
|
87
|
+
"""One resolution walk over a where string, and the things every step of it reads.
|
|
88
|
+
|
|
89
|
+
A node that cannot be typed comes back unresolved with its refusal
|
|
90
|
+
appended to ``errors``; every sibling is still read, so a mask with two
|
|
91
|
+
faults reports both. ``self_variable`` is the variable whose own
|
|
92
|
+
``where`` is being read, which may not ask whether it exists. A side
|
|
93
|
+
that is an expression is built by an :class:`ExpressionResolver` over
|
|
94
|
+
the same namespace.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
ns: Namespace
|
|
98
|
+
context: str
|
|
99
|
+
errors: list[str]
|
|
100
|
+
self_variable: str | None = None
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def _expressions(self) -> ExpressionResolver:
|
|
104
|
+
return ExpressionResolver(self.ns, self.context, self.errors)
|
|
105
|
+
|
|
106
|
+
def where(self, node: Predicate | UnresolvedWhereNode) -> Predicate | UnresolvedWhereNode:
|
|
107
|
+
"""One predicate node typed, or returned unresolved with its refusal appended."""
|
|
108
|
+
if isinstance(node, BooleanLiteral | TypedPredicate):
|
|
109
|
+
return node
|
|
110
|
+
if isinstance(node, NameNode):
|
|
111
|
+
return self._where_name(node)
|
|
112
|
+
if isinstance(node, UnresolvedComparisonNode):
|
|
113
|
+
return self._comparison(node)
|
|
114
|
+
if isinstance(node, UnresolvedPredicateCallNode):
|
|
115
|
+
return self._predicate_call(node)
|
|
116
|
+
if isinstance(node, UnresolvedCountNode):
|
|
117
|
+
return self._count(node)
|
|
118
|
+
if isinstance(node, Not):
|
|
119
|
+
return Not(self._child(node.operand))
|
|
120
|
+
if isinstance(node, And):
|
|
121
|
+
return And(self._child(node.left), self._child(node.right))
|
|
122
|
+
if isinstance(node, Or):
|
|
123
|
+
return Or(self._child(node.left), self._child(node.right))
|
|
124
|
+
assert_never(node)
|
|
125
|
+
|
|
126
|
+
def _child(self, node: Predicate | UnresolvedWhereNode) -> Predicate:
|
|
127
|
+
"""A connective's child, typed as resolved: an unresolved one survives only with its refusal appended."""
|
|
128
|
+
return cast('Predicate', self.where(node))
|
|
129
|
+
|
|
130
|
+
def _where_name(self, node: NameNode) -> Predicate | UnresolvedWhereNode:
|
|
131
|
+
"""A bare name: a parameter's or relation's definedness, or a variable's existence."""
|
|
132
|
+
ns, context = self.ns, self.context
|
|
133
|
+
kind = ns.kind(node.name)
|
|
134
|
+
if kind is None:
|
|
135
|
+
self.errors.append(ns.unknown(node.name, context, allow_dims=True))
|
|
136
|
+
return node
|
|
137
|
+
match kind:
|
|
138
|
+
case 'parameter':
|
|
139
|
+
return ParameterDefined(node.name, ns.leaf_dims[node.name])
|
|
140
|
+
case 'dimension':
|
|
141
|
+
self.errors.append(
|
|
142
|
+
f"{context}: '{node.name}' is a dimension, and a bare dimension "
|
|
143
|
+
f'name is true at every coordinate — the mask has no effect. '
|
|
144
|
+
f'Remove it, or compare it: where: "{node.name} > 0".'
|
|
145
|
+
)
|
|
146
|
+
case 'relation':
|
|
147
|
+
shape = ns.relations[node.name]
|
|
148
|
+
dims = tuple(shape.dim(k) for k in shape.key)
|
|
149
|
+
if len(set(dims)) < len(dims):
|
|
150
|
+
self.errors.append(
|
|
151
|
+
f"{context}: '{node.name}' has two columns over one dimension ({list(shape.roles)}), so a "
|
|
152
|
+
f'bare name cannot say which the frame supplies. Compare a column: '
|
|
153
|
+
f'{node.name}.{shape.values[0] if shape.values else shape.roles[-1]} == ....'
|
|
154
|
+
)
|
|
155
|
+
return node
|
|
156
|
+
return RelationDefined(node.name, dims)
|
|
157
|
+
case 'variable':
|
|
158
|
+
if node.name == self.self_variable:
|
|
159
|
+
self.errors.append(
|
|
160
|
+
f"{context}: variable '{node.name}' asks whether it exists in its own "
|
|
161
|
+
f'where, which nothing can answer — the mask is what decides where it '
|
|
162
|
+
f'exists. Test a parameter, or another variable declared before it.'
|
|
163
|
+
)
|
|
164
|
+
else:
|
|
165
|
+
return VariableDefined(node.name, ns.leaf_dims[node.name])
|
|
166
|
+
return node
|
|
167
|
+
|
|
168
|
+
def _predicate_call(self, node: UnresolvedPredicateCallNode) -> Predicate | UnresolvedWhereNode:
|
|
169
|
+
"""``shift(<predicate>, along=, offset=)`` or ``at(<predicate>, by=, over=, into=)`` — the two operators that read a predicate and answer one.
|
|
170
|
+
|
|
171
|
+
``count`` answers a number, so it stands on a comparison's side and
|
|
172
|
+
:meth:`_count` reads it there. Anything else naming a predicate is
|
|
173
|
+
refused here rather than resolved into arithmetic it cannot be.
|
|
174
|
+
|
|
175
|
+
An operand that failed to resolve is handed straight back: resolution
|
|
176
|
+
collects problems rather than raising, and asking an unresolved
|
|
177
|
+
predicate for its dims asserts instead of refusing.
|
|
178
|
+
"""
|
|
179
|
+
context, found = self.context, len(self.errors)
|
|
180
|
+
if node.name == 'count':
|
|
181
|
+
self.errors.append(
|
|
182
|
+
f'{context}: count() answers a number, and a where is a predicate. Compare it: '
|
|
183
|
+
f'count(<predicate>, over=<dimension>) <op> <integer>.'
|
|
184
|
+
)
|
|
185
|
+
return node
|
|
186
|
+
if node.name not in ('shift', 'at'):
|
|
187
|
+
self.errors.append(
|
|
188
|
+
f"{context}: '{node.name}()' does not read a predicate. `shift` and `at` read one and answer "
|
|
189
|
+
f'one, `count` reads one and answers a number, and every other operator reads arithmetic. '
|
|
190
|
+
f'Compare the predicate, or name a parameter carrying it.'
|
|
191
|
+
)
|
|
192
|
+
return node
|
|
193
|
+
operand = self._child(node.operand)
|
|
194
|
+
if len(self.errors) > found:
|
|
195
|
+
return node
|
|
196
|
+
if node.name == 'at':
|
|
197
|
+
return self._pulled_back(node, Mask(operand))
|
|
198
|
+
if (refusal := _kwargs_error(context, 'shift', node.kwargs, required=('along', 'offset'))) is not None:
|
|
199
|
+
self.errors.append(refusal)
|
|
200
|
+
return node
|
|
201
|
+
along = node.kwargs['along']
|
|
202
|
+
offset = literal_number(node.kwargs['offset'])
|
|
203
|
+
if not isinstance(along, NameNode) or self.ns.kind(along.name) != 'dimension':
|
|
204
|
+
self.errors.append(
|
|
205
|
+
f'{context}: shift(<predicate>, along=) names the dimension the predicate is read back along. '
|
|
206
|
+
f'Name a declared dimension.'
|
|
207
|
+
)
|
|
208
|
+
return node
|
|
209
|
+
if offset is None or not offset.value.is_integer():
|
|
210
|
+
self.errors.append(
|
|
211
|
+
f'{context}: shift(<predicate>, offset=) counts whole coordinates back along '
|
|
212
|
+
f"'{along.name}'. Write an integer."
|
|
213
|
+
)
|
|
214
|
+
return node
|
|
215
|
+
mask = Mask(operand)
|
|
216
|
+
if along.name not in mask.dims:
|
|
217
|
+
self.errors.append(
|
|
218
|
+
f"{context}: shift(<predicate>, along='{along.name}') reads the predicate back along a dimension "
|
|
219
|
+
f'it does not carry — it reads {_listed(sorted(mask.dims))}. Translate it along one of those.'
|
|
220
|
+
)
|
|
221
|
+
return node
|
|
222
|
+
return TranslatedPredicate(mask, along.name, int(offset.value), tuple(sorted(mask.dims)))
|
|
223
|
+
|
|
224
|
+
def _pulled_back(self, node: UnresolvedPredicateCallNode, mask: Mask) -> Predicate | UnresolvedWhereNode:
|
|
225
|
+
"""``at(<predicate>, by=, over=, into=)`` — the predicate read through a relation, as ``at`` reads an array.
|
|
226
|
+
|
|
227
|
+
The relation and its two ends are read by the rules an expression's
|
|
228
|
+
``at`` is, so the one refusal a file meets for a bad read is the same
|
|
229
|
+
in a ``where:`` and in an expression.
|
|
230
|
+
"""
|
|
231
|
+
context = self.context
|
|
232
|
+
if (refusal := _kwargs_error(context, 'at', node.kwargs, required=('by', 'over', 'into'))) is not None:
|
|
233
|
+
self.errors.append(refusal)
|
|
234
|
+
return node
|
|
235
|
+
found = len(self.errors)
|
|
236
|
+
roles = {key: node.kwargs[key] for key in ('over', 'into')}
|
|
237
|
+
by = self._expressions.relation_ref(node.kwargs['by'], 'at', 'by', roles, None)
|
|
238
|
+
if len(self.errors) > found or not isinstance(by, Direction):
|
|
239
|
+
return node
|
|
240
|
+
try:
|
|
241
|
+
dims = pulled_back_dims(by, mask.dims, context, 'the predicate')
|
|
242
|
+
except DimensionError as refusal:
|
|
243
|
+
self.errors.append(str(refusal))
|
|
244
|
+
return node
|
|
245
|
+
return PulledBackPredicate(mask, by, tuple(sorted(dims)))
|
|
246
|
+
|
|
247
|
+
def _count(self, node: UnresolvedCountNode) -> Predicate | UnresolvedWhereNode:
|
|
248
|
+
"""``count(<predicate>, over=<dim>) <op> <integer>`` — how many coordinates the predicate admits.
|
|
249
|
+
|
|
250
|
+
The reduction leaves every dim but ``over``, so the count is one
|
|
251
|
+
number per remaining coordinate and a claim about each group needs no
|
|
252
|
+
word for the group.
|
|
253
|
+
"""
|
|
254
|
+
context, found = self.context, len(self.errors)
|
|
255
|
+
operand = self._child(node.call.operand)
|
|
256
|
+
if len(self.errors) > found:
|
|
257
|
+
return node
|
|
258
|
+
if (refusal := _kwargs_error(context, 'count', node.call.kwargs, required=('over',))) is not None:
|
|
259
|
+
self.errors.append(refusal)
|
|
260
|
+
return node
|
|
261
|
+
over = node.call.kwargs['over']
|
|
262
|
+
if not isinstance(over, NameNode) or self.ns.kind(over.name) != 'dimension':
|
|
263
|
+
self.errors.append(
|
|
264
|
+
f'{context}: count(<predicate>, over=) names the dimension the coordinates are counted along. '
|
|
265
|
+
f'Name a declared dimension.'
|
|
266
|
+
)
|
|
267
|
+
return node
|
|
268
|
+
value = literal_number(node.value)
|
|
269
|
+
if value is None or not value.value.is_integer():
|
|
270
|
+
self.errors.append(
|
|
271
|
+
f'{context}: a count is a whole number of coordinates, so it is compared against one. '
|
|
272
|
+
f'Write count(…, over={over.name}) {node.op} <integer>.'
|
|
273
|
+
)
|
|
274
|
+
return node
|
|
275
|
+
if (decided := _decided_count(node.op, value.value)) is not None:
|
|
276
|
+
self.errors.append(
|
|
277
|
+
f'{context}: count(…, over={over.name}) {node.op} {value} holds at {decided} coordinate, because '
|
|
278
|
+
f'a count is never negative. Delete the comparison, or write the bound it means.'
|
|
279
|
+
)
|
|
280
|
+
return node
|
|
281
|
+
mask = Mask(operand)
|
|
282
|
+
if over.name not in mask.dims:
|
|
283
|
+
self.errors.append(
|
|
284
|
+
f"{context}: count(<predicate>, over='{over.name}') counts along a dimension the predicate does "
|
|
285
|
+
f'not carry — it reads {_listed(sorted(mask.dims))}. Count along one of those.'
|
|
286
|
+
)
|
|
287
|
+
return node
|
|
288
|
+
dims = tuple(sorted(mask.dims - {over.name}))
|
|
289
|
+
return CountComparison(mask, over.name, node.op, value.value, dims)
|
|
290
|
+
|
|
291
|
+
def _comparison(self, node: UnresolvedComparisonNode) -> Predicate | UnresolvedWhereNode:
|
|
292
|
+
"""``side <op> side``, read for what each side is.
|
|
293
|
+
|
|
294
|
+
A ``position()`` call on the left is the position form. A name against
|
|
295
|
+
a literal or a second column is the plain form the dtype rules are
|
|
296
|
+
written for, unless a side names a parameter or an ``expressions:``
|
|
297
|
+
entry against the other, which is arithmetic however plain it looks.
|
|
298
|
+
Everything else is a comparison of expressions.
|
|
299
|
+
"""
|
|
300
|
+
if isinstance(node.left, FunctionCallNode) and node.left.name == 'position':
|
|
301
|
+
return self._position(node.left, node)
|
|
302
|
+
plain = self._plain(node)
|
|
303
|
+
if plain is None:
|
|
304
|
+
return self._expression_comparison(node)
|
|
305
|
+
return self._plain_comparison(node, plain)
|
|
306
|
+
|
|
307
|
+
def _plain(self, node: UnresolvedComparisonNode) -> _Plain | None:
|
|
308
|
+
"""The comparison as ``name <op> literal`` or ``name <op> name``, or ``None`` where the language reads it as arithmetic.
|
|
309
|
+
|
|
310
|
+
A side that is arithmetic makes it so, and so does a name that is a
|
|
311
|
+
value — a parameter or an ``expressions:`` entry — against another,
|
|
312
|
+
however plain the two look.
|
|
313
|
+
"""
|
|
314
|
+
ns = self.ns
|
|
315
|
+
name, right = _side_name(node.left), node.right
|
|
316
|
+
value: float | str | None
|
|
317
|
+
quoted = isinstance(right, KeywordNode)
|
|
318
|
+
if isinstance(right, KeywordNode):
|
|
319
|
+
value = right.value
|
|
320
|
+
elif isinstance(right, ColumnNode):
|
|
321
|
+
value = right.shown
|
|
322
|
+
elif (literal := literal_number(right)) is not None:
|
|
323
|
+
value = literal.value
|
|
324
|
+
else:
|
|
325
|
+
value = _side_name(right)
|
|
326
|
+
if value is not None and (value in ns.schema.expressions or ns.kind(value) == 'parameter'):
|
|
327
|
+
return None
|
|
328
|
+
if name is None or value is None or name in ns.schema.expressions:
|
|
329
|
+
return None
|
|
330
|
+
return _Plain(name, node.op, value, quoted)
|
|
331
|
+
|
|
332
|
+
def _expression_comparison(self, node: UnresolvedComparisonNode) -> ExpressionComparison | UnresolvedComparisonNode:
|
|
333
|
+
"""``expression <op> expression``: each side expanded, typed and held to what a mask may read.
|
|
334
|
+
|
|
335
|
+
A side is read as an expression is — macros and named expressions
|
|
336
|
+
expand, every operator and dim rule applies — except that it names no
|
|
337
|
+
variable and no dual, since a mask is built before either exists.
|
|
338
|
+
"""
|
|
339
|
+
ns, context = self.ns, self.context
|
|
340
|
+
found = len(self.errors)
|
|
341
|
+
sides: list[Expression] = []
|
|
342
|
+
for side in (node.left, node.right):
|
|
343
|
+
if isinstance(side, ColumnNode | KeywordNode):
|
|
344
|
+
self.errors.append(_not_arithmetic(context, side))
|
|
345
|
+
continue
|
|
346
|
+
if any(isinstance(n, FunctionCallNode) and n.name == 'count' for n in nodes(side)):
|
|
347
|
+
self.errors.append(
|
|
348
|
+
f'{context}: count() stands on the left of its comparison, and reads a predicate rather than '
|
|
349
|
+
f'arithmetic. Write count(<predicate>, over=<dimension>) <op> <integer>.'
|
|
350
|
+
)
|
|
351
|
+
continue
|
|
352
|
+
try:
|
|
353
|
+
expanded = expand(side, ns, context)
|
|
354
|
+
except ValueError as e:
|
|
355
|
+
self.errors.append(prefixed(context, e))
|
|
356
|
+
continue
|
|
357
|
+
if (resolved := self._expressions.build(expanded)) is not None:
|
|
358
|
+
sides.append(resolved)
|
|
359
|
+
if len(self.errors) > found:
|
|
360
|
+
return node
|
|
361
|
+
assert len(sides) == 2, 'a side of a where builds or refuses, since a where holds no formal'
|
|
362
|
+
dims: set[str] = set()
|
|
363
|
+
for side in sides:
|
|
364
|
+
if carries_variable(side):
|
|
365
|
+
self.errors.append(
|
|
366
|
+
f'{context}: a where compares expressions, and one side names a variable. A where mask '
|
|
367
|
+
f'is built before variables exist — it may test parameters and dimension coordinates only.'
|
|
368
|
+
)
|
|
369
|
+
elif degree.calls_dual(side):
|
|
370
|
+
self.errors.append(
|
|
371
|
+
f'{context}: a where compares expressions, and one side reads a dual, which only a solve '
|
|
372
|
+
f'produces. A mask is built before it — test the data instead.'
|
|
373
|
+
)
|
|
374
|
+
else:
|
|
375
|
+
try:
|
|
376
|
+
degree.check_expression(side, context)
|
|
377
|
+
dims |= dims_of(side, ns.schema, context)
|
|
378
|
+
except LanguageError as e:
|
|
379
|
+
self.errors.append(str(e))
|
|
380
|
+
if len(self.errors) > found:
|
|
381
|
+
return node
|
|
382
|
+
left, right = sides
|
|
383
|
+
if all(_is_number(side) for side in sides):
|
|
384
|
+
self.errors.append(
|
|
385
|
+
f"{context}: '{node.left} {node.op} {node.right}' compares two numbers, so it is decided before any "
|
|
386
|
+
f'data arrives and admits every row or none. Name the parameter one side stands for, or drop '
|
|
387
|
+
f'the comparison.'
|
|
388
|
+
)
|
|
389
|
+
return node
|
|
390
|
+
return ExpressionComparison(left, node.op, right, tuple(d for d in ns.schema.dimensions if d in dims))
|
|
391
|
+
|
|
392
|
+
def _position(
|
|
393
|
+
self, call: FunctionCallNode, node: UnresolvedComparisonNode
|
|
394
|
+
) -> DimensionPosition | UnresolvedComparisonNode:
|
|
395
|
+
"""``position(dim[, by=relation, within=columns]) <op> i``: the name a dimension, ``by=`` a relation keyed over it."""
|
|
396
|
+
ns, context = self.ns, self.context
|
|
397
|
+
shape = _position_shape(call)
|
|
398
|
+
if shape is None:
|
|
399
|
+
self.errors.append(
|
|
400
|
+
f'{context}: position() is written position(<dim>[, by=<relation>, within=<column>]), and this '
|
|
401
|
+
f'call is not of that shape. It takes the dimension it counts along and nothing else beside by= and within=.'
|
|
402
|
+
)
|
|
403
|
+
return node
|
|
404
|
+
dimension, by, into = shape
|
|
405
|
+
index = None if isinstance(node.right, ColumnNode | KeywordNode) else literal_number(node.right)
|
|
406
|
+
if index is None or not index.value.is_integer():
|
|
407
|
+
self.errors.append(
|
|
408
|
+
f'{context}: position({dimension}) is compared against an integer index, where 0 is first and a '
|
|
409
|
+
f'negative number counts from the end. Write position({dimension}) {node.op} <integer>.'
|
|
410
|
+
)
|
|
411
|
+
return node
|
|
412
|
+
position = int(index.value)
|
|
413
|
+
if dimension not in ns.dimensions:
|
|
414
|
+
self.errors.append(
|
|
415
|
+
f"{context}: position() counts along a dimension's coordinates, and "
|
|
416
|
+
f"'{dimension}' is {_declared_as(ns, dimension)}. "
|
|
417
|
+
f'{did_you_mean(dimension, ns.dimensions, label="Dimensions")}'
|
|
418
|
+
)
|
|
419
|
+
return node
|
|
420
|
+
if by is None:
|
|
421
|
+
return DimensionPosition(dimension, node.op, position)
|
|
422
|
+
if (problem := self._expressions.not_a_relation(by, 'position', 'by')) is not None:
|
|
423
|
+
self.errors.append(problem)
|
|
424
|
+
return node
|
|
425
|
+
spelled = f'position({dimension}, by={by})'
|
|
426
|
+
if into is None:
|
|
427
|
+
self.errors.append(
|
|
428
|
+
f'{context}: {spelled} leaves within= unsaid. {PARTITION_NAMES_ITS_GROUP} Write '
|
|
429
|
+
f"position({dimension}, by={by}, within=<column>) — the value columns of '{by}' "
|
|
430
|
+
f'are {list(ns.relations[by].values)}.'
|
|
431
|
+
)
|
|
432
|
+
return node
|
|
433
|
+
partition = self._expressions.partition(by, 'position', dimension, into)
|
|
434
|
+
if partition is None:
|
|
435
|
+
return node
|
|
436
|
+
return DimensionPosition(dimension, node.op, position, partition)
|
|
437
|
+
|
|
438
|
+
def _plain_comparison(self, node: UnresolvedComparisonNode, plain: _Plain) -> Predicate | UnresolvedWhereNode:
|
|
439
|
+
"""``name <op> literal``, or the one structural form ``relation <op> relation``."""
|
|
440
|
+
ns, context = self.ns, self.context
|
|
441
|
+
value = plain.value
|
|
442
|
+
left_name, _, left_column = plain.name.partition('.')
|
|
443
|
+
if not plain.quoted and isinstance(value, str):
|
|
444
|
+
right_name, _, right_column = value.partition('.')
|
|
445
|
+
if (rhs_kind := ns.kind(right_name)) is not None:
|
|
446
|
+
if rhs_kind == 'relation' and ns.kind(left_name) == 'relation':
|
|
447
|
+
left = self._relation_column(left_name, left_column or None, plain.name, plain.op)
|
|
448
|
+
right = self._relation_column(right_name, right_column or None, value, plain.op)
|
|
449
|
+
if left is None or right is None:
|
|
450
|
+
return node
|
|
451
|
+
if (refusal := _relation_pair_error(context, plain, value, ns, left, right)) is not None:
|
|
452
|
+
self.errors.append(refusal)
|
|
453
|
+
return node
|
|
454
|
+
dims = tuple(ns.relations[left_name].dim(k) for k in ns.relations[left_name].key)
|
|
455
|
+
return RelationPairComparison(left_name, left, right_name, right, plain.op, dims)
|
|
456
|
+
self.errors.append(_declared_rhs_error(context, plain, value, rhs_kind))
|
|
457
|
+
return node
|
|
458
|
+
|
|
459
|
+
kind = ns.kind(left_name)
|
|
460
|
+
if kind is None:
|
|
461
|
+
self.errors.append(ns.unknown(left_name, context, allow_dims=True))
|
|
462
|
+
return node
|
|
463
|
+
if left_column and kind != 'relation':
|
|
464
|
+
self.errors.append(
|
|
465
|
+
f"{context}: '{plain.name}' reads a column of '{left_name}', which is {_declared_as(ns, left_name)}. "
|
|
466
|
+
f'Only a relation has columns.'
|
|
467
|
+
)
|
|
468
|
+
return node
|
|
469
|
+
column = None
|
|
470
|
+
dtype: DeclaredDtype | None = None
|
|
471
|
+
if kind == 'relation':
|
|
472
|
+
column = self._relation_column(left_name, left_column or None, plain.name, plain.op)
|
|
473
|
+
if column is None:
|
|
474
|
+
return node
|
|
475
|
+
dtype = ns.dtypes[ns.relations[left_name].dim(column)]
|
|
476
|
+
elif kind in ('parameter', 'dimension'):
|
|
477
|
+
dtype = ns.dtypes[left_name]
|
|
478
|
+
if dtype is not None:
|
|
479
|
+
typed = self._typed_literal(plain, dtype)
|
|
480
|
+
if typed is None:
|
|
481
|
+
return node
|
|
482
|
+
value = typed
|
|
483
|
+
|
|
484
|
+
match kind:
|
|
485
|
+
case 'parameter':
|
|
486
|
+
assert not isinstance(value, datetime.date)
|
|
487
|
+
return ParameterComparison(left_name, plain.op, value, ns.leaf_dims[left_name])
|
|
488
|
+
case 'dimension':
|
|
489
|
+
return DimensionComparison(left_name, plain.op, value)
|
|
490
|
+
case 'relation':
|
|
491
|
+
assert column is not None
|
|
492
|
+
shape = ns.relations[left_name]
|
|
493
|
+
return RelationComparison(left_name, column, plain.op, value, tuple(shape.dim(k) for k in shape.key))
|
|
494
|
+
case 'variable':
|
|
495
|
+
self.errors.append(
|
|
496
|
+
f"{context}: where references variable '{left_name}'. A where "
|
|
497
|
+
f'mask is built before variables exist — it may test parameters '
|
|
498
|
+
f'and dimension coordinates only.'
|
|
499
|
+
)
|
|
500
|
+
return node
|
|
501
|
+
|
|
502
|
+
def _relation_column(self, name: str, column: str | None, spelling: str, op: PredicateOperator) -> str | None:
|
|
503
|
+
"""The value column a where-comparison on relation *name* reads, or the refusal.
|
|
504
|
+
|
|
505
|
+
A comparison reads one value per coordinate, so the relation is keyed
|
|
506
|
+
and the column is one the key determines; unsaid, it is the one value
|
|
507
|
+
column where there is exactly one.
|
|
508
|
+
"""
|
|
509
|
+
ns, context = self.ns, self.context
|
|
510
|
+
shape = ns.relations[name]
|
|
511
|
+
if not shape.values:
|
|
512
|
+
self.errors.append(
|
|
513
|
+
f"{context}: '{spelling}' compares a column of '{name}', a bare relation — every column is in its "
|
|
514
|
+
f'key — so it has no one value per coordinate to compare. Declare that column under values:, or '
|
|
515
|
+
f"test the bare name — '{name}' — for whether a row exists."
|
|
516
|
+
)
|
|
517
|
+
return None
|
|
518
|
+
if column is None:
|
|
519
|
+
if len(shape.values) != 1:
|
|
520
|
+
self.errors.append(
|
|
521
|
+
f"{context}: '{spelling}': '{name}' has {len(shape.values)} value columns ({list(shape.values)}), "
|
|
522
|
+
f'so say which the comparison reads: {name}.{shape.values[0] if shape.values else "..."}.'
|
|
523
|
+
)
|
|
524
|
+
return None
|
|
525
|
+
return shape.values[0]
|
|
526
|
+
if column not in shape.roles:
|
|
527
|
+
self.errors.append(
|
|
528
|
+
f"{context}: '{spelling}': '{column}' is not a column of '{name}', whose columns are {list(shape.roles)}."
|
|
529
|
+
)
|
|
530
|
+
return None
|
|
531
|
+
if column in shape.key:
|
|
532
|
+
self.errors.append(
|
|
533
|
+
f"{context}: '{spelling}': '{column}' is a key column of '{name}', which the frame supplies rather "
|
|
534
|
+
f"than reads. Compare the frame's own coordinate — {shape.dim(column)} {op} ... — or a value column."
|
|
535
|
+
)
|
|
536
|
+
return None
|
|
537
|
+
return column
|
|
538
|
+
|
|
539
|
+
def _typed_literal(self, node: _Plain, dtype: DeclaredDtype) -> float | str | datetime.date | None:
|
|
540
|
+
"""The comparison's literal, checked against the declared dtype.
|
|
541
|
+
|
|
542
|
+
Getting it wrong is silent: polars reads a datetime column against an
|
|
543
|
+
integer as an epoch offset, so ``snapshot > 0`` drops every coordinate
|
|
544
|
+
before 1970 without a word (#460). Returns ``None`` once it has recorded
|
|
545
|
+
an error, so the caller leaves the node unresolved.
|
|
546
|
+
"""
|
|
547
|
+
context = self.context
|
|
548
|
+
value = node.value
|
|
549
|
+
text = isinstance(value, str)
|
|
550
|
+
|
|
551
|
+
if dtype == 'datetime':
|
|
552
|
+
if not text:
|
|
553
|
+
self.errors.append(
|
|
554
|
+
f"{context}: '{node.name}' is a datetime dimension, so comparing it to "
|
|
555
|
+
f'{value!r} compares against the epoch — {node.name} > 0 means "after '
|
|
556
|
+
f'1970-01-01", not what it looks like. Quote an ISO date instead: '
|
|
557
|
+
f"{node.name} {node.op} '2030-01-01'."
|
|
558
|
+
)
|
|
559
|
+
return None
|
|
560
|
+
try:
|
|
561
|
+
return (
|
|
562
|
+
datetime.datetime.fromisoformat(value)
|
|
563
|
+
if _HAS_TIME.search(value)
|
|
564
|
+
else datetime.date.fromisoformat(value)
|
|
565
|
+
)
|
|
566
|
+
except ValueError:
|
|
567
|
+
self.errors.append(
|
|
568
|
+
f"{context}: '{node.name}' is a datetime dimension and {value!r} is not an "
|
|
569
|
+
f"ISO date. Write '2030-01-01' or '2030-01-01T06:00'."
|
|
570
|
+
)
|
|
571
|
+
return None
|
|
572
|
+
|
|
573
|
+
if dtype == 'str' and not text:
|
|
574
|
+
self.errors.append(
|
|
575
|
+
f"{context}: '{node.name}' has dtype 'str', so comparing it to the number "
|
|
576
|
+
f'{value!r} matches no label. Quote it if it is one: {node.name} {node.op} '
|
|
577
|
+
f"'{value:g}'."
|
|
578
|
+
)
|
|
579
|
+
return None
|
|
580
|
+
if dtype in ('int', 'float', 'bool') and text:
|
|
581
|
+
self.errors.append(
|
|
582
|
+
f"{context}: '{node.name}' has dtype '{dtype}', so comparing it to the string "
|
|
583
|
+
f'{value!r} matches nothing. Drop the quotes if it is a number.'
|
|
584
|
+
)
|
|
585
|
+
return None
|
|
586
|
+
return value
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
#: An ISO literal carrying a time-of-day, which decides date vs datetime.
|
|
590
|
+
_HAS_TIME = re.compile(r'[T ]\d')
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _declared_as(ns: Namespace, name: str) -> str:
|
|
594
|
+
kind = ns.kind(name)
|
|
595
|
+
return f'a {kind}' if kind else 'not declared'
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
class _Plain(NamedTuple):
|
|
599
|
+
"""A where-comparison read as ``name <op> literal`` or ``name <op> name`` — the shape the dtype rules are written for.
|
|
600
|
+
|
|
601
|
+
``quoted`` says the right-hand side arrived in quotes, and so is a label
|
|
602
|
+
rather than a name to look up.
|
|
603
|
+
"""
|
|
604
|
+
|
|
605
|
+
name: str
|
|
606
|
+
op: PredicateOperator
|
|
607
|
+
value: float | str
|
|
608
|
+
quoted: bool
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _side_name(side: ArithmeticNode | ColumnNode) -> str | None:
|
|
612
|
+
"""The name a side of a where-comparison spells — bare or ``relation.column`` — or ``None`` where it is arithmetic."""
|
|
613
|
+
if isinstance(side, NameNode):
|
|
614
|
+
return side.name
|
|
615
|
+
if isinstance(side, ColumnNode):
|
|
616
|
+
return side.shown
|
|
617
|
+
return None
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _position_shape(call: FunctionCallNode) -> tuple[str, str | None, tuple[str, ...] | None] | None:
|
|
621
|
+
"""``(dim, by, within)`` off a ``position(...)`` call, or ``None`` where the call is not of that shape."""
|
|
622
|
+
if len(call.args) != 1 or not isinstance(call.args[0], NameNode) or set(call.kwargs) - {'by', 'within'}:
|
|
623
|
+
return None
|
|
624
|
+
by, within = call.kwargs.get('by'), call.kwargs.get('within')
|
|
625
|
+
if by is not None and not isinstance(by, NameNode):
|
|
626
|
+
return None
|
|
627
|
+
if within is not None and not isinstance(within, NameNode | NameListNode):
|
|
628
|
+
return None
|
|
629
|
+
into = names_in(within) if within is not None else None
|
|
630
|
+
return call.args[0].name, by.name if by is not None else None, into
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _kwargs_error(
|
|
634
|
+
context: str, name: str, kwargs: Mapping[str, ArithmeticNode], required: tuple[str, ...]
|
|
635
|
+
) -> str | None:
|
|
636
|
+
"""Why *kwargs* is not what *name* takes over a predicate, or ``None`` where it is.
|
|
637
|
+
|
|
638
|
+
A predicate-reading call takes exactly the keywords named here. The
|
|
639
|
+
arithmetic forms of these operators take more — an ``edge=``, a ``by=`` —
|
|
640
|
+
and each is refused rather than ignored, since a predicate answers the
|
|
641
|
+
vacated coordinate itself and a grouped form has nobody asking for it yet.
|
|
642
|
+
"""
|
|
643
|
+
missing = [key for key in required if key not in kwargs]
|
|
644
|
+
if missing:
|
|
645
|
+
return f'{context}: {name}(<predicate>) needs {_listed([f"{key}=" for key in missing])}.'
|
|
646
|
+
if extra := sorted(set(kwargs) - set(required)):
|
|
647
|
+
edge = ' A predicate is false where a translation vacates, so there is no edge to state.'
|
|
648
|
+
return (
|
|
649
|
+
f'{context}: {name}(<predicate>) does not take {_listed([f"{key}=" for key in extra])}. '
|
|
650
|
+
f'It takes {_listed([f"{key}=" for key in required])}, and nothing else.'
|
|
651
|
+
f'{edge if "edge" in extra and name == "shift" else ""}'
|
|
652
|
+
)
|
|
653
|
+
return None
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _decided_count(op: str, value: float) -> str | None:
|
|
657
|
+
"""Whether comparing a count with *op* against *value* is settled by the count never being negative.
|
|
658
|
+
|
|
659
|
+
Returns ``'every'`` where the comparison always holds, ``'no'`` where it
|
|
660
|
+
never does, and ``None`` where the data decides.
|
|
661
|
+
"""
|
|
662
|
+
if value < 0:
|
|
663
|
+
return 'every' if op in ('>', '>=', '!=') else 'no'
|
|
664
|
+
if value == 0 and op in ('>=', '<'):
|
|
665
|
+
return 'every' if op == '>=' else 'no'
|
|
666
|
+
return None
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _listed(items: list[str]) -> str:
|
|
670
|
+
"""``'a'``, ``'a' and 'b'``, ``'a', 'b' and 'c'`` — one rule, so every message reads the same."""
|
|
671
|
+
quoted = [f"'{item}'" for item in items]
|
|
672
|
+
if len(quoted) <= 1:
|
|
673
|
+
return quoted[0] if quoted else 'nothing'
|
|
674
|
+
return f'{", ".join(quoted[:-1])} and {quoted[-1]}'
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _is_number(side: Expression) -> bool:
|
|
678
|
+
"""Whether *side* is arithmetic over literals alone — a value the language can fold, and a where may not test."""
|
|
679
|
+
return all(isinstance(n, Constant | Negate | Add | Multiply | Divide | Power) for n in walk(side))
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _not_arithmetic(context: str, side: ColumnNode | KeywordNode) -> str:
|
|
683
|
+
"""Why a relation column or a quoted label may not stand on a side of a comparison of expressions."""
|
|
684
|
+
if isinstance(side, ColumnNode):
|
|
685
|
+
return (
|
|
686
|
+
f"{context}: '{side.shown}' is a column of a relation, which is compared against a literal or a "
|
|
687
|
+
f'second column and is not read in arithmetic. Compare it on its own, or carry the value in a '
|
|
688
|
+
f'parameter and test that.'
|
|
689
|
+
)
|
|
690
|
+
return (
|
|
691
|
+
f"{context}: '{side.value}' is a quoted label, which is compared against one name. Put the name alone on "
|
|
692
|
+
f'the other side, or drop the quotes if it is a number.'
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _declared_rhs_error(context: str, node: _Plain, value: str, kind: str) -> str:
|
|
697
|
+
"""Why the right-hand side of a where-comparison may not name a variable, a relation or a dimension."""
|
|
698
|
+
comparison = f"'{node.name} {node.op} {value}'"
|
|
699
|
+
if kind == 'variable':
|
|
700
|
+
return (
|
|
701
|
+
f'{context}: {comparison} compares against variable {value!r}. '
|
|
702
|
+
f'A where mask is built before variables exist.'
|
|
703
|
+
)
|
|
704
|
+
if kind == 'relation':
|
|
705
|
+
return (
|
|
706
|
+
f'{context}: {comparison} compares {node.name!r} against relation {value!r}, and a '
|
|
707
|
+
f'relation is structure rather than data — a where tests values: a name against a literal, '
|
|
708
|
+
f'or arithmetic over parameters. A relation stands on the right-hand side only against a '
|
|
709
|
+
f'relation on the left sharing its dimension and its target.'
|
|
710
|
+
)
|
|
711
|
+
return (
|
|
712
|
+
f'{context}: {comparison} compares against dimension {value!r}, which the RHS reads '
|
|
713
|
+
f'as the literal coordinate {value!r} and so masks everything out. Comparing two '
|
|
714
|
+
f'dimensions is not in the language; if {value!r} is a coordinate rather than the '
|
|
715
|
+
f'dimension, rename one of the two.'
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _relation_pair_error(context: str, node: _Plain, other: str, ns: Namespace, left: str, right: str) -> str | None:
|
|
720
|
+
"""Why two relation columns may not be compared, or ``None`` where they may.
|
|
721
|
+
|
|
722
|
+
Both relations are read at their keys, so the keys must be over the same
|
|
723
|
+
dimensions or no row carries both; and the two columns must be over one
|
|
724
|
+
dimension, or no value of one is ever a value of the other. Both wrong
|
|
725
|
+
answers are silent, and a build's data library decides which one.
|
|
726
|
+
"""
|
|
727
|
+
comparison = f"'{node.name} {node.op} {other}'"
|
|
728
|
+
left_name, right_name = node.name.partition('.')[0], other.partition('.')[0]
|
|
729
|
+
ls, rs = ns.relations[left_name], ns.relations[right_name]
|
|
730
|
+
left_keys, right_keys = {ls.dim(k) for k in ls.key}, {rs.dim(k) for k in rs.key}
|
|
731
|
+
if left_keys != right_keys:
|
|
732
|
+
return (
|
|
733
|
+
f'{context}: {comparison} compares relations keyed over different dimensions '
|
|
734
|
+
f"('{left_name}' by {sorted(left_keys)}, '{right_name}' by {sorted(right_keys)}) — there is no row "
|
|
735
|
+
f'carrying both, so the comparison has nothing to test. Two relations may be compared only '
|
|
736
|
+
f'where their keys are over the same dimensions.'
|
|
737
|
+
)
|
|
738
|
+
if ls.dim(left) != rs.dim(right):
|
|
739
|
+
return (
|
|
740
|
+
f"{context}: {comparison} compares '{node.name}' (a column over '{ls.dim(left)}') with "
|
|
741
|
+
f"'{other}' (a column over '{rs.dim(right)}'). No value of one is ever a value of the other, so "
|
|
742
|
+
f'the predicate can only mask everything out. Two columns may be compared only '
|
|
743
|
+
f'where they are over the same dimension.'
|
|
744
|
+
)
|
|
745
|
+
return None
|