qqideal 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.
qqideal-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.
qqideal-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: qqideal
3
+ Version: 0.1.0
4
+ Summary: Exact ideals over QQ: python-flint for arithmetic, msolve for Groebner bases.
5
+ Author: DC Posch
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/dcposch/qqideal
8
+ Project-URL: Source, https://github.com/dcposch/qqideal
9
+ Keywords: msolve,groebner,ideal,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
+ Requires-Dist: msolveio>=0.1.0
20
+ Requires-Dist: python-flint<1,>=0.8
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=7; extra == "dev"
23
+ Dynamic: license-file
24
+
25
+ # qqideal
26
+
27
+ Exact ideals over QQ: python-flint for arithmetic, msolve for Gröbner bases, verdicts that
28
+ refuse to guess. Emptiness, dimension, and 0-dimensional degree from grevlex leading ideals.
29
+ No solver-mode I/O.
30
+
31
+ ## Install
32
+
33
+ ```
34
+ pip install qqideal
35
+ ```
36
+
37
+ That pulls [msolveio](https://pypi.org/project/msolveio/) and python-flint from PyPI. You also
38
+ need a system `msolve` 0.10.x binary on `PATH`.
39
+
40
+ ## Usage
41
+
42
+ ```python
43
+ from qqideal import Ideal, Kind, Ring, ideal_verdict
44
+
45
+ R = Ring("x", "y")
46
+ I = Ideal(["x^2-1", "y-x"], ring=R)
47
+
48
+ verdict = ideal_verdict(I)
49
+ print(verdict.kind) # Kind.NONEMPTY
50
+ print(verdict.dim) # 0
51
+ print(verdict.degree) # 2
52
+ print(verdict.certainty) # Certainty.PROVEN
53
+
54
+ if verdict.kind is Kind.NONEMPTY: # never `if verdict:` -- that raises
55
+ print(I.groebner()) # (Poly('x - y', ...), Poly('y^2 - 1', ...))
56
+ ```
57
+
58
+ `Verdict.__bool__` raises `TypeError`. `TIMEOUT` and `ERROR` are not answers, and a
59
+ truthiness test would silently fold them into one of the two that are.
60
+
61
+ `opens=` saturates before the test, so you can ask about the complement of a hypersurface:
62
+
63
+ ```python
64
+ ideal_verdict(["x*y", "x"], ring=R, opens=["x"]).kind # Kind.EMPTY
65
+ Ideal(["x^2"], ring=R).radical_member("x").kind # Kind.EMPTY: x is in the radical
66
+ ```
67
+
68
+ ## Certainty
69
+
70
+ A unit ideal over Q from msolve `-g` is `Certainty.MODULAR`, not `PROVEN`: msolve 0.10.1
71
+ returns after its first modular prime and still prints characteristic 0. A nonempty result
72
+ over Q uses a lifted `-g 2` basis and is `Certainty.PROVEN`, as is any result over a prime
73
+ field.
74
+
75
+ ## Saturation
76
+
77
+ `I.saturate(f)` is Rabinowitsch: it returns `I + (u*f - 1)` in the ring extended by one slack
78
+ variable. Its variety is `V(I) \ V(f)`, so emptiness, dimension, and degree are those of
79
+ `I : f^∞` -- but its generators are not that ideal written back in `R`, which would need an
80
+ elimination order msolve's Gröbner mode does not offer. `colon` is an alias, and in v0.1 the
81
+ colon is the saturation.
82
+
83
+ ## Not in v0.1
84
+
85
+ Primary decomposition, positive-dimensional radicals, radical computation of any kind
86
+ (membership only), solver mode / `-P`, Macaulay2. These raise `NotImplementedError` rather
87
+ than returning an approximation.
88
+
89
+ ## License
90
+
91
+ MIT © 2026 DC Posch — <https://github.com/dcposch/qqideal>
@@ -0,0 +1,67 @@
1
+ # qqideal
2
+
3
+ Exact ideals over QQ: python-flint for arithmetic, msolve for Gröbner bases, verdicts that
4
+ refuse to guess. Emptiness, dimension, and 0-dimensional degree from grevlex leading ideals.
5
+ No solver-mode I/O.
6
+
7
+ ## Install
8
+
9
+ ```
10
+ pip install qqideal
11
+ ```
12
+
13
+ That pulls [msolveio](https://pypi.org/project/msolveio/) and python-flint from PyPI. You also
14
+ need a system `msolve` 0.10.x binary on `PATH`.
15
+
16
+ ## Usage
17
+
18
+ ```python
19
+ from qqideal import Ideal, Kind, Ring, ideal_verdict
20
+
21
+ R = Ring("x", "y")
22
+ I = Ideal(["x^2-1", "y-x"], ring=R)
23
+
24
+ verdict = ideal_verdict(I)
25
+ print(verdict.kind) # Kind.NONEMPTY
26
+ print(verdict.dim) # 0
27
+ print(verdict.degree) # 2
28
+ print(verdict.certainty) # Certainty.PROVEN
29
+
30
+ if verdict.kind is Kind.NONEMPTY: # never `if verdict:` -- that raises
31
+ print(I.groebner()) # (Poly('x - y', ...), Poly('y^2 - 1', ...))
32
+ ```
33
+
34
+ `Verdict.__bool__` raises `TypeError`. `TIMEOUT` and `ERROR` are not answers, and a
35
+ truthiness test would silently fold them into one of the two that are.
36
+
37
+ `opens=` saturates before the test, so you can ask about the complement of a hypersurface:
38
+
39
+ ```python
40
+ ideal_verdict(["x*y", "x"], ring=R, opens=["x"]).kind # Kind.EMPTY
41
+ Ideal(["x^2"], ring=R).radical_member("x").kind # Kind.EMPTY: x is in the radical
42
+ ```
43
+
44
+ ## Certainty
45
+
46
+ A unit ideal over Q from msolve `-g` is `Certainty.MODULAR`, not `PROVEN`: msolve 0.10.1
47
+ returns after its first modular prime and still prints characteristic 0. A nonempty result
48
+ over Q uses a lifted `-g 2` basis and is `Certainty.PROVEN`, as is any result over a prime
49
+ field.
50
+
51
+ ## Saturation
52
+
53
+ `I.saturate(f)` is Rabinowitsch: it returns `I + (u*f - 1)` in the ring extended by one slack
54
+ variable. Its variety is `V(I) \ V(f)`, so emptiness, dimension, and degree are those of
55
+ `I : f^∞` -- but its generators are not that ideal written back in `R`, which would need an
56
+ elimination order msolve's Gröbner mode does not offer. `colon` is an alias, and in v0.1 the
57
+ colon is the saturation.
58
+
59
+ ## Not in v0.1
60
+
61
+ Primary decomposition, positive-dimensional radicals, radical computation of any kind
62
+ (membership only), solver mode / `-P`, Macaulay2. These raise `NotImplementedError` rather
63
+ than returning an approximation.
64
+
65
+ ## License
66
+
67
+ MIT © 2026 DC Posch — <https://github.com/dcposch/qqideal>
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "qqideal"
7
+ version = "0.1.0"
8
+ description = "Exact ideals over QQ: python-flint for arithmetic, msolve for Groebner bases."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "DC Posch" }]
13
+ keywords = ["msolve", "groebner", "ideal", "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
+ "msolveio>=0.1.0",
24
+ "python-flint>=0.8,<1",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ dev = ["pytest>=7"]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/dcposch/qqideal"
32
+ Source = "https://github.com/dcposch/qqideal"
33
+
34
+ [tool.setuptools.packages.find]
35
+ where = ["src"]
36
+
37
+ [tool.setuptools.package-data]
38
+ qqideal = ["py.typed"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,37 @@
1
+ """Exact ideals over Q, with python-flint for arithmetic and msolve for
2
+ Groebner bases.
3
+
4
+ qqideal answers three questions about an ideal -- is its variety empty, what is
5
+ its dimension, and (when the dimension is zero) what is its degree -- and
6
+ refuses the rest. It is not a computer algebra system: anything outside that
7
+ slate raises :class:`NotImplementedError` rather than returning something
8
+ approximate.
9
+
10
+ msolve is called in Groebner mode only. Solver mode and ``-P`` parametrizations
11
+ are never invoked, so no output whose meaning depends on the mode is ever
12
+ interpreted.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from .doublepoint import double_point_ideal
18
+ from .errors import MsolveInputError, QQIdealError, RingMismatch
19
+ from .ideal import Ideal
20
+ from .ring import Poly, Ring
21
+ from .verdict import Certainty, Kind, Verdict, ideal_verdict
22
+
23
+ __version__ = "0.1.0"
24
+
25
+ __all__ = [
26
+ "Ring",
27
+ "Poly",
28
+ "Ideal",
29
+ "Kind",
30
+ "Certainty",
31
+ "Verdict",
32
+ "ideal_verdict",
33
+ "double_point_ideal",
34
+ "QQIdealError",
35
+ "RingMismatch",
36
+ "MsolveInputError",
37
+ ]
@@ -0,0 +1,127 @@
1
+ """Krull dimension and 0-dimensional degree, read off a leading ideal.
2
+
3
+ Both quantities are combinatorial once you have the leading monomials of a
4
+ Groebner basis: ``k[x]/I`` and ``k[x]/LT(I)`` have the same Hilbert function, so
5
+ they have the same dimension, and in the 0-dimensional case the same vector
6
+ space dimension over ``k``. Nothing here talks to msolve; the input is just
7
+ exponent vectors, which makes it cheap to test.
8
+
9
+ The convention for the unit ideal is ``dimension == -1``: ``LT(I)`` contains the
10
+ constant monomial, ``k[x]/I`` is the zero ring, and the variety is empty.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from itertools import product
16
+ from typing import Iterable, Sequence
17
+
18
+ __all__ = ["dimension", "degree", "dim_and_degree"]
19
+
20
+
21
+ def dimension(leading: Iterable[Sequence[int]], nvars: int) -> int:
22
+ """Krull dimension of ``k[x1..xn]/I`` from the leading monomials of ``I``.
23
+
24
+ The dimension is the largest number of variables ``S`` such that ``LT(I)``
25
+ contains no monomial supported entirely inside ``S``.
26
+
27
+ :param leading: exponent vectors of the leading monomials, each of length
28
+ ``nvars``. An empty collection means the zero ideal.
29
+ :param nvars: the number of variables.
30
+ :returns: the dimension, or ``-1`` for the unit ideal.
31
+ """
32
+ supports = _supports(leading, nvars)
33
+ best = -1
34
+
35
+ def search(start: int, chosen: int, size: int) -> None:
36
+ nonlocal best
37
+ # Independence is downward closed, so a dependent set prunes the whole
38
+ # subtree below it.
39
+ if any(support & ~chosen == 0 for support in supports):
40
+ return
41
+ if size > best:
42
+ best = size
43
+ for index in range(start, nvars):
44
+ if size + (nvars - index) <= best:
45
+ break
46
+ search(index + 1, chosen | (1 << index), size + 1)
47
+
48
+ search(0, 0, 0)
49
+ return best
50
+
51
+
52
+ def degree(leading: Iterable[Sequence[int]], nvars: int) -> int:
53
+ """Number of standard monomials, i.e. ``dim_k k[x1..xn]/I``.
54
+
55
+ This is the affine degree of a 0-dimensional ideal, counted with
56
+ multiplicity.
57
+
58
+ :raises ValueError: unless the ideal is 0-dimensional. In positive dimension
59
+ there are infinitely many standard monomials and no such number.
60
+ """
61
+ monomials = _normalize(leading, nvars)
62
+ bounds: list[int] = []
63
+ for index in range(nvars):
64
+ pure = [
65
+ monomial[index]
66
+ for monomial in monomials
67
+ if all(e == 0 for position, e in enumerate(monomial) if position != index)
68
+ ]
69
+ if not pure:
70
+ raise ValueError(
71
+ f"the ideal is not 0-dimensional: the leading ideal contains no "
72
+ f"pure power of variable {index}"
73
+ )
74
+ bounds.append(min(pure))
75
+ if any(bound == 0 for bound in bounds):
76
+ # A pure power with exponent 0 is the constant monomial: the unit ideal.
77
+ return 0
78
+
79
+ count = 0
80
+ for candidate in product(*(range(bound) for bound in bounds)):
81
+ if not any(_divides(monomial, candidate) for monomial in monomials):
82
+ count += 1
83
+ return count
84
+
85
+
86
+ def dim_and_degree(
87
+ leading: Iterable[Sequence[int]], nvars: int
88
+ ) -> tuple[int, int | None]:
89
+ """``(dimension, degree)``, with ``degree`` only when the dimension is 0."""
90
+ monomials = _normalize(leading, nvars)
91
+ dim = dimension(monomials, nvars)
92
+ if dim != 0:
93
+ return dim, None
94
+ return dim, degree(monomials, nvars)
95
+
96
+
97
+ def _normalize(
98
+ leading: Iterable[Sequence[int]], nvars: int
99
+ ) -> tuple[tuple[int, ...], ...]:
100
+ monomials: list[tuple[int, ...]] = []
101
+ for monomial in leading:
102
+ exponents = tuple(int(e) for e in monomial)
103
+ if len(exponents) != nvars:
104
+ raise ValueError(
105
+ f"leading monomial {exponents} has {len(exponents)} exponents, "
106
+ f"expected {nvars}"
107
+ )
108
+ if any(e < 0 for e in exponents):
109
+ raise ValueError(f"leading monomial {exponents} has a negative exponent")
110
+ monomials.append(exponents)
111
+ return tuple(monomials)
112
+
113
+
114
+ def _supports(leading: Iterable[Sequence[int]], nvars: int) -> tuple[int, ...]:
115
+ """One bitmask per leading monomial, marking which variables it uses."""
116
+ masks = set()
117
+ for monomial in _normalize(leading, nvars):
118
+ mask = 0
119
+ for index, exponent in enumerate(monomial):
120
+ if exponent:
121
+ mask |= 1 << index
122
+ masks.add(mask)
123
+ return tuple(masks)
124
+
125
+
126
+ def _divides(monomial: Sequence[int], candidate: Sequence[int]) -> bool:
127
+ return all(a <= b for a, b in zip(monomial, candidate))
@@ -0,0 +1,96 @@
1
+ """The double-point ideal of a polynomial parametrization.
2
+
3
+ For a plane curve parametrized by ``t -> (p(t), q(t))``, two distinct parameters
4
+ land on the same point exactly when ``p(s) = p(t)`` and ``q(s) = q(t)`` with
5
+ ``s != t``. That is the ideal
6
+
7
+ I_DP = (p(s) - p(t), q(s) - q(t)) : (s - t)^oo
8
+
9
+ in ``QQ[s, t]``: saturating at ``s - t`` throws away the diagonal, which solves
10
+ the equations trivially and says nothing.
11
+
12
+ This module builds that ideal and stops there. It is not a test for whether a
13
+ parametrization is an embedding: an injective map can still fail to be one, and
14
+ the cusp ``t -> (t^2, t^3)`` is the standard example -- it has no double point
15
+ at all, and fails on the derivative instead. Deciding embedding needs the rest
16
+ of the postcheck (length, immersivity, distinct tangents), which is not here.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from .errors import MsolveInputError, RingMismatch
22
+ from .ideal import Ideal
23
+ from .ring import Poly, Ring
24
+
25
+ __all__ = ["double_point_ideal"]
26
+
27
+
28
+ def double_point_ideal(
29
+ p: "Poly | str",
30
+ q: "Poly | str",
31
+ *,
32
+ ring: Ring | None = None,
33
+ names: tuple[str, str] = ("s", "t"),
34
+ ) -> Ideal:
35
+ """Build ``(p(s)-p(t), q(s)-q(t)) : (s-t)^oo`` for the parametrization
36
+ ``(p, q)``.
37
+
38
+ :param p: first coordinate, a univariate polynomial or a string.
39
+ :param q: second coordinate, likewise.
40
+ :param ring: the univariate source ring, required if ``p`` or ``q`` is a
41
+ string.
42
+ :param names: the two parameter names to use.
43
+ :returns: an :class:`~qqideal.Ideal`. As with
44
+ :meth:`~qqideal.Ideal.saturate`, the saturation is carried as a
45
+ Rabinowitsch ideal, so the ideal lives in ``QQ[s, t, u]`` with
46
+ ``u*(s-t) - 1`` among its generators, and its variety is the set of
47
+ ordered pairs ``s != t`` with the same image. Emptiness, dimension and
48
+ degree are the ones of the double-point locus.
49
+ :raises MsolveInputError: if the source ring is not univariate.
50
+ """
51
+ source = _source_ring(p, q, ring)
52
+ if source.nvars != 1:
53
+ raise MsolveInputError(
54
+ f"a parametrization is univariate; got {source} with "
55
+ f"{source.nvars} variables"
56
+ )
57
+ if len(names) != 2 or names[0] == names[1]:
58
+ raise MsolveInputError(f"names must be two distinct variables, got {names!r}")
59
+
60
+ poly_p = _coerce(source, p)
61
+ poly_q = _coerce(source, q)
62
+
63
+ plane = Ring(*names, characteristic=source.characteristic)
64
+ s, t = plane.gens()
65
+ equations = [
66
+ _substitute(poly_p, plane, 0) - _substitute(poly_p, plane, 1),
67
+ _substitute(poly_q, plane, 0) - _substitute(poly_q, plane, 1),
68
+ ]
69
+ return Ideal(equations, ring=plane).saturate(s - t)
70
+
71
+
72
+ def _source_ring(p: "Poly | str", q: "Poly | str", ring: Ring | None) -> Ring:
73
+ rings = {value.ring for value in (p, q) if isinstance(value, Poly)}
74
+ if ring is not None:
75
+ rings.add(ring)
76
+ if not rings:
77
+ raise MsolveInputError("ring= is required when p and q are strings")
78
+ if len(rings) > 1:
79
+ raise RingMismatch(
80
+ f"p and q must live in one ring, got {sorted(str(r) for r in rings)}"
81
+ )
82
+ return rings.pop()
83
+
84
+
85
+ def _coerce(ring: Ring, value: "Poly | str") -> Poly:
86
+ return ring(value) if isinstance(value, str) else ring._check(value)
87
+
88
+
89
+ def _substitute(poly: Poly, plane: Ring, position: int) -> Poly:
90
+ """Send the source variable to variable ``position`` of the plane ring."""
91
+ terms = {}
92
+ for exponents, coeff in poly.terms():
93
+ key = [0, 0]
94
+ key[position] = exponents[0]
95
+ terms[tuple(key)] = coeff
96
+ return plane._wrap(plane._ctx.from_dict(terms))
@@ -0,0 +1,52 @@
1
+ """Exceptions raised by qqideal.
2
+
3
+ Two rules decide which exception a failure gets:
4
+
5
+ * A caller mistake -- an unparseable polynomial, a ring mismatch, a request
6
+ outside the v0.1 slate -- raises. Callers should not have to inspect a
7
+ :class:`~qqideal.Verdict` to discover they wrote ``x/2``.
8
+ * A solver failure -- msolve timing out, dying, or emitting bytes we refuse to
9
+ interpret -- becomes a :class:`~qqideal.Verdict` with
10
+ :attr:`~qqideal.Kind.TIMEOUT` or :attr:`~qqideal.Kind.ERROR`.
11
+
12
+ Input errors are reported as msolveio's :class:`~msolveio.MsolveInputError`
13
+ rather than a new type, so that a bad polynomial raises the same exception
14
+ whether qqideal or msolveio caught it.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from msolveio import (
20
+ MsolveAmbiguous,
21
+ MsolveDied,
22
+ MsolveError,
23
+ MsolveInputError,
24
+ MsolveOutputError,
25
+ MsolveTimeout,
26
+ MsolveVersionUnsupported,
27
+ )
28
+
29
+ __all__ = [
30
+ "QQIdealError",
31
+ "RingMismatch",
32
+ "MsolveError",
33
+ "MsolveInputError",
34
+ "MsolveOutputError",
35
+ "MsolveAmbiguous",
36
+ "MsolveTimeout",
37
+ "MsolveDied",
38
+ "MsolveVersionUnsupported",
39
+ ]
40
+
41
+
42
+ class QQIdealError(Exception):
43
+ """Base class for errors qqideal raises on its own behalf."""
44
+
45
+
46
+ class RingMismatch(QQIdealError, TypeError):
47
+ """Two polynomials from different rings were combined.
48
+
49
+ qqideal never coerces across rings implicitly: ``QQ[x]`` and ``QQ[x,y]``
50
+ are different rings, and so are ``QQ[x,y]`` and ``F_p[x,y]``. Use
51
+ :meth:`qqideal.Ring.embed` to move a polynomial deliberately.
52
+ """