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/operators.py ADDED
@@ -0,0 +1,235 @@
1
+ # SPDX-FileCopyrightText: mathspec Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """The closed set of built-in operators and their call shapes.
6
+
7
+ One home for each signature: a composition is a macro, and the set is closed
8
+ to a file.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from dataclasses import dataclass
15
+ from typing import TYPE_CHECKING, Literal, NamedTuple
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Iterable
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Builtin:
23
+ """The call shape of one built-in operator.
24
+
25
+ Keyword arguments come in four kinds, and the kind decides what resolution
26
+ turns the value into: ``dimension_kwargs`` name a dimension
27
+ (``sum(x, over=generator)``); ``relation_kwargs`` name a relation, which
28
+ carries its own dimensions, so it needs no sibling kwarg;
29
+ ``edge_kwargs`` take a closed keyword or a number;
30
+ ``required_value_kwargs`` are ordinary values that must be present — a
31
+ number, never a name to resolve (``shift(..., offset=1)``).
32
+
33
+ Every operator takes one positional argument, the expression; every
34
+ dimension or relation it names arrives in a kwarg *value*, which is what
35
+ lets a macro pass one as a formal. ``usage`` is the wording every refusal
36
+ quotes back.
37
+ """
38
+
39
+ usage: str
40
+ dimension_kwargs: tuple[str, ...] = ()
41
+ relation_kwargs: tuple[str, ...] = ()
42
+ #: Kwargs naming a column of the relation ``by=`` names — ``over=`` and
43
+ #: ``into=`` — which resolution folds into the direction it is read in.
44
+ role_kwargs: tuple[str, ...] = ()
45
+ #: Kwargs naming a dimension on their own and a column of the relation where
46
+ #: ``by=`` names one. ``sum(x, over=generator)`` reduces the dimension
47
+ #: away; ``sum(x, by=l, over=c)`` names the column the call consumes.
48
+ #: One meaning — what leaves the frame — read in the namespace ``by=``
49
+ #: decides.
50
+ dimension_or_role_kwargs: tuple[str, ...] = ()
51
+ edge_kwargs: tuple[str, ...] = ()
52
+ required_value_kwargs: tuple[str, ...] = ()
53
+ #: Kwargs the call may omit. Their *kind* still comes from the tuples
54
+ #: above — this says only that the operator has an answer without them.
55
+ optional_kwargs: tuple[str, ...] = ()
56
+ #: Kwargs required exactly when the call addresses a relation. A call
57
+ #: names both of its ends and a partition names the columns it groups by,
58
+ #: so that adding a value column to the relation cannot change what an
59
+ #: existing call means.
60
+ with_relation: tuple[str, ...] = ()
61
+
62
+ @property
63
+ def required(self) -> frozenset[str]:
64
+ """Every keyword the call must carry."""
65
+ return (
66
+ frozenset(self.dimension_kwargs) | frozenset(self.relation_kwargs) | frozenset(self.required_value_kwargs)
67
+ ) - frozenset(self.optional_kwargs)
68
+
69
+ def kind_of(
70
+ self, kwarg: str, *, with_relation: bool = False
71
+ ) -> Literal['dimension', 'relation', 'role', 'edge', 'value'] | None:
72
+ """What resolution turns the value of *kwarg* into, or ``None`` where the operator does not declare it.
73
+
74
+ A dimension, a relation, a column of it, an edge policy, or a plain value.
75
+ *with_relation* says whether the call carries a ``by=``, which is what
76
+ decides the kind of a :attr:`dimension_or_role_kwargs` member.
77
+ """
78
+ if kwarg in self.dimension_or_role_kwargs:
79
+ return 'role' if with_relation else 'dimension'
80
+ if kwarg in self.dimension_kwargs:
81
+ return 'dimension'
82
+ if kwarg in self.relation_kwargs:
83
+ return 'relation'
84
+ if kwarg in self.role_kwargs:
85
+ return 'role'
86
+ if kwarg in self.edge_kwargs:
87
+ return 'edge'
88
+ if kwarg in self.required_value_kwargs:
89
+ return 'value'
90
+ return None
91
+
92
+
93
+ #: The closed operator set. ``by=`` is the one keyword that addresses a relation,
94
+ #: and a relation carries its own dimensions, so no sibling kwarg restates them.
95
+ #: On ``shift`` and ``sum_back`` it partitions the axis the operator steps along: it
96
+ #: says which rows are neighbours, not which group a term lands in, and
97
+ #: ``within=`` names the value columns the group is made of, on every call
98
+ #: that names a ``by=``.
99
+ BUILTINS: dict[str, Builtin] = {
100
+ 'sum': Builtin(
101
+ 'sum(<expr>), sum(<expr>, over=<dim>) or sum(<expr>, by=<relation>, over=<column>, into=<column>)',
102
+ relation_kwargs=('by',),
103
+ role_kwargs=('into',),
104
+ dimension_or_role_kwargs=('over',),
105
+ optional_kwargs=('by',),
106
+ with_relation=('over', 'into'),
107
+ ),
108
+ 'at': Builtin(
109
+ 'at(<expr>, by=<relation>, over=<column>, into=<column>)',
110
+ relation_kwargs=('by',),
111
+ role_kwargs=('over', 'into'),
112
+ with_relation=('over', 'into'),
113
+ ),
114
+ 'sum_back': Builtin(
115
+ "sum_back(<expr>, along=<dim>, window=<n|parameter>[, edge='wrap'][, by=<relation>, within=<column>])",
116
+ dimension_kwargs=('along',),
117
+ relation_kwargs=('by',),
118
+ role_kwargs=('within',),
119
+ required_value_kwargs=('window',),
120
+ edge_kwargs=('edge',),
121
+ optional_kwargs=('by',),
122
+ with_relation=('within',),
123
+ ),
124
+ 'shift': Builtin(
125
+ "shift(<expr>, along=<dim>, offset=<n>[, edge='wrap'|<number>][, by=<relation>, within=<column>])",
126
+ dimension_kwargs=('along',),
127
+ relation_kwargs=('by',),
128
+ role_kwargs=('within',),
129
+ required_value_kwargs=('offset',),
130
+ edge_kwargs=('edge',),
131
+ optional_kwargs=('by',),
132
+ with_relation=('within',),
133
+ ),
134
+ 'dual': Builtin('dual(<constraint>)'),
135
+ }
136
+
137
+ BUILTIN_NAMES = frozenset(BUILTINS)
138
+
139
+
140
+ class Amount(NamedTuple):
141
+ """What the errors of an operator that steps along an axis say about the amount it takes."""
142
+
143
+ #: The word for the amount.
144
+ noun: str
145
+ #: Why negating a named one at the call site is not what the caller means.
146
+ negated: str
147
+ #: What a named one that varies over the axis it steps along becomes.
148
+ varies: str
149
+ #: The least whole number a literal may be.
150
+ minimum: float
151
+ #: What a literal must be written as, after ``operator(kwarg=...)``.
152
+ form: str
153
+
154
+
155
+ #: The amount each operator that steps along an axis takes, by operator name.
156
+ AMOUNTS: dict[str, Amount] = {
157
+ 'shift': Amount(
158
+ 'offset',
159
+ 'A named offset carries its sign in its values, so that one row pointing backwards says '
160
+ 'so where the data is read — negate the column instead.',
161
+ 'a permutation rather than a lag',
162
+ -math.inf,
163
+ 'must be a whole number, or the name of an integer parameter when the offset differs per '
164
+ 'entity — a lead time, a transit time, a minimum up time.',
165
+ ),
166
+ 'sum_back': Amount(
167
+ 'width',
168
+ 'A width counts positions and so has no direction; which way a window reaches is the '
169
+ "operator's own name rather than the sign of its width.",
170
+ 'a different window at every position, which is no longer "the last n"',
171
+ 1,
172
+ 'needs a whole number of positions of at least 1, or the name of an integer parameter when '
173
+ 'the window differs per entity. A width of 1 is the operand itself.',
174
+ ),
175
+ }
176
+
177
+ #: The one closed keyword an ``edge=`` accepts. Everything else in that
178
+ #: position is a number: the value the vacated positions contribute.
179
+ EDGE_WRAP = 'wrap'
180
+
181
+
182
+ def edge_error(name: str, given: str) -> str:
183
+ """Why an ``edge=`` value is not one the language has."""
184
+ return (
185
+ f'{name}(edge={given}) is not an edge policy.\n'
186
+ f"Write edge='{EDGE_WRAP}' for a cyclic translation, a number for the "
187
+ f'value the vacated positions contribute, or omit it and they are '
188
+ f'absent — which drops the row.'
189
+ )
190
+
191
+
192
+ def call_shape_error(name: str, positional: int, kwargs: Iterable[str]) -> str | None:
193
+ """Why a call to *name* does not fit its signature; ``None`` if it fits."""
194
+ builtin = BUILTINS[name]
195
+ keys = set(kwargs)
196
+ optional = {*builtin.edge_kwargs, *builtin.optional_kwargs}
197
+ reads = bool(keys & set(builtin.relation_kwargs))
198
+ required = builtin.required | frozenset(builtin.with_relation) if reads else builtin.required
199
+ optional |= set() if reads else set(builtin.with_relation)
200
+ if reads and (unsaid := sorted(frozenset(builtin.with_relation) - keys)):
201
+ return unsaid_ends_error(name, unsaid)
202
+ fits = positional == 1 and keys - optional == required
203
+ return None if fits else f'{name}() expects {builtin.usage}'
204
+
205
+
206
+ #: Why a partition writes ``within=`` whenever it writes ``by=``; ``position()``
207
+ #: in a where string says the same, so the sentence has one home.
208
+ PARTITION_NAMES_ITS_GROUP = (
209
+ 'A partition names the value columns it groups by, so that a relation may gain a value '
210
+ 'column without changing what this call means.'
211
+ )
212
+
213
+
214
+ def unsaid_ends_error(name: str, unsaid: list[str]) -> str:
215
+ """Why a call through a relation has to write every column it reads: both ends of a read, the group of a partition."""
216
+ reason = (
217
+ PARTITION_NAMES_ITS_GROUP
218
+ if 'within' in BUILTINS[name].with_relation
219
+ else 'A call names both of its ends, so that a relation may gain a value column without changing what this call means.'
220
+ )
221
+ return (
222
+ f'{name}() through a relation leaves {", ".join(f"{k}=" for k in unsaid)} unsaid.\n'
223
+ f'{reason}\n'
224
+ f'Write: {BUILTINS[name].usage}'
225
+ )
226
+
227
+
228
+ def unknown_operator_message(name: str) -> str:
229
+ """The one wording for "that is not an operator"."""
230
+ return (
231
+ f"Unknown operator '{name}'.\n"
232
+ f'Available: {sorted(BUILTIN_NAMES)}\n'
233
+ f"Define '{name}' as a macro under 'macros:' if it composes built-ins. "
234
+ f'A file cannot add an operator: see docs/about/limits.md.'
235
+ )