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,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
+ )