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.
@@ -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