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/expansion.py ADDED
@@ -0,0 +1,124 @@
1
+ # SPDX-FileCopyrightText: mathspec Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """Macro calls, expanded into the syntax tree before resolution reads it.
6
+
7
+ A named expression is resolution's: it resolves the entry once and puts that
8
+ node where the name stood.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, overload
14
+
15
+ from mathspec._expression_parser import (
16
+ ArithmeticNode,
17
+ ComparisonNode,
18
+ FunctionCallNode,
19
+ NameNode,
20
+ ParsedNode,
21
+ parse_expression,
22
+ with_children,
23
+ )
24
+ from mathspec.errors import SchemaError
25
+
26
+ if TYPE_CHECKING:
27
+ from mathspec.model import MacroBlock
28
+ from mathspec.resolution import Namespace
29
+
30
+
31
+ def parse_and_expand(text: str, ns: Namespace, context: str) -> ParsedNode:
32
+ """Parse *text* and expand every macro call in it.
33
+
34
+ Args:
35
+ text: The expression as the file wrote it.
36
+ ns: Where the macros are declared.
37
+ context: What an error names.
38
+ """
39
+ return expand(parse_expression(text), ns, context)
40
+
41
+
42
+ @overload
43
+ def expand(node: ArithmeticNode, ns: Namespace, context: str) -> ArithmeticNode: ...
44
+ @overload
45
+ def expand(node: ComparisonNode, ns: Namespace, context: str) -> ComparisonNode: ...
46
+
47
+
48
+ def expand(node: ParsedNode, ns: Namespace, context: str) -> ParsedNode:
49
+ """Expand every macro call under *node*; a comparison stays a comparison and arithmetic stays arithmetic.
50
+
51
+ Args:
52
+ node: The parsed expression.
53
+ ns: Where the macros are declared.
54
+ context: What an error names.
55
+ """
56
+ if isinstance(node, ComparisonNode):
57
+ return ComparisonNode(node.op, _expand(node.left, ns, context, ()), _expand(node.right, ns, context, ()))
58
+ return _expand(node, ns, context, ())
59
+
60
+
61
+ def macro_signature(name: str, macro: MacroBlock) -> str:
62
+ """Human-readable call signature, for error messages."""
63
+ parts = [*macro.args, *(f'{k}=...' for k in macro.kwargs)]
64
+ return f'{name}({", ".join(parts)})'
65
+
66
+
67
+ def parse_template(name: str, macro: MacroBlock, context: str) -> ArithmeticNode:
68
+ """Parse a macro template, rejecting comparisons."""
69
+ body = parse_expression(macro.template)
70
+ if isinstance(body, ComparisonNode):
71
+ msg = f"{context}: macro '{name}' template must not contain a comparison operator. Got: {macro.template!r}"
72
+ raise SchemaError(msg)
73
+ return body
74
+
75
+
76
+ def _expand(node: ArithmeticNode, ns: Namespace, context: str, stack: tuple[str, ...]) -> ArithmeticNode:
77
+ """*node* with every macro call under it substituted; *stack* is the macros this walk is inside.
78
+
79
+ A name a template reads is checked against the entries being resolved
80
+ here, where the macros it came through are known, so a cycle closed
81
+ through a macro is reported with the macros in its chain.
82
+ """
83
+ if isinstance(node, NameNode) and stack and (refusal := ns.cycle(node.name, context, stack)) is not None:
84
+ raise SchemaError(refusal)
85
+ if isinstance(node, FunctionCallNode) and node.name in ns.schema.macros:
86
+ if node.name in stack:
87
+ msg = f'{context}: circular macro reference: {" -> ".join([*stack, node.name])}'
88
+ raise SchemaError(msg)
89
+ return _expand_macro(node, ns, context, stack)
90
+ return with_children(node, lambda child: _expand(child, ns, context, stack))
91
+
92
+
93
+ def _expand_macro(call: FunctionCallNode, ns: Namespace, context: str, stack: tuple[str, ...]) -> ArithmeticNode:
94
+ """Call-by-value: arguments are expanded before substitution, and the substituted body is expanded again."""
95
+ macro = ns.schema.macros[call.name]
96
+ signature = macro_signature(call.name, macro)
97
+ if len(call.args) != len(macro.args):
98
+ msg = (
99
+ f"{context}: macro '{call.name}' expects {len(macro.args)} "
100
+ f'positional argument(s), got {len(call.args)}. Signature: {signature}'
101
+ )
102
+ raise SchemaError(msg)
103
+ if set(call.kwargs) != set(macro.kwargs):
104
+ msg = (
105
+ f"{context}: macro '{call.name}' expects keyword argument(s) "
106
+ f'{sorted(macro.kwargs)}, got {sorted(call.kwargs)}. '
107
+ f'Signature: {signature}'
108
+ )
109
+ raise SchemaError(msg)
110
+
111
+ bindings = {
112
+ **{formal: _expand(arg, ns, context, stack) for formal, arg in zip(macro.args, call.args, strict=True)},
113
+ **{formal: _expand(call.kwargs[formal], ns, context, stack) for formal in macro.kwargs},
114
+ }
115
+ body = parse_template(call.name, macro, context)
116
+ substituted = _substitute(body, bindings)
117
+ return _expand(substituted, ns, context, (*stack, call.name))
118
+
119
+
120
+ def _substitute(node: ArithmeticNode, bindings: dict[str, ArithmeticNode]) -> ArithmeticNode:
121
+ """Replace formal-name NameNodes in *node* with their bound subtrees."""
122
+ if isinstance(node, NameNode) and node.name in bindings:
123
+ return bindings[node.name]
124
+ return with_children(node, lambda child: _substitute(child, bindings))
mathspec/lowering.py ADDED
@@ -0,0 +1,302 @@
1
+ # SPDX-FileCopyrightText: mathspec Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """Lower a model to a :class:`~mathspec.program.Program` — the pass that decides every expression.
6
+
7
+ One lowering, on the language side, run when a :class:`~mathspec.model.Spec`
8
+ loads: it reads every expression and where string into the program's own
9
+ nodes, checks every rule decidable without data, and packages the
10
+ declarations, section for section. The program mirrors the model it was
11
+ lowered from: a ``piecewise:`` block the model still declares is a curve on
12
+ the program, and :meth:`~mathspec.model.Spec.expand` is what writes it out
13
+ as rows.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import TYPE_CHECKING
19
+
20
+ from mathspec.dimensions import check_schema, dims_of
21
+ from mathspec.errors import SchemaError, prefixed
22
+ from mathspec.expansion import expand, parse_template
23
+ from mathspec.piecewise import assumptions_of, curve_frame, lp_domain_refusal, resolve_links
24
+ from mathspec.program import (
25
+ Assumption,
26
+ BooleanLiteral,
27
+ Cases,
28
+ Constant,
29
+ ConstraintDeclaration,
30
+ DimensionDeclaration,
31
+ ExpressionDeclaration,
32
+ Link,
33
+ Mask,
34
+ Named,
35
+ ObjectiveDeclaration,
36
+ Parameter,
37
+ ParameterDeclaration,
38
+ PiecewiseDeclaration,
39
+ Program,
40
+ SosDeclaration,
41
+ VariableDeclaration,
42
+ VariableDefined,
43
+ walk,
44
+ )
45
+ from mathspec.resolution import (
46
+ Namespace,
47
+ mask_of,
48
+ resolve_constraint_text,
49
+ resolve_expression,
50
+ resolve_expression_text,
51
+ resolve_where_text,
52
+ )
53
+ from mathspec.validation import emitted_name_errors, reference_errors
54
+
55
+ if TYPE_CHECKING:
56
+ from mathspec.model import AssumptionBlock, Spec
57
+ from mathspec.program import Expression
58
+
59
+
60
+ def lower(schema: Spec) -> Program:
61
+ """Lower *schema*'s own declarations, checking every rule decidable without data.
62
+
63
+ What is checked:
64
+
65
+ - every rule one declaration is held to against the others
66
+ (:func:`~mathspec.validation.reference_errors`), before any expression
67
+ is read, since resolution assumes each of them;
68
+ - the expression parses, and constraints hold exactly one comparison where
69
+ objectives hold none;
70
+ - every referenced name resolves, and every operator is a built-in whose
71
+ dimension arguments name declared dimensions;
72
+ - where strings parse *and* resolve — an unknown name there is an error,
73
+ not a silently-empty mask;
74
+ - macro formals may shadow model names but not a declared dimension, since
75
+ ``over=snapshot`` under a formal ``snapshot`` cannot say which it means;
76
+ - no name a set or curve writes out is one the file declares
77
+ (:func:`~mathspec.validation.emitted_name_errors`), read off the
78
+ curve as lowered;
79
+ - every dim rule (``dimensions.check_schema``), once names resolve.
80
+
81
+ A ``piecewise:`` block's links are resolved and its frame checked here, on
82
+ the link the file wrote, so the expansion writes rows the language has
83
+ already held to every rule; what its method assumes of the breakpoints
84
+ stands under the program's assumptions with the file's own, so a model
85
+ states what it assumes whether or not its curves are written out.
86
+
87
+ Returns:
88
+ The program of what *schema* declares, section for section.
89
+
90
+ Raises:
91
+ SchemaError: Listing every problem found, one per line. A name a set
92
+ or curve writes that the file declares is listed once every other
93
+ problem is gone, since it is read off the curve as lowered.
94
+ DimensionError: The first dim rule a declaration breaks, once every
95
+ name resolves.
96
+ """
97
+ errors = reference_errors(schema)
98
+ if errors:
99
+ raise SchemaError('\n'.join(errors))
100
+
101
+ ns = Namespace(schema)
102
+ for mname, macro in schema.macros.items():
103
+ context = f"Macro '{mname}'"
104
+ formals = frozenset((*macro.args, *macro.kwargs))
105
+ try:
106
+ body_ast = expand(parse_template(mname, macro, context), ns, context)
107
+ except ValueError as e:
108
+ errors.append(prefixed(context, e))
109
+ continue
110
+ errors.extend(
111
+ f"{context}: formal '{f}' collides with declared dimension '{f}'. "
112
+ f'Rename the formal — a dimension name inside a template is '
113
+ f'ambiguous with the dimension itself.'
114
+ for f in sorted(formals & ns.dimensions)
115
+ )
116
+ resolve_expression(body_ast, ns, context, errors, formals=formals)
117
+
118
+ entries: dict[str, Named] = {}
119
+ for ename in schema.expressions:
120
+ node, refusals = ns.named_entry(ename)
121
+ errors.extend(refusals)
122
+ if node is not None:
123
+ entries[ename] = node
124
+
125
+ variables = {}
126
+ for vname, vdef in schema.variables.items():
127
+ where = resolve_where_text(vdef.where, ns, f"Variable '{vname}'", errors, self_variable=vname)
128
+ if vdef.domain == 'binary':
129
+ lower_bound, upper_bound = Constant(0.0), Constant(1.0)
130
+ else:
131
+ lower_bound, upper_bound = _bound(vdef.bounds.lower), _bound(vdef.bounds.upper)
132
+ variables[vname] = VariableDeclaration(
133
+ tuple(vdef.dims),
134
+ where=mask_of(where),
135
+ lower=lower_bound,
136
+ upper=upper_bound,
137
+ domain=vdef.domain,
138
+ absence=vdef.absence,
139
+ description=vdef.description,
140
+ )
141
+
142
+ constraints: dict[str, ConstraintDeclaration] = {}
143
+ for cname, cdef in schema.constraints.items():
144
+ context = f"Constraint '{cname}'"
145
+ where = resolve_where_text(cdef.where, ns, context, errors)
146
+ if (sides := resolve_constraint_text(cdef.expression, ns, context, errors)) is not None:
147
+ lhs, sense, rhs = sides
148
+ constraints[cname] = ConstraintDeclaration(
149
+ tuple(cdef.dims), lhs, sense, rhs, mask_of(where), description=cdef.description
150
+ )
151
+
152
+ objective = None
153
+ if schema.objective is not None:
154
+ expression = resolve_expression_text(schema.objective.expression, ns, 'The objective', errors, ceiling=2)
155
+ if expression is not None:
156
+ objective = ObjectiveDeclaration(schema.objective.sense, expression, schema.objective.description)
157
+
158
+ assumptions: dict[str, Assumption] = {}
159
+ for aname, adef in schema.assumptions.items():
160
+ if (assumption := _assumption(aname, adef, ns, errors)) is not None:
161
+ assumptions[aname] = assumption
162
+
163
+ curves: dict[str, tuple[Expression, ...]] = {}
164
+ for pname, pdef in schema.piecewise.items():
165
+ links = resolve_links(pname, pdef, ns, errors)
166
+ if links is None:
167
+ continue
168
+ if pdef.method == 'lp' and (refusal := lp_domain_refusal(pname, pdef, links)) is not None:
169
+ errors.append(refusal)
170
+ curves[pname] = links
171
+
172
+ if errors:
173
+ raise SchemaError('\n'.join(errors))
174
+
175
+ roots = [side for c in constraints.values() for side in (c.lhs, c.rhs)]
176
+ if objective is not None:
177
+ roots.append(objective.expression)
178
+ roots.extend(link for links in curves.values() for link in links)
179
+ in_math = frozenset(node.name for node in walk(*roots) if isinstance(node, Named))
180
+
181
+ piecewise = {}
182
+ for pname, links in curves.items():
183
+ pdef = schema.piecewise[pname]
184
+ piecewise[pname] = PiecewiseDeclaration(
185
+ over=pdef.over,
186
+ links=tuple(Link(node, link.values, link.sign) for node, link in zip(links, pdef.links, strict=True)),
187
+ method=pdef.method,
188
+ frame=curve_frame(schema, pname, pdef, links),
189
+ activity=pdef.activity,
190
+ points=pdef.points,
191
+ description=pdef.description,
192
+ )
193
+ for aname, assumed in assumptions_of(pname, piecewise[pname]).items():
194
+ assumption = _assumption(aname, assumed, ns, errors)
195
+ assert assumption is not None and not errors, 'what a method assumes is stated in the language'
196
+ assumptions[aname] = assumption
197
+
198
+ program = Program(
199
+ parameters={
200
+ name: ParameterDeclaration(tuple(pdef.dims), pdef.dtype, pdef.description)
201
+ for name, pdef in schema.parameters.items()
202
+ },
203
+ variables=variables,
204
+ constraints=constraints,
205
+ objective=objective,
206
+ dimensions={
207
+ name: DimensionDeclaration(ddef.dtype, ddef.description) for name, ddef in schema.dimensions.items()
208
+ },
209
+ relations=ns.relations,
210
+ sos={
211
+ name: SosDeclaration(sdef.variable, sdef.along, sos_type=sdef.type, description=sdef.description)
212
+ for name, sdef in schema.sos.items()
213
+ },
214
+ piecewise=piecewise,
215
+ assumptions=assumptions,
216
+ expressions={
217
+ name: ExpressionDeclaration(
218
+ entry.body,
219
+ _frame_of(name, entry, schema),
220
+ in_math=name in in_math,
221
+ description=schema.expressions[name].description,
222
+ )
223
+ for name, entry in entries.items()
224
+ },
225
+ description=schema.description,
226
+ )
227
+ if errors := emitted_name_errors(schema, program):
228
+ raise SchemaError('\n'.join(errors))
229
+ check_schema(schema, program)
230
+ return program
231
+
232
+
233
+ def _frame_of(name: str, entry: Named, schema: Spec) -> tuple[str, ...]:
234
+ """The dims an entry is read over, in declaration order: declared for a cased entry, the body's for a plain one."""
235
+ if isinstance(entry.body, Cases):
236
+ return tuple(schema.expressions[name].dims or ())
237
+ carried = dims_of(entry.body, schema, f"Named expression '{name}'")
238
+ return tuple(d for d in schema.dimensions if d in carried)
239
+
240
+
241
+ def _bound(value: float | str | None) -> Constant | Parameter | None:
242
+ if value is None:
243
+ return None
244
+ if isinstance(value, str):
245
+ return Parameter(value)
246
+ return Constant(value)
247
+
248
+
249
+ def _assumption(name: str, block: AssumptionBlock, ns: Namespace, errors: list[str]) -> Assumption | None:
250
+ """One ``assumptions:`` entry typed, or ``None`` once anything in it failed.
251
+
252
+ A predicate the connectives decide is refused: one that folds to true
253
+ assumes nothing, and one that folds to false refuses every dataset. A
254
+ variable is refused too, since an assumption is about the data and a
255
+ variable is what the solver decides from it.
256
+ """
257
+ context = f"Assumption '{name}'"
258
+ found = len(errors)
259
+ holds = resolve_where_text(block.holds, ns, context, errors)
260
+ where = resolve_where_text(block.where, ns, f'{context}, where', errors)
261
+ if isinstance(holds, BooleanLiteral):
262
+ errors.append(_decided_assumption(context, block.holds, value=holds.value))
263
+ if isinstance(where, BooleanLiteral):
264
+ assert block.where is not None, 'a where the file did not write resolves to nothing'
265
+ errors.append(_decided_where(context, block.where, value=where.value))
266
+ for mask, part in ((holds, 'assumes'), (where, 'is checked where')):
267
+ if mask is None or isinstance(mask, BooleanLiteral):
268
+ continue
269
+ errors.extend(
270
+ f"{context}: variable '{atom.name}' stands in what the assumption {part}, and an assumption is "
271
+ f'about the data — a variable is what the solver decides from it. Name a parameter, or state the '
272
+ f'rule as a constraint.'
273
+ for atom in Mask(mask).atoms
274
+ if isinstance(atom, VariableDefined)
275
+ )
276
+ if len(errors) > found:
277
+ return None
278
+ assert holds is not None, 'a where string that read to nothing appended an error'
279
+ return Assumption(Mask(holds), mask_of(where), block.description)
280
+
281
+
282
+ def _decided_assumption(context: str, text: str, *, value: bool) -> str:
283
+ """The refusal for a predicate the connectives already decided, whose data is never read."""
284
+ if value:
285
+ return (
286
+ f'{context}: the predicate {text!r} folds to true, so it assumes nothing of the data. '
287
+ f'Delete it, or name a parameter it constrains.'
288
+ )
289
+ return (
290
+ f'{context}: the predicate {text!r} folds to false, so it holds on no data at all. '
291
+ f'Delete it, or write the predicate the data can satisfy.'
292
+ )
293
+
294
+
295
+ def _decided_where(context: str, text: str, *, value: bool) -> str:
296
+ """The refusal for a ``where`` the connectives already decided, which narrows nothing or everything."""
297
+ if value:
298
+ return f'{context}: the where {text!r} folds to true, so it narrows nothing. Delete the where.'
299
+ return (
300
+ f'{context}: the where {text!r} folds to false, so the assumption is checked on no row. '
301
+ f'Delete the entry, or write the where the data can satisfy.'
302
+ )