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/py.typed ADDED
File without changes
mathspec/resolution.py ADDED
@@ -0,0 +1,475 @@
1
+ # SPDX-FileCopyrightText: mathspec Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """Name resolution — the pass that reads the syntax tree into the program vocabulary.
6
+
7
+ The grammars emit bare names and calls; resolution builds the
8
+ :mod:`mathspec.program` node each stands for, so every pass after — the dim
9
+ rules, the degree rules, the typesetter, lowering — reads one vocabulary. This
10
+ module holds the :class:`Namespace` a resolution reads names from and the doors
11
+ lowering calls, one per kind of text; the walks are
12
+ :class:`~mathspec._expression_resolver.ExpressionResolver` for arithmetic and
13
+ :class:`~mathspec._where_resolver.WhereResolver` for a where string. The rules
14
+ live in the language reference.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import TYPE_CHECKING, Literal, cast
20
+
21
+ import mathspec.degree as degree
22
+ from mathspec._expression_parser import (
23
+ ArithmeticNode,
24
+ ComparisonNode,
25
+ NameNode,
26
+ nodes,
27
+ )
28
+ from mathspec._expression_resolver import ExpressionResolver
29
+ from mathspec._where_parser import (
30
+ UnresolvedWhereNode,
31
+ nested,
32
+ parse_where,
33
+ )
34
+ from mathspec._where_resolver import WhereResolver
35
+ from mathspec.errors import LanguageError, SchemaError, case_context, prefixed
36
+ from mathspec.exclusivity import overlapping
37
+ from mathspec.expansion import expand, parse_and_expand
38
+ from mathspec.program import (
39
+ BooleanLiteral,
40
+ Cases,
41
+ Expression,
42
+ Mask,
43
+ Named,
44
+ Predicate,
45
+ Region,
46
+ RelationDeclaration,
47
+ carries_variable,
48
+ )
49
+
50
+ if TYPE_CHECKING:
51
+ from collections.abc import Iterable
52
+
53
+ from mathspec._expression_parser import ComparisonOperator, ParsedNode
54
+ from mathspec._where_parser import ParsedWhere
55
+ from mathspec.model import ExpressionBlock, Spec
56
+ from mathspec.program import DeclaredDtype
57
+
58
+
59
+ #: What a name a file may write turns out to be. Answered by
60
+ #: :meth:`Namespace.kind`, so a pass reading a name switches over this rather
61
+ #: than over the stores it would otherwise have to try in order.
62
+ DeclarationKind = Literal['variable', 'parameter', 'dimension', 'relation']
63
+
64
+
65
+ class Namespace:
66
+ """The declared names of one schema, by kind — the whole of what a file may name, read once.
67
+
68
+ A name has one kind: validation.py refuses one declared under two sections.
69
+ """
70
+
71
+ __slots__ = (
72
+ '_loading',
73
+ '_named',
74
+ 'constraints',
75
+ 'dimensions',
76
+ 'dtypes',
77
+ 'leaf_dims',
78
+ 'parameters',
79
+ 'relations',
80
+ 'schema',
81
+ 'variables',
82
+ )
83
+
84
+ def __init__(self, schema: Spec) -> None:
85
+ #: The schema the names come from — what an expression is expanded and
86
+ #: dim-checked against, since macros, named expressions and the dim
87
+ #: rules read declarations the flat listing below does not carry.
88
+ self.schema = schema
89
+ self.variables = frozenset(schema.variables)
90
+ self.parameters = frozenset(schema.parameters)
91
+ self.dimensions = frozenset(schema.dimensions)
92
+ #: The declared constraint names, off the flat namespace: a bare name
93
+ #: never reaches them, so a model may name a constraint after a variable.
94
+ #: Consulted only in ``dual()``'s argument position.
95
+ self.constraints = frozenset(schema.constraints)
96
+ #: name -> declared dtype, for dimensions, parameters and relations alike;
97
+ #: what a where comparison checks its literal against.
98
+ self.dtypes: dict[str, DeclaredDtype] = {
99
+ **{p: pd.dtype for p, pd in schema.parameters.items()},
100
+ **{d: dd.dtype for d, dd in schema.dimensions.items()},
101
+ }
102
+ #: relation name -> its columns and key, as declared.
103
+ self.relations: dict[str, RelationDeclaration] = {
104
+ n: RelationDeclaration(lk.pairs, lk.key_roles, lk.description) for n, lk in schema.relations.items()
105
+ }
106
+ #: parameter or variable name -> the dims it is read through —
107
+ #: parameters by their ``dims``, variables by their frame. Stamped onto
108
+ #: each leaf a where names, the way a relation leaf carries ``over``.
109
+ self.leaf_dims: dict[str, tuple[str, ...]] = {
110
+ **{p: tuple(pd.dims) for p, pd in schema.parameters.items()},
111
+ **{v: tuple(vd.dims) for v, vd in schema.variables.items()},
112
+ }
113
+ #: named expression -> its resolved node, or ``None``, and its refusals;
114
+ #: filled the first time anything reads the name.
115
+ self._named: dict[str, tuple[Named | None, tuple[str, ...]]] = {}
116
+ #: The named expressions waiting to be resolved, the one asked for
117
+ #: first — each above the entries it reads, so it is a cycle's chain.
118
+ self._loading: list[str] = []
119
+
120
+ def named(self, name: str, context: str) -> Named:
121
+ """The ``expressions:`` entry *name* as the node that stands where its name is written.
122
+
123
+ Resolved under the entry's own context the first time it is asked
124
+ for, and read from then on, so a fault in it is reported once.
125
+
126
+ Raises:
127
+ SchemaError: The entry reads itself, or does not load.
128
+ """
129
+ if (refusal := self.cycle(name, context)) is not None:
130
+ raise SchemaError(refusal)
131
+ node, _ = self.named_entry(name)
132
+ if node is None:
133
+ msg = f"{context}: named expression '{name}' does not load. Its refusal is listed with it."
134
+ raise SchemaError(msg)
135
+ return node
136
+
137
+ def cycle(self, name: str, context: str, through: Iterable[str] = ()) -> str | None:
138
+ """The refusal for reading *name* while it is being resolved, or ``None``; *through* names the macros the read went through."""
139
+ if name not in self._loading:
140
+ return None
141
+ chain = ' -> '.join([*self._loading[self._loading.index(name) :], *through, name])
142
+ return f'{context}: circular expression reference: {chain}'
143
+
144
+ def named_entry(self, name: str) -> tuple[Named | None, tuple[str, ...]]:
145
+ """The ``expressions:`` entry *name* resolved, or ``None``, with every refusal it earned.
146
+
147
+ The entries it reads are resolved before it, walked from a stack that
148
+ holds the path of reads from *name* rather than by recursing into
149
+ each, so a chain of entries however long costs no stack. An entry
150
+ that reads one on the path is a cycle, which :meth:`named` refuses
151
+ with that path when the resolution reaches the read.
152
+ """
153
+ base = len(self._loading)
154
+ self._loading.append(name)
155
+ while len(self._loading) > base:
156
+ top = self._loading[-1]
157
+ if top in self._named:
158
+ self._loading.pop()
159
+ continue
160
+ waiting = [n for n in self._references(top) if n not in self._named and n not in self._loading]
161
+ if waiting:
162
+ self._loading.append(waiting[0])
163
+ continue
164
+ errors: list[str] = []
165
+ node = _named(top, self.schema.expressions[top], self, errors)
166
+ self._named[top] = (node, tuple(errors))
167
+ self._loading.pop()
168
+ return self._named[name]
169
+
170
+ def _references(self, name: str) -> tuple[str, ...]:
171
+ """The ``expressions:`` entries the texts of entry *name* read, macros expanded, in first-mention order.
172
+
173
+ A text that does not parse or expand reads nothing here: the
174
+ resolution that follows reports it.
175
+ """
176
+ block, context = self.schema.expressions[name], f"Named expression '{name}'"
177
+ arithmetic: list[ParsedNode] = []
178
+ for text in (block.expression, *(case.expression for case in (block.cases or {}).values()), block.otherwise):
179
+ if text is not None:
180
+ try:
181
+ arithmetic.append(parse_and_expand(text, self, context))
182
+ except ValueError:
183
+ continue
184
+ for case in (block.cases or {}).values():
185
+ try:
186
+ pending: list[ParsedWhere] = [parse_where(case.when)]
187
+ except ValueError:
188
+ continue
189
+ while pending:
190
+ node = pending.pop()
191
+ if isinstance(node, ArithmeticNode):
192
+ try:
193
+ arithmetic.append(expand(node, self, context))
194
+ except ValueError:
195
+ continue
196
+ else:
197
+ pending.extend(nested(node))
198
+ names = (n.name for n in nodes(*arithmetic) if isinstance(n, NameNode) and n.name in self.schema.expressions)
199
+ return tuple(dict.fromkeys(names))
200
+
201
+ def kind(self, name: str) -> DeclarationKind | None:
202
+ """What *name* was declared as, or ``None`` where the file declares it nowhere."""
203
+ if name in self.variables:
204
+ return 'variable'
205
+ if name in self.parameters:
206
+ return 'parameter'
207
+ if name in self.dimensions:
208
+ return 'dimension'
209
+ if name in self.relations:
210
+ return 'relation'
211
+ return None
212
+
213
+ def unknown(self, name: str, context: str, *, allow_dims: bool, formals: Iterable[str] = ()) -> str:
214
+ """The refusal for a *name* declared nowhere, listing what it could have been.
215
+
216
+ Args:
217
+ name: The name the file wrote.
218
+ context: The declaration it was found in.
219
+ allow_dims: Whether a dimension would have been accepted there. It marks a
220
+ where string, which reads a relation as readily as a parameter, so the
221
+ listing carries the relations too; an expression, where a relation is not a
222
+ value, lists the variables instead.
223
+ formals: A macro's formals, listed first when there are any.
224
+ """
225
+ shown: list[tuple[str, Iterable[str]]] = [('Formals', formals)] if formals else []
226
+ shown += (
227
+ [('Parameters', self.parameters), ('Dimensions', self.dimensions), ('Relations', self.relations)]
228
+ if allow_dims
229
+ else [('Variables', self.variables), ('Parameters', self.parameters)]
230
+ )
231
+ listing = '\n'.join(f' {kind}: {sorted(names)}' for kind, names in shown)
232
+ return f"{context}: '{name}' not found.\n{listing}\nCheck for typos, or ensure '{name}' is declared."
233
+
234
+ def unknown_constraint(self, name: str, context: str, *, formals: Iterable[str] = ()) -> str:
235
+ """The refusal for a ``dual(name)`` naming no constraint — nor, inside a template, a formal."""
236
+ also = ' or a formal of this macro' if formals else ''
237
+ return (
238
+ f"{context}: dual({name}): '{name}' is not a declared constraint{also}.\n"
239
+ f' Constraints: {sorted(self.constraints)}\n'
240
+ f"Check for typos, or declare '{name}' under 'constraints:'."
241
+ )
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # the seam the rest of the package uses
246
+ # ---------------------------------------------------------------------------
247
+
248
+
249
+ def mask_of(node: Predicate | None) -> Mask | None:
250
+ """The mask a declaration carries for a resolved where: ``None`` where there is none, or where every row passes."""
251
+ if node is None or (isinstance(node, BooleanLiteral) and node.value):
252
+ return None
253
+ return Mask(node)
254
+
255
+
256
+ def remainder(masks: Iterable[Mask]) -> Mask:
257
+ """The region left over: where not one of *masks* holds.
258
+
259
+ The ``otherwise`` arm's own mask, built rather than written. ``cases:``
260
+ carries at least one case, so there is no vacuous truth to spell.
261
+ """
262
+ first, *rest = masks
263
+ left = ~first
264
+ for mask in rest:
265
+ left = left & ~mask
266
+ return left
267
+
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # expressions
271
+ # ---------------------------------------------------------------------------
272
+
273
+
274
+ def resolve_expression(
275
+ node: ArithmeticNode,
276
+ ns: Namespace,
277
+ context: str,
278
+ errors: list[str],
279
+ *,
280
+ formals: frozenset[str] = frozenset(),
281
+ ) -> Expression | None:
282
+ """Build the program tree *node* stands for, checking every name and operator call shape on the way.
283
+
284
+ Returns:
285
+ The tree, or ``None`` once anything failed — appending to *errors*
286
+ rather than raising, so a caller collecting problems across a whole
287
+ schema reports them together. Also ``None``, with nothing appended,
288
+ where a name in *formals* stands under *node*: a macro template is
289
+ checked by the rules a call site is before anything calls it, and
290
+ only the call site that binds its formals has a tree to build.
291
+
292
+ """
293
+ before = len(errors)
294
+ resolved = ExpressionResolver(ns, context, errors, formals=formals).build(node)
295
+ return None if len(errors) > before else resolved
296
+
297
+
298
+ def resolve_where(
299
+ node: Predicate | UnresolvedWhereNode,
300
+ ns: Namespace,
301
+ context: str,
302
+ errors: list[str],
303
+ self_variable: str | None = None,
304
+ ) -> Predicate | None:
305
+ """Rewrite a parsed where AST into typed predicates, folded as :class:`~mathspec.program.Mask` folds.
306
+
307
+ Returns:
308
+ The typed tree — a mask admitting every row or none comes back as the
309
+ one ``BooleanLiteral`` — or ``None`` once anything failed, with the
310
+ problems appended to *errors*.
311
+ """
312
+ before = len(errors)
313
+ resolved = WhereResolver(ns, context, errors, self_variable).where(node)
314
+ return None if len(errors) > before else Mask(cast('Predicate', resolved)).root
315
+
316
+
317
+ def resolve_where_text(
318
+ text: str | None,
319
+ ns: Namespace,
320
+ context: str,
321
+ errors: list[str],
322
+ self_variable: str | None = None,
323
+ ) -> Predicate | None:
324
+ """Parse and resolve one where string as :func:`resolve_where` does, a parse failure appended to *errors*.
325
+
326
+ Returns:
327
+ ``None`` where there is no mask to read, and where reading it failed.
328
+ """
329
+ if text is None:
330
+ return None
331
+ try:
332
+ node = parse_where(text)
333
+ except ValueError as e:
334
+ errors.append(f'{context}: {e}')
335
+ return None
336
+ return resolve_where(node, ns, context, errors, self_variable)
337
+
338
+
339
+ def resolve_expression_text(
340
+ text: str, ns: Namespace, context: str, errors: list[str], *, ceiling: int | None
341
+ ) -> Expression | None:
342
+ """Parse, expand, resolve and degree-check one expression string that stands for a value.
343
+
344
+ *ceiling* is the degree the position honours, and ``None`` for an
345
+ ``expressions:`` entry's body: what the math admits
346
+ (:func:`~mathspec.degree.check_expression`) is a rule about the position
347
+ that *reads* it, so it fires on the expanded tree of every objective and
348
+ piecewise link, and not where an entry is declared. A constraint is
349
+ :func:`resolve_constraint_text`'s.
350
+
351
+ Returns:
352
+ The typed tree, or ``None`` once anything failed, the problem appended
353
+ to *errors*.
354
+ """
355
+ ast = _parsed(text, ns, context, errors)
356
+ if ast is None:
357
+ return None
358
+ if isinstance(ast, ComparisonNode):
359
+ errors.append(f'{context}: expression must not contain a comparison operator.\nGot: {text!r}')
360
+ return None
361
+ resolved = resolve_expression(ast, ns, context, errors)
362
+ if resolved is None or ceiling is None:
363
+ return resolved
364
+ return None if _over_the_ceiling(resolved, context, errors, ceiling=ceiling) else resolved
365
+
366
+
367
+ def resolve_constraint_text(
368
+ text: str, ns: Namespace, context: str, errors: list[str]
369
+ ) -> tuple[Expression, ComparisonOperator, Expression] | None:
370
+ """Parse, expand, resolve and degree-check one constraint string: exactly one comparison, a variable on a side (#1171).
371
+
372
+ Returns:
373
+ The two sides and the sense between them, or ``None`` once anything
374
+ failed, the problem appended to *errors*.
375
+ """
376
+ ast = _parsed(text, ns, context, errors)
377
+ if ast is None:
378
+ return None
379
+ if not isinstance(ast, ComparisonNode):
380
+ errors.append(
381
+ f'{context}: expression must contain exactly one comparison operator (<=, >=, ==).\nGot: {text!r}'
382
+ )
383
+ return None
384
+ found = len(errors)
385
+ resolver = ExpressionResolver(ns, context, errors)
386
+ left, right = resolver.build(ast.left), resolver.build(ast.right)
387
+ if len(errors) > found or left is None or right is None:
388
+ return None
389
+ if any(_over_the_ceiling(side, context, errors, ceiling=2) for side in (left, right)):
390
+ return None
391
+ if not (carries_variable(left) or carries_variable(right)):
392
+ errors.append(
393
+ f'{context}: neither side of the comparison carries a variable, so the row decides nothing.\n'
394
+ f'Got: {text!r}\n'
395
+ f'A constraint is a claim about a decision, and a comparison of numbers and parameters '
396
+ f'is settled before the solve — no consumer builds a row for it. Name the variable it should '
397
+ f'bound, or state the fact under `assumptions:`, where the consumer attaching the data checks it.'
398
+ )
399
+ return None
400
+ return left, ast.op, right
401
+
402
+
403
+ def _parsed(text: str, ns: Namespace, context: str, errors: list[str]) -> ComparisonNode | ArithmeticNode | None:
404
+ """*text* parsed and its macros expanded, or ``None`` with the refusal appended."""
405
+ try:
406
+ return parse_and_expand(text, ns, context)
407
+ except ValueError as e:
408
+ errors.append(prefixed(context, e))
409
+ return None
410
+
411
+
412
+ def _over_the_ceiling(node: Expression, context: str, errors: list[str], *, ceiling: int) -> bool:
413
+ """Whether *node* breaks the degree rules at *ceiling*, the refusal appended."""
414
+ try:
415
+ degree.check_expression(node, context, ceiling=ceiling)
416
+ except LanguageError as e:
417
+ errors.append(str(e))
418
+ return True
419
+ return False
420
+
421
+
422
+ def _named(name: str, block: ExpressionBlock, ns: Namespace, errors: list[str]) -> Named | None:
423
+ """One ``expressions:`` entry as the node every use of it holds, or ``None`` once anything in it failed.
424
+
425
+ A cased entry's arms are checked one by one, so every fault is collected
426
+ rather than the first, and proved apart only once all of them resolve.
427
+ The ``otherwise`` arm becomes the region left over, so a consumer adds
428
+ regions rather than working out which one is left; the language proved
429
+ the rest apart, so the regions are disjoint and total.
430
+ """
431
+ context = f"Named expression '{name}'"
432
+ if not block.cases:
433
+ assert block.expression is not None
434
+ body = resolve_expression_text(block.expression, ns, context, errors, ceiling=None)
435
+ return None if body is None else Named(name, body)
436
+
437
+ found = len(errors)
438
+ regions: list[Region] = []
439
+ masks: dict[str, Predicate] = {}
440
+ for case_name, case in block.cases.items():
441
+ arm_context = case_context(name, case_name)
442
+ when = resolve_where_text(case.when, ns, arm_context, errors)
443
+ if isinstance(when, BooleanLiteral):
444
+ errors.append(_constant_arm(arm_context, value=when.value))
445
+ elif when is not None:
446
+ masks[case_name] = when
447
+ value = resolve_expression_text(case.expression, ns, arm_context, errors, ceiling=None)
448
+ if when is not None and value is not None:
449
+ regions.append(Region(Mask(when), value))
450
+ assert block.otherwise is not None
451
+ fallback = resolve_expression_text(block.otherwise, ns, case_context(name, None), errors, ceiling=None)
452
+ if len(errors) > found or fallback is None:
453
+ return None
454
+ errors.extend(f'{context}: {problem}' for problem in overlapping(masks, ns.dtypes))
455
+ if len(errors) > found:
456
+ return None
457
+ left_over = Region(remainder(region.when for region in regions), fallback)
458
+ return Named(name, Cases((*regions, left_over)))
459
+
460
+
461
+ def _constant_arm(context: str, *, value: bool) -> str:
462
+ """The refusal for a case arm whose mask the connectives already decided.
463
+
464
+ Cases are proved apart rather than ranked, so an always-true arm is not
465
+ one that shadows the arms under it — it is one no other arm can be proved
466
+ apart from, and the ``otherwise`` it leaves is empty. An always-false arm
467
+ is the plainer half: nothing to apply to.
468
+ """
469
+ if value:
470
+ return (
471
+ f'{context}: the mask admits every row, so no other arm can hold anywhere '
472
+ f'and `otherwise:` covers nothing. Write the expression without `cases:`, '
473
+ f'or narrow the `when`.'
474
+ )
475
+ return f'{context}: the mask admits no row, so this arm never applies. Delete the arm, or widen the `when`.'
@@ -0,0 +1,185 @@
1
+ # SPDX-FileCopyrightText: mathspec Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """The walk behind :attr:`~mathspec.program.Program.separability` — every axis's verdict, in one pass over a program."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Literal, NamedTuple
10
+
11
+ from mathspec.program import (
12
+ Cases,
13
+ DimensionPosition,
14
+ GroupSum,
15
+ Mask,
16
+ Pullback,
17
+ Reach,
18
+ Separability,
19
+ Sum,
20
+ Translate,
21
+ WindowSum,
22
+ walk,
23
+ )
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Iterator
27
+
28
+ from mathspec.program import Expression, Program
29
+
30
+
31
+ class _Block(NamedTuple):
32
+ """One group of expressions the walk judges together, as the lowering's own messages label it.
33
+
34
+ ``row`` is the constraint whose rows the block builds, and ``None`` where
35
+ it builds none — a bound narrows a column, and the objective is one row no
36
+ window cuts. A coupling reported against a block without a row is still a
37
+ coupling, and never a linking row.
38
+ """
39
+
40
+ label: str
41
+ row: str | None
42
+ nodes: tuple[Expression, ...]
43
+ mask: Mask | None
44
+ reductions_couple: bool
45
+
46
+
47
+ def _built_blocks(program: Program) -> Iterator[_Block]:
48
+ """Every block that builds rows.
49
+
50
+ A named expression is not one: its body stands under each use, so walking
51
+ the constraint sides reaches it, and walking it again would report one
52
+ coupling twice.
53
+ """
54
+ for name, block in program.constraints.items():
55
+ yield _Block(f"constraint '{name}'", name, (block.lhs, block.rhs), block.where, True)
56
+ for name, variable in program.variables.items():
57
+ bounds = tuple(side for side in (variable.lower, variable.upper) if side is not None)
58
+ yield _Block(f"variable '{name}'", None, bounds, variable.where, True)
59
+ if program.objective is not None:
60
+ yield _Block('the objective', None, (program.objective.expression,), None, False)
61
+
62
+
63
+ def separabilities(program: Program) -> dict[str, Separability]:
64
+ """Every axis's verdict, in one walk.
65
+
66
+ One traversal rather than one per axis, because every construct that ties an
67
+ axis together names the axis it ties: asking each node *which* dimension it
68
+ is about answers for all of them at what answering for one cost.
69
+
70
+ ``reductions_couple`` is the position a block stands in rather than anything
71
+ about the block — a sum over the axis couples a constraint row to the whole
72
+ horizon and leaves an objective additively separable. A translation reads
73
+ ahead for a negative offset; what one reads behind is the window's edge,
74
+ which is not asked. Each coupling carries the one modelling change that
75
+ would lift it, after the dash.
76
+
77
+ The border a decomposition cuts along falls out of the same walk, which is
78
+ why it is taken here rather than in a pass of its own: a constraint the axis
79
+ does not index, and one a coupling names, are the rows no window holds.
80
+ """
81
+ ahead = dict.fromkeys(program.dimensions, 0)
82
+ reasons: dict[str, dict[str, dict[str, list[str]]]] = {
83
+ kind: {dimension: {} for dimension in program.dimensions} for kind in ('coupled', 'restarts')
84
+ }
85
+ undecided: dict[str, dict[Reach, None]] = {dimension: {} for dimension in program.dimensions}
86
+ rows: dict[str, str] = {}
87
+
88
+ def report(kind: str, dimension: str, label: str, reason: str) -> None:
89
+ reasons[kind][dimension].setdefault(label, []).append(reason)
90
+
91
+ def waits_on(dimension: str, label: str, name: str, kind: Literal['offset', 'partition', 'coordinate']) -> None:
92
+ undecided[dimension][Reach(label, name, kind)] = None
93
+
94
+ for label, row, nodes, mask, reductions_couple in _built_blocks(program):
95
+ if row is not None:
96
+ rows[label] = row
97
+ masks: list[Mask | None] = [mask]
98
+ for node in walk(*nodes):
99
+ if isinstance(node, Cases):
100
+ masks.extend(region.when for region in node.regions)
101
+ elif isinstance(node, Sum):
102
+ if reductions_couple:
103
+ for dimension in node.over:
104
+ report(
105
+ 'coupled',
106
+ dimension,
107
+ label,
108
+ f'sums over {dimension} — a rolling sum_back(window=n) windows, a total over the horizon does not',
109
+ )
110
+ elif isinstance(node, GroupSum):
111
+ for dimension in node.direction.consumed_dims:
112
+ report(
113
+ 'coupled',
114
+ dimension,
115
+ label,
116
+ f'groups {dimension} into {", ".join(node.direction.produced_dims)} — window that dimension instead, or cut only at the group edges',
117
+ )
118
+ elif isinstance(node, Pullback):
119
+ for dimension in node.direction.consumed_dims:
120
+ waits_on(dimension, label, node.direction.name, 'coordinate')
121
+ elif isinstance(node, (Translate, WindowSum)):
122
+ dimension = node.along
123
+ if node.wrap:
124
+ report(
125
+ 'coupled',
126
+ dimension,
127
+ label,
128
+ f'wraps around {dimension}, so its first row reads its last — an opening-state seed at '
129
+ f'position({dimension}) == 0 is what a rolling horizon replaces the wrap with',
130
+ )
131
+ continue
132
+ if node.partition is not None:
133
+ waits_on(dimension, label, node.partition.name, 'partition')
134
+ if isinstance(node, WindowSum):
135
+ continue
136
+ if isinstance(node.offset, str):
137
+ waits_on(dimension, label, node.offset, 'offset')
138
+ else:
139
+ ahead[dimension] = max(ahead[dimension], -node.offset)
140
+ for candidate in masks:
141
+ for atom in candidate.atoms if candidate is not None else ():
142
+ if isinstance(atom, DimensionPosition):
143
+ report('restarts', atom.name, label, f'counts a position along {atom.name}')
144
+
145
+ for name, block in program.sos.items():
146
+ report(
147
+ 'coupled',
148
+ block.along,
149
+ f"set '{name}'",
150
+ f'is a set along {block.along}, which a window would cut — only a window holding every whole set keeps it',
151
+ )
152
+
153
+ def joined(kind: str, dimension: str) -> dict[str, str]:
154
+ return {label: ', '.join(dict.fromkeys(found)) for label, found in reasons[kind][dimension].items()}
155
+
156
+ def linking_rows(dimension: str) -> tuple[str, ...]:
157
+ """Each constraint no one window of *dimension* holds whole, in declaration order.
158
+
159
+ Two shapes reach the border by different routes, and a constraint that
160
+ takes both is still one name: a row the axis does not index stands in
161
+ every window, and a row a coupling names reads the whole axis. Only a
162
+ declaration that builds a row can put one here, which is what ``rows``
163
+ holds the coupled labels to.
164
+ """
165
+ coupled = {rows[label] for label in reasons['coupled'][dimension] if label in rows}
166
+ return tuple(
167
+ name
168
+ for name, constraint in program.constraints.items()
169
+ if dimension not in constraint.dims or name in coupled
170
+ )
171
+
172
+ return {
173
+ dimension: Separability(
174
+ dimension=dimension,
175
+ ahead=ahead[dimension],
176
+ coupled=joined('coupled', dimension),
177
+ undecided=tuple(undecided[dimension]),
178
+ restarts=joined('restarts', dimension),
179
+ linking_rows=linking_rows(dimension),
180
+ linking_columns=tuple(
181
+ name for name, variable in program.variables.items() if dimension not in variable.dims
182
+ ),
183
+ )
184
+ for dimension in program.dimensions
185
+ }