msolveio 0.1.0__tar.gz

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-0.1.0/LICENSE ADDED
@@ -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,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,70 @@
1
+ # msolveio
2
+
3
+ Strict Python I/O for msolve: canonical input, mode-required output.
4
+ Gröbner-mode (`-g`) only in v0.1. Solver-mode bytes are rejected, not interpreted.
5
+
6
+ msolveio writes `.ms` files that msolve 0.10.x will parse the way you meant, and reads back
7
+ only the one output language it can identify with certainty. It is not a CAS and not a
8
+ Gröbner engine.
9
+
10
+ ## Install
11
+
12
+ ```
13
+ pip install msolveio
14
+ ```
15
+
16
+ You also need a system `msolve` 0.10.x binary on `PATH` (or pass `binary=`). msolveio has no
17
+ runtime dependencies.
18
+
19
+ ## Usage
20
+
21
+ ```python
22
+ from msolveio import emit_system, parse_groebner, run_groebner, MsolveAmbiguous
23
+
24
+ source = emit_system(
25
+ ["x^2+y", "x*y-1"],
26
+ variables=["x", "y"],
27
+ characteristic=0,
28
+ )
29
+
30
+ result = run_groebner(source, gb=2, timeout=60)
31
+
32
+ print(result.output.unit_ideal) # False
33
+ print(result.output.basis) # ('y^2+x', 'x*y-1', 'x^2+y')
34
+ print(result.msolve_version) # '0.10.1'
35
+ ```
36
+
37
+ `emit_system` raises `MsolveInputError` rather than rewriting input: parentheses,
38
+ post-monomial division (`x/2`), repeated monomials, unknown identifiers, and coefficients
39
+ that would overflow msolve's 64-bit read are all refused. Leading rationals (`1/2*x`) are
40
+ allowed over Q only.
41
+
42
+ `parse_groebner` requires msolve's `#` comment header. That header is the only thing in the
43
+ bytes that says which mode produced them, so it is load-bearing. Feeding it solver output
44
+ raises `MsolveAmbiguous` instead of returning a basis:
45
+
46
+ ```python
47
+ parse_groebner("[-1]:") # MsolveAmbiguous
48
+ ```
49
+
50
+ This matters because the two languages invert each other. In solver mode `[-1]:` means *no
51
+ solutions*; in Gröbner mode the unit ideal — the same fact — prints as `[1]:`. A parser that
52
+ guesses gets the answer exactly backwards.
53
+
54
+ ## Not supported in v0.1
55
+
56
+ - Solver mode, real-root isolation, and `-P` parametrization output — these raise, and are
57
+ never interpreted as a basis.
58
+ - JSON output, Macaulay2 format, or any other msolve serialization.
59
+ - sympy / flint / numpy interop. Basis elements are returned as strings, exactly as msolve
60
+ printed them. Nothing is `eval`'d.
61
+
62
+ ## A note on characteristic
63
+
64
+ msolve 0.10.1 labels some unlifted rational Gröbner bases as characteristic 0 regardless of
65
+ whether a lift to Q actually happened. `GroebnerOutput.characteristic` reports what msolve
66
+ printed and nothing more; msolveio does not pretend to know better.
67
+
68
+ ## License
69
+
70
+ MIT © 2026 DC Posch — <https://github.com/dcposch/msolveio>
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "msolveio"
7
+ version = "0.1.0"
8
+ description = "Strict Python I/O for msolve: canonical input, mode-required output."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "DC Posch" }]
13
+ keywords = ["msolve", "groebner", "polynomial", "computer-algebra"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Science/Research",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Scientific/Engineering :: Mathematics",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.optional-dependencies]
25
+ dev = ["pytest>=7"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/dcposch/msolveio"
29
+ Source = "https://github.com/dcposch/msolveio"
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
33
+
34
+ [tool.setuptools.package-data]
35
+ msolveio = ["py.typed"]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]
@@ -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
+ )