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/sos.py ADDED
@@ -0,0 +1,159 @@
1
+ # SPDX-FileCopyrightText: mathspec Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """Expand ``sos:`` blocks into the binaries and rows that state the same restriction.
6
+
7
+ A set becomes ordinary declarations under names prefixed with the block's own,
8
+ the way a ``piecewise:`` block becomes weights and rows; what it emits is
9
+ tabled in ``docs/reference/language/piecewise.md``. An unpicked member is held
10
+ at zero from both sides, so the rewrite states the same feasible set whatever
11
+ sign the member takes — what it needs is a coefficient on each side, which a
12
+ model declaring a set without is refused at load for
13
+ (:meth:`mathspec.model.Spec` validates it) rather than here.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from typing import TYPE_CHECKING
20
+
21
+ from mathspec.model import Spec
22
+
23
+ if TYPE_CHECKING:
24
+ from mathspec.program import SosType
25
+
26
+ #: One member's two linking coefficients, below and above. ``None`` on a side is
27
+ #: a side the model leaves open, where no rewrite can hold the member at zero.
28
+ Coefficients = tuple[float | str | None, float | str | None]
29
+
30
+
31
+ def coefficients(domain: str, lower: float | str | None, upper: float | str | None) -> Coefficients:
32
+ """What a member's two linking rows multiply its binary by, ``None`` on a side the model leaves open.
33
+
34
+ The 0 and 1 a binary's domain fixes, which no bounds block carries;
35
+ otherwise the member's own declared bounds, each a number or the name of a
36
+ parameter. A parameter is a coefficient like any other: it is what the row
37
+ multiplies by, and no rewrite needs to know its value. Nothing else is a
38
+ coefficient: a number below the bound would cap a picked member the set
39
+ does not cap, and one above it is a looser row than the bound already
40
+ states.
41
+ """
42
+ if domain == 'binary':
43
+ return 0.0, 1.0
44
+ return lower, upper
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Emitted:
49
+ """Every name one set's expansion writes, spelled once for the emitter and the collision check.
50
+
51
+ The linking row is named after what it says, which the two orders do not
52
+ share: order 2 admits a member in either half of one segment, and order 1
53
+ admits it alone. ``below`` is the same row from underneath, written only
54
+ where the member may be nonzero below zero.
55
+ """
56
+
57
+ seg: str
58
+ pick: str
59
+ link: str
60
+
61
+ @classmethod
62
+ def of(cls, name: str, order: SosType) -> Emitted:
63
+ """The names set *name* of *order* writes."""
64
+ admits = 'adjacency' if order == 2 else 'nonzero'
65
+ return cls(f'{name}_seg', f'{name}_pick', f'{name}_{admits}')
66
+
67
+ @property
68
+ def below(self) -> str:
69
+ """The linking row from underneath."""
70
+ return f'{self.link}_below'
71
+
72
+ @property
73
+ def by_kind(self) -> tuple[tuple[str, tuple[str, ...]], ...]:
74
+ """Each name by the kind of declaration it would collide with."""
75
+ return (('variable', (self.seg,)), ('constraint', (self.pick, self.link, self.below)))
76
+
77
+
78
+ #: What the binary says, per order — the description its legend entry carries.
79
+ _SEGMENTS = {
80
+ 1: 'a binary per member, 1 where that member may be nonzero',
81
+ 2: 'a binary per segment, 1 where the two members it spans may be nonzero',
82
+ }
83
+
84
+
85
+ def expand_sets(schema: Spec) -> Spec:
86
+ """*schema* with every ``sos:`` block written out as binaries and the rows that link them.
87
+
88
+ The curves an expansion wrote out ride along, because a model whose
89
+ curves are already written out is the one this is usually asked of.
90
+ """
91
+ raw = schema.model_dump()
92
+ for name in list(schema.sos):
93
+ emit(raw, name)
94
+ return Spec.model_validate(raw)
95
+
96
+
97
+ def emit(raw: dict[str, object], name: str) -> None:
98
+ """Write what the set *name* states as declarations of *raw*, and drop the block.
99
+
100
+ Args:
101
+ raw: A model as data, mid-expansion, declaring the set and the variable
102
+ it runs over.
103
+ name: Which set to lower.
104
+ """
105
+ sets = section(raw, 'sos')
106
+ block = sets.pop(name)
107
+ assert isinstance(block, dict), 'a validated model carries each set as a mapping'
108
+ variable, over, order = block['variable'], block['along'], block['type']
109
+ member = section(raw, 'variables')[variable]
110
+ assert isinstance(member, dict), 'a validated model carries each variable as a mapping'
111
+ dims = list(member['dims'])
112
+ emitted = Emitted.of(name, order)
113
+
114
+ section(raw, 'variables')[emitted.seg] = {
115
+ 'dims': dims,
116
+ **({'where': member['where']} if member.get('where') else {}),
117
+ 'domain': 'binary',
118
+ 'description': _SEGMENTS[order],
119
+ }
120
+ picked = emitted.seg if order == 1 else f'{emitted.seg} + shift({emitted.seg}, along={over}, offset=1, edge=0)'
121
+ constraints = section(raw, 'constraints')
122
+ constraints[emitted.pick] = {
123
+ 'dims': [d for d in dims if d != over],
124
+ 'expression': f'sum({emitted.seg}, over={over}) <= 1',
125
+ }
126
+ below, above = _coefficients(member)
127
+ constraints[emitted.link] = {'dims': dims, 'expression': f'{variable} <= {_scaled(above, picked)}'}
128
+ if below != 0.0:
129
+ constraints[emitted.below] = {'dims': dims, 'expression': f'{variable} >= {_scaled(below, picked)}'}
130
+
131
+
132
+ def _scaled(factor: float | str, picked: str) -> str:
133
+ """The binaries a member is admitted by, times *factor*.
134
+
135
+ A coefficient of 1 is left out. It is the common one — a weight and a binary
136
+ are both bounded by 1 — and ``x <= 1 * (seg)`` is a factor every reader of
137
+ the row has to work out means nothing.
138
+ """
139
+ return f'({picked})' if factor == 1.0 else f'{factor} * ({picked})'
140
+
141
+
142
+ def _coefficients(member: dict[str, object]) -> tuple[float | str, float | str]:
143
+ """The two coefficients as an expression writes them, read off the member."""
144
+ declared = member.get('bounds')
145
+ assert declared is None or isinstance(declared, dict), 'a validated model carries a bounds block as a mapping'
146
+ lower, upper = (declared.get('lower'), declared.get('upper')) if declared else (None, None)
147
+ assert isinstance(lower, float | str | None) and isinstance(upper, float | str | None), (
148
+ 'a bound is a number, the name of a parameter, or open'
149
+ )
150
+ below, above = coefficients(str(member.get('domain', 'continuous')), lower, upper)
151
+ assert below is not None and above is not None, 'a set with a side left open is refused at load'
152
+ return below, above
153
+
154
+
155
+ def section(raw: dict[str, object], name: str) -> dict[str, object]:
156
+ """The *name* section of the raw model, created empty where the file declares none."""
157
+ section = raw.setdefault(name, {})
158
+ assert isinstance(section, dict), f'{name}: is a mapping in a validated model'
159
+ return section
@@ -0,0 +1,110 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: mathspec Contributors
3
+ SPDX-License-Identifier: MIT
4
+ -->
5
+
6
+ # `typesetting/` — the model, printed
7
+
8
+ This package is a consumer of the program. It builds no model and attaches no
9
+ data. It walks a program, the one a loaded `Spec` holds or one handed to it,
10
+ and prints it.
11
+
12
+ | Module | Role |
13
+ | ------------- | ------------------------------------------------------------------------------------------------------ |
14
+ | `__init__.py` | `typeset` / `to_latex` / `to_markdown` / `to_typst`, `typeset_declaration`, and the `FORMATS` registry |
15
+ | `walk.py` | the program's trees to `Line`s. Every decision about the **math**, written once |
16
+ | `legend.py` | the notes under the equations, read off what the program uses |
17
+ | `format.py` | the boundary: what a format must spell, and the operator vocabulary |
18
+ | `symbols.py` | which symbol a name gets, and the `SymbolTable` sidecar that overrides it |
19
+ | `latex.py` | amsmath, the format that lands in a journal |
20
+ | `typst.py` | Typst, the format that compiles without a toolchain |
21
+ | `markdown.py` | GitHub-flavoured Markdown: LaTeX math, with a Markdown document layer |
22
+
23
+ ## Why the walk and the formats are separate files
24
+
25
+ `walk.py` makes the decisions about the math. It decides where a bracket changes
26
+ the reading, which dimension a reduction binds, that a mask belongs on the ∀
27
+ rather than in the equation, and that a translation shows at the leaf it
28
+ re-indexes.
29
+
30
+ A `Format` makes a much smaller decision: that a sum is written `\sum_{…}` or
31
+ `sum_(…)`.
32
+
33
+ Those are different questions, and they live in different files. With one
34
+ module, the second format becomes a _copy of the walk_. Two copies of a walk are
35
+ two walks that can disagree about what the model says. That matters more here
36
+ than it looks, because a typeset model is what a reader checks the math against.
37
+
38
+ Two rules keep the split honest:
39
+
40
+ - **A walk emits bare math.** No `$`, and no environment. The format wraps the
41
+ math with `math()` when it embeds it in prose, so the walk never knows which
42
+ mode it is in.
43
+ - **A format spells; it never decides.** No method in `format.py` takes a
44
+ syntax-tree node or a schema. If a format had to look at the model to answer a
45
+ question, that question belongs in the walk.
46
+
47
+ ## Notation
48
+
49
+ Symbols are derived by default, so a model prints with no setup at all.
50
+
51
+ A `SymbolTable` overrides the derived symbols. `symbols=` takes the table in
52
+ whichever form the caller already has. The `--symbols` flag on the command line
53
+ is the path case:
54
+
55
+ ```python
56
+ mathspec.to_latex('dispatch.yaml', symbols='dispatch.symbols.yaml') # a path
57
+ mathspec.to_latex('dispatch.yaml', symbols={'notation': 'latex', 'names': {'load': r'\ell'}}) # a dict
58
+ mathspec.to_latex('dispatch.yaml', symbols=mathspec.SymbolTable.load(table)) # the object
59
+ ```
60
+
61
+ The dict has the same sections as the file, which are `notation`, `dimensions`
62
+ and `names`. It is what the YAML parses to, and not a flat `{name: symbol}` map.
63
+
64
+ Whichever form you use, the table is checked against the model. A key that names
65
+ nothing is an error, and the message gives the near miss. Without that check, a
66
+ silent typo would be a symbol that never applies, and a reader who never finds
67
+ out.
68
+
69
+ Every value is a spelling, and it is printed exactly as written. Nothing parses
70
+ or translates it. `notation` says which language the table is written in, and a
71
+ format that reads the other language refuses the table. Everything past that
72
+ comparison is the caller's business.
73
+
74
+ The table carries **notation only**. What a declaration _is_, which is the prose
75
+ in the right-hand column of the legend, comes from the model's own
76
+ `description:`, read straight off the block. That is the model talking about
77
+ itself, rather than a reader choosing symbols.
78
+
79
+ A description travels with the file, survives a rename, and needs no sidecar.
80
+ The price is that it has to be plain prose, because every format sets the same
81
+ words.
82
+
83
+ ## Adding a format
84
+
85
+ 1. Add a module here with a class that satisfies `Format`. That means atoms,
86
+ structure, document, and a spelling for every name in `OPERATOR_NAMES`.
87
+ 2. Add a row to `FORMATS` in `__init__.py`. The command-line verb comes from the
88
+ key.
89
+ 3. Change nothing in `walk.py`. If you find that you need to, then either the
90
+ walk is making a syntax decision that it should not make, or `format.py` is
91
+ missing a method. Fix that, rather than adding a special case.
92
+
93
+ `markdown.py` is the cheap case. Markdown has no math of its own, so it
94
+ subclasses `LatexFormat` and overrides only the document layer.
95
+
96
+ `tests/typesetting/test_typeset.py` runs the shared expectations against
97
+ **every** entry in `FORMATS`, so a new format inherits the whole suite. Two of
98
+ those expectations are the point: every operator name is spelled, and no format
99
+ leaks another format's syntax.
100
+
101
+ ## What CI checks, and what it cannot
102
+
103
+ LaTeX and Typst are **compiled** in CI, not just matched as strings. LaTeX needs
104
+ a two-package apt install. Typst is a pip wheel, so the suite compiles it
105
+ in-process.
106
+
107
+ The structural checks run as well. Those check brace balance, environment
108
+ nesting, and `\left` and `\right` pairing, which are the things a _generator_
109
+ gets wrong. But a structural check is not a compile. A malformed `\mathcal`
110
+ passes every one of them.
@@ -0,0 +1,223 @@
1
+ # SPDX-FileCopyrightText: mathspec Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """Typeset a validated model — a *reading* of the math.
6
+
7
+ Symbols are **derived** by default, aiming at unambiguous rather than
8
+ beautiful, so it prints with no setup; a
9
+ :class:`~mathspec.typesetting.symbols.SymbolTable` (``--symbols``) makes it
10
+ conventional. It does not line-break: a wide equation runs off the page.
11
+
12
+ Usage::
13
+
14
+ import mathspec
15
+
16
+ print(mathspec.to_latex('model.yaml'))
17
+ print(mathspec.to_typst('model.yaml', standalone=True))
18
+ print(mathspec.to_markdown('model.yaml')) # renders as-is on GitHub
19
+ print(mathspec.to_latex('model.yaml', symbols='model.symbols.yaml'))
20
+
21
+ or from a shell::
22
+
23
+ python -m mathspec latex model.yaml --symbols model.symbols.yaml --standalone -o model.tex
24
+ python -m mathspec typst model.yaml --standalone -o model.typ
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from typing import TYPE_CHECKING, Literal, TypedDict, Unpack
30
+
31
+ from mathspec.program import Program
32
+ from mathspec.typesetting.latex import LatexFormat
33
+ from mathspec.typesetting.legend import Legend, notice
34
+ from mathspec.typesetting.markdown import MarkdownFormat
35
+ from mathspec.typesetting.symbols import SymbolTable, symbols_for
36
+ from mathspec.typesetting.typst import TypstFormat
37
+ from mathspec.typesetting.walk import Walk
38
+ from mathspec.validation import to_spec
39
+
40
+ if TYPE_CHECKING:
41
+ from collections.abc import Mapping
42
+ from pathlib import Path
43
+
44
+ from mathspec.model import Spec
45
+ from mathspec.typesetting.format import Format
46
+
47
+ __all__ = [
48
+ 'FORMATS',
49
+ 'FormatName',
50
+ 'SymbolTable',
51
+ 'to_latex',
52
+ 'to_markdown',
53
+ 'to_typst',
54
+ 'typeset',
55
+ 'typeset_declaration',
56
+ ]
57
+
58
+ #: A format by the name the CLI takes — what every renderer here is asked for.
59
+ FormatName = Literal['latex', 'markdown', 'typst']
60
+
61
+ #: Every format, by name. Adding one is a module plus a row.
62
+ FORMATS: dict[FormatName, Format] = {
63
+ 'latex': LatexFormat(),
64
+ 'markdown': MarkdownFormat(),
65
+ 'typst': TypstFormat(),
66
+ }
67
+
68
+
69
+ class _Options(TypedDict, total=False):
70
+ """The keyword arguments :func:`typeset` takes, which the three per-format doors forward whole."""
71
+
72
+ symbols: str | Path | Mapping[str, object] | SymbolTable | None
73
+ standalone: bool
74
+ legend: bool
75
+ numbered: bool
76
+ inline_expressions: bool
77
+
78
+
79
+ def _walk(
80
+ model: str | Path | Mapping[str, object] | Spec | Program,
81
+ fmt: FormatName,
82
+ symbols: str | Path | Mapping[str, object] | SymbolTable | None,
83
+ *,
84
+ inline_expressions: bool,
85
+ ) -> Walk:
86
+ """The loaded, symbol-resolved walk every renderer builds from model, format and table."""
87
+ if fmt not in FORMATS:
88
+ msg = f"'{fmt}' is not a format this package prints. Formats: {', '.join(FORMATS)}."
89
+ raise ValueError(msg)
90
+ program = model if isinstance(model, Program) else to_spec(model).program
91
+ format_ = FORMATS[fmt]
92
+ if symbols is None:
93
+ symbols = SymbolTable(format_.notation)
94
+ table = symbols if isinstance(symbols, SymbolTable) else SymbolTable.load(symbols)
95
+ return Walk(
96
+ program,
97
+ symbols_for(program, format_, table.checked_against(program)),
98
+ format_,
99
+ inline_expressions=inline_expressions,
100
+ )
101
+
102
+
103
+ def typeset(
104
+ model: str | Path | Mapping[str, object] | Spec | Program,
105
+ fmt: FormatName,
106
+ *,
107
+ symbols: str | Path | Mapping[str, object] | SymbolTable | None = None,
108
+ standalone: bool = False,
109
+ legend: bool = True,
110
+ numbered: bool = True,
111
+ inline_expressions: bool = False,
112
+ ) -> str:
113
+ """Render *model*'s math in *fmt*.
114
+
115
+ Args:
116
+ model: Anything :func:`mathspec.to_spec` accepts, or a
117
+ :class:`~mathspec.program.Program`. A ``Spec`` or a ``Program``
118
+ is rendered as it stands, so printing one model in several formats
119
+ reads and checks the file once rather than once per format, and a
120
+ curve prints as the curve it states. Pass ``spec.expand()`` for the rows a solver holds
121
+ instead.
122
+ fmt: What spells the math — a key of :data:`FORMATS`.
123
+ symbols: How names print, as a :class:`SymbolTable`, a path or a
124
+ mapping. Names it does not carry are derived, and it must be
125
+ written in *fmt*'s notation.
126
+ standalone: Emit a compilable document rather than a fragment.
127
+ legend: Prepend the sets/parameters/variables table. The model's own
128
+ ``description:`` opens the document either way — it is what the
129
+ file says it is, not a symbol table.
130
+ numbered: Number the equations.
131
+ inline_expressions: Substitute each plain named expression into the equations that
132
+ use it, rather than printing its symbol there and its definition
133
+ once. A cased expression is a definition either way: its block is
134
+ taller than the line it would sit in.
135
+
136
+ Returns:
137
+ The rendered text.
138
+
139
+ Raises:
140
+ ValueError: *fmt* names no format.
141
+ LanguageError: A model that does not compile; it does not print.
142
+ SchemaError: A symbol table entry naming nothing in the model, or a
143
+ table written in a notation *fmt* does not read.
144
+ """
145
+ walk = _walk(model, fmt, symbols, inline_expressions=inline_expressions)
146
+ program, format_ = walk.program, walk.format
147
+
148
+ rendered = [
149
+ format_.section(title, format_.equations(lines, numbered=numbered))
150
+ for title, lines in walk.equations()
151
+ if lines
152
+ ]
153
+
154
+ blocks = [format_.note(format_.escape(program.description))] if program.description else []
155
+ if legend:
156
+ explained, noticed = Legend(program, walk.symbols, format_), notice(program)
157
+ blocks += [
158
+ format_.section(title, format_.glossary(entries))
159
+ for title, entries in explained.glossaries(noticed, walk.defined())
160
+ ]
161
+ blocks += [format_.note(text) for text in explained.convention_notes()]
162
+ blocks += [format_.note(text) for text in explained.translation_notes(noticed)]
163
+ blocks += [format_.note(text) for text in explained.position_notes(noticed)]
164
+ return format_.document([*blocks, *rendered], standalone=standalone)
165
+
166
+
167
+ def typeset_declaration(
168
+ model: str | Path | Mapping[str, object] | Spec | Program,
169
+ name: str,
170
+ fmt: FormatName,
171
+ *,
172
+ symbols: str | Path | Mapping[str, object] | SymbolTable | None = None,
173
+ inline_expressions: bool = True,
174
+ ) -> str:
175
+ """Render one declaration as the bare line the document prints for it.
176
+
177
+ The line the whole-model render prints for it — a named expression's
178
+ definition, a constraint, an assumption, a ``piecewise:`` curve, or a
179
+ variable's domain, quantifier included —
180
+ with no document, label, equation number or math delimiters around it, for
181
+ a math context the caller lays out: a docstring, a table cell. A line on
182
+ its own has no Definitions section beside it, so the plain named
183
+ expressions it uses are substituted unless *inline_expressions* says otherwise; a cased
184
+ one prints by symbol, and a second call with its name prints its block.
185
+
186
+ Args:
187
+ model: Anything :func:`mathspec.to_spec` accepts, or a :class:`~mathspec.program.Program`.
188
+ name: A named expression, constraint, assumption, ``piecewise:``
189
+ block or variable the model declares.
190
+ fmt: What spells the math — a key of :data:`FORMATS`.
191
+ symbols: How names print; see :func:`typeset`.
192
+ inline_expressions: Substitute the plain named expressions the line uses, so it
193
+ stands on its own; ``False`` prints their symbols, as the document
194
+ does. A plain expression asked for by name prints its definition
195
+ either way.
196
+
197
+ Returns:
198
+ The line, math only.
199
+
200
+ Raises:
201
+ ValueError: *fmt* names no format.
202
+ LanguageError: A model that does not compile; it does not print.
203
+ SchemaError: *name* is declared as none of the five, or as two — a
204
+ constraint may share a variable's name; or a symbol table entry
205
+ names nothing in the model.
206
+ """
207
+ walk = _walk(model, fmt, symbols, inline_expressions=inline_expressions)
208
+ return walk.format.equation(walk.line(name))
209
+
210
+
211
+ def to_latex(model: str | Path | Mapping[str, object] | Spec | Program, **options: Unpack[_Options]) -> str:
212
+ """Render *model* as LaTeX (amsmath ``align``). See :func:`typeset`."""
213
+ return typeset(model, 'latex', **options)
214
+
215
+
216
+ def to_typst(model: str | Path | Mapping[str, object] | Spec | Program, **options: Unpack[_Options]) -> str:
217
+ """Render *model* as Typst. See :func:`typeset`."""
218
+ return typeset(model, 'typst', **options)
219
+
220
+
221
+ def to_markdown(model: str | Path | Mapping[str, object] | Spec | Program, **options: Unpack[_Options]) -> str:
222
+ """Render *model* as GitHub-flavoured Markdown. See :func:`typeset`."""
223
+ return typeset(model, 'markdown', **options)