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,310 @@
1
+ # SPDX-FileCopyrightText: mathspec Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """Which symbol each declared name prints as, and the sidecar that overrides it.
6
+
7
+ This module decides *which* symbol a name gets; a :class:`~mathspec.typesetting.format.Format` decides how it is written.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import string
13
+ from collections.abc import Mapping
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, cast
17
+
18
+ from mathspec._yaml import read_yaml
19
+ from mathspec.errors import SchemaError, did_you_mean
20
+ from mathspec.piecewise import Emitted, leaves_ungated
21
+ from mathspec.program import Dual, Variable, walk
22
+ from mathspec.sos import Emitted as EmittedSet
23
+ from mathspec.typesetting.format import NOTATIONS
24
+
25
+ if TYPE_CHECKING:
26
+ from mathspec.program import Program
27
+ from mathspec.typesetting.format import Format, Notation
28
+
29
+ __all__ = ['SymbolTable', 'Symbols', 'symbols_for']
30
+
31
+ #: Dimensions whose conventional index letter is not their own initial, which
32
+ #: is what anything unlisted falls back to.
33
+ _INDEX_ALIASES = {'snapshot': 't', 'snapshots': 't', 'time': 't', 'timestep': 't', 'timesteps': 't'}
34
+
35
+
36
+ #: Names that are a Greek letter written out. Lower case only — every one has
37
+ #: a letter in LaTeX and in Typst, which the capitals do not — and ``omicron``
38
+ #: is left out because LaTeX spells it ``o``.
39
+ _GREEK = frozenset(
40
+ {
41
+ 'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'eta', 'theta',
42
+ 'iota', 'kappa', 'lambda', 'mu', 'nu', 'xi', 'rho', 'sigma', 'tau',
43
+ 'upsilon', 'phi', 'chi', 'psi', 'omega',
44
+ }
45
+ ) # fmt: skip
46
+
47
+
48
+ def _word(name: str, fmt: Format, *, given: bool) -> str:
49
+ r"""One name as one symbol: upright where *given*, italic where chosen.
50
+
51
+ A Greek name is set as the letter only where chosen. Upright lower-case
52
+ Greek needs ``upgreek``, which the two-package preamble and GitHub's
53
+ MathJax both lack, so a given ``eta`` prints as ``\mathrm{eta}``; a table
54
+ entry is how an author who loads ``upgreek`` writes ``\upeta``.
55
+ """
56
+ if given:
57
+ return fmt.upright(name)
58
+ if name in _GREEK:
59
+ return fmt.greek(name)
60
+ return name if len(name) == 1 else fmt.italic(name)
61
+
62
+
63
+ def _derive_name_symbol(name: str, declared: frozenset[str], fmt: Format, *, given: bool = False) -> str:
64
+ r"""``p`` → ``p``; ``load`` → ``\mathit{load}``; ``p_max`` → ``p^{\mathrm{max}}``.
65
+
66
+ An underscore is a qualifier, landing in the superscript, only where its
67
+ head is a symbol in its own right — a single letter, a Greek letter, or a
68
+ declared parameter or variable. A dimension is not a head: ``zone_cap`` is
69
+ a capacity *indexed by* zone. Anywhere else the name prints as written.
70
+ """
71
+ head, _, tail = name.partition('_')
72
+ if tail and (len(head) == 1 or head in _GREEK or head in declared):
73
+ return fmt.superscript(_word(head, fmt, given=given), fmt.upright(tail.replace('_', ',')))
74
+ return _word(name, fmt, given=given)
75
+
76
+
77
+ def chosen_expressions(program: Program) -> frozenset[str]:
78
+ """The named expressions the solver decides, rather than is handed.
79
+
80
+ A ``when`` does not move one: a variable there asks whether the variable
81
+ *exists*, which the model settles when it is built. Only a value reaching a
82
+ variable does — through another named expression too, since a use of one
83
+ stands where the name was written.
84
+ A ``dual`` moves one for the same reason a variable does: the solve settles
85
+ it, and no data hands it over.
86
+ """
87
+ return frozenset(
88
+ name
89
+ for name, entry in program.expressions.items()
90
+ if any(isinstance(node, Variable | Dual) for node in walk(entry.expression))
91
+ )
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class Symbols:
96
+ r"""How every declared name prints: overrides first, derivation for the rest.
97
+
98
+ Built by :func:`symbols_for`. Name symbols settle *before* dimension
99
+ indices, so an index is kept off a single letter a variable owns — a
100
+ dimension ``plant`` beside a variable ``p`` would otherwise render
101
+ ``p_{t,p}``. A parameter is upright, so ``\mathrm{p}`` beside an index
102
+ ``p`` is not a collision.
103
+
104
+ Attributes:
105
+ overridden: Names the table spelled; the convention note quotes only
106
+ derived symbols.
107
+ name: Each parameter's, variable's and expression's symbol.
108
+ constraint: Each constraint's symbol, the subscript ``dual(c)`` prints
109
+ λ against. Off the flat namespace, like the constraints themselves
110
+ — a model may name a constraint after a variable, so this is its
111
+ own map rather than an entry in :attr:`name`. Given structure, so
112
+ upright unless a table overrides it.
113
+ index: Each dimension's index letter.
114
+ set: Each dimension's set symbol.
115
+ """
116
+
117
+ overridden: frozenset[str]
118
+ name: Mapping[str, str]
119
+ constraint: Mapping[str, str]
120
+ index: Mapping[str, str]
121
+ set: Mapping[str, str]
122
+
123
+
124
+ def symbols_for(program: Program, fmt: Format, table: SymbolTable) -> Symbols:
125
+ """The :class:`Symbols` *program* prints with in *fmt*, *table* overriding the derivation.
126
+
127
+ Raises:
128
+ SchemaError: If *table* is written in a notation *fmt* does not read.
129
+ """
130
+ if table.notation != fmt.notation:
131
+ msg = (
132
+ f'symbol table: written in {table.notation}, but this is a {fmt.notation} render '
133
+ f'and nothing translates between notations — write a {fmt.notation} table.'
134
+ )
135
+ raise SchemaError(msg)
136
+ chosen = frozenset(program.variables) | chosen_expressions(program)
137
+ names = (*program.parameters, *program.variables, *program.expressions)
138
+ declared = frozenset(names)
139
+
140
+ name = {
141
+ n: table.names[n] if n in table.names else _derive_name_symbol(n, declared, fmt, given=n not in chosen)
142
+ for n in names
143
+ }
144
+ spoken_for = {s for s in name.values() if len(s) == 1}
145
+ constraint = {
146
+ n: table.names[n] if n in table.names else _derive_name_symbol(n, declared, fmt, given=True)
147
+ for n in program.constraints
148
+ }
149
+
150
+ index: dict[str, str] = {}
151
+ sets: dict[str, str] = {}
152
+ taken_index, taken_set = set(spoken_for), set()
153
+ for dim in program.dimensions:
154
+ overridden = dim in table.indices
155
+ letter = table.indices[dim] if overridden else _first_free(_index_candidates(dim), taken_index)
156
+ taken_index.add(letter)
157
+ index[dim] = letter if len(letter) <= 1 or overridden else fmt.upright(letter)
158
+ upper = _first_free(_set_candidates(dim, letter), taken_set)
159
+ taken_set.add(upper)
160
+ sets[dim] = table.sets[dim] if dim in table.sets else fmt.script(upper)
161
+ return Symbols(frozenset(table.names) & declared, name, constraint, index, sets)
162
+
163
+
164
+ def _index_candidates(dim: str) -> list[str]:
165
+ alias = _INDEX_ALIASES.get(dim)
166
+ letters = [c for c in dim.lower() if c.isalpha()]
167
+ return [*([alias] if alias else []), *letters, *string.ascii_lowercase, dim]
168
+
169
+
170
+ def _set_candidates(dim: str, index_letter: str) -> list[str]:
171
+ first = next((c for c in index_letter if c.isalpha()), '')
172
+ letters = [c.upper() for c in dim if c.isalpha()]
173
+ return [*([first.upper()] if first else []), *letters, *string.ascii_uppercase]
174
+
175
+
176
+ def _first_free(candidates: list[str], taken: set[str]) -> str:
177
+ return next((c for c in candidates if c not in taken), candidates[-1])
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # the symbol table (a sidecar file, not the model)
182
+ # ---------------------------------------------------------------------------
183
+
184
+
185
+ @dataclass(frozen=True)
186
+ class SymbolTable:
187
+ r"""How a *reader* wants the model to print — notation only, kept out of the model.
188
+
189
+ Every entry is a spelling, printed verbatim. ``notation:`` says which
190
+ language they are written in, and a render in the other one refuses::
191
+
192
+ notation: latex
193
+ dimensions:
194
+ snapshot: {index: t, set: "\\mathcal{T}"}
195
+ plant: {index: n}
196
+ names:
197
+ marginal_cost: "c^{\\mathrm{marg}}"
198
+
199
+ An entry naming nothing in the model is an error naming the near miss.
200
+
201
+ Attributes:
202
+ notation: The language the entries are written in; :meth:`load`
203
+ lower-cases it.
204
+ """
205
+
206
+ notation: Notation
207
+ indices: dict[str, str] = field(default_factory=dict)
208
+ sets: dict[str, str] = field(default_factory=dict)
209
+ names: dict[str, str] = field(default_factory=dict)
210
+
211
+ @classmethod
212
+ def load(cls, source: str | Path | Mapping[str, object]) -> SymbolTable:
213
+ """A table from a YAML path or the mapping it parses to.
214
+
215
+ Raises:
216
+ SchemaError: An unknown section, a section or a dimension that is
217
+ not a mapping, or a ``notation:`` that is missing or not
218
+ ``latex``/``typst``.
219
+ """
220
+ raw = dict(source) if isinstance(source, Mapping) else read_yaml(Path(source))
221
+ unknown = set(raw) - {'notation', 'dimensions', 'names'}
222
+ if unknown:
223
+ msg = f'symbol table: unknown section(s) {sorted(unknown)}. Valid sections: notation, dimensions, names.'
224
+ raise SchemaError(msg)
225
+ if 'notation' not in raw:
226
+ msg = "symbol table: 'notation:' is required — latex or typst, the language the entries are written in."
227
+ raise SchemaError(msg)
228
+ notation = str(raw['notation']).lower()
229
+ if notation not in NOTATIONS:
230
+ msg = f'symbol table: unknown notation {raw["notation"]!r}. Valid notations: latex, typst.'
231
+ raise SchemaError(msg)
232
+
233
+ indices: dict[str, str] = {}
234
+ sets: dict[str, str] = {}
235
+ for dim, spec in _section(raw, 'dimensions').items():
236
+ if not isinstance(spec, Mapping):
237
+ msg = f"symbol table: dimension '{dim}' must be a mapping like {{index: t, set: '\\\\mathcal{{T}}'}}"
238
+ raise SchemaError(msg)
239
+ extra = set(spec) - {'index', 'set'}
240
+ if extra:
241
+ msg = f"symbol table: dimension '{dim}' has unknown key(s) {sorted(extra)}. Valid keys: index, set."
242
+ raise SchemaError(msg)
243
+ if 'index' in spec:
244
+ indices[dim] = str(spec['index'])
245
+ if 'set' in spec:
246
+ sets[dim] = str(spec['set'])
247
+
248
+ return cls(
249
+ notation=cast('Notation', notation),
250
+ indices=indices,
251
+ sets=sets,
252
+ names={k: str(v) for k, v in _section(raw, 'names').items()},
253
+ )
254
+
255
+ def checked_against(self, program: Program) -> SymbolTable:
256
+ """Reject entries naming nothing in *program* or in what its formulations state, with the near miss.
257
+
258
+ A name a ``piecewise:`` or ``sos:`` block emits counts as declared, so
259
+ one table spells both readings of a model: the blocks as the file states
260
+ them, and the rows :meth:`~mathspec.model.Spec.expand` writes out.
261
+ """
262
+ dims = set(program.dimensions)
263
+ everything = dims | _declared(program) | _emitted(program)
264
+ errors = [
265
+ *(_unknown_entry(d, 'dimensions', dims) for d in {*self.indices, *self.sets} - dims),
266
+ *(_unknown_entry(n, 'names', everything - dims) for n in set(self.names) - everything),
267
+ ]
268
+ if errors:
269
+ raise SchemaError('\n'.join(sorted(errors)))
270
+ return self
271
+
272
+
273
+ def _declared(program: Program) -> set[str]:
274
+ """Every name *program* declares that a table entry may spell."""
275
+ return set(program.parameters) | set(program.variables) | set(program.expressions) | set(program.constraints)
276
+
277
+
278
+ def _emitted(program: Program) -> set[str]:
279
+ """Every variable and constraint writing *program*'s curves and sets out would declare."""
280
+ curves = (
281
+ Emitted.of(name, curve).written(
282
+ curve.method,
283
+ ungated=leaves_ungated(program.variables[curve.activity] if curve.activity is not None else None),
284
+ )
285
+ for name, curve in program.piecewise.items()
286
+ )
287
+ sets = (EmittedSet.of(name, block.sos_type).by_kind for name, block in program.sos.items())
288
+ return {
289
+ *(name for names in curves for name in names),
290
+ *(name for by_kind in sets for _, names in by_kind for name in names),
291
+ }
292
+
293
+
294
+ def _section(raw: Mapping[str, object], name: str) -> Mapping[str, object]:
295
+ """The *name* section of a symbol table as the mapping it has to be, empty where it is absent or null.
296
+
297
+ Raises:
298
+ SchemaError: The section is something else, such as a list.
299
+ """
300
+ section = raw.get(name)
301
+ if section is None:
302
+ return {}
303
+ if not isinstance(section, Mapping):
304
+ msg = f'symbol table: {name}: must be a mapping of names to entries, got {type(section).__name__}.'
305
+ raise SchemaError(msg)
306
+ return section
307
+
308
+
309
+ def _unknown_entry(name: str, section: str, known: set[str]) -> str:
310
+ return f"symbol table: '{name}' under {section}: is not declared by the model. {did_you_mean(name, known)}"
@@ -0,0 +1,147 @@
1
+ # SPDX-FileCopyrightText: mathspec Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """Typst. Multi-letter identifiers in math are upright by default, which is why names go through ``italic("…")``."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import TYPE_CHECKING, ClassVar
11
+
12
+ from mathspec.typesetting.format import OPERATOR_SPELLINGS, aligned_rows, escaped, paragraphs
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Mapping
16
+
17
+ from mathspec.typesetting.format import Entry, Line, Notation, OperatorName
18
+
19
+ _PREAMBLE = """#set page(margin: 2.5cm)
20
+ #set text(size: 11pt)
21
+ """
22
+
23
+
24
+ #: What Typst reads as markup in text mode, each escaped by a leading
25
+ #: backslash. They are not LaTeX's: ``%`` and ``&`` are ordinary characters
26
+ #: here, while ``*``, ``@``, ``<``, ``/`` and the square brackets are not.
27
+ _SPECIALS = frozenset('\\#$*_@`<>~/[]')
28
+
29
+ #: A list or heading marker is markup only at the start of a line.
30
+ _LEADING_MARKER = re.compile(r'(^|\n)([-+=])(?= )')
31
+
32
+
33
+ def _quote(text: str) -> str:
34
+ """A Typst string literal — only the quote and the backslash can bite."""
35
+ return '"' + text.replace('\\', '\\\\').replace('"', '\\"') + '"'
36
+
37
+
38
+ def _escape(text: str) -> str:
39
+ escaped = ''.join(f'\\{c}' if c in _SPECIALS else c for c in text)
40
+ return _LEADING_MARKER.sub(r'\1\\\2', escaped)
41
+
42
+
43
+ def _raw(text: str) -> str:
44
+ """Inline raw text. Backticks are the only character that can end it."""
45
+ return '`' + text.replace('`', "'") + '`'
46
+
47
+
48
+ class TypstFormat:
49
+ """See :class:`mathspec.typesetting.format.Format`.
50
+
51
+ The cyclic operators spell with ``.o``, Typst's circled modifier;
52
+ ``minus.circle`` does not compile.
53
+ """
54
+
55
+ notation: ClassVar[Notation] = 'typst'
56
+ #: Typst applies the same substitution TeX does.
57
+ dash: ClassVar[str] = '---'
58
+
59
+ operators: ClassVar[Mapping[OperatorName, str]] = {name: typst for name, (_, typst) in OPERATOR_SPELLINGS.items()}
60
+
61
+ # -- atoms -------------------------------------------------------------
62
+
63
+ def italic(self, name: str) -> str:
64
+ return f'italic({_quote(name)})'
65
+
66
+ def upright(self, name: str) -> str:
67
+ return f'upright({_quote(name)})'
68
+
69
+ def script(self, letter: str) -> str:
70
+ return f'cal({letter})'
71
+
72
+ def greek(self, name: str) -> str:
73
+ return name
74
+
75
+ def prose(self, text: str) -> str:
76
+ return f'upright({_quote(text)})'
77
+
78
+ def quoted(self, label: str) -> str:
79
+ in_quotes = f"'{label}'"
80
+ return f'upright({_quote(in_quotes)})'
81
+
82
+ def mono(self, text: str) -> str:
83
+ return _raw(text)
84
+
85
+ def escape(self, prose: str) -> str:
86
+ return escaped(prose, _escape, self.mono)
87
+
88
+ def math(self, expression: str) -> str:
89
+ return f'${expression}$'
90
+
91
+ # -- structure ---------------------------------------------------------
92
+
93
+ def subscript(self, base: str, indices: list[str]) -> str:
94
+ return f'{base}_({",".join(indices)})' if indices else base
95
+
96
+ def superscript(self, base: str, tail: str) -> str:
97
+ return f'{base}^({tail})'
98
+
99
+ def parenthesise(self, inner: str) -> str:
100
+ return f'({inner})'
101
+
102
+ def cardinality(self, inner: str) -> str:
103
+ return f'abs({inner})'
104
+
105
+ def fraction(self, numerator: str, denominator: str) -> str:
106
+ return f'frac({numerator}, {denominator})'
107
+
108
+ def set_of(self, members: str, condition: str) -> str:
109
+ return f'{{{members} {self.operators["such_that"]} {condition}}}'
110
+
111
+ def cases(self, arms: list[tuple[str, str]]) -> str:
112
+ return 'cases({})'.format(', '.join(f'{value} & {condition}' for value, condition in arms))
113
+
114
+ def summation(self, domain: str, body: str) -> str:
115
+ return f'sum_({domain}) {body}'
116
+
117
+ def apply(self, function: str, argument: str) -> str:
118
+ return f'{function}({argument})'
119
+
120
+ def joined(self, parts: list[str], operator: str) -> str:
121
+ return f' {operator} '.join(parts) if operator else ', '.join(parts)
122
+
123
+ # -- document ----------------------------------------------------------
124
+
125
+ def equation(self, line: Line) -> str:
126
+ body = f'{line.left} {line.right}'.strip()
127
+ return f'{body} quad {line.condition}' if line.condition else body
128
+
129
+ def equations(self, lines: list[Line], *, numbered: bool) -> str:
130
+ """A block equation, aligned on ``&`` as amsmath does."""
131
+ body = ' \\\n '.join(aligned_rows(lines, self, gap=' & '))
132
+ numbering = '#set math.equation(numbering: "(1)")\n' if numbered else ''
133
+ return f'{numbering}$ {body} $'
134
+
135
+ def glossary(self, entries: list[Entry]) -> str:
136
+ return '\n'.join(f'/ {self.math(e.symbol)}: {e.meaning}' for e in entries)
137
+
138
+ def section(self, title: str, body: str) -> str:
139
+ return f'== {title}\n{body}'
140
+
141
+ def note(self, text: str) -> str:
142
+ return text
143
+
144
+ def document(self, blocks: list[str], *, standalone: bool) -> str:
145
+ """No preamble/body split: ``standalone`` only adds the page setup."""
146
+ body = paragraphs(blocks)
147
+ return f'{_PREAMBLE}\n{body}' if standalone else body