msolveio 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.
- msolveio/__init__.py +43 -0
- msolveio/emit.py +373 -0
- msolveio/errors.py +76 -0
- msolveio/mode.py +20 -0
- msolveio/parse.py +234 -0
- msolveio/py.typed +0 -0
- msolveio/run.py +236 -0
- msolveio-0.1.0.dist-info/METADATA +92 -0
- msolveio-0.1.0.dist-info/RECORD +12 -0
- msolveio-0.1.0.dist-info/WHEEL +5 -0
- msolveio-0.1.0.dist-info/licenses/LICENSE +21 -0
- msolveio-0.1.0.dist-info/top_level.txt +1 -0
msolveio/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Strict Python I/O for msolve: canonical input, mode-required output.
|
|
2
|
+
|
|
3
|
+
Groebner-mode (``-g``) only in v0.1. Solver-mode bytes are rejected, not
|
|
4
|
+
interpreted.
|
|
5
|
+
|
|
6
|
+
msolveio is not a computer algebra system and not a Groebner engine. It writes
|
|
7
|
+
``.ms`` files msolve will parse the way you meant, and reads back only the one
|
|
8
|
+
output language it can identify with certainty.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .emit import emit_system
|
|
14
|
+
from .errors import (
|
|
15
|
+
MsolveAmbiguous,
|
|
16
|
+
MsolveDied,
|
|
17
|
+
MsolveError,
|
|
18
|
+
MsolveInputError,
|
|
19
|
+
MsolveOutputError,
|
|
20
|
+
MsolveTimeout,
|
|
21
|
+
MsolveVersionUnsupported,
|
|
22
|
+
)
|
|
23
|
+
from .mode import Mode
|
|
24
|
+
from .parse import GroebnerOutput, parse_groebner
|
|
25
|
+
from .run import RunResult, run_groebner
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Mode",
|
|
31
|
+
"emit_system",
|
|
32
|
+
"parse_groebner",
|
|
33
|
+
"run_groebner",
|
|
34
|
+
"GroebnerOutput",
|
|
35
|
+
"RunResult",
|
|
36
|
+
"MsolveError",
|
|
37
|
+
"MsolveInputError",
|
|
38
|
+
"MsolveOutputError",
|
|
39
|
+
"MsolveAmbiguous",
|
|
40
|
+
"MsolveVersionUnsupported",
|
|
41
|
+
"MsolveTimeout",
|
|
42
|
+
"MsolveDied",
|
|
43
|
+
]
|
msolveio/emit.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Canonical emission of msolve ``.ms`` input files.
|
|
2
|
+
|
|
3
|
+
The msolve 0.10.x parser is permissive in ways that silently change meaning:
|
|
4
|
+
it accepts text it will not evaluate the way a CAS user expects, and it
|
|
5
|
+
truncates oversized coefficients instead of complaining. This module refuses
|
|
6
|
+
anything in that grey zone.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from typing import Sequence
|
|
13
|
+
|
|
14
|
+
from .errors import MsolveInputError
|
|
15
|
+
|
|
16
|
+
__all__ = ["emit_system", "MAX_TERM_VAR_PRODUCT", "MAX_CHARACTERISTIC"]
|
|
17
|
+
|
|
18
|
+
#: msolve 0.10.1 indexes the exponent matrix with a 32-bit signed counter.
|
|
19
|
+
#: ``total_terms * nvars`` above this bound segfaults an unpatched build, so we
|
|
20
|
+
#: refuse to write such a file. Exposed as a constant so it can be lowered in
|
|
21
|
+
#: tests without constructing a two-billion-term system.
|
|
22
|
+
MAX_TERM_VAR_PRODUCT = 2**31 - 1
|
|
23
|
+
|
|
24
|
+
#: msolve's documented prime-field range is ``2 .. 2**31 - 1``.
|
|
25
|
+
MAX_CHARACTERISTIC = 2**31 - 1
|
|
26
|
+
|
|
27
|
+
#: Coefficient magnitudes are read into signed 64-bit words before reduction.
|
|
28
|
+
_MAX_COEFF_MAGNITUDE = 2**63 - 1
|
|
29
|
+
|
|
30
|
+
_VARIABLE_RE = re.compile(r"[A-Za-z][A-Za-z0-9]*\Z")
|
|
31
|
+
_TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9]*|[0-9]+|[+\-*/^]|\s+")
|
|
32
|
+
|
|
33
|
+
_Monomial = tuple[tuple[str, int], ...]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def emit_system(
|
|
37
|
+
polynomials: Sequence[str],
|
|
38
|
+
*,
|
|
39
|
+
variables: Sequence[str],
|
|
40
|
+
characteristic: int = 0,
|
|
41
|
+
) -> str:
|
|
42
|
+
"""Render a polynomial system as msolve input text.
|
|
43
|
+
|
|
44
|
+
The result is the three-part msolve file format::
|
|
45
|
+
|
|
46
|
+
x,y
|
|
47
|
+
0
|
|
48
|
+
x^2+y,
|
|
49
|
+
x*y-1
|
|
50
|
+
|
|
51
|
+
Each polynomial must already be an expanded sum of monomials. Whitespace in
|
|
52
|
+
the input strings is insignificant and is removed; nothing else about a
|
|
53
|
+
polynomial is rewritten. Any construct msolve would mis-parse -- parentheses,
|
|
54
|
+
post-monomial division, unknown identifiers, repeated monomials -- raises
|
|
55
|
+
:class:`~msolveio.MsolveInputError`.
|
|
56
|
+
|
|
57
|
+
:param polynomials: the generators, as expanded monomial sums.
|
|
58
|
+
:param variables: the variable order, which msolve echoes back in its output.
|
|
59
|
+
:param characteristic: ``0`` for Q, or a prime in ``2 .. 2**31 - 1``.
|
|
60
|
+
:raises MsolveInputError: if anything about the system is unsafe to write.
|
|
61
|
+
"""
|
|
62
|
+
var_tuple = _check_variables(variables)
|
|
63
|
+
_check_characteristic(characteristic)
|
|
64
|
+
|
|
65
|
+
if isinstance(polynomials, str):
|
|
66
|
+
raise MsolveInputError(
|
|
67
|
+
"polynomials must be a sequence of strings, not a single string"
|
|
68
|
+
)
|
|
69
|
+
polys = list(polynomials)
|
|
70
|
+
if not polys:
|
|
71
|
+
raise MsolveInputError("at least one polynomial is required")
|
|
72
|
+
|
|
73
|
+
varset = set(var_tuple)
|
|
74
|
+
rendered: list[str] = []
|
|
75
|
+
total_terms = 0
|
|
76
|
+
for index, poly in enumerate(polys):
|
|
77
|
+
if not isinstance(poly, str):
|
|
78
|
+
raise MsolveInputError(
|
|
79
|
+
f"polynomial {index}: expected a string, got {type(poly).__name__}"
|
|
80
|
+
)
|
|
81
|
+
text, nterms = _check_polynomial(poly, index, varset, characteristic)
|
|
82
|
+
rendered.append(text)
|
|
83
|
+
total_terms += nterms
|
|
84
|
+
|
|
85
|
+
# Read the bound through the module global so tests can lower it.
|
|
86
|
+
bound = MAX_TERM_VAR_PRODUCT
|
|
87
|
+
product = total_terms * len(var_tuple)
|
|
88
|
+
if product > bound:
|
|
89
|
+
raise MsolveInputError(
|
|
90
|
+
f"system is too large for msolve 0.10.1: total_terms * nvars = "
|
|
91
|
+
f"{total_terms} * {len(var_tuple)} = {product} exceeds {bound}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
header = ",".join(var_tuple)
|
|
95
|
+
body = ",\n".join(rendered)
|
|
96
|
+
return f"{header}\n{characteristic}\n{body}\n"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _check_variables(variables: Sequence[str]) -> tuple[str, ...]:
|
|
100
|
+
if isinstance(variables, str):
|
|
101
|
+
raise MsolveInputError(
|
|
102
|
+
"variables must be a sequence of strings, not a single string"
|
|
103
|
+
)
|
|
104
|
+
var_tuple = tuple(variables)
|
|
105
|
+
if not var_tuple:
|
|
106
|
+
raise MsolveInputError("at least one variable is required")
|
|
107
|
+
|
|
108
|
+
seen: set[str] = set()
|
|
109
|
+
for name in var_tuple:
|
|
110
|
+
if not isinstance(name, str):
|
|
111
|
+
raise MsolveInputError(
|
|
112
|
+
f"variable names must be strings, got {type(name).__name__}"
|
|
113
|
+
)
|
|
114
|
+
if not name:
|
|
115
|
+
raise MsolveInputError("empty variable name")
|
|
116
|
+
if not _VARIABLE_RE.match(name):
|
|
117
|
+
raise MsolveInputError(
|
|
118
|
+
f"invalid variable name {name!r}: names must match "
|
|
119
|
+
f"[A-Za-z][A-Za-z0-9]* (no underscores, spaces, commas, or operators)"
|
|
120
|
+
)
|
|
121
|
+
if name in seen:
|
|
122
|
+
raise MsolveInputError(f"duplicate variable name {name!r}")
|
|
123
|
+
seen.add(name)
|
|
124
|
+
return var_tuple
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _check_characteristic(characteristic: int) -> None:
|
|
128
|
+
if isinstance(characteristic, bool) or not isinstance(characteristic, int):
|
|
129
|
+
raise MsolveInputError(
|
|
130
|
+
f"characteristic must be an int, got {type(characteristic).__name__}"
|
|
131
|
+
)
|
|
132
|
+
if characteristic == 0:
|
|
133
|
+
return
|
|
134
|
+
if characteristic < 0:
|
|
135
|
+
raise MsolveInputError("characteristic must be 0 or a positive prime")
|
|
136
|
+
if characteristic == 1:
|
|
137
|
+
raise MsolveInputError("characteristic 1 is not a field")
|
|
138
|
+
if characteristic > MAX_CHARACTERISTIC:
|
|
139
|
+
raise MsolveInputError(
|
|
140
|
+
f"characteristic {characteristic} is outside msolve's prime-field "
|
|
141
|
+
f"range 2 .. {MAX_CHARACTERISTIC}"
|
|
142
|
+
)
|
|
143
|
+
if not _is_prime(characteristic):
|
|
144
|
+
raise MsolveInputError(f"characteristic {characteristic} is not prime")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _is_prime(n: int) -> bool:
|
|
148
|
+
"""Deterministic Miller-Rabin. Exact for every n we accept (n < 2**31)."""
|
|
149
|
+
if n < 2:
|
|
150
|
+
return False
|
|
151
|
+
for p in (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37):
|
|
152
|
+
if n % p == 0:
|
|
153
|
+
return n == p
|
|
154
|
+
d = n - 1
|
|
155
|
+
r = 0
|
|
156
|
+
while d % 2 == 0:
|
|
157
|
+
d //= 2
|
|
158
|
+
r += 1
|
|
159
|
+
for a in (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37):
|
|
160
|
+
x = pow(a, d, n)
|
|
161
|
+
if x == 1 or x == n - 1:
|
|
162
|
+
continue
|
|
163
|
+
for _ in range(r - 1):
|
|
164
|
+
x = x * x % n
|
|
165
|
+
if x == n - 1:
|
|
166
|
+
break
|
|
167
|
+
else:
|
|
168
|
+
return False
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _tokenize(poly: str, index: int) -> list[str]:
|
|
173
|
+
tokens: list[str] = []
|
|
174
|
+
pos = 0
|
|
175
|
+
for match in _TOKEN_RE.finditer(poly):
|
|
176
|
+
if match.start() != pos:
|
|
177
|
+
bad = poly[pos : match.start()]
|
|
178
|
+
raise MsolveInputError(_bad_char_message(index, bad))
|
|
179
|
+
pos = match.end()
|
|
180
|
+
token = match.group()
|
|
181
|
+
if not token.isspace():
|
|
182
|
+
tokens.append(token)
|
|
183
|
+
if pos != len(poly):
|
|
184
|
+
raise MsolveInputError(_bad_char_message(index, poly[pos:]))
|
|
185
|
+
return tokens
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _bad_char_message(index: int, bad: str) -> str:
|
|
189
|
+
char = bad[0]
|
|
190
|
+
if char in "()":
|
|
191
|
+
return (
|
|
192
|
+
f"polynomial {index}: parentheses are not allowed; msolve input must "
|
|
193
|
+
f"be an expanded sum of monomials"
|
|
194
|
+
)
|
|
195
|
+
if char == "_":
|
|
196
|
+
return (
|
|
197
|
+
f"polynomial {index}: '_' is not a legal msolve identifier character"
|
|
198
|
+
)
|
|
199
|
+
return f"polynomial {index}: unexpected character {char!r}"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _check_polynomial(
|
|
203
|
+
poly: str,
|
|
204
|
+
index: int,
|
|
205
|
+
varset: set[str],
|
|
206
|
+
characteristic: int,
|
|
207
|
+
) -> tuple[str, int]:
|
|
208
|
+
"""Validate one polynomial. Returns its canonical text and its term count."""
|
|
209
|
+
tokens = _tokenize(poly, index)
|
|
210
|
+
if not tokens:
|
|
211
|
+
raise MsolveInputError(f"polynomial {index}: empty polynomial")
|
|
212
|
+
|
|
213
|
+
seen: set[_Monomial] = set()
|
|
214
|
+
pos = 0
|
|
215
|
+
ntokens = len(tokens)
|
|
216
|
+
first = True
|
|
217
|
+
while pos < ntokens:
|
|
218
|
+
if tokens[pos] in ("+", "-"):
|
|
219
|
+
pos += 1
|
|
220
|
+
if pos >= ntokens:
|
|
221
|
+
raise MsolveInputError(
|
|
222
|
+
f"polynomial {index}: trailing '{tokens[pos - 1]}'"
|
|
223
|
+
)
|
|
224
|
+
elif not first: # unreachable: _parse_term stops only at end or a sign
|
|
225
|
+
raise MsolveInputError(
|
|
226
|
+
f"polynomial {index}: expected '+' or '-' before {tokens[pos]!r}"
|
|
227
|
+
)
|
|
228
|
+
if tokens[pos] == "*":
|
|
229
|
+
raise MsolveInputError(
|
|
230
|
+
f"polynomial {index}: term begins with '*'"
|
|
231
|
+
)
|
|
232
|
+
pos, monomial = _parse_term(tokens, pos, index, varset, characteristic)
|
|
233
|
+
if monomial in seen:
|
|
234
|
+
raise MsolveInputError(
|
|
235
|
+
f"polynomial {index}: monomial {_show_monomial(monomial)} appears "
|
|
236
|
+
f"more than once; msolve's parser is undefined on repeated "
|
|
237
|
+
f"monomials, so collect terms first"
|
|
238
|
+
)
|
|
239
|
+
seen.add(monomial)
|
|
240
|
+
first = False
|
|
241
|
+
|
|
242
|
+
return "".join(tokens), len(seen)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _parse_term(
|
|
246
|
+
tokens: list[str],
|
|
247
|
+
pos: int,
|
|
248
|
+
index: int,
|
|
249
|
+
varset: set[str],
|
|
250
|
+
characteristic: int,
|
|
251
|
+
) -> tuple[int, _Monomial]:
|
|
252
|
+
"""Parse one monomial starting at ``pos``. Returns (next position, monomial)."""
|
|
253
|
+
ntokens = len(tokens)
|
|
254
|
+
exponents: dict[str, int] = {}
|
|
255
|
+
factor = 0
|
|
256
|
+
|
|
257
|
+
while True:
|
|
258
|
+
token = tokens[pos]
|
|
259
|
+
if token.isdigit():
|
|
260
|
+
if factor != 0:
|
|
261
|
+
raise MsolveInputError(
|
|
262
|
+
f"polynomial {index}: numeric coefficient {token} must come "
|
|
263
|
+
f"first in its term"
|
|
264
|
+
)
|
|
265
|
+
pos = _parse_coefficient(tokens, pos, index, characteristic)
|
|
266
|
+
elif token[0].isalpha():
|
|
267
|
+
if token not in varset:
|
|
268
|
+
raise MsolveInputError(
|
|
269
|
+
f"polynomial {index}: unknown identifier {token!r}; only "
|
|
270
|
+
f"declared variables and integer/rational literals are allowed"
|
|
271
|
+
)
|
|
272
|
+
pos += 1
|
|
273
|
+
exponent = 1
|
|
274
|
+
if pos < ntokens and tokens[pos] == "^":
|
|
275
|
+
pos += 1
|
|
276
|
+
if pos >= ntokens or not tokens[pos].isdigit():
|
|
277
|
+
raise MsolveInputError(
|
|
278
|
+
f"polynomial {index}: exponent of {token!r} must be a "
|
|
279
|
+
f"positive integer"
|
|
280
|
+
)
|
|
281
|
+
exponent = int(tokens[pos])
|
|
282
|
+
if exponent < 1:
|
|
283
|
+
raise MsolveInputError(
|
|
284
|
+
f"polynomial {index}: exponent of {token!r} must be a "
|
|
285
|
+
f"positive integer, got {exponent}"
|
|
286
|
+
)
|
|
287
|
+
pos += 1
|
|
288
|
+
if token in exponents:
|
|
289
|
+
raise MsolveInputError(
|
|
290
|
+
f"polynomial {index}: variable {token!r} appears twice in one "
|
|
291
|
+
f"monomial; write it as a single power instead"
|
|
292
|
+
)
|
|
293
|
+
exponents[token] = exponent
|
|
294
|
+
elif token == "/":
|
|
295
|
+
raise MsolveInputError(
|
|
296
|
+
f"polynomial {index}: division is only allowed in a leading "
|
|
297
|
+
f"rational coefficient such as '1/2*x'"
|
|
298
|
+
)
|
|
299
|
+
else:
|
|
300
|
+
raise MsolveInputError(
|
|
301
|
+
f"polynomial {index}: unexpected {token!r}"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
factor += 1
|
|
305
|
+
if pos >= ntokens or tokens[pos] in ("+", "-"):
|
|
306
|
+
break
|
|
307
|
+
if tokens[pos] == "/":
|
|
308
|
+
raise MsolveInputError(
|
|
309
|
+
f"polynomial {index}: division is only allowed in a leading "
|
|
310
|
+
f"rational coefficient such as '1/2*x'"
|
|
311
|
+
)
|
|
312
|
+
if tokens[pos] != "*":
|
|
313
|
+
raise MsolveInputError(
|
|
314
|
+
f"polynomial {index}: expected '*' between factors, got "
|
|
315
|
+
f"{tokens[pos]!r}"
|
|
316
|
+
)
|
|
317
|
+
pos += 1
|
|
318
|
+
if pos >= ntokens:
|
|
319
|
+
raise MsolveInputError(f"polynomial {index}: trailing '*'")
|
|
320
|
+
if not (tokens[pos].isdigit() or tokens[pos][0].isalpha()):
|
|
321
|
+
raise MsolveInputError(
|
|
322
|
+
f"polynomial {index}: expected a factor after '*', got "
|
|
323
|
+
f"{tokens[pos]!r}"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
return pos, tuple(sorted(exponents.items()))
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _parse_coefficient(
|
|
330
|
+
tokens: list[str],
|
|
331
|
+
pos: int,
|
|
332
|
+
index: int,
|
|
333
|
+
characteristic: int,
|
|
334
|
+
) -> int:
|
|
335
|
+
ntokens = len(tokens)
|
|
336
|
+
numerator = int(tokens[pos])
|
|
337
|
+
pos += 1
|
|
338
|
+
parts = [numerator]
|
|
339
|
+
|
|
340
|
+
if pos < ntokens and tokens[pos] == "/":
|
|
341
|
+
if characteristic != 0:
|
|
342
|
+
raise MsolveInputError(
|
|
343
|
+
f"polynomial {index}: rational coefficients are not allowed over "
|
|
344
|
+
f"a prime field; reduce the coefficient modulo {characteristic} first"
|
|
345
|
+
)
|
|
346
|
+
pos += 1
|
|
347
|
+
if pos >= ntokens or not tokens[pos].isdigit():
|
|
348
|
+
raise MsolveInputError(
|
|
349
|
+
f"polynomial {index}: expected an integer denominator after '/'"
|
|
350
|
+
)
|
|
351
|
+
denominator = int(tokens[pos])
|
|
352
|
+
if denominator == 0:
|
|
353
|
+
raise MsolveInputError(f"polynomial {index}: zero denominator")
|
|
354
|
+
parts.append(denominator)
|
|
355
|
+
pos += 1
|
|
356
|
+
|
|
357
|
+
if characteristic != 0:
|
|
358
|
+
for value in parts:
|
|
359
|
+
if value > _MAX_COEFF_MAGNITUDE:
|
|
360
|
+
raise MsolveInputError(
|
|
361
|
+
f"polynomial {index}: coefficient {value} does not fit in a "
|
|
362
|
+
f"signed 64-bit word; msolve would truncate it instead of "
|
|
363
|
+
f"reducing it modulo {characteristic}"
|
|
364
|
+
)
|
|
365
|
+
return pos
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _show_monomial(monomial: _Monomial) -> str:
|
|
369
|
+
if not monomial:
|
|
370
|
+
return "1"
|
|
371
|
+
return "*".join(
|
|
372
|
+
name if exponent == 1 else f"{name}^{exponent}" for name, exponent in monomial
|
|
373
|
+
)
|
msolveio/errors.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Exception hierarchy for :mod:`msolveio`.
|
|
2
|
+
|
|
3
|
+
Every failure mode is a distinct type. Nothing here is a warning: msolveio
|
|
4
|
+
refuses ambiguous bytes rather than guessing at their meaning.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"MsolveError",
|
|
11
|
+
"MsolveInputError",
|
|
12
|
+
"MsolveOutputError",
|
|
13
|
+
"MsolveAmbiguous",
|
|
14
|
+
"MsolveVersionUnsupported",
|
|
15
|
+
"MsolveTimeout",
|
|
16
|
+
"MsolveDied",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MsolveError(Exception):
|
|
21
|
+
"""Base class for every error raised by msolveio."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MsolveInputError(MsolveError):
|
|
25
|
+
"""The caller asked us to emit something msolve would mis-parse.
|
|
26
|
+
|
|
27
|
+
Raised by :func:`msolveio.emit_system`. msolveio never rewrites input to
|
|
28
|
+
make it legal; it reports what is wrong and stops.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MsolveOutputError(MsolveError):
|
|
33
|
+
"""msolve output was malformed, truncated, or not Groebner-mode output."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MsolveAmbiguous(MsolveOutputError):
|
|
37
|
+
"""The bytes look like output from a *different* msolve mode.
|
|
38
|
+
|
|
39
|
+
Most importantly, solver-mode output such as ``[-1]:`` is a valid-looking
|
|
40
|
+
list that means "no solutions", not "the Groebner basis is ``[-1]``".
|
|
41
|
+
Interpreting it as a basis would silently invert the answer, so this is a
|
|
42
|
+
hard error.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MsolveVersionUnsupported(MsolveError):
|
|
47
|
+
"""The msolve binary is not a 0.10.x release (and the caller did not opt in)."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, message: str, *, version: str | None = None) -> None:
|
|
50
|
+
super().__init__(message)
|
|
51
|
+
self.version = version
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class MsolveTimeout(MsolveError):
|
|
55
|
+
"""msolve exceeded the caller-supplied wall-clock timeout and was killed."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, message: str, *, timeout: float) -> None:
|
|
58
|
+
super().__init__(message)
|
|
59
|
+
self.timeout = timeout
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class MsolveDied(MsolveError):
|
|
63
|
+
"""msolve exited nonzero, was killed by a signal, or produced no output."""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
message: str,
|
|
68
|
+
*,
|
|
69
|
+
returncode: int | None = None,
|
|
70
|
+
signal: int | None = None,
|
|
71
|
+
stderr: str = "",
|
|
72
|
+
) -> None:
|
|
73
|
+
super().__init__(message)
|
|
74
|
+
self.returncode = returncode
|
|
75
|
+
self.signal = signal
|
|
76
|
+
self.stderr = stderr
|
msolveio/mode.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Output-language selector.
|
|
2
|
+
|
|
3
|
+
msolve prints a different language in each mode and does not always make the
|
|
4
|
+
mode obvious from the bytes. Callers name the mode they expect; v0.1 supports
|
|
5
|
+
Groebner mode only. There is deliberately no ``Mode.SOLVER`` member: an enum
|
|
6
|
+
value the library cannot honour would be a promise it does not keep.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import enum
|
|
12
|
+
|
|
13
|
+
__all__ = ["Mode"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Mode(enum.Enum):
|
|
17
|
+
"""The msolve output language a caller expects."""
|
|
18
|
+
|
|
19
|
+
#: Groebner basis output, i.e. msolve invoked with ``-g 1`` or ``-g 2``.
|
|
20
|
+
GROEBNER = "groebner"
|
msolveio/parse.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Strict parsing of msolve 0.10.x Groebner-mode (``-g 1`` / ``-g 2``) output.
|
|
2
|
+
|
|
3
|
+
msolve prints different languages in different modes, and they overlap
|
|
4
|
+
syntactically. Solver mode prints ``[-1]:`` to mean "no solutions"; Groebner
|
|
5
|
+
mode prints ``[1]:`` to mean "the unit ideal". Both are bracketed lists, and
|
|
6
|
+
mistaking one for the other inverts the answer. So the ``#`` comment header is
|
|
7
|
+
load-bearing here: it is the only thing that says which language the bytes are
|
|
8
|
+
in, and this module refuses to proceed without it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import NoReturn
|
|
16
|
+
|
|
17
|
+
from .errors import MsolveAmbiguous, MsolveOutputError
|
|
18
|
+
from .mode import Mode
|
|
19
|
+
|
|
20
|
+
__all__ = ["GroebnerOutput", "parse_groebner"]
|
|
21
|
+
|
|
22
|
+
_GB_HEADER = "#Reduced Groebner basis data"
|
|
23
|
+
_LEADING_HEADER = "#Leading ideal data"
|
|
24
|
+
_SEPARATOR = "#---"
|
|
25
|
+
|
|
26
|
+
# `[1, <nvars>, -1, []]:` -- solver mode reporting an inconsistent system.
|
|
27
|
+
_SOLVER_EMPTY_RE = re.compile(r"\[\s*1\s*,\s*\d+\s*,\s*-1\s*,\s*\[\s*\]\s*\]\s*:")
|
|
28
|
+
_LENGTH_RE = re.compile(r"(\d+)\s+element")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class GroebnerOutput:
|
|
33
|
+
"""A parsed msolve Groebner basis.
|
|
34
|
+
|
|
35
|
+
:param unit_ideal: ``True`` iff the basis is exactly ``["1"]``, i.e. the
|
|
36
|
+
ideal is the whole ring and the system has no solutions.
|
|
37
|
+
:param basis: the basis polynomials as msolve printed them, stripped, with
|
|
38
|
+
separating commas removed. ``("1",)`` for the unit ideal.
|
|
39
|
+
:param leading_only: ``True`` iff this was ``-g 1`` output, so the entries
|
|
40
|
+
are leading monomials rather than full basis elements.
|
|
41
|
+
:param characteristic: the characteristic msolve printed. Note that msolve
|
|
42
|
+
0.10.1 labels some unlifted rational bases as characteristic 0 whether
|
|
43
|
+
or not a lift happened; this field reports what was printed, and nothing
|
|
44
|
+
more.
|
|
45
|
+
:param variables: the variable order msolve echoed back.
|
|
46
|
+
:param monomial_order: the monomial order msolve printed, verbatim.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
unit_ideal: bool
|
|
50
|
+
basis: tuple[str, ...]
|
|
51
|
+
leading_only: bool
|
|
52
|
+
characteristic: int
|
|
53
|
+
variables: tuple[str, ...]
|
|
54
|
+
monomial_order: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_groebner(text: str, *, mode: Mode = Mode.GROEBNER) -> GroebnerOutput:
|
|
58
|
+
"""Parse msolve 0.10.x Groebner-mode output.
|
|
59
|
+
|
|
60
|
+
:param text: the contents of msolve's ``-o`` file, or its stdout.
|
|
61
|
+
:param mode: must be :attr:`Mode.GROEBNER`. Present so callers can state
|
|
62
|
+
which output language they believe they have; v0.1 parses no other.
|
|
63
|
+
:raises MsolveAmbiguous: if the bytes look like a different msolve mode.
|
|
64
|
+
:raises MsolveOutputError: if the bytes are not well-formed Groebner output.
|
|
65
|
+
"""
|
|
66
|
+
if mode is not Mode.GROEBNER:
|
|
67
|
+
raise ValueError(f"unsupported mode {mode!r}; v0.1 parses Mode.GROEBNER only")
|
|
68
|
+
if not isinstance(text, str):
|
|
69
|
+
raise TypeError(f"expected str, got {type(text).__name__}")
|
|
70
|
+
|
|
71
|
+
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
72
|
+
start = _first_content_line(lines)
|
|
73
|
+
if start is None:
|
|
74
|
+
raise MsolveOutputError("empty msolve output")
|
|
75
|
+
|
|
76
|
+
header = lines[start].strip()
|
|
77
|
+
if header.startswith(_GB_HEADER):
|
|
78
|
+
leading_only = False
|
|
79
|
+
elif header.startswith(_LEADING_HEADER):
|
|
80
|
+
leading_only = True
|
|
81
|
+
else:
|
|
82
|
+
_reject_headerless(header)
|
|
83
|
+
|
|
84
|
+
fields, body_start = _parse_header_block(lines, start)
|
|
85
|
+
basis = _parse_basis(lines, body_start)
|
|
86
|
+
|
|
87
|
+
characteristic = _require_int(fields, "field characteristic")
|
|
88
|
+
variables = _require_variables(fields)
|
|
89
|
+
monomial_order = _require_field(fields, "monomial order")
|
|
90
|
+
_check_length(fields, basis)
|
|
91
|
+
|
|
92
|
+
return GroebnerOutput(
|
|
93
|
+
unit_ideal=basis == ("1",),
|
|
94
|
+
basis=basis,
|
|
95
|
+
leading_only=leading_only,
|
|
96
|
+
characteristic=characteristic,
|
|
97
|
+
variables=variables,
|
|
98
|
+
monomial_order=monomial_order,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _first_content_line(lines: list[str]) -> int | None:
|
|
103
|
+
for i, line in enumerate(lines):
|
|
104
|
+
if line.strip():
|
|
105
|
+
return i
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _reject_headerless(header: str) -> NoReturn:
|
|
110
|
+
"""Raise. Ambiguous if the bytes look like solver mode, malformed otherwise."""
|
|
111
|
+
if (
|
|
112
|
+
header.startswith("[-1")
|
|
113
|
+
or header.startswith("[0,")
|
|
114
|
+
or _SOLVER_EMPTY_RE.match(header)
|
|
115
|
+
):
|
|
116
|
+
raise MsolveAmbiguous(
|
|
117
|
+
f"refusing to parse solver-mode output as a Groebner basis: "
|
|
118
|
+
f"{header[:60]!r}. In solver mode '[-1]:' means the system has no "
|
|
119
|
+
f"solutions; in Groebner mode the unit ideal prints as '[1]:'. "
|
|
120
|
+
f"Re-run msolve with -g 1 or -g 2."
|
|
121
|
+
)
|
|
122
|
+
raise MsolveOutputError(
|
|
123
|
+
f"missing msolve Groebner header: expected a line starting with "
|
|
124
|
+
f"{_GB_HEADER!r} or {_LEADING_HEADER!r}, got {header[:60]!r}"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _parse_header_block(lines: list[str], start: int) -> tuple[dict[str, str], int]:
|
|
129
|
+
"""Parse ``#---`` / fields / ``#---``. Returns the fields and the body index."""
|
|
130
|
+
pos = start + 1
|
|
131
|
+
if pos >= len(lines) or lines[pos].strip() != _SEPARATOR:
|
|
132
|
+
got = lines[pos].strip() if pos < len(lines) else "<end of output>"
|
|
133
|
+
raise MsolveOutputError(
|
|
134
|
+
f"malformed msolve header: expected {_SEPARATOR!r} after the header "
|
|
135
|
+
f"line, got {got[:60]!r}"
|
|
136
|
+
)
|
|
137
|
+
pos += 1
|
|
138
|
+
|
|
139
|
+
fields: dict[str, str] = {}
|
|
140
|
+
while pos < len(lines):
|
|
141
|
+
line = lines[pos].strip()
|
|
142
|
+
if line == _SEPARATOR:
|
|
143
|
+
return fields, pos + 1
|
|
144
|
+
if not line.startswith("#"):
|
|
145
|
+
raise MsolveOutputError(
|
|
146
|
+
f"malformed msolve header: expected a '#' field line or "
|
|
147
|
+
f"{_SEPARATOR!r}, got {line[:60]!r}"
|
|
148
|
+
)
|
|
149
|
+
key, sep, value = line[1:].partition(":")
|
|
150
|
+
if not sep:
|
|
151
|
+
raise MsolveOutputError(
|
|
152
|
+
f"malformed msolve header field: {line[:60]!r}"
|
|
153
|
+
)
|
|
154
|
+
fields[key.strip().lower()] = value.strip()
|
|
155
|
+
pos += 1
|
|
156
|
+
|
|
157
|
+
raise MsolveOutputError(
|
|
158
|
+
f"truncated msolve output: header block was never closed with {_SEPARATOR!r}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _parse_basis(lines: list[str], body_start: int) -> tuple[str, ...]:
|
|
163
|
+
body = "\n".join(lines[body_start:]).strip()
|
|
164
|
+
if not body:
|
|
165
|
+
raise MsolveOutputError(
|
|
166
|
+
"truncated msolve output: header present but no basis body"
|
|
167
|
+
)
|
|
168
|
+
if not body.startswith("["):
|
|
169
|
+
raise MsolveOutputError(
|
|
170
|
+
f"malformed msolve basis: expected '[', got {body[:60]!r}"
|
|
171
|
+
)
|
|
172
|
+
if not body.endswith("]:"):
|
|
173
|
+
if "]:" in body:
|
|
174
|
+
raise MsolveOutputError(
|
|
175
|
+
"malformed msolve output: unexpected trailing content after ']:'"
|
|
176
|
+
)
|
|
177
|
+
raise MsolveOutputError(
|
|
178
|
+
"truncated msolve output: basis list is not closed with ']:'"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
inner = body[1:-2]
|
|
182
|
+
if not inner.strip():
|
|
183
|
+
raise MsolveOutputError("malformed msolve basis: empty list")
|
|
184
|
+
|
|
185
|
+
entries = []
|
|
186
|
+
for raw in inner.split(","):
|
|
187
|
+
entry = raw.strip()
|
|
188
|
+
if not entry:
|
|
189
|
+
raise MsolveOutputError(
|
|
190
|
+
"malformed msolve basis: empty entry (stray comma)"
|
|
191
|
+
)
|
|
192
|
+
entries.append(entry)
|
|
193
|
+
return tuple(entries)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _require_field(fields: dict[str, str], key: str) -> str:
|
|
197
|
+
if key not in fields:
|
|
198
|
+
raise MsolveOutputError(f"missing '#{key}' in msolve header")
|
|
199
|
+
value = fields[key]
|
|
200
|
+
if not value:
|
|
201
|
+
raise MsolveOutputError(f"empty '#{key}' in msolve header")
|
|
202
|
+
return value
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _require_int(fields: dict[str, str], key: str) -> int:
|
|
206
|
+
value = _require_field(fields, key)
|
|
207
|
+
try:
|
|
208
|
+
return int(value)
|
|
209
|
+
except ValueError:
|
|
210
|
+
raise MsolveOutputError(
|
|
211
|
+
f"'#{key}' is not an integer: {value[:60]!r}"
|
|
212
|
+
) from None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _require_variables(fields: dict[str, str]) -> tuple[str, ...]:
|
|
216
|
+
value = _require_field(fields, "variable order")
|
|
217
|
+
variables = tuple(part.strip() for part in value.split(","))
|
|
218
|
+
if any(not name for name in variables):
|
|
219
|
+
raise MsolveOutputError(f"malformed '#variable order': {value[:60]!r}")
|
|
220
|
+
return variables
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _check_length(fields: dict[str, str], basis: tuple[str, ...]) -> None:
|
|
224
|
+
"""Cross-check the printed basis length against what we actually read."""
|
|
225
|
+
value = _require_field(fields, "length of basis")
|
|
226
|
+
match = _LENGTH_RE.search(value)
|
|
227
|
+
if match is None:
|
|
228
|
+
raise MsolveOutputError(f"malformed '#length of basis': {value[:60]!r}")
|
|
229
|
+
declared = int(match.group(1))
|
|
230
|
+
if declared != len(basis):
|
|
231
|
+
raise MsolveOutputError(
|
|
232
|
+
f"msolve declared {declared} basis element(s) but {len(basis)} were "
|
|
233
|
+
f"parsed; the output is truncated or corrupt"
|
|
234
|
+
)
|
msolveio/py.typed
ADDED
|
File without changes
|
msolveio/run.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Subprocess wrapper around the msolve CLI.
|
|
2
|
+
|
|
3
|
+
Runs msolve with stdin closed, a mandatory timeout, and a version gate, then
|
|
4
|
+
hands the output to :func:`msolveio.parse_groebner`. Nothing here interprets
|
|
5
|
+
msolve's bytes itself.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Literal
|
|
19
|
+
|
|
20
|
+
from .errors import MsolveDied, MsolveTimeout, MsolveVersionUnsupported
|
|
21
|
+
from .mode import Mode
|
|
22
|
+
from .parse import GroebnerOutput, parse_groebner
|
|
23
|
+
|
|
24
|
+
__all__ = ["RunResult", "run_groebner", "SUPPORTED_VERSION_PREFIX"]
|
|
25
|
+
|
|
26
|
+
#: The only msolve series v0.1 claims to understand.
|
|
27
|
+
SUPPORTED_VERSION_PREFIX = "0.10."
|
|
28
|
+
|
|
29
|
+
_VERSION_RE = re.compile(r"\b(\d+\.\d+(?:\.\d+)?)\b")
|
|
30
|
+
_VERSION_PROBE_TIMEOUT = 20.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class RunResult:
|
|
35
|
+
"""Everything one msolve invocation produced, plus how it was produced.
|
|
36
|
+
|
|
37
|
+
:param output: the parsed Groebner basis.
|
|
38
|
+
:param argv: the exact command line, with the temp paths msolve saw.
|
|
39
|
+
:param msolve_version: the version string reported by ``msolve --version``,
|
|
40
|
+
or ``"unknown"`` when the probe failed under ``allow_unknown_version``.
|
|
41
|
+
:param wall_seconds: wall-clock time for the solve, excluding the probe.
|
|
42
|
+
:param returncode: msolve's exit status.
|
|
43
|
+
:param stderr: msolve's captured stderr.
|
|
44
|
+
:param input_sha256: SHA-256 of the ``.ms`` bytes written to disk.
|
|
45
|
+
:param output_sha256: SHA-256 of the output-file bytes read back.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
output: GroebnerOutput
|
|
49
|
+
argv: tuple[str, ...]
|
|
50
|
+
msolve_version: str
|
|
51
|
+
wall_seconds: float
|
|
52
|
+
returncode: int
|
|
53
|
+
stderr: str
|
|
54
|
+
input_sha256: str
|
|
55
|
+
output_sha256: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run_groebner(
|
|
59
|
+
source: str,
|
|
60
|
+
*,
|
|
61
|
+
gb: Literal[1, 2] = 2,
|
|
62
|
+
timeout: float,
|
|
63
|
+
threads: int = 1,
|
|
64
|
+
binary: str | Path | None = None,
|
|
65
|
+
allow_unknown_version: bool = False,
|
|
66
|
+
mode: Mode = Mode.GROEBNER,
|
|
67
|
+
) -> RunResult:
|
|
68
|
+
"""Run msolve in Groebner mode on ``source`` and parse the result.
|
|
69
|
+
|
|
70
|
+
:param source: msolve input text, normally from :func:`msolveio.emit_system`.
|
|
71
|
+
:param gb: ``2`` for the reduced Groebner basis, ``1`` for the leading ideal.
|
|
72
|
+
:param timeout: wall-clock limit in seconds. Required; there is no default,
|
|
73
|
+
because an unbounded Groebner computation is not a thing to opt into by
|
|
74
|
+
accident.
|
|
75
|
+
:param threads: value for msolve's ``-t``.
|
|
76
|
+
:param binary: path to the msolve executable. Defaults to
|
|
77
|
+
``shutil.which("msolve")``.
|
|
78
|
+
:param allow_unknown_version: run anyway if the binary is not 0.10.x. The
|
|
79
|
+
parser is written against 0.10.x output and may reject or misread others.
|
|
80
|
+
:param mode: must be :attr:`Mode.GROEBNER`.
|
|
81
|
+
:raises MsolveVersionUnsupported: if the version gate fails.
|
|
82
|
+
:raises MsolveTimeout: if msolve exceeded ``timeout``.
|
|
83
|
+
:raises MsolveDied: if msolve exited nonzero, took a signal, or wrote nothing.
|
|
84
|
+
:raises MsolveOutputError: if msolve's output is not well-formed.
|
|
85
|
+
"""
|
|
86
|
+
if mode is not Mode.GROEBNER:
|
|
87
|
+
raise ValueError(f"unsupported mode {mode!r}; v0.1 runs Mode.GROEBNER only")
|
|
88
|
+
if gb not in (1, 2):
|
|
89
|
+
raise ValueError(f"gb must be 1 or 2, got {gb!r}")
|
|
90
|
+
if not isinstance(threads, int) or isinstance(threads, bool) or threads < 1:
|
|
91
|
+
raise ValueError(f"threads must be a positive int, got {threads!r}")
|
|
92
|
+
if not isinstance(timeout, (int, float)) or isinstance(timeout, bool):
|
|
93
|
+
raise TypeError(f"timeout must be a number, got {type(timeout).__name__}")
|
|
94
|
+
if timeout <= 0:
|
|
95
|
+
raise ValueError(f"timeout must be positive, got {timeout!r}")
|
|
96
|
+
if not isinstance(source, str):
|
|
97
|
+
raise TypeError(f"source must be str, got {type(source).__name__}")
|
|
98
|
+
|
|
99
|
+
executable = _resolve_binary(binary)
|
|
100
|
+
version = _check_version(executable, allow_unknown_version)
|
|
101
|
+
|
|
102
|
+
payload = source.encode("utf-8")
|
|
103
|
+
with tempfile.TemporaryDirectory(prefix="msolveio-") as tmp:
|
|
104
|
+
in_path = Path(tmp) / "system.ms"
|
|
105
|
+
out_path = Path(tmp) / "basis.out"
|
|
106
|
+
in_path.write_bytes(payload)
|
|
107
|
+
|
|
108
|
+
argv = (
|
|
109
|
+
executable,
|
|
110
|
+
"-g",
|
|
111
|
+
str(gb),
|
|
112
|
+
"-f",
|
|
113
|
+
str(in_path),
|
|
114
|
+
"-o",
|
|
115
|
+
str(out_path),
|
|
116
|
+
"-t",
|
|
117
|
+
str(threads),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
started = time.monotonic()
|
|
121
|
+
try:
|
|
122
|
+
completed = subprocess.run(
|
|
123
|
+
argv,
|
|
124
|
+
stdin=subprocess.DEVNULL,
|
|
125
|
+
capture_output=True,
|
|
126
|
+
timeout=timeout,
|
|
127
|
+
check=False,
|
|
128
|
+
)
|
|
129
|
+
except subprocess.TimeoutExpired as exc:
|
|
130
|
+
raise MsolveTimeout(
|
|
131
|
+
f"msolve exceeded the {timeout}s timeout and was killed",
|
|
132
|
+
timeout=float(timeout),
|
|
133
|
+
) from exc
|
|
134
|
+
except OSError as exc:
|
|
135
|
+
raise MsolveDied(f"could not execute {executable!r}: {exc}") from exc
|
|
136
|
+
wall_seconds = time.monotonic() - started
|
|
137
|
+
|
|
138
|
+
stderr = completed.stderr.decode("utf-8", errors="replace")
|
|
139
|
+
_check_exit(completed.returncode, argv, stderr)
|
|
140
|
+
|
|
141
|
+
raw = out_path.read_bytes() if out_path.exists() else b""
|
|
142
|
+
if not raw.strip():
|
|
143
|
+
# Fall back to stdout: some builds print the basis rather than
|
|
144
|
+
# writing it when the output file cannot be produced.
|
|
145
|
+
raw = completed.stdout
|
|
146
|
+
|
|
147
|
+
if not raw.strip():
|
|
148
|
+
raise MsolveDied(
|
|
149
|
+
"msolve exited 0 but produced no output",
|
|
150
|
+
returncode=completed.returncode,
|
|
151
|
+
stderr=stderr,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
text = raw.decode("utf-8", errors="replace")
|
|
155
|
+
output = parse_groebner(text, mode=mode)
|
|
156
|
+
|
|
157
|
+
return RunResult(
|
|
158
|
+
output=output,
|
|
159
|
+
argv=argv,
|
|
160
|
+
msolve_version=version,
|
|
161
|
+
wall_seconds=wall_seconds,
|
|
162
|
+
returncode=completed.returncode,
|
|
163
|
+
stderr=stderr,
|
|
164
|
+
input_sha256=hashlib.sha256(payload).hexdigest(),
|
|
165
|
+
output_sha256=hashlib.sha256(raw).hexdigest(),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _resolve_binary(binary: str | Path | None) -> str:
|
|
170
|
+
if binary is None:
|
|
171
|
+
found = shutil.which("msolve")
|
|
172
|
+
if found is None:
|
|
173
|
+
raise MsolveDied(
|
|
174
|
+
"no msolve binary found on PATH; install msolve 0.10.x or pass "
|
|
175
|
+
"binary=..."
|
|
176
|
+
)
|
|
177
|
+
return found
|
|
178
|
+
return str(binary)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _check_exit(returncode: int, argv: tuple[str, ...], stderr: str) -> None:
|
|
182
|
+
if returncode == 0:
|
|
183
|
+
return
|
|
184
|
+
if returncode < 0:
|
|
185
|
+
raise MsolveDied(
|
|
186
|
+
f"msolve was killed by signal {-returncode}"
|
|
187
|
+
+ (f": {stderr.strip()[:200]}" if stderr.strip() else ""),
|
|
188
|
+
returncode=returncode,
|
|
189
|
+
signal=-returncode,
|
|
190
|
+
stderr=stderr,
|
|
191
|
+
)
|
|
192
|
+
raise MsolveDied(
|
|
193
|
+
f"msolve exited with status {returncode}"
|
|
194
|
+
+ (f": {stderr.strip()[:200]}" if stderr.strip() else ""),
|
|
195
|
+
returncode=returncode,
|
|
196
|
+
stderr=stderr,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _check_version(executable: str, allow_unknown_version: bool) -> str:
|
|
201
|
+
"""Probe ``msolve --version``. Returns the version string it reported."""
|
|
202
|
+
try:
|
|
203
|
+
probe = subprocess.run(
|
|
204
|
+
[executable, "--version"],
|
|
205
|
+
stdin=subprocess.DEVNULL,
|
|
206
|
+
capture_output=True,
|
|
207
|
+
timeout=_VERSION_PROBE_TIMEOUT,
|
|
208
|
+
check=False,
|
|
209
|
+
)
|
|
210
|
+
blob = (probe.stdout + b"\n" + probe.stderr).decode("utf-8", errors="replace")
|
|
211
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
212
|
+
if allow_unknown_version:
|
|
213
|
+
return "unknown"
|
|
214
|
+
raise MsolveVersionUnsupported(
|
|
215
|
+
f"could not determine the version of {executable!r}: {exc}. Pass "
|
|
216
|
+
f"allow_unknown_version=True to run anyway."
|
|
217
|
+
) from exc
|
|
218
|
+
|
|
219
|
+
match = _VERSION_RE.search(blob)
|
|
220
|
+
if match is None:
|
|
221
|
+
if allow_unknown_version:
|
|
222
|
+
return "unknown"
|
|
223
|
+
raise MsolveVersionUnsupported(
|
|
224
|
+
f"{executable!r} did not report a recognizable version. Pass "
|
|
225
|
+
f"allow_unknown_version=True to run anyway."
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
version = match.group(1)
|
|
229
|
+
if not version.startswith(SUPPORTED_VERSION_PREFIX) and not allow_unknown_version:
|
|
230
|
+
raise MsolveVersionUnsupported(
|
|
231
|
+
f"msolve {version} is not supported; msolveio v0.1 targets "
|
|
232
|
+
f"{SUPPORTED_VERSION_PREFIX}x output. Pass "
|
|
233
|
+
f"allow_unknown_version=True to run anyway.",
|
|
234
|
+
version=version,
|
|
235
|
+
)
|
|
236
|
+
return version
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: msolveio
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Strict Python I/O for msolve: canonical input, mode-required output.
|
|
5
|
+
Author: DC Posch
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/dcposch/msolveio
|
|
8
|
+
Project-URL: Source, https://github.com/dcposch/msolveio
|
|
9
|
+
Keywords: msolve,groebner,polynomial,computer-algebra
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# msolveio
|
|
24
|
+
|
|
25
|
+
Strict Python I/O for msolve: canonical input, mode-required output.
|
|
26
|
+
Gröbner-mode (`-g`) only in v0.1. Solver-mode bytes are rejected, not interpreted.
|
|
27
|
+
|
|
28
|
+
msolveio writes `.ms` files that msolve 0.10.x will parse the way you meant, and reads back
|
|
29
|
+
only the one output language it can identify with certainty. It is not a CAS and not a
|
|
30
|
+
Gröbner engine.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
pip install msolveio
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
You also need a system `msolve` 0.10.x binary on `PATH` (or pass `binary=`). msolveio has no
|
|
39
|
+
runtime dependencies.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from msolveio import emit_system, parse_groebner, run_groebner, MsolveAmbiguous
|
|
45
|
+
|
|
46
|
+
source = emit_system(
|
|
47
|
+
["x^2+y", "x*y-1"],
|
|
48
|
+
variables=["x", "y"],
|
|
49
|
+
characteristic=0,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
result = run_groebner(source, gb=2, timeout=60)
|
|
53
|
+
|
|
54
|
+
print(result.output.unit_ideal) # False
|
|
55
|
+
print(result.output.basis) # ('y^2+x', 'x*y-1', 'x^2+y')
|
|
56
|
+
print(result.msolve_version) # '0.10.1'
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`emit_system` raises `MsolveInputError` rather than rewriting input: parentheses,
|
|
60
|
+
post-monomial division (`x/2`), repeated monomials, unknown identifiers, and coefficients
|
|
61
|
+
that would overflow msolve's 64-bit read are all refused. Leading rationals (`1/2*x`) are
|
|
62
|
+
allowed over Q only.
|
|
63
|
+
|
|
64
|
+
`parse_groebner` requires msolve's `#` comment header. That header is the only thing in the
|
|
65
|
+
bytes that says which mode produced them, so it is load-bearing. Feeding it solver output
|
|
66
|
+
raises `MsolveAmbiguous` instead of returning a basis:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
parse_groebner("[-1]:") # MsolveAmbiguous
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
This matters because the two languages invert each other. In solver mode `[-1]:` means *no
|
|
73
|
+
solutions*; in Gröbner mode the unit ideal — the same fact — prints as `[1]:`. A parser that
|
|
74
|
+
guesses gets the answer exactly backwards.
|
|
75
|
+
|
|
76
|
+
## Not supported in v0.1
|
|
77
|
+
|
|
78
|
+
- Solver mode, real-root isolation, and `-P` parametrization output — these raise, and are
|
|
79
|
+
never interpreted as a basis.
|
|
80
|
+
- JSON output, Macaulay2 format, or any other msolve serialization.
|
|
81
|
+
- sympy / flint / numpy interop. Basis elements are returned as strings, exactly as msolve
|
|
82
|
+
printed them. Nothing is `eval`'d.
|
|
83
|
+
|
|
84
|
+
## A note on characteristic
|
|
85
|
+
|
|
86
|
+
msolve 0.10.1 labels some unlifted rational Gröbner bases as characteristic 0 regardless of
|
|
87
|
+
whether a lift to Q actually happened. `GroebnerOutput.characteristic` reports what msolve
|
|
88
|
+
printed and nothing more; msolveio does not pretend to know better.
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT © 2026 DC Posch — <https://github.com/dcposch/msolveio>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
msolveio/__init__.py,sha256=bWPGDDzlqcYpogO-Y6o6S0yAXBsScnAvwo1ZCWe28Fc,1035
|
|
2
|
+
msolveio/emit.py,sha256=2vf9XgkI9N1LAMMgt0GCVujMV98ozORMf0j5fdtd_Bw,13017
|
|
3
|
+
msolveio/errors.py,sha256=xyuBW2aDzAYrANmnBspaBXypigcxzW01uHVUX7QW_ao,2168
|
|
4
|
+
msolveio/mode.py,sha256=ChPjg-hwvzqHh72HMs_b-QSFZOZBCnLWQbpzjwP3uEU,588
|
|
5
|
+
msolveio/parse.py,sha256=QeqtIGgJW9ZZnyWZ81EuNlt_pjWBMwpcYJP0Yi8uQso,8586
|
|
6
|
+
msolveio/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
msolveio/run.py,sha256=oaRSXzAqwd_lZEQOrOoJCfTwf64uKJ6JjZJ6MXEjJUM,8367
|
|
8
|
+
msolveio-0.1.0.dist-info/licenses/LICENSE,sha256=FevCuy6qOnFNl21jI0io0OCoi82mi0dAvboCzFNkPAc,1065
|
|
9
|
+
msolveio-0.1.0.dist-info/METADATA,sha256=ujDv0LIAjhoLD8bPDpx_nsbasm-YEI3QdL9BvD1nrZA,3219
|
|
10
|
+
msolveio-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
msolveio-0.1.0.dist-info/top_level.txt,sha256=WG3OY_xYkzhNScI8nrpvo8HClmaImUgaDpwplsSgMgo,9
|
|
12
|
+
msolveio-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DC Posch
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
msolveio
|