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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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