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/_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
@@ -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))