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,941 @@
1
+ # SPDX-FileCopyrightText: mathspec Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """The walk: program → typeset lines. Written once, for every format.
6
+
7
+ Everything here is a decision about the *math* — where a bracket changes the
8
+ reading, which dimension a reduction binds, that a mask belongs on the ∀ rather
9
+ than in the equation, that a translation shows at the leaf it re-indexes. None
10
+ of it is about syntax, so none is duplicated per format.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field, replace
16
+ from typing import TYPE_CHECKING, assert_never
17
+
18
+ from mathspec.errors import SchemaError, did_you_mean
19
+ from mathspec.program import (
20
+ Add,
21
+ And,
22
+ BooleanLiteral,
23
+ Cases,
24
+ Constant,
25
+ CountComparison,
26
+ DimensionComparison,
27
+ DimensionPosition,
28
+ Direction,
29
+ Divide,
30
+ Dual,
31
+ Expression,
32
+ ExpressionComparison,
33
+ GroupSum,
34
+ Mask,
35
+ Multiply,
36
+ Named,
37
+ Negate,
38
+ Not,
39
+ Or,
40
+ Parameter,
41
+ ParameterComparison,
42
+ ParameterDefined,
43
+ Partition,
44
+ Power,
45
+ Predicate,
46
+ PredicateOperator,
47
+ Pullback,
48
+ PulledBackPredicate,
49
+ RelationComparison,
50
+ RelationDefined,
51
+ RelationPairComparison,
52
+ Sum,
53
+ Translate,
54
+ TranslatedPredicate,
55
+ Variable,
56
+ VariableDefined,
57
+ WindowSum,
58
+ )
59
+ from mathspec.typesetting.format import Line, OperatorName
60
+ from mathspec.typesetting.legend import TranslationPolicy, policy_of
61
+
62
+ if TYPE_CHECKING:
63
+ import datetime
64
+ from collections.abc import Iterable, Mapping
65
+
66
+ from mathspec._expression_parser import BinaryOperator
67
+ from mathspec.program import PiecewiseDeclaration, Program, SosDeclaration
68
+ from mathspec.typesetting.format import Format
69
+ from mathspec.typesetting.symbols import Symbols
70
+
71
+ #: Operator precedence, for deciding brackets. A reduction sits at the bottom
72
+ #: with ``+``: an unbracketed sum reads as capturing whatever follows it, so as
73
+ #: a factor it has to be bracketed.
74
+ _PRECEDENCE: dict[BinaryOperator, int] = {'+': 1, '-': 1, '*': 2, '/': 2, '**': 3}
75
+ _ATOM = 5
76
+
77
+ #: The same for a predicate: ``OR`` binds loosest, then ``AND``, then ``NOT``;
78
+ #: a comparison sits above the connectives and is bracketed under none.
79
+ _WHERE_PRECEDENCE = {'or': 0, 'and': 1, 'comparison': 2, 'not': 3}
80
+
81
+ #: The predicates that are one relation between two sides, which a line may
82
+ #: align on the way it aligns a constraint.
83
+ AlignedComparison = (
84
+ ParameterComparison
85
+ | ExpressionComparison
86
+ | CountComparison
87
+ | DimensionComparison
88
+ | DimensionPosition
89
+ | RelationComparison
90
+ | RelationPairComparison
91
+ )
92
+
93
+ _PREDICATES: dict[PredicateOperator, OperatorName] = {
94
+ '==': 'equal',
95
+ '!=': 'ne',
96
+ '<=': 'le',
97
+ '>=': 'ge',
98
+ '<': 'lt',
99
+ '>': 'gt',
100
+ }
101
+
102
+
103
+ #: Edge policy -> the operator pair that renders it, backward then forward —
104
+ #: the vacated row dropped, wrapped, or filled.
105
+ _TRANSLATIONS: dict[TranslationPolicy, tuple[OperatorName, OperatorName]] = {
106
+ 'plain': ('minus', 'plus'),
107
+ 'wrap': ('cyclic_minus', 'cyclic_plus'),
108
+ 'edge': ('edge_minus', 'edge_plus'),
109
+ }
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class _Step:
114
+ """One translation of an index, and what stands where it vacated.
115
+
116
+ ``fill`` is the rendered ``edge=`` value, and it rides on the operator
117
+ rather than in the legend because it is per call site: one model may pad a
118
+ sum with ``0`` and a product with ``1``, and one legend entry cannot say
119
+ which term is which. It is empty for the two policies that substitute
120
+ nothing.
121
+ """
122
+
123
+ by: int | str
124
+ policy: TranslationPolicy
125
+ fill: str = ''
126
+ #: The rendered group a partitioned translation walks inside, or empty.
127
+ within: str = ''
128
+
129
+ def merged(self, other: _Step) -> _Step | None:
130
+ """*other* applied under this one as a single translation, or ``None`` where it is two.
131
+
132
+ Only numbers add, and only under an identical policy, fill and group:
133
+ a cyclic step under an acyclic one folded into ``t ⊖ 2`` claims the
134
+ outer step wraps when it drops.
135
+ """
136
+ if isinstance(self.by, str) or isinstance(other.by, str):
137
+ return None
138
+ same = (self.policy, self.fill, self.within) == (other.policy, other.fill, other.within)
139
+ return replace(self, by=self.by + other.by) if same else None
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class _Context:
144
+ """What a subscript means at this point in the tree.
145
+
146
+ ``offsets`` is how ``shift`` renders: it emits no operator of its own but
147
+ re-indexes its operand, so the translation shows at the *leaves*. Its steps
148
+ are outermost first, the order they apply to the index — the outer shift of
149
+ ``shift(shift(x, offset=a), offset=b)`` moves ``t`` to ``t - b``, and the inner one
150
+ reads ``x`` from there.
151
+ """
152
+
153
+ walk: Walk
154
+ offsets: dict[str, tuple[_Step, ...]] = field(default_factory=dict)
155
+ #: dim -> the rendered subscript that replaces its index, as ``at`` re-indexes a leaf.
156
+ pullbacks: dict[str, str] = field(default_factory=dict)
157
+ #: Every dimension whose index is in use here — the frame, then one entry
158
+ #: per reduction entered — so a reduction over one takes a fresh dummy.
159
+ bound: tuple[str, ...] = ()
160
+
161
+ def translated(self, dim: str, step: _Step) -> _Context:
162
+ steps = self.offsets.get(dim, ())
163
+ merged = steps[-1].merged(step) if steps else None
164
+ steps = (*steps[:-1], merged) if merged is not None else (*steps, step)
165
+ return _Context(self.walk, {**self.offsets, dim: steps}, self.pullbacks, self.bound)
166
+
167
+ def pulled_back(self, dim: str, rendered: str) -> _Context:
168
+ return _Context(self.walk, self.offsets, {**self.pullbacks, dim: rendered}, self.bound)
169
+
170
+ def reducing(self, dim: str) -> tuple[str, _Context]:
171
+ """A dummy index for a reduction over *dim*, and the context its body reads under.
172
+
173
+ The plain index where nothing outside the reduction uses it; primed
174
+ once per enclosing use of the same dimension, so ``sum(q, by=bus_of)``
175
+ under ``∀ g`` sums over ``g'`` and its condition can still name ``g``.
176
+ """
177
+ primes = "'" * self.bound.count(dim)
178
+ dummy = f'{self.walk.symbols.index[dim]}{primes}'
179
+ body = _Context(self.walk, self.offsets, {**self.pullbacks, dim: dummy}, (*self.bound, dim))
180
+ return dummy, body
181
+
182
+ def subscript(self, dim: str) -> str:
183
+ """The index for *dim* here: its pullback if it has one, then every translation.
184
+
185
+ A pullback is a base like any other rather than a stopping point.
186
+ ``at`` and ``shift`` both re-index the leaf and the leaf has one
187
+ subscript, so a reading that showed only whichever ran last dropped the
188
+ other operator out of the equation.
189
+ """
190
+ text = self.pullbacks.get(dim, self.walk.symbols.index[dim])
191
+ translated = False
192
+ for step in self.offsets.get(dim, ()):
193
+ if step.by == 0:
194
+ continue
195
+ base = self.walk.format.parenthesise(text) if translated else text
196
+ amount = self.walk.symbols.name[step.by] if isinstance(step.by, str) else str(abs(step.by))
197
+ text = f'{base} {self.walk._translation(step)} {amount}'
198
+ translated = True
199
+ return text
200
+
201
+ def indexed(self, symbol: str, dims: list[str]) -> str:
202
+ return self.walk.format.subscript(symbol, [self.subscript(d) for d in dims])
203
+
204
+
205
+ def _unsigned(node: Expression) -> Expression | None:
206
+ """*node* without its leading minus — on the node, or on the first factor of a product it heads — else ``None``."""
207
+ if isinstance(node, Negate):
208
+ return node.operand
209
+ if isinstance(node, Multiply | Divide):
210
+ first, second = (node.left, node.right) if isinstance(node, Multiply) else (node.numerator, node.divisor)
211
+ if (head := _unsigned(first)) is not None:
212
+ return Multiply(head, second) if isinstance(node, Multiply) else Divide(head, second)
213
+ return None
214
+
215
+
216
+ class Walk:
217
+ """Walks a program, emitting :class:`Line`s in one format.
218
+
219
+ :meth:`equations` prints every section; what those sections use, the
220
+ legend reads off the program (:func:`~mathspec.typesetting.legend.notice`).
221
+ """
222
+
223
+ def __init__(
224
+ self,
225
+ program: Program,
226
+ symbols: Symbols,
227
+ fmt: Format,
228
+ *,
229
+ inline_expressions: bool = False,
230
+ ) -> None:
231
+ self.program = program
232
+ self.symbols = symbols
233
+ self.format = fmt
234
+ #: Substitute each plain named expression where it is used, rather than
235
+ #: printing its symbol there and its definition once.
236
+ self.inline_expressions = inline_expressions
237
+
238
+ def _frame_of(self, name: str) -> list[str]:
239
+ """The dims named expression *name* is read over, as its declaration carries them."""
240
+ return list(self.program.expressions[name].dims)
241
+
242
+ def _op(self, name: OperatorName) -> str:
243
+ return self.format.operators[name]
244
+
245
+ def _translation(self, step: _Step) -> str:
246
+ """The operator for one translation, its fill below and its group above.
247
+
248
+ Two slots, because one subscript holding both says nothing about which
249
+ is the fill and which the group. A named offset is always backward,
250
+ since ``offset=-p`` is refused at load.
251
+ """
252
+ backward, forward = _TRANSLATIONS[step.policy]
253
+ operator = self._op(backward if isinstance(step.by, str) or step.by > 0 else forward)
254
+ if step.fill:
255
+ operator = self.format.subscript(operator, [step.fill])
256
+ if not step.within:
257
+ return operator
258
+ return self.format.superscript(operator, step.within)
259
+
260
+ def _relation_read(self, name: str, at: Mapping[str, str], read: str) -> str:
261
+ """Relation *name*'s column *read* as a function at the columns *at* fixes: ``bus(g)``, ``zone_of(g, p)`` or ``ends.bus0(l)``.
262
+
263
+ *at* maps each key role to the index it is read at. The function is
264
+ named after the relation alone where the key determines one column, and
265
+ after the column read otherwise.
266
+ """
267
+ lk = self.program.relations[name]
268
+ function = name if len(lk.values) == 1 else f'{name}.{read}'
269
+ return self.format.apply(self.format.upright(function), self.format.joined([at[k] for k in lk.key], ''))
270
+
271
+ def _relation_row(self, name: str, at: Mapping[str, str]) -> str:
272
+ """That relation *name* has a row at the key *at* fixes.
273
+
274
+ A keyed table is a function, so the claim is that it is defined there:
275
+ ``gen_zone(g, t) is defined``. A bare one is a set of rows, and every
276
+ column of it is a key column, so the row is written out:
277
+ ``(g, b) ∈ connection``.
278
+ """
279
+ lk = self.program.relations[name]
280
+ key = self.format.joined([at[k] for k in lk.key], '')
281
+ if lk.values:
282
+ return f'{self.format.apply(self.format.upright(name), key)} {self.format.prose(" is defined")}'
283
+ return f'{self.format.parenthesise(key)} {self._op("in")} {self.format.upright(name)}'
284
+
285
+ def _frame_key(self, name: str, ctx: _Context) -> dict[str, str]:
286
+ """Relation *name*'s key roles at the frame's own indices of their dimensions."""
287
+ lk = self.program.relations[name]
288
+ return {k: ctx.subscript(lk.dim(k)) for k in lk.key}
289
+
290
+ def _value_read(self, name: str, column: str, ctx: _Context) -> str:
291
+ """A keyed relation's value *column* read at the frame's own indices of its key: ``period_of(t)``."""
292
+ return self._relation_read(name, self._frame_key(name, ctx), column)
293
+
294
+ def _tuple(self, reads: list[str]) -> str:
295
+ """Several reads as one group label: the read alone where there is one, a bracketed tuple otherwise."""
296
+ return reads[0] if len(reads) == 1 else self.format.parenthesise(self.format.joined(reads, ''))
297
+
298
+ def _context(self, frame: Iterable[str] = ()) -> _Context:
299
+ return _Context(self, bound=tuple(frame))
300
+
301
+ def _number(self, value: float) -> str:
302
+ if value == float('inf'):
303
+ return self._op('infinity')
304
+ if value == int(value):
305
+ return str(int(value))
306
+ mantissa, _, exponent = repr(value).partition('e')
307
+ if not exponent:
308
+ return mantissa
309
+ power = self.format.superscript('10', str(int(exponent)))
310
+ return power if mantissa == '1' else f'{mantissa} {self._op("times")} {power}'
311
+
312
+ # -- arithmetic --------------------------------------------------------
313
+
314
+ def _expression(self, node: Expression, ctx: _Context, *, need: int = 0) -> str:
315
+ text, precedence = self._arithmetic(node, ctx)
316
+ return self.format.parenthesise(text) if precedence < need else text
317
+
318
+ def _arithmetic(self, node: Expression, ctx: _Context) -> tuple[str, int]:
319
+ """Render *node*, returning the text and the precedence it binds at."""
320
+ if isinstance(node, Named):
321
+ if self.inline_expressions and not isinstance(node.body, Cases):
322
+ return self._arithmetic(node.body, ctx)
323
+ return ctx.indexed(self.symbols.name[node.name], self._frame_of(node.name)), _ATOM
324
+
325
+ if isinstance(node, Constant):
326
+ return self._number(node.value), _ATOM if node.value >= 0 else 1
327
+
328
+ if isinstance(node, Parameter):
329
+ return ctx.indexed(self.symbols.name[node.name], list(self.program.parameters[node.name].dims)), _ATOM
330
+
331
+ if isinstance(node, Variable):
332
+ return ctx.indexed(self.symbols.name[node.name], list(self.program.variables[node.name].dims)), _ATOM
333
+
334
+ if isinstance(node, Negate):
335
+ text, precedence = self._arithmetic(node.operand, ctx)
336
+ operand = self.format.parenthesise(text) if precedence < 2 else text
337
+ return f'{self._op("minus")}{operand}', 2
338
+
339
+ if isinstance(node, Add | Multiply | Divide | Power):
340
+ return self._binary(node, ctx)
341
+
342
+ if isinstance(node, Sum):
343
+ return self._sum(node, ctx)
344
+
345
+ if isinstance(node, GroupSum):
346
+ return self._group_sum(node, ctx)
347
+
348
+ if isinstance(node, Pullback):
349
+ return self._pullback(node, ctx)
350
+
351
+ if isinstance(node, Translate):
352
+ return self._translate(node, ctx)
353
+
354
+ if isinstance(node, WindowSum):
355
+ return self._window_sum(node, ctx)
356
+
357
+ if isinstance(node, Cases):
358
+ return self.format.cases(self._arms(node, ctx)), _ATOM
359
+
360
+ if isinstance(node, Dual):
361
+ return self._dual(node, ctx), _ATOM
362
+
363
+ assert_never(node)
364
+
365
+ def _dual(self, node: Dual, ctx: _Context) -> str:
366
+ """λ subscripted by the constraint's symbol, then the indices of the constraint's own frame."""
367
+ frame = self._sorted(frozenset(self.program.constraints[node.constraint].dims))
368
+ return self.format.subscript(
369
+ self._op('dual'), [self.symbols.constraint[node.constraint], *(ctx.subscript(d) for d in frame)]
370
+ )
371
+
372
+ def _binary(self, node: Add | Multiply | Divide | Power, ctx: _Context) -> tuple[str, int]:
373
+ """Render a binary operator, bracketing only where the reading demands.
374
+
375
+ A subtraction arrives as an addition of a negation and prints as the
376
+ subtraction it was: ``a + -b`` is ``a - b`` and ``a - -b`` is ``a + b``,
377
+ the sign folding until the right operand carries none. Subtraction
378
+ raises the requirement on its right operand by one: ``a - (b - c)``
379
+ and ``a - (b + c)`` need the bracket; ``a - b*c`` does not. A negated
380
+ factor is bracketed, since ``a · -b`` is a spelling nobody reads. A
381
+ power is atomic to everything but another power, a stacked
382
+ superscript being ambiguous.
383
+ """
384
+ if isinstance(node, Divide):
385
+ top = self._expression(node.numerator, ctx)
386
+ bottom = self._expression(node.divisor, ctx)
387
+ return self.format.fraction(top, bottom), _ATOM
388
+ if isinstance(node, Power):
389
+ base = self._expression(node.base, ctx, need=_PRECEDENCE['**'] + 1)
390
+ return self.format.superscript(base, self._expression(node.exponent, ctx)), _PRECEDENCE['**']
391
+ op: BinaryOperator = '*' if isinstance(node, Multiply) else '+'
392
+ precedence = _PRECEDENCE[op]
393
+ left = self._expression(node.left, ctx, need=precedence)
394
+ operand = node.right
395
+ if op == '+':
396
+ while (unsigned := _unsigned(operand)) is not None:
397
+ operand, op = unsigned, '-' if op == '+' else '+'
398
+ negated_factor = op == '*' and isinstance(operand, Negate)
399
+ need = _ATOM if negated_factor else _PRECEDENCE[op] + (1 if op == '-' else 0)
400
+ right = self._expression(operand, ctx, need=need)
401
+ names: dict[BinaryOperator, OperatorName] = {'*': 'cdot', '+': 'plus', '-': 'minus'}
402
+ return self.format.joined([left, right], self._op(names[op])), precedence
403
+
404
+ def _sum(self, node: Sum, ctx: _Context) -> tuple[str, int]:
405
+ """A reduction over named dims: one dummy index per dim, in declaration order."""
406
+ memberships = []
407
+ inner = ctx
408
+ for d in self._sorted(frozenset(node.over)):
409
+ dummy, inner = inner.reducing(d)
410
+ memberships.append(self._membership(d, dummy))
411
+ domain = self.format.joined(memberships, '')
412
+ return self.format.summation(domain, self._reduction_body(node.operand, inner)), _PRECEDENCE['+']
413
+
414
+ def _group_sum(self, node: GroupSum, ctx: _Context) -> tuple[str, int]:
415
+ """A sum through a relation: a dummy per consumed dim, and the row it joins on as the domain's condition."""
416
+ direction = node.direction
417
+ dummies: dict[str, str] = {}
418
+ inner = ctx
419
+ for d in direction.consumed_dims:
420
+ dummies[d], inner = inner.reducing(d)
421
+ conditions = list(self._grouping(direction, dummies, ctx))
422
+ domain = (
423
+ f'{self.format.joined([self._membership(d, dummies[d]) for d in direction.consumed_dims], "")} '
424
+ f'{self._op("such_that")} {self.format.joined(conditions, self._op("and"))}'
425
+ )
426
+ return self.format.summation(domain, self._reduction_body(node.operand, inner)), _PRECEDENCE['+']
427
+
428
+ def _pullback(self, node: Pullback, ctx: _Context) -> tuple[str, int]:
429
+ """``at`` emits no operator of its own: it re-indexes the operand, so the read shows at the leaves."""
430
+ return self._arithmetic(node.operand, self._pulled_back(node.direction, ctx))
431
+
432
+ def _translate(self, node: Translate, ctx: _Context) -> tuple[str, int]:
433
+ """``shift`` emits no operator of its own: it re-indexes the operand, so the translation shows at the leaves.
434
+
435
+ ``edge='wrap'`` and a number are the two policies that print a symbol
436
+ of their own; absent is the bare shift, whose vacated positions are
437
+ absent.
438
+ """
439
+ fill = '' if node.fill is None else self._number(node.fill)
440
+ step = _Step(node.offset, policy_of(node), fill, self._group(node.partition))
441
+ return self._arithmetic(node.operand, ctx.translated(node.along, step))
442
+
443
+ def _window_sum(self, node: WindowSum, ctx: _Context) -> tuple[str, int]:
444
+ """``sum_back``: a sum over the positions behind the row, the lag written as a translation of the index."""
445
+ step = _Step(1, policy_of(node), within=self._group(node.partition))
446
+ source, inner = ctx.reducing(node.along)
447
+ lag = f'{ctx.subscript(node.along)} {self._translation(step)} {source}'
448
+ domain = (
449
+ f'{source} {self._op("in")} {self.symbols.set[node.along]} {self._op("such_that")} '
450
+ f'0 {self._op("le")} {lag} {self._op("lt")} {self._width(node.width)}'
451
+ )
452
+ body = self._reduction_body(node.operand, inner)
453
+ return self.format.summation(domain, body), _PRECEDENCE['+']
454
+
455
+ def _pulled_back(self, direction: Direction, ctx: _Context) -> _Context:
456
+ """*ctx* with each dimension *direction* consumes read at the relation, as ``at`` re-indexes a leaf."""
457
+ at = {r: ctx.subscript(direction.dim(r)) for r in (*direction.produced, *direction.joined)}
458
+ for read in direction.consumed:
459
+ ctx = ctx.pulled_back(direction.dim(read), self._relation_read(direction.name, at, read))
460
+ return ctx
461
+
462
+ def _grouping(self, direction: Direction, dummies: Mapping[str, str], ctx: _Context) -> list[str]:
463
+ """The conditions a grouped sum's domain carries for one direction: what it fixes of the row it joins on.
464
+
465
+ A direction fixes its relation's key either way, so each value column
466
+ it fixes — consumed or produced, one lookup at the key either way — is
467
+ that column read there. One that fixes none of them asks only that the
468
+ row is there, because a value column it does not touch is not read,
469
+ and a bare relation has no value column to read at all.
470
+ """
471
+ at = {
472
+ **{r: dummies[direction.dim(r)] for r in direction.consumed},
473
+ **{r: ctx.subscript(direction.dim(r)) for r in (*direction.joined, *direction.produced)},
474
+ }
475
+ fixed = [r for r in self.program.relations[direction.name].values if r in at]
476
+ if not fixed:
477
+ return [self._relation_row(direction.name, at)]
478
+ return [f'{self._relation_read(direction.name, at, r)} {self._op("equal")} {at[r]}' for r in fixed]
479
+
480
+ def _group(self, partition: Partition | None) -> str:
481
+ """A ``by=`` as the superscript its translation operator carries.
482
+
483
+ The bare index, not the subscript in force: the group is a property of
484
+ the row being written, and a window whose operand is itself translated
485
+ still asks which group *that row* is in.
486
+ """
487
+ if partition is None:
488
+ return ''
489
+ at = {r: self.symbols.index[partition.dim(r)] for r in (partition.along, *partition.joined)}
490
+ return self._tuple([self._relation_read(partition.name, at, r) for r in partition.group])
491
+
492
+ def _width(self, width: int | str) -> str:
493
+ """``sum_back``'s ``window=``: a number, or a parameter's own symbol.
494
+
495
+ Unsubscripted where it is named, as a translation's named offset is:
496
+ the symbol identifies the parameter and the legend carries its dims,
497
+ where repeating them inside a summation's domain crowds out the
498
+ condition that domain exists to state.
499
+ """
500
+ if isinstance(width, str):
501
+ return self.symbols.name[width]
502
+ return self._number(float(width))
503
+
504
+ def _membership(self, dim: str, index: str | None = None) -> str:
505
+ return f'{index or self.symbols.index[dim]} {self._op("in")} {self.symbols.set[dim]}'
506
+
507
+ def _reduction_body(self, node: Expression, ctx: _Context) -> str:
508
+ """What sits to the right of a sum, bracketed only where it must be.
509
+
510
+ A sum binds everything up to the next ``+`` or ``-`` at its own level,
511
+ so an additive body needs the bracket and nothing else does — including
512
+ a nested reduction, which is unambiguous.
513
+ """
514
+ additive = isinstance(node, Negate | Add)
515
+ return self._expression(node, ctx, need=2 if additive else 0)
516
+
517
+ # -- where strings -----------------------------------------------------
518
+
519
+ def _predicate(self, node: Predicate, ctx: _Context, *, need: int = 0) -> str:
520
+ text, precedence = self._where(node, ctx)
521
+ return self.format.parenthesise(text) if precedence < need else text
522
+
523
+ def _where(self, node: Predicate, ctx: _Context) -> tuple[str, int]:
524
+ comparison = _WHERE_PRECEDENCE['comparison']
525
+ if isinstance(node, BooleanLiteral):
526
+ assert not node.value, 'an always-true mask is folded away or refused before anything prints it'
527
+ return self._op('false'), _ATOM
528
+
529
+ if isinstance(node, ParameterDefined):
530
+ indexed = ctx.indexed(self.symbols.name[node.name], list(node.dims))
531
+ if self.program.parameters[node.name].dtype == 'bool':
532
+ return indexed, _ATOM
533
+ return f'{indexed} {self.format.prose(" is defined")}', comparison
534
+
535
+ if isinstance(node, VariableDefined):
536
+ return (
537
+ f'{ctx.indexed(self.symbols.name[node.name], list(node.dims))} {self.format.prose(" exists")}',
538
+ comparison,
539
+ )
540
+
541
+ if isinstance(node, AlignedComparison):
542
+ left, right = self.sides(node, ctx)
543
+ return f'{left} {right}', comparison
544
+
545
+ if isinstance(node, TranslatedPredicate):
546
+ moved = ctx.translated(node.along, _Step(node.offset, 'plain'))
547
+ return self._where(node.operand.root, moved)
548
+
549
+ if isinstance(node, PulledBackPredicate):
550
+ return self._where(node.operand.root, self._pulled_back(node.direction, ctx))
551
+
552
+ if isinstance(node, RelationDefined):
553
+ return self._relation_row(node.name, self._frame_key(node.name, ctx)), comparison
554
+
555
+ if isinstance(node, Not):
556
+ return (
557
+ f'{self._op("not")} {self._predicate(node.operand, ctx, need=_WHERE_PRECEDENCE["not"])}',
558
+ _WHERE_PRECEDENCE['not'],
559
+ )
560
+
561
+ if isinstance(node, And):
562
+ need = _WHERE_PRECEDENCE['and']
563
+ sides = [self._predicate(node.left, ctx, need=need), self._predicate(node.right, ctx, need=need)]
564
+ return self.format.joined(sides, self._op('and')), need
565
+
566
+ if isinstance(node, Or):
567
+ need = _WHERE_PRECEDENCE['or']
568
+ sides = [self._predicate(node.left, ctx, need=need), self._predicate(node.right, ctx, need=need)]
569
+ return self.format.joined(sides, self._op('or')), need
570
+
571
+ assert_never(node)
572
+
573
+ def sides(self, node: AlignedComparison, ctx: _Context) -> tuple[str, str]:
574
+ """One comparison as its two sides, the relation symbol leading the right.
575
+
576
+ Split so that a line whose whole predicate is one comparison aligns on
577
+ the relation, as a constraint does.
578
+ """
579
+ if isinstance(node, ParameterComparison):
580
+ left, right = ctx.indexed(self.symbols.name[node.name], list(node.dims)), self._literal(node.value)
581
+ elif isinstance(node, ExpressionComparison):
582
+ left, right = self._expression(node.left, ctx), self._expression(node.right, ctx)
583
+ elif isinstance(node, DimensionComparison):
584
+ left, right = ctx.subscript(node.name), self._literal(node.value)
585
+ elif isinstance(node, DimensionPosition):
586
+ grouping = (
587
+ None
588
+ if node.partition is None
589
+ else self._tuple([self._value_read(node.partition.name, c, ctx) for c in node.partition.group])
590
+ )
591
+ left = self._position(ctx.subscript(node.name), grouping)
592
+ right = self._ordinal(node.name, node.position, grouping)
593
+ elif isinstance(node, RelationComparison):
594
+ left, right = self._value_read(node.name, node.column, ctx), self._literal(node.value)
595
+ elif isinstance(node, RelationPairComparison):
596
+ left = self._value_read(node.name, node.column, ctx)
597
+ right = self._value_read(node.other, node.other_column, ctx)
598
+ elif isinstance(node, CountComparison):
599
+ index, inner = ctx.reducing(node.over)
600
+ counted = self.format.set_of(
601
+ self._membership(node.over, index), self._predicate(node.predicate.root, inner)
602
+ )
603
+ left, right = self.format.cardinality(counted), self._number(node.value)
604
+ else:
605
+ assert_never(node)
606
+ return left, f'{self._op(_PREDICATES[node.op])} {right}'
607
+
608
+ def _literal(self, value: float | str | datetime.date) -> str:
609
+ return self._number(value) if isinstance(value, int | float) else self.format.quoted(str(value))
610
+
611
+ def _position(self, index: str, grouping: str | None) -> str:
612
+ """``position(dim)`` applied to the row, *grouping* as a subscript — as an argument it read as a second position."""
613
+ symbol = self._op('position')
614
+ if grouping is not None:
615
+ symbol = self.format.subscript(symbol, [grouping])
616
+ return self.format.apply(symbol, index)
617
+
618
+ def _ordinal(self, dimension: str, at: int, grouping: str | None) -> str:
619
+ """The position compared against; a negative one counts back from the size of the set it is a position in — the group's where grouped."""
620
+ if at >= 0:
621
+ return self._number(at)
622
+ size = self.symbols.set[dimension]
623
+ if grouping is not None:
624
+ size = self.format.subscript(size, [grouping])
625
+ return f'{self.format.cardinality(size)} {self._op("minus")} {self._number(-at)}'
626
+
627
+ def _condition(self, ctx: _Context, mask: Mask | None) -> str:
628
+ """The mask on a quantifier, printed.
629
+
630
+ A mask every row passes arrives as ``None`` — resolution folds it,
631
+ so this prints what a program carries — and a quantifier with no
632
+ condition prints none.
633
+ """
634
+ return '' if mask is None else self._predicate(mask.root, ctx)
635
+
636
+ def _quantifier(self, dims: list[str], condition: str) -> str:
637
+ if not dims and not condition:
638
+ return ''
639
+ over = self.format.joined([self._membership(d) for d in dims], '')
640
+ if not condition:
641
+ return f'{self._op("forall")} {over}'
642
+ if not over:
643
+ return f'{self.format.prose("where ")} {condition}'
644
+ return f'{self._op("forall")} {over} {self._op("such_that")} {condition}'
645
+
646
+ # -- declarations ------------------------------------------------------
647
+
648
+ def equations(self) -> list[tuple[str, list[Line]]]:
649
+ """Every titled section of equations."""
650
+ return [
651
+ ('Objective', self._objective()),
652
+ ('Subject to', self._constraints()),
653
+ ('Definitions', self._definitions()),
654
+ ('Variable domains', self._variables()),
655
+ ('Assumptions', self._assumptions()),
656
+ ]
657
+
658
+ def _objective(self) -> list[Line]:
659
+ """The objective's line.
660
+
661
+ The expression is scalar — every reduction in it is one the file wrote
662
+ — so it renders like any other, and the line carries no label: the
663
+ block has no name, and the section heading already says what it is.
664
+ """
665
+ objective = self.program.objective
666
+ if objective is None:
667
+ return []
668
+ sense = self._op('minimize' if objective.sense == 'minimize' else 'maximize')
669
+ return [Line(label='', left=sense, right=self._expression(objective.expression, self._context()))]
670
+
671
+ def _constraints(self) -> list[Line]:
672
+ """Every constraint, then every curve.
673
+
674
+ A ``piecewise:`` block restricts what its link expressions may be
675
+ together, which is what a row does, so it prints here rather than among
676
+ the domains — where a set prints, being a property of one variable.
677
+ """
678
+ return [
679
+ *(self._constraint(name) for name in self.program.constraints),
680
+ *(self._piecewise(name) for name in self.program.piecewise),
681
+ ]
682
+
683
+ def _constraint(self, name: str) -> Line:
684
+ constraint = self.program.constraints[name]
685
+ ctx = self._context(frame=constraint.dims)
686
+ condition = self._condition(ctx, constraint.where)
687
+ return Line(
688
+ label=name,
689
+ left=self._expression(constraint.lhs, ctx),
690
+ right=f'{self._op(_PREDICATES[constraint.sense])} {self._expression(constraint.rhs, ctx)}',
691
+ condition=self._quantifier(list(constraint.dims), condition),
692
+ )
693
+
694
+ def _definitions(self) -> list[Line]:
695
+ """One line per named expression, in declaration order, defining it.
696
+
697
+ A use prints the symbol and the block prints here, as a paper states a
698
+ quantity it names. Every declared one prints, used or not. Inlining
699
+ substitutes away the plain ones the math reads; a ``cases`` block has
700
+ no single body to substitute, and an entry the math never reads has
701
+ nowhere to be substituted *into*, so both still print.
702
+ """
703
+ return [self.definition(name) for name in self.defined()]
704
+
705
+ def defined(self) -> list[str]:
706
+ """The named expressions that print under their own symbol: every one, or only the unsubstitutable when inlining.
707
+
708
+ Inlining leaves a name standing only where substitution cannot reach
709
+ it — a ``cases`` block, and an entry the objective and constraints
710
+ never read, which is a quantity reported back rather than solved for.
711
+ """
712
+ entries = self.program.expressions
713
+ if not self.inline_expressions:
714
+ return list(entries)
715
+ return [name for name, entry in entries.items() if isinstance(entry.expression, Cases) or not entry.in_math]
716
+
717
+ def definition(self, name: str) -> Line:
718
+ """The line defining one named expression, ``symbol = body`` over its frame."""
719
+ body = self.program.expressions[name].expression
720
+ frame = self._frame_of(name)
721
+ ctx = self._context(frame)
722
+ rendered = self.format.cases(self._arms(body, ctx)) if isinstance(body, Cases) else self._expression(body, ctx)
723
+ return Line(
724
+ label=name,
725
+ left=ctx.indexed(self.symbols.name[name], frame),
726
+ right=f'{self._op("equal")} {rendered}',
727
+ condition=self._quantifier(frame, ''),
728
+ )
729
+
730
+ def line(self, name: str) -> Line:
731
+ """The one line *name* prints as: a named expression, a constraint, an assumption, a curve, or a variable's domain.
732
+
733
+ An assumption is looked up where the document prints it from, so a
734
+ condition a curve's method states is a line a reader can ask for
735
+ before the curve is written out.
736
+
737
+ Raises:
738
+ SchemaError: *name* is declared as none of the five, or as two — a
739
+ constraint may share a variable's name, and one line prints
740
+ one of them.
741
+ """
742
+ program = self.program
743
+ kinds = {
744
+ 'named expression': (program.expressions, self.definition),
745
+ 'constraint': (program.constraints, self._constraint),
746
+ 'assumption': (program.assumptions, self._assumption),
747
+ 'curve': (program.piecewise, self._piecewise),
748
+ 'variable': (program.variables, self._variable),
749
+ }
750
+ found = [kind for kind, (group, _) in kinds.items() if name in group]
751
+ if not found:
752
+ everything = {n for group, _ in kinds.values() for n in group}
753
+ msg = (
754
+ f"'{name}' is not a named expression, constraint, assumption, curve or variable. "
755
+ f'{did_you_mean(name, everything)}'
756
+ )
757
+ raise SchemaError(msg)
758
+ if len(found) > 1:
759
+ msg = f"'{name}' is declared twice, as {found[0]} and as {found[1]}, and one line prints one of them — rename one."
760
+ raise SchemaError(msg)
761
+ return kinds[found[0]][1](name)
762
+
763
+ def _arms(self, node: Cases, ctx: _Context) -> list[tuple[str, str]]:
764
+ """Each region as its value and the words saying where it applies.
765
+
766
+ Which region is the fallback is a fact about the math, so the *walk*
767
+ says "if" or "otherwise" and a Format only stacks the rows: the last
768
+ region is the ``otherwise``, since resolution builds it as the
769
+ remainder of the others, and its mask is never printed.
770
+ """
771
+ *stated, last = node.regions
772
+ arms = [(self._expression(region.value, ctx), self._arm_condition(region.when, ctx)) for region in stated]
773
+ return [*arms, (self._expression(last.value, ctx), self.format.prose('otherwise'))]
774
+
775
+ def _arm_condition(self, when: Mask, ctx: _Context) -> str:
776
+ return f'{self.format.prose("if ")} {self._predicate(when.root, ctx, need=_WHERE_PRECEDENCE["and"])}'
777
+
778
+ def _variables(self) -> list[Line]:
779
+ """One line per variable, and one more for a set the variable carries.
780
+
781
+ A ``sos:`` block restricts the *domain* — which members of a family may
782
+ be nonzero at once — so it prints under this heading, beside the
783
+ variable it is a property of, rather than among the constraints, where
784
+ it would read as a row a solver holds.
785
+ """
786
+ sets = {block.variable: (key, block) for key, block in self.program.sos.items()}
787
+ lines = []
788
+ for name, block in self.program.variables.items():
789
+ lines.append(self._variable(name))
790
+ if name in sets:
791
+ lines.append(self._sos(name, *sets[name], self._context(frame=block.dims)))
792
+ return lines
793
+
794
+ def _variable(self, name: str) -> Line:
795
+ block = self.program.variables[name]
796
+ ctx = self._context(frame=block.dims)
797
+ symbol = ctx.indexed(self.symbols.name[name], list(block.dims))
798
+ condition = self._quantifier(list(block.dims), self._condition(ctx, block.where))
799
+ lower, upper = block.lower, block.upper
800
+
801
+ if block.domain == 'binary':
802
+ left, right = symbol, f'{self._op("in")} {self._op("binary_set")}'
803
+ else:
804
+ if lower is not None and upper is not None:
805
+ left = f'{self._bound(ctx, lower)} {self._op("le")} {symbol}'
806
+ right = f'{self._op("le")} {self._bound(ctx, upper)}'
807
+ elif upper is not None:
808
+ left, right = symbol, f'{self._op("le")} {self._bound(ctx, upper)}'
809
+ elif lower is not None:
810
+ left, right = symbol, f'{self._op("ge")} {self._bound(ctx, lower)}'
811
+ else:
812
+ domain = self._op('integers' if block.domain == 'integer' else 'reals')
813
+ left, right = symbol, f'{self._op("in")} {domain}'
814
+ if block.domain == 'integer' and (lower is not None or upper is not None):
815
+ right = f'{right}, {symbol} {self._op("in")} {self._op("integers")}'
816
+ return Line(label=name, left=left, right=right, condition=condition)
817
+
818
+ def _sos(self, name: str, key: str, block: SosDeclaration, ctx: _Context) -> Line:
819
+ """The variable's family along the set's dim, as one member of the SOS set, quantified over the other dims."""
820
+ dims = self.program.variables[name].dims
821
+ family = self.format.parenthesise(ctx.indexed(self.symbols.name[name], list(dims)))
822
+ return Line(
823
+ label=key,
824
+ left=self.format.subscript(family, [self._membership(block.along)]),
825
+ right=f'{self._op("in")} {self._op("sos_set")}{block.sos_type}',
826
+ condition=self._quantifier([d for d in dims if d != block.along], ''),
827
+ )
828
+
829
+ # -- assumptions -------------------------------------------------------
830
+
831
+ def _assumptions(self) -> list[Line]:
832
+ """What the model assumes of its data, in the order a program carries it.
833
+
834
+ A curve's conditions stand here with the file's own, because the
835
+ method states them in the same language: the reader sees every
836
+ condition the data is held to, whoever stated it.
837
+ """
838
+ return [self._assumption(name) for name in self.program.assumptions]
839
+
840
+ def _assumption(self, name: str) -> Line:
841
+ """One assumption: the predicate over the frame both its masks name, under its ``where``."""
842
+ assumption = self.program.assumptions[name]
843
+ holds, where = assumption.predicate, assumption.where
844
+ frame = self._sorted(holds.dims | (where.dims if where is not None else frozenset()))
845
+ ctx = self._context(frame)
846
+ if isinstance(holds.root, AlignedComparison):
847
+ left, right = self.sides(holds.root, ctx)
848
+ else:
849
+ left, right = self._predicate(holds.root, ctx), ''
850
+ return Line(label=name, left=left, right=right, condition=self._quantifier(frame, self._condition(ctx, where)))
851
+
852
+ def _piecewise(self, name: str) -> Line:
853
+ """One ``piecewise:`` block as the curve it states, over the frame it states one per coordinate of.
854
+
855
+ The links' expressions are a point, and the block says that point lies
856
+ on the piecewise-linear locus through the breakpoints. A bounded link
857
+ states one side of the locus instead, so there the locus prints as the
858
+ function of the pinned link that it is and the link's own sign says
859
+ which side.
860
+ """
861
+ block = self.program.piecewise[name]
862
+ links = [link.expression for link in block.links]
863
+ frame = list(block.frame)
864
+ ctx = self._context([*frame, block.over])
865
+ locus = self._locus(block, ctx)
866
+ bounded = next((i for i, link in enumerate(block.links) if link.sign != '=='), None)
867
+ if bounded is None:
868
+ left = self._tuple([self._expression(node, ctx) for node in links])
869
+ right = f'{self._op("in")} {locus}'
870
+ else:
871
+ pinned = links[1 - bounded]
872
+ left = self._expression(links[bounded], ctx)
873
+ sign = self._op(_PREDICATES[block.links[bounded].sign])
874
+ right = f'{sign} {self.format.apply(locus, self._expression(pinned, ctx))}'
875
+ return Line(label=name, left=left, right=right, condition=self._quantifier(frame, ''))
876
+
877
+ def _locus(self, block: PiecewiseDeclaration, ctx: _Context) -> str:
878
+ """The set the links lie on: the curve through the breakpoints, or the hull ``convex`` relaxes it onto.
879
+
880
+ A gate multiplies it, which is what gating a curve does — the weights
881
+ sum to the gate, so the locus is the origin where the gate is 0 and the
882
+ curve where it is 1.
883
+ """
884
+ operator = self._op('hull' if block.method == 'convex' else 'curve')
885
+ through = self.format.subscript(operator, [self._breakpoints(block, ctx)])
886
+ values = self.format.joined(
887
+ [
888
+ ctx.indexed(self.symbols.name[link.values], list(self.program.parameters[link.values].dims))
889
+ for link in block.links
890
+ ],
891
+ '',
892
+ )
893
+ locus = self.format.apply(through, values)
894
+ gate = self._gate(block, ctx)
895
+ return f'{gate} {self._op("cdot")} {locus}' if gate else locus
896
+
897
+ def _breakpoints(self, block: PiecewiseDeclaration, ctx: _Context) -> str:
898
+ """Which breakpoints the curve runs through: every one of the dimension, or the ones ``points:`` admits.
899
+
900
+ A ``points:`` naming a boolean parameter reads as the flag it is, and
901
+ one naming a values parameter as the rows that parameter has, which is
902
+ the same reading a ``where`` gives either of them.
903
+ """
904
+ over = self._membership(block.over)
905
+ if block.points is None:
906
+ return over
907
+ admitted = ParameterDefined(block.points, tuple(self.program.parameters[block.points].dims))
908
+ return f'{over} {self._op("such_that")} {self._predicate(admitted, ctx)}'
909
+
910
+ def _gate(self, block: PiecewiseDeclaration, ctx: _Context) -> str:
911
+ """The factor an ``activity:`` puts on the locus, or ``''`` where the block has none.
912
+
913
+ Where the gate is a variable that does not exist at every coordinate
914
+ the curve is built for, the factor is the gate where it exists and 1
915
+ where it does not — the two rows the expansion writes there, because a
916
+ variable that does not exist takes its row with it and would leave the
917
+ curve unstated rather than ungated. ``absence: zero`` is the other
918
+ reading and pins the curve off, which is the factor on its own.
919
+ """
920
+ if (activity := block.activity) is None:
921
+ return ''
922
+ gate = self.program.variables[activity]
923
+ symbol = ctx.indexed(self.symbols.name[activity], list(gate.dims))
924
+ mask = gate.where
925
+ if mask is None or gate.absence == 'zero':
926
+ return symbol
927
+ where = self._predicate(mask.root, ctx, need=_WHERE_PRECEDENCE['and'])
928
+ return self.format.cases(
929
+ [(symbol, f'{self.format.prose("if ")} {where}'), ('1', self.format.prose('otherwise'))]
930
+ )
931
+
932
+ def _bound(self, ctx: _Context, value: Expression) -> str:
933
+ """A bound as the file wrote it: a number, or a parameter indexed over its dims."""
934
+ if isinstance(value, Parameter):
935
+ return ctx.indexed(self.symbols.name[value.name], list(self.program.parameters[value.name].dims))
936
+ assert isinstance(value, Constant), 'a bound is a number or the name of a parameter'
937
+ return self._number(value.value)
938
+
939
+ def _sorted(self, dims: frozenset[str]) -> list[str]:
940
+ order = list(self.program.dimensions)
941
+ return sorted(dims, key=order.index)