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/__init__.py +59 -0
- mathspec/__main__.py +87 -0
- mathspec/_expression_parser.py +436 -0
- mathspec/_expression_resolver.py +739 -0
- mathspec/_sealed.py +62 -0
- mathspec/_where_parser.py +288 -0
- mathspec/_where_resolver.py +745 -0
- mathspec/_yaml.py +141 -0
- mathspec/advice.py +79 -0
- mathspec/boundedness.py +168 -0
- mathspec/degree.py +209 -0
- mathspec/dimensions.py +377 -0
- mathspec/errors.py +105 -0
- mathspec/exclusivity.py +587 -0
- mathspec/expansion.py +124 -0
- mathspec/lowering.py +302 -0
- mathspec/model.py +905 -0
- mathspec/operators.py +235 -0
- mathspec/piecewise.py +505 -0
- mathspec/program.py +1514 -0
- mathspec/py.typed +0 -0
- mathspec/resolution.py +475 -0
- mathspec/separability.py +185 -0
- mathspec/sos.py +159 -0
- mathspec/typesetting/README.md +110 -0
- mathspec/typesetting/__init__.py +223 -0
- mathspec/typesetting/format.py +273 -0
- mathspec/typesetting/latex.py +139 -0
- mathspec/typesetting/legend.py +286 -0
- mathspec/typesetting/markdown.py +105 -0
- mathspec/typesetting/symbols.py +310 -0
- mathspec/typesetting/typst.py +147 -0
- mathspec/typesetting/walk.py +941 -0
- mathspec/validation.py +338 -0
- mathspec-0.1.0.dist-info/METADATA +247 -0
- mathspec-0.1.0.dist-info/RECORD +38 -0
- mathspec-0.1.0.dist-info/WHEEL +4 -0
- mathspec-0.1.0.dist-info/licenses/LICENSE +21 -0
mathspec/_yaml.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: mathspec Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""How this project reads a YAML file.
|
|
6
|
+
|
|
7
|
+
`yaml.safe_load` implements YAML 1.1, and two of its rules are wrong for a
|
|
8
|
+
language whose scalars are user data; both are fixed here:
|
|
9
|
+
|
|
10
|
+
- **1.2 booleans.** ``on``/``off``/``yes``/``no``/``y``/``n`` are ordinary
|
|
11
|
+
names in this language — a country code as a dimension, a mode as a relation.
|
|
12
|
+
YAML 1.1 resolves them to ``True``/``False``; only ``true``/``false`` are
|
|
13
|
+
booleans here, which is the YAML 1.2 core schema.
|
|
14
|
+
- **Duplicate keys.** 1.1 lets the last one win silently, discarding a
|
|
15
|
+
declaration the file plainly contains.
|
|
16
|
+
|
|
17
|
+
The output is plain ``dict``/``str``: no loader wrapper reaches the schema
|
|
18
|
+
or the AST.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
import yaml
|
|
28
|
+
|
|
29
|
+
from mathspec.errors import SchemaError
|
|
30
|
+
|
|
31
|
+
#: The YAML 1.2 core-schema boolean set — nothing else resolves to a bool.
|
|
32
|
+
_BOOL_1_2 = re.compile(r'^(?:true|True|TRUE|false|False|FALSE)$')
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
# Typed as SafeLoader: typeshed declares CSafeLoader unconditionally, and a PyYAML without libyaml lacks it.
|
|
37
|
+
_BaseLoader = yaml.SafeLoader
|
|
38
|
+
else:
|
|
39
|
+
# Same document either way: both drive the Python Resolver and SafeConstructor.
|
|
40
|
+
_BaseLoader = getattr(yaml, 'CSafeLoader', yaml.SafeLoader)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _StrictLoader(_BaseLoader):
|
|
44
|
+
"""SafeLoader with 1.2 booleans. Duplicate keys are checked on the nodes."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Rebuilt rather than edited: the table is inherited, and mutating it reconfigures PyYAML process-wide.
|
|
48
|
+
_StrictLoader.yaml_implicit_resolvers = {
|
|
49
|
+
ch: [(tag, rx) for tag, rx in pairs if tag != 'tag:yaml.org,2002:bool']
|
|
50
|
+
for ch, pairs in _BaseLoader.yaml_implicit_resolvers.items()
|
|
51
|
+
}
|
|
52
|
+
_StrictLoader.add_implicit_resolver('tag:yaml.org,2002:bool', _BOOL_1_2, list('tTfF'))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
#: PyYAML's tag for ``<<:``, the one key a mapping may carry more than once.
|
|
56
|
+
_MERGE = 'tag:yaml.org,2002:merge'
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _check_duplicate_keys(node: yaml.Node, origin: str) -> None:
|
|
60
|
+
"""Reject a mapping that declares the same key twice, or a key that is not a scalar.
|
|
61
|
+
|
|
62
|
+
Checked on the node tree before construction, so a ``<<:`` merge key that
|
|
63
|
+
a mapping overrides is not a duplicate — the override is the point — and
|
|
64
|
+
two merge keys are two merges, which PyYAML accumulates.
|
|
65
|
+
"""
|
|
66
|
+
if isinstance(node, yaml.MappingNode):
|
|
67
|
+
seen: dict[str, int] = {}
|
|
68
|
+
pairs: list[tuple[yaml.Node, yaml.Node]] = node.value
|
|
69
|
+
for key_node, value_node in pairs:
|
|
70
|
+
line = key_node.start_mark.line + 1
|
|
71
|
+
if not isinstance(key_node, yaml.ScalarNode):
|
|
72
|
+
msg = f'{origin}:{line}: a key must be a scalar — a name, not a list or a mapping.'
|
|
73
|
+
raise SchemaError(msg)
|
|
74
|
+
key = key_node.value
|
|
75
|
+
if key_node.tag == _MERGE:
|
|
76
|
+
_check_duplicate_keys(value_node, origin)
|
|
77
|
+
continue
|
|
78
|
+
if key in seen:
|
|
79
|
+
msg = (
|
|
80
|
+
f'{origin}:{line}: duplicate key {key!r} — first declared on '
|
|
81
|
+
f'line {seen[key]}. YAML would silently keep the last one, '
|
|
82
|
+
f'discarding a declaration the file contains.'
|
|
83
|
+
)
|
|
84
|
+
raise SchemaError(msg)
|
|
85
|
+
seen[key] = line
|
|
86
|
+
_check_duplicate_keys(value_node, origin)
|
|
87
|
+
elif isinstance(node, yaml.SequenceNode):
|
|
88
|
+
for item in node.value:
|
|
89
|
+
_check_duplicate_keys(item, origin)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def read_yaml(path: Path | str) -> dict[str, object]:
|
|
93
|
+
"""Read *path* off disk and parse it, in YAML 1.2's reading of scalars."""
|
|
94
|
+
return parse_yaml(Path(path).read_text(encoding='utf-8'), str(path))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def read_model(model: str | Path) -> dict[str, object]:
|
|
98
|
+
"""A model from a file or from its text — a newline decides which a ``str`` is.
|
|
99
|
+
|
|
100
|
+
A :class:`~pathlib.Path` names a file, and so does a ``str`` with no
|
|
101
|
+
newline in it, since no path holds one; a ``str`` with a newline is the
|
|
102
|
+
YAML itself, which every file and every ``to_yaml()`` ends with. Nothing
|
|
103
|
+
else is read off the content and nothing is read off the disk to decide.
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
FileNotFoundError: A ``str`` with no newline that names no file — a
|
|
107
|
+
one-line text is told to end with one.
|
|
108
|
+
"""
|
|
109
|
+
if isinstance(model, str) and '\n' not in model and not Path(model).is_file():
|
|
110
|
+
msg = (
|
|
111
|
+
f'no file named {model!r}. A str with no newline in it is read as a path, and YAML text '
|
|
112
|
+
f'is told apart by one — end the text with a newline, or pass a Path.'
|
|
113
|
+
)
|
|
114
|
+
raise FileNotFoundError(msg)
|
|
115
|
+
if isinstance(model, Path) or '\n' not in model:
|
|
116
|
+
return read_yaml(Path(model))
|
|
117
|
+
return parse_yaml(model, 'YAML text')
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parse_yaml(text: str, origin: str = '<string>') -> dict[str, object]:
|
|
121
|
+
"""Parse YAML *text* as a mapping of sections.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
text: The YAML source.
|
|
125
|
+
origin: What a load error calls this source — a file's path, or the default for text that never was one.
|
|
126
|
+
"""
|
|
127
|
+
loader = _StrictLoader(text)
|
|
128
|
+
try:
|
|
129
|
+
node = loader.get_single_node()
|
|
130
|
+
if node is None:
|
|
131
|
+
return {}
|
|
132
|
+
_check_duplicate_keys(node, origin)
|
|
133
|
+
data = loader.construct_document(node)
|
|
134
|
+
finally:
|
|
135
|
+
loader.dispose()
|
|
136
|
+
if not data:
|
|
137
|
+
return {}
|
|
138
|
+
if not isinstance(data, dict):
|
|
139
|
+
msg = f'{origin}: a model file must be a mapping of sections (dimensions:, variables:, …), got {type(data).__name__}.'
|
|
140
|
+
raise SchemaError(msg)
|
|
141
|
+
return data
|
mathspec/advice.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: mathspec Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""Advice — what is decidable without data and is a note rather than a refusal.
|
|
6
|
+
|
|
7
|
+
One door, :func:`advice`, over every pass of that kind.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from mathspec.boundedness import unbounded_notes
|
|
15
|
+
from mathspec.errors import Advice
|
|
16
|
+
from mathspec.program import GroupSum, Program, Pullback, walk
|
|
17
|
+
from mathspec.validation import to_spec
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Mapping
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from mathspec.model import Spec
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def advice(model: str | Path | Mapping[str, object] | Spec | Program) -> tuple[Advice, ...]:
|
|
27
|
+
"""Everything the language advises about *model* — never an error, decidable without data.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
model: A YAML path, a mapping, a loaded :class:`Spec`, or a
|
|
31
|
+
:class:`Program`, read as it arrived. A ``piecewise:`` or ``sos:``
|
|
32
|
+
block is read as the rows it states, so the answer is the one its
|
|
33
|
+
expansion gets, with nothing expanded.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
The never-an-axis advice in declaration order, then the unboundedness
|
|
37
|
+
advice; ``str()`` of each is its sentence.
|
|
38
|
+
"""
|
|
39
|
+
program = model if isinstance(model, Program) else to_spec(model).program
|
|
40
|
+
return tuple(_never_an_axis(program) + unbounded_notes(program))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _never_an_axis(program: Program) -> list[Advice]:
|
|
44
|
+
"""One piece of advice per dimension nothing reaches.
|
|
45
|
+
|
|
46
|
+
A dimension a relation has a column over is reached: its members are the
|
|
47
|
+
labels that column is checked against, and a ``where`` selects on them,
|
|
48
|
+
so it is in use even where nothing is indexed by it.
|
|
49
|
+
"""
|
|
50
|
+
reached: set[str] = set()
|
|
51
|
+
for declaration in (*program.parameters.values(), *program.variables.values(), *program.constraints.values()):
|
|
52
|
+
reached.update(declaration.dims)
|
|
53
|
+
reached |= _produced_axes(program)
|
|
54
|
+
reached |= {dim for lk in program.relations.values() for dim in lk.dims}
|
|
55
|
+
|
|
56
|
+
return [
|
|
57
|
+
Advice(
|
|
58
|
+
'never-an-axis',
|
|
59
|
+
name,
|
|
60
|
+
f"dimension '{name}' is never used: nothing is indexed by it, nothing "
|
|
61
|
+
f'aggregates into it, and no relation has a column over it. Remove it — or keep it '
|
|
62
|
+
f'knowingly, if the declarations that use it are still to be written.',
|
|
63
|
+
)
|
|
64
|
+
for name in program.dimensions
|
|
65
|
+
if name not in reached
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _produced_axes(program: Program) -> set[str]:
|
|
70
|
+
"""The axes the expressions create beyond what any declaration indexes.
|
|
71
|
+
|
|
72
|
+
``sum(by=)`` lands on its target and ``at()`` spreads onto its fine
|
|
73
|
+
dimension: either way, the dims the direction produces.
|
|
74
|
+
"""
|
|
75
|
+
axes: set[str] = set()
|
|
76
|
+
for node in walk(*program.roots):
|
|
77
|
+
if isinstance(node, GroupSum | Pullback):
|
|
78
|
+
axes.update(node.direction.produced_dims)
|
|
79
|
+
return axes
|
mathspec/boundedness.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: mathspec Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""Provably unbounded models, named before a solver says a bare ``unbounded``.
|
|
6
|
+
|
|
7
|
+
A variable unbounded on the side its objective term improves toward, and named
|
|
8
|
+
by no constraint, runs to infinity for any data. Which side is read off the
|
|
9
|
+
sign the variable enters the objective with: under ``minimize`` a ``+v`` term
|
|
10
|
+
runs down toward ``lower``. Where that sign is not decidable without data — a
|
|
11
|
+
parameter coefficient, or occurrences of both signs — nothing is claimed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import TYPE_CHECKING, Literal, assert_never
|
|
17
|
+
|
|
18
|
+
from mathspec.errors import Advice
|
|
19
|
+
from mathspec.program import (
|
|
20
|
+
Add,
|
|
21
|
+
Cases,
|
|
22
|
+
Constant,
|
|
23
|
+
Divide,
|
|
24
|
+
Dual,
|
|
25
|
+
Expression,
|
|
26
|
+
GroupSum,
|
|
27
|
+
Multiply,
|
|
28
|
+
Named,
|
|
29
|
+
Negate,
|
|
30
|
+
Parameter,
|
|
31
|
+
Power,
|
|
32
|
+
Pullback,
|
|
33
|
+
Sum,
|
|
34
|
+
Translate,
|
|
35
|
+
Variable,
|
|
36
|
+
WindowSum,
|
|
37
|
+
children,
|
|
38
|
+
variables_of,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from mathspec.program import Program, VariableDeclaration
|
|
43
|
+
|
|
44
|
+
#: The sign a term carries into the objective, or ``None`` where the file does
|
|
45
|
+
#: not decide it: a parameter coefficient (which may be zero), a variable
|
|
46
|
+
#: appearing with both signs (which may cancel), a divisor carrying one.
|
|
47
|
+
Sign = Literal['+', '-'] | None
|
|
48
|
+
|
|
49
|
+
#: Which of a variable's two bounds a term drives it toward.
|
|
50
|
+
BoundSide = Literal['lower', 'upper']
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def unbounded_notes(program: Program) -> list[Advice]:
|
|
54
|
+
"""Name every variable the objective can drive to infinity unopposed.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
program: The program as it is. A curve's links and a set's variable
|
|
58
|
+
count as named by a row, as the rows they expand into would be.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
One note per variable that is unbounded on the side its objective term
|
|
62
|
+
improves toward and named by no constraint.
|
|
63
|
+
"""
|
|
64
|
+
if program.objective is None:
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
constrained = {block.variable for block in program.sos.values()}
|
|
68
|
+
for curve in program.piecewise.values():
|
|
69
|
+
constrained |= variables_of(*(link.expression for link in curve.links))
|
|
70
|
+
for constraint in program.constraints.values():
|
|
71
|
+
constrained |= variables_of(constraint.lhs, constraint.rhs)
|
|
72
|
+
|
|
73
|
+
signs: dict[str, Sign] = {}
|
|
74
|
+
_record_signs(program.objective.expression, '+', signs)
|
|
75
|
+
|
|
76
|
+
minimize = program.objective.sense == 'minimize'
|
|
77
|
+
notes: list[Advice] = []
|
|
78
|
+
for vname, sign in signs.items():
|
|
79
|
+
if sign is None or vname in constrained:
|
|
80
|
+
continue
|
|
81
|
+
side: BoundSide = 'lower' if minimize == (sign == '+') else 'upper'
|
|
82
|
+
if _is_open(program.variables[vname], side):
|
|
83
|
+
notes.append(
|
|
84
|
+
Advice(
|
|
85
|
+
'unbounded',
|
|
86
|
+
vname,
|
|
87
|
+
f"Variable '{vname}' makes this model unbounded: no constraint names it, and "
|
|
88
|
+
f'bounds.{side} is open, which is the direction a {sign}{vname} term '
|
|
89
|
+
f'improves a {program.objective.sense} objective in. No data can change that, so '
|
|
90
|
+
f'the solve would answer `unbounded` and name nothing.\n'
|
|
91
|
+
f'Give it a finite bounds.{side}, or the constraint that was meant to define it.',
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
return notes
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _is_open(vdef: VariableDeclaration, side: BoundSide) -> bool:
|
|
98
|
+
"""Whether *vdef* states no bound on *side*.
|
|
99
|
+
|
|
100
|
+
A bound naming a parameter is finite or not by data, so it does not count.
|
|
101
|
+
"""
|
|
102
|
+
return (vdef.lower if side == 'lower' else vdef.upper) is None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _flip(sign: Sign) -> Sign:
|
|
106
|
+
return None if sign is None else ('-' if sign == '+' else '+')
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _times(sign: Sign, other: Sign) -> Sign:
|
|
110
|
+
return None if sign is None or other is None else ('+' if sign == other else '-')
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _coefficient_sign(node: Expression) -> Sign:
|
|
114
|
+
"""The sign *node* scales a term by, or ``None`` unless it is a signed constant.
|
|
115
|
+
|
|
116
|
+
``-2`` lowers to a negation over a constant, so the sign of a literal
|
|
117
|
+
coefficient is not always on the node itself, and a named one is its
|
|
118
|
+
body's. Zero is ``None`` on purpose: a term multiplied away is not in the
|
|
119
|
+
objective, so the variable it names is driven nowhere.
|
|
120
|
+
"""
|
|
121
|
+
if isinstance(node, Negate):
|
|
122
|
+
return _flip(_coefficient_sign(node.operand))
|
|
123
|
+
if isinstance(node, Named):
|
|
124
|
+
return _coefficient_sign(node.body)
|
|
125
|
+
if isinstance(node, Constant) and node.value != 0:
|
|
126
|
+
return '+' if node.value > 0 else '-'
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _record_signs(node: Expression, sign: Sign, signs: dict[str, Sign]) -> None:
|
|
131
|
+
"""Record the sign each variable under *node* carries into the objective.
|
|
132
|
+
|
|
133
|
+
A variable reached twice with different signs, or once with an undecidable
|
|
134
|
+
one, lands on ``None``, which claims nothing. A reduction, a re-index, a
|
|
135
|
+
window and a cases selection pass *sign* to their children unchanged; a
|
|
136
|
+
power carries no sign in either half. A Constant, Parameter or Dual is a
|
|
137
|
+
variable-free leaf and contributes nothing — the Dual arm only keeps the
|
|
138
|
+
walk exhaustive, since the loader refuses one in any objective.
|
|
139
|
+
"""
|
|
140
|
+
if isinstance(node, Variable):
|
|
141
|
+
signs[node.name] = sign if signs.setdefault(node.name, sign) == sign else None
|
|
142
|
+
return
|
|
143
|
+
if isinstance(node, Constant | Parameter | Dual):
|
|
144
|
+
return
|
|
145
|
+
if isinstance(node, Negate):
|
|
146
|
+
_record_signs(node.operand, _flip(sign), signs)
|
|
147
|
+
return
|
|
148
|
+
if isinstance(node, Add):
|
|
149
|
+
_record_signs(node.left, sign, signs)
|
|
150
|
+
_record_signs(node.right, sign, signs)
|
|
151
|
+
return
|
|
152
|
+
if isinstance(node, Multiply):
|
|
153
|
+
_record_signs(node.left, _times(sign, _coefficient_sign(node.right)), signs)
|
|
154
|
+
_record_signs(node.right, _times(sign, _coefficient_sign(node.left)), signs)
|
|
155
|
+
return
|
|
156
|
+
if isinstance(node, Divide):
|
|
157
|
+
_record_signs(node.numerator, _times(sign, _coefficient_sign(node.divisor)), signs)
|
|
158
|
+
_record_signs(node.divisor, None, signs)
|
|
159
|
+
return
|
|
160
|
+
if isinstance(node, Power):
|
|
161
|
+
_record_signs(node.base, None, signs)
|
|
162
|
+
_record_signs(node.exponent, None, signs)
|
|
163
|
+
return
|
|
164
|
+
if isinstance(node, Sum | GroupSum | Pullback | Translate | WindowSum | Cases | Named):
|
|
165
|
+
for child in children(node):
|
|
166
|
+
_record_signs(child, sign, signs)
|
|
167
|
+
return
|
|
168
|
+
assert_never(node)
|
mathspec/degree.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: mathspec Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""Degree — the one admissibility rule that is a scope choice (docs/about/limits.md).
|
|
6
|
+
|
|
7
|
+
**Degree 2 in the math, degree 1 in what stands beside it.** An objective and a
|
|
8
|
+
constraint both take ``variable * variable``; a *bound* and a ``piecewise:``
|
|
9
|
+
link do not — each of those is read affinely. An ``expressions:`` entry is not
|
|
10
|
+
degree-checked at declaration at all: the math reading it is checked where it
|
|
11
|
+
reads, at that position's own ceiling, and an entry the math never reads
|
|
12
|
+
(``ExpressionDeclaration.in_math``) is held to no degree.
|
|
13
|
+
|
|
14
|
+
A degree-2 product has a second rule: **at most one factor may be a sum of
|
|
15
|
+
terms**. ``sum(x, over=i) * sum(y, over=j)`` is a cross join whose size the
|
|
16
|
+
file states nowhere. Factors carrying *different dims* are not that: ``x[i] *
|
|
17
|
+
y[j]`` broadcasts.
|
|
18
|
+
|
|
19
|
+
A divisor's **shape** is decided here too: a quotient is multiplication by one
|
|
20
|
+
reciprocal factor, so a divisor that adds is refused at load, where the message
|
|
21
|
+
can name the rewrite.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from mathspec.errors import LanguageError
|
|
27
|
+
from mathspec.program import (
|
|
28
|
+
Add,
|
|
29
|
+
Divide,
|
|
30
|
+
Dual,
|
|
31
|
+
Expression,
|
|
32
|
+
GroupSum,
|
|
33
|
+
Multiply,
|
|
34
|
+
Power,
|
|
35
|
+
Sum,
|
|
36
|
+
Variable,
|
|
37
|
+
WindowSum,
|
|
38
|
+
carries_variable,
|
|
39
|
+
children,
|
|
40
|
+
walk,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _adds(node: Expression) -> bool:
|
|
45
|
+
"""Whether *node* adds anywhere inside it.
|
|
46
|
+
|
|
47
|
+
Anywhere, not only at its head: every operator over a variable-free
|
|
48
|
+
expression maps over its parts rather than folding them, so an addition
|
|
49
|
+
under a ``sum`` or a product reaches the quotient as two factors just as
|
|
50
|
+
one at the top does.
|
|
51
|
+
"""
|
|
52
|
+
return any(isinstance(found, Add) for found in walk(node))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def check_binary(node: Multiply | Divide | Power, context: str, *, ceiling: int) -> None:
|
|
56
|
+
"""Check that *node* stays inside the degree its position allows.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
node: The product, quotient or power to judge.
|
|
60
|
+
context: What to name in the message — the declaration being read.
|
|
61
|
+
ceiling: The highest degree this position can honour — 2 in an
|
|
62
|
+
objective or a constraint, 1 everywhere else.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
LanguageError: A product of two variable-carrying factors where the
|
|
66
|
+
position allows only degree 1 or where both factors are sums of
|
|
67
|
+
terms, a power over anything carrying a variable, a divisor carrying
|
|
68
|
+
a variable or adding.
|
|
69
|
+
"""
|
|
70
|
+
where = f'{context}: ' if context else ''
|
|
71
|
+
if isinstance(node, Power):
|
|
72
|
+
if carries_variable(node):
|
|
73
|
+
raise LanguageError(_a_variable_under_a_power_message(where))
|
|
74
|
+
if _adds(node.base) or _adds(node.exponent):
|
|
75
|
+
raise LanguageError(
|
|
76
|
+
f'{where}a base and an exponent must each be a single Constant/Parameter factor, '
|
|
77
|
+
f'not a sum — addition does not distribute over `**`, so `(1 + rate) ** period` is '
|
|
78
|
+
f'refused where `growth ** period` is not. Declare the factor itself as a parameter.'
|
|
79
|
+
)
|
|
80
|
+
return
|
|
81
|
+
if isinstance(node, Divide):
|
|
82
|
+
if carries_variable(node.divisor):
|
|
83
|
+
raise LanguageError(
|
|
84
|
+
f'{where}the divisor contains variables, which is not affine. '
|
|
85
|
+
f'Divide by a parameter, or precompute the reciprocal as one.'
|
|
86
|
+
)
|
|
87
|
+
if _adds(node.divisor):
|
|
88
|
+
raise LanguageError(
|
|
89
|
+
f'{where}a divisor must be a single Constant/Parameter factor, '
|
|
90
|
+
f'not a sum — rewrite as multiplication by a precomputed parameter'
|
|
91
|
+
)
|
|
92
|
+
return
|
|
93
|
+
if not (carries_variable(node.left) and carries_variable(node.right)):
|
|
94
|
+
return
|
|
95
|
+
if ceiling < 2:
|
|
96
|
+
raise LanguageError(_degree_two_here_message(where))
|
|
97
|
+
if (degree := _degree(node)) > ceiling:
|
|
98
|
+
raise LanguageError(_above_the_ceiling_message(where, degree))
|
|
99
|
+
_check_single_term_factor(node, where)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _degree(node: Expression) -> int:
|
|
103
|
+
"""The polynomial degree *node* stands for, counted structurally.
|
|
104
|
+
|
|
105
|
+
A product adds its factors' degrees and a division keeps the dividend's
|
|
106
|
+
(:func:`check_binary` has already refused a divisor carrying a variable);
|
|
107
|
+
everything else — a sum, a reduction, a shape operator — is the highest
|
|
108
|
+
degree beneath it. No data, so this answers at ``check`` time, which is
|
|
109
|
+
what stops a cubic from reaching a consumer to be refused by whichever one
|
|
110
|
+
happens to notice.
|
|
111
|
+
"""
|
|
112
|
+
if isinstance(node, Variable):
|
|
113
|
+
return 1
|
|
114
|
+
if isinstance(node, Multiply):
|
|
115
|
+
return _degree(node.left) + _degree(node.right)
|
|
116
|
+
if isinstance(node, Divide):
|
|
117
|
+
return _degree(node.numerator)
|
|
118
|
+
return max((_degree(child) for child in children(node)), default=0)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _above_the_ceiling_message(where: str, degree: int) -> str:
|
|
122
|
+
"""About the product's own degree, since ``p * p * p`` is two admissible products nested."""
|
|
123
|
+
return (
|
|
124
|
+
f'{where}this product is degree {degree}. The language takes degree 2 and nothing above it.\n'
|
|
125
|
+
f'Multiply by a parameter instead, or give the inner product a name — a variable '
|
|
126
|
+
f'constrained to equal it is degree 1 wherever it is used.'
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _a_variable_under_a_power_message(where: str) -> str:
|
|
131
|
+
return (
|
|
132
|
+
f'{where}`**` is not in the language over variables: it takes a base and an exponent that '
|
|
133
|
+
f'carry none.\n'
|
|
134
|
+
f'Write the product out — `x * x` for a square — or precompute the factor as a parameter. '
|
|
135
|
+
f'A variable base above degree 2 has no rewrite at all, and one whose exponent is data has '
|
|
136
|
+
f'no degree until the data arrives — see docs/about/limits.md.'
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _degree_two_here_message(where: str) -> str:
|
|
141
|
+
return (
|
|
142
|
+
f'{where}both factors of a product contain variables, which is degree 2. '
|
|
143
|
+
f'The **objective and constraints** take that; a bound and a piecewise: '
|
|
144
|
+
f'link do not — each of those is read affinely by something '
|
|
145
|
+
f'downstream.\n'
|
|
146
|
+
f'Multiply the variable by a parameter instead, or state the product where '
|
|
147
|
+
f'it can stand: as a constraint of its own, with a variable holding the '
|
|
148
|
+
f'result.'
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _check_single_term_factor(node: Multiply, where: str) -> None:
|
|
153
|
+
"""Refuse a degree-2 product of two multi-term factors."""
|
|
154
|
+
if not (_multi_term(node.left) and _multi_term(node.right)):
|
|
155
|
+
return
|
|
156
|
+
raise LanguageError(
|
|
157
|
+
f'{where}both factors of this product are sums of more than one term, so it is an outer '
|
|
158
|
+
f'product — every term of one against every term of the other, and nothing in the file '
|
|
159
|
+
f'says how many that is.\n'
|
|
160
|
+
f'Multiply *before* reducing (``sum(x * y, over=d)`` rather than '
|
|
161
|
+
f'``sum(x, over=d) * sum(y, over=d)``).'
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _multi_term(node: Expression) -> bool:
|
|
166
|
+
"""Whether *node* stands for more than one variable term at a coordinate.
|
|
167
|
+
|
|
168
|
+
A reduction does, and so does an addition of two variable-carrying
|
|
169
|
+
operands; a product is multi-term exactly when one of its factors is, a
|
|
170
|
+
coefficient not multiplying the count. Structural, so it needs no data.
|
|
171
|
+
"""
|
|
172
|
+
return any(_joins_terms(found) for found in walk(node))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _joins_terms(node: Expression) -> bool:
|
|
176
|
+
"""Whether *node* itself makes several terms of one: a reduction over a variable, or a sum of two variable-carrying sides.
|
|
177
|
+
|
|
178
|
+
A pullback and a translation re-index and are not reductions: they move a
|
|
179
|
+
term, leaving one term where there was one.
|
|
180
|
+
"""
|
|
181
|
+
if isinstance(node, Sum | GroupSum | WindowSum):
|
|
182
|
+
return carries_variable(node.operand)
|
|
183
|
+
return isinstance(node, Add) and carries_variable(node.left) and carries_variable(node.right)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def check_expression(node: Expression, context: str, *, ceiling: int = 1) -> None:
|
|
187
|
+
"""What the math admits at one position: no dual anywhere under *node*, then :func:`check_binary` everywhere in it.
|
|
188
|
+
|
|
189
|
+
Asked of the resolved tree, so a dual or a product reached through a
|
|
190
|
+
macro or a named expression is caught alongside one written in place.
|
|
191
|
+
What a plan node can represent is the consumer's question, not this one's.
|
|
192
|
+
|
|
193
|
+
Raises:
|
|
194
|
+
LanguageError: A dual, which exists only after a solve; or what
|
|
195
|
+
:func:`check_binary` refuses.
|
|
196
|
+
"""
|
|
197
|
+
for found in walk(node):
|
|
198
|
+
if isinstance(found, Dual):
|
|
199
|
+
raise LanguageError(
|
|
200
|
+
f'{context}: a dual exists only after a solve; the math cannot read one — '
|
|
201
|
+
f'keep the entry that carries it out of constraints, the objective, bounds and where.'
|
|
202
|
+
)
|
|
203
|
+
if isinstance(found, Multiply | Divide | Power):
|
|
204
|
+
check_binary(found, context, ceiling=ceiling)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def calls_dual(node: Expression) -> bool:
|
|
208
|
+
"""Whether a :class:`~mathspec.program.Dual` stands anywhere under *node*."""
|
|
209
|
+
return any(isinstance(found, Dual) for found in walk(node))
|