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 +21 -0
- msolveio-0.1.0/PKG-INFO +92 -0
- msolveio-0.1.0/README.md +70 -0
- msolveio-0.1.0/pyproject.toml +38 -0
- msolveio-0.1.0/setup.cfg +4 -0
- msolveio-0.1.0/src/msolveio/__init__.py +43 -0
- msolveio-0.1.0/src/msolveio/emit.py +373 -0
- msolveio-0.1.0/src/msolveio/errors.py +76 -0
- msolveio-0.1.0/src/msolveio/mode.py +20 -0
- msolveio-0.1.0/src/msolveio/parse.py +234 -0
- msolveio-0.1.0/src/msolveio/py.typed +0 -0
- msolveio-0.1.0/src/msolveio/run.py +236 -0
- msolveio-0.1.0/src/msolveio.egg-info/PKG-INFO +92 -0
- msolveio-0.1.0/src/msolveio.egg-info/SOURCES.txt +18 -0
- msolveio-0.1.0/src/msolveio.egg-info/dependency_links.txt +1 -0
- msolveio-0.1.0/src/msolveio.egg-info/requires.txt +3 -0
- msolveio-0.1.0/src/msolveio.egg-info/top_level.txt +1 -0
- msolveio-0.1.0/tests/test_emit.py +150 -0
- msolveio-0.1.0/tests/test_parse.py +149 -0
- msolveio-0.1.0/tests/test_run.py +155 -0
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.
|
msolveio-0.1.0/PKG-INFO
ADDED
|
@@ -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>
|
msolveio-0.1.0/README.md
ADDED
|
@@ -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"]
|
msolveio-0.1.0/setup.cfg
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
|
+
]
|
|
@@ -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
|
+
)
|