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,286 @@
1
+ # SPDX-FileCopyrightText: mathspec Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """The legend: the glossary of symbols, and a note for each notation the equations use.
6
+
7
+ What the equations use is read off the program before anything prints
8
+ (:func:`notice`), so the legend explains every symbol the walk will print and
9
+ nothing the walk decides is asked of it twice.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from typing import TYPE_CHECKING, Literal
16
+
17
+ from mathspec.program import (
18
+ CountComparison,
19
+ DimensionComparison,
20
+ DimensionPosition,
21
+ ExpressionComparison,
22
+ PulledBackPredicate,
23
+ Translate,
24
+ TranslatedPredicate,
25
+ WindowSum,
26
+ walk_regions,
27
+ )
28
+ from mathspec.typesetting.format import Entry
29
+
30
+ if TYPE_CHECKING:
31
+ from collections.abc import Iterable
32
+
33
+ from mathspec.program import Expression, Mask, Program, RelationDeclaration
34
+ from mathspec.typesetting.format import Format, OperatorName
35
+ from mathspec.typesetting.symbols import Symbols
36
+
37
+ #: What a translation does with the row the shift vacates. Three policies get
38
+ #: three spellings because they are three different equations at the boundary.
39
+ TranslationPolicy = Literal['plain', 'wrap', 'edge']
40
+
41
+ #: The positional forms an equation can print, each of which the legend explains once.
42
+ PositionForm = Literal['plain', 'grouped', 'from_end']
43
+
44
+
45
+ def policy_of(node: Translate | WindowSum) -> TranslationPolicy:
46
+ """Which translation symbol *node* prints with: cyclic, filled at the edge, or plain."""
47
+ if node.wrap:
48
+ return 'wrap'
49
+ if isinstance(node, Translate) and node.fill is not None:
50
+ return 'edge'
51
+ return 'plain'
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Noticed:
56
+ """What the equations use that the legend has to explain.
57
+
58
+ Attributes:
59
+ policies: Each translation policy some ``shift`` or ``sum_back`` prints.
60
+ grouped: Whether some translation is counted inside a relation's group.
61
+ positions: Each form a ``position()`` prints in.
62
+ numeric_coordinates: Each dimension whose index is compared against a
63
+ number, where "position 3" and "the coordinate 3" are both
64
+ readings of a line.
65
+ """
66
+
67
+ policies: frozenset[TranslationPolicy]
68
+ grouped: bool
69
+ positions: frozenset[PositionForm]
70
+ numeric_coordinates: frozenset[str]
71
+
72
+
73
+ def notice(program: Program) -> Noticed:
74
+ """What printing *program* uses, read off every tree the walk prints."""
75
+ policies: set[TranslationPolicy] = set()
76
+ grouped = False
77
+ positions: set[PositionForm] = set()
78
+ numeric: set[str] = set()
79
+
80
+ def expressions(*roots: Expression) -> None:
81
+ nonlocal grouped
82
+ for node, regions in walk_regions(*roots):
83
+ if isinstance(node, WindowSum) or (isinstance(node, Translate) and node.offset != 0):
84
+ policies.add(policy_of(node))
85
+ grouped = grouped or node.partition is not None
86
+ for region in regions:
87
+ masks(region)
88
+
89
+ seen: set[int] = set()
90
+
91
+ def masks(mask: Mask) -> None:
92
+ if id(mask) in seen:
93
+ return
94
+ seen.add(id(mask))
95
+ for atom in mask.atoms:
96
+ if isinstance(atom, DimensionComparison) and isinstance(atom.value, int | float):
97
+ numeric.add(atom.name)
98
+ elif isinstance(atom, DimensionPosition):
99
+ positions.add('grouped' if atom.partition is not None else 'plain')
100
+ if atom.position < 0:
101
+ positions.add('from_end')
102
+ elif isinstance(atom, ExpressionComparison):
103
+ expressions(atom.left, atom.right)
104
+ elif isinstance(atom, CountComparison):
105
+ masks(atom.predicate)
106
+ elif isinstance(atom, TranslatedPredicate | PulledBackPredicate):
107
+ masks(atom.operand)
108
+
109
+ expressions(*program.roots)
110
+ for entry in program.expressions.values():
111
+ expressions(entry.expression)
112
+ for curve in program.piecewise.values():
113
+ expressions(*(link.expression for link in curve.links))
114
+ for declaration in (*program.constraints.values(), *program.variables.values()):
115
+ if declaration.where is not None:
116
+ masks(declaration.where)
117
+ for assumption in program.assumptions.values():
118
+ masks(assumption.predicate)
119
+ if assumption.where is not None:
120
+ masks(assumption.where)
121
+ return Noticed(frozenset(policies), grouped, frozenset(positions), frozenset(numeric))
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class Legend:
126
+ """The glossary and the notes, spelled with one program's symbols in one format."""
127
+
128
+ program: Program
129
+ symbols: Symbols
130
+ format: Format
131
+
132
+ def _op(self, name: OperatorName) -> str:
133
+ return self.format.operators[name]
134
+
135
+ def glossaries(self, noticed: Noticed, defined: Iterable[str]) -> list[tuple[str, list[Entry]]]:
136
+ """The sets, parameters, variables and definitions, each with its symbol, its dims and its description.
137
+
138
+ *defined* names the expressions that print under their own symbol, so
139
+ a legend row stands exactly where a symbol does.
140
+ """
141
+ fmt, program = self.format, self.program
142
+ sets = [
143
+ self._entry(
144
+ self.symbols.set[d],
145
+ f'index {fmt.math(self.symbols.index[d])} {fmt.dash} {fmt.mono(d)}{self._coords(d, noticed)}',
146
+ block.description,
147
+ )
148
+ for d, block in program.dimensions.items()
149
+ ]
150
+ parameters = [
151
+ self._entry(self.symbols.name[p], f'{fmt.mono(p)}{self._over(list(block.dims))}', block.description)
152
+ for p, block in program.parameters.items()
153
+ ]
154
+ variables = [
155
+ self._entry(self.symbols.name[v], f'{fmt.mono(v)}{self._over(list(block.dims))}', block.description)
156
+ for v, block in program.variables.items()
157
+ ]
158
+ shown = set(defined)
159
+ definitions = [
160
+ self._entry(self.symbols.name[e], f'{fmt.mono(e)}{self._over(list(block.dims))}', block.description)
161
+ for e, block in program.expressions.items()
162
+ if e in shown
163
+ ]
164
+ groups = (('Sets', sets), ('Parameters', parameters), ('Variables', variables), ('Definitions', definitions))
165
+ return [(title, entries) for title, entries in groups if entries]
166
+
167
+ def _entry(self, symbol: str, what: str, description: str | None) -> Entry:
168
+ meaning = f'{what} {self.format.dash} {self.format.escape(description)}' if description else what
169
+ return Entry(symbol, meaning)
170
+
171
+ def _over(self, dims: list[str]) -> str:
172
+ if not dims:
173
+ return ' (scalar)'
174
+ product = self.format.joined([self.symbols.set[d] for d in dims], self._op('times'))
175
+ return f' over {self.format.math(product)}'
176
+
177
+ def _signature(self, name: str, lk: RelationDeclaration) -> str:
178
+ """A relation in the legend: a function from its key sets to its value sets, or a relation inside the product."""
179
+
180
+ def product(roles: Iterable[str]) -> str:
181
+ return self.format.joined([self.symbols.set[lk.dim(r)] for r in roles], self._op('times'))
182
+
183
+ if lk.values:
184
+ return f'{self.format.upright(name)}: {product(lk.key)} {self._op("maps_to")} {product(lk.values)}'
185
+ return f'{self.format.upright(name)} {self._op("subset_of")} {product(lk.roles)}'
186
+
187
+ def _coords(self, dim: str, noticed: Noticed) -> str:
188
+ """The dimension's carried structure: each relation with a column over it, as the map or relation it is.
189
+
190
+ The dtype is named only where an equation compared the index against a
191
+ number, the one place "position 3" and "the coordinate 3" are both
192
+ readings of a line.
193
+ """
194
+ carried = self.program.relations_of(dim)
195
+ clauses = []
196
+ if dim in noticed.numeric_coordinates:
197
+ clauses.append(f' ({self.format.mono(self.program.dimensions[dim].dtype)} coordinates)')
198
+ if carried:
199
+ maps = self.format.joined([self._signature(c, lk) for c, lk in carried.items()], '')
200
+ clauses.append(f' with {self.format.math(maps)}')
201
+ return ''.join(clauses)
202
+
203
+ def convention_notes(self) -> list[str]:
204
+ """What the two faces mean, with the model's own symbols.
205
+
206
+ Only where the model has both, and quoting only derived symbols: a
207
+ table is the author's to write, so a symbol it supplies is not one this
208
+ note governs.
209
+ """
210
+ derived = [
211
+ next((n for n in names if n not in self.symbols.overridden), None)
212
+ for names in (self.program.parameters, self.program.variables)
213
+ ]
214
+ if not all(derived):
215
+ return []
216
+ given, chosen = (self.format.math(self.symbols.name[n]) for n in derived if n is not None)
217
+ return [
218
+ f'Upright is what the model is given {self.format.dash} a parameter such as {given}, a coordinate '
219
+ f'map, a label {self.format.dash} and italic is what the solver chooses, such as {chosen}. '
220
+ f'An index is italic too, being what a quantifier chooses, and a set is script.'
221
+ ]
222
+
223
+ def translation_notes(self, noticed: Noticed) -> list[str]:
224
+ """A sentence for each translation symbol the model printed; plain ``t-k`` needs none."""
225
+ notes = []
226
+ if 'wrap' in noticed.policies:
227
+ cyclic = self.format.math(f't {self._op("cyclic_minus")} k')
228
+ notes.append(
229
+ f'{cyclic} denotes cyclic translation: index {self.format.math("t-k")} taken modulo the size of '
230
+ f'the dimension ({self.format.mono("roll")}). Plain {self.format.math("t-k")} '
231
+ f'({self.format.mono("shift")}) has no wraparound {self.format.dash} terms translated past '
232
+ f'the edge are simply absent.'
233
+ )
234
+ if 'edge' in noticed.policies:
235
+ filled = self.format.math(f't {self.format.subscript(self._op("edge_minus"), ["v"])} k')
236
+ notes.append(
237
+ f'{filled} denotes translation with {self.format.math("v")} standing where index '
238
+ f'{self.format.math("t-k")} leaves the dimension ({self.format.mono("shift(edge=v)")}), so the row '
239
+ f'at that boundary is built and carries {self.format.math("v")} rather than being dropped.'
240
+ )
241
+ if noticed.grouped:
242
+ applied = self.format.apply(self.format.upright('relation'), 't')
243
+ counted = self.format.math(f't {self.format.superscript(self._op("cyclic_minus"), applied)} k')
244
+ note = (
245
+ f'{counted} denotes a translation counted inside the group a relation puts {self.format.math("t")} '
246
+ f'in ({self.format.mono("shift(by=relation)")}), so a term never crosses out of its own group.'
247
+ )
248
+ if 'edge' in noticed.policies:
249
+ both = self.format.superscript(self.format.subscript(self._op('edge_minus'), ['v']), applied)
250
+ note += (
251
+ f' The two modifiers take different slots {self.format.dash} the group above, the fill '
252
+ f'below {self.format.dash} so {self.format.math(f"t {both} k")} is both at once.'
253
+ )
254
+ notes.append(note)
255
+ return notes
256
+
257
+ def position_notes(self, noticed: Noticed) -> list[str]:
258
+ """A sentence for each positional symbol the model printed; the first says which of ``pos(t)`` and ``t`` is the position."""
259
+ notes = []
260
+ if noticed.positions:
261
+ index = self.format.math('t')
262
+ place = self.format.math(self.format.apply(self._op('position'), 't'))
263
+ dash = self.format.dash
264
+ notes.append(
265
+ f"{place} denotes where index {index} sits along its dimension's own order {dash} the order "
266
+ f'{self.format.mono("shift")} steps along, not the order labels sort in {dash} counted from '
267
+ f'{self.format.math("0")}. The index itself stays the coordinate, so {index} compares against '
268
+ f'labels and {place} against positions.'
269
+ )
270
+ if 'grouped' in noticed.positions:
271
+ applied = self.format.apply(self.format.upright('relation'), 't')
272
+ grouped = self.format.math(self.format.apply(self.format.subscript(self._op('position'), [applied]), 't'))
273
+ group = self.format.math(self.format.subscript(self.format.script('T'), [applied]))
274
+ notes.append(
275
+ f'{grouped} counts within the group a relation puts {self.format.math("t")} in: the subscript names '
276
+ f'the map, {group} is the group it lands in, and that group has a first position of its own.'
277
+ )
278
+ if 'from_end' in noticed.positions:
279
+ size = self.format.cardinality(self.format.script('T'))
280
+ last = self.format.math(f'{size} {self._op("minus")} 1')
281
+ notes.append(
282
+ f'{self.format.math(size)} denotes the size of the set being counted along, and a position '
283
+ f'counted from the end prints against it {self.format.dash} {last} is the last position, one '
284
+ f'less than the size because the first is {self.format.math("0")}.'
285
+ )
286
+ return notes
@@ -0,0 +1,105 @@
1
+ # SPDX-FileCopyrightText: mathspec Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ r"""GitHub-flavoured Markdown. GitHub renders math with MathJax, so the math is :class:`LatexFormat`'s and only the document layer differs.
6
+
7
+ Both delimiters are the verbatim pair — ``$`…`$`` inline and a ``math`` fence
8
+ for a block — because GitHub runs Markdown's escape pass *inside* a ``$…$``
9
+ span, stripping the backslash from every escape TeX needs: ``\mathrm{gen\_bus}``
10
+ reached MathJax as ``\mathrm{gen_bus}``, a subscript, and ``\{0, 1\}`` as a
11
+ group with no braces. The verbatim pair hands the span over untouched, so the
12
+ math this prints is the math :mod:`~mathspec.typesetting.latex` prints, and
13
+ stays what every other MathJax and KaTeX reads too.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from typing import TYPE_CHECKING, ClassVar, override
20
+
21
+ from mathspec.typesetting.format import escaped, paragraphs
22
+ from mathspec.typesetting.latex import LatexFormat
23
+
24
+ if TYPE_CHECKING:
25
+ from mathspec.typesetting.format import Entry, Line
26
+
27
+
28
+ #: What Markdown reads as markup inside a paragraph, each escaped by a leading
29
+ #: backslash — CommonMark lets any ASCII punctuation be. ``$`` is GitHub's
30
+ #: inline math, ``~`` its strikethrough; the pipe is a table cell's to escape.
31
+ _SPECIALS = frozenset('\\`*_[]<>~$#')
32
+
33
+ #: A list, quote or heading marker is markup only at the start of a line.
34
+ _LEADING_MARKER = re.compile(r'(^|\n)([-+>]|\d+[.)])(?= )')
35
+
36
+
37
+ def _escape(text: str) -> str:
38
+ escaped = ''.join(f'\\{c}' if c in _SPECIALS else c for c in text)
39
+ return _LEADING_MARKER.sub(r'\1\\\2', escaped)
40
+
41
+
42
+ def _cell(text: str) -> str:
43
+ """*text* as one table cell: a pipe would end it and a newline would end the row."""
44
+ return text.replace('|', r'\|').replace('\n', ' ')
45
+
46
+
47
+ class MarkdownFormat(LatexFormat):
48
+ """See :class:`mathspec.typesetting.format.Format`. Math is LaTeX's; prose is not."""
49
+
50
+ #: The character, not TeX's ligature: no Markdown renderer this output
51
+ #: is aimed at substitutes one, so `---` reaches the reader as three
52
+ #: hyphens in the middle of a legend row.
53
+ dash: ClassVar[str] = '\N{EM DASH}'
54
+
55
+ @override
56
+ def mono(self, text: str) -> str:
57
+ """A backtick span — this one lands in prose, not in math."""
58
+ return f'`{text}`'
59
+
60
+ @override
61
+ def escape(self, prose: str) -> str:
62
+ """Prose with every special escaped, as the other two notations do, and a backtick span kept as the code span it is."""
63
+ return escaped(prose, _escape, self.mono)
64
+
65
+ @override
66
+ def math(self, expression: str) -> str:
67
+ r"""Bare math in prose, in the verbatim inline pair rather than ``$…$``."""
68
+ return f'$`{expression}`$'
69
+
70
+ @override
71
+ def equations(self, lines: list[Line], *, numbered: bool) -> str:
72
+ """One fenced block per equation, with the name *outside* the math.
73
+
74
+ A label is the name the file gives the line rather than a symbol, so it
75
+ sets as the code span prose has and math does not, and ``aligned`` has
76
+ nothing to line up across one-equation blocks. ``numbered`` is ignored:
77
+ ``aligned`` cannot carry numbers.
78
+ """
79
+ del numbered
80
+ blocks = []
81
+ for line in lines:
82
+ block = f'```math\n{self.equation(line)}\n```'
83
+ if line.label:
84
+ block = f'**{self.mono(line.label)}**\n\n{block}'
85
+ blocks.append(block)
86
+ return '\n\n'.join(blocks)
87
+
88
+ @override
89
+ def glossary(self, entries: list[Entry]) -> str:
90
+ rows = '\n'.join(f'| {_cell(self.math(e.symbol))} | {_cell(e.meaning)} |' for e in entries)
91
+ return f'| Symbol | Meaning |\n|---|---|\n{rows}'
92
+
93
+ @override
94
+ def section(self, title: str, body: str) -> str:
95
+ return f'#### {title}\n\n{body}'
96
+
97
+ @override
98
+ def note(self, text: str) -> str:
99
+ return text
100
+
101
+ @override
102
+ def document(self, blocks: list[str], *, standalone: bool) -> str:
103
+ """No preamble: ``standalone`` adds the heading a fragment is pasted under."""
104
+ body = paragraphs(blocks)
105
+ return f'## The math\n\n{body}' if standalone else body