From 5f38c4d69d1f431869ecad9624dfb10d2c52c487 Mon Sep 17 00:00:00 2001 From: Areg Gevorgyan Date: Mon, 17 Aug 2026 02:04:03 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20q-root-of-unity=20specialisation,?= =?UTF-8?q?=20Gr=C3=B6bner=20param=20fields,=20SOS=20multipliers,=20novelt?= =?UTF-8?q?y=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four autoresearch capability items (M4, M9, M10, M11): - M4: QZeilbergerCertificate.specialize_at_root_of_unity closes the q-supercongruence gap in q_zeilberger — a three-valued decision (never an assumption) on whether a Q(q) certificate survives specialising q to a primitive root of unity, backed by exact cyclotomic-field arithmetic. - M9: GroebnerBasis.compute(..., params=[...]) runs Buchberger over Q(params)[vars] instead of treating parameters as ring variables, with an honest degeneracy-locus report (conditions(), specialize() refusing on it) rather than silent genericity assumptions. - M10: sos_decompose gains a general PSD Gram search and a Reznick multiplier search past diagonal dominance. Infrastructure is solid and tested, but the hardest classical boundary-case examples (Motzkin, Robinson) are not yet certified — diagnosed as an alternating-projection convergence limitation at a tangential PSD-cone intersection, not a soundness bug, and the tests/docs say so directly rather than overclaiming. - M11: experimental.novelty normalises P-recursive claims to a canonical hash and checks them against OEIS (offline-fixture-tested) before a search loop can call a rediscovery a finding. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 236 +++ alkahest-core/src/errors/codes.rs | 7 + alkahest-core/src/holonomic/mod.rs | 6 +- .../src/holonomic/qzeil/cyclotomic.rs | 416 +++++ alkahest-core/src/holonomic/qzeil/mod.rs | 24 +- .../src/holonomic/qzeil/rootofunity.rs | 972 +++++++++++ alkahest-core/src/lib.rs | 9 + alkahest-core/src/poly/groebner/buchberger.rs | 191 +-- alkahest-core/src/poly/groebner/mod.rs | 17 + alkahest-core/src/poly/groebner/pairs.rs | 159 ++ alkahest-core/src/poly/groebner/parametric.rs | 982 +++++++++++ alkahest-core/src/poly/groebner/paramfield.rs | 822 +++++++++ alkahest-core/src/real/sos/cert.rs | 191 ++- alkahest-core/src/real/sos/linalg.rs | 307 ++++ alkahest-core/src/real/sos/mod.rs | 236 ++- alkahest-core/src/real/sos/psd.rs | 719 ++++++++ alkahest-core/src/real/sos/ratpoly.rs | 30 + alkahest-core/src/real/sos/sdp.rs | 305 ++++ alkahest-py/src/lib.rs | 1197 ++++++++++++- alkahest-skill/alkahest.md | 25 +- docs/features.md | 1 + docs/mdbook/src/SUMMARY.md | 1 + docs/mdbook/src/novelty.md | 160 ++ docs/mdbook/src/positivity.md | 92 +- docs/mdbook/src/search-plumbing.md | 1 + docs/mdbook/src/solving.md | 32 + docs/mdbook/src/telescoping.md | 86 +- python/alkahest/__init__.py | 6 + python/alkahest/exceptions.py | 27 + python/alkahest/experimental/__init__.py | 68 + python/alkahest/experimental/novelty.py | 1490 +++++++++++++++++ tests/data/oeis_novelty_fixture.json | 657 ++++++++ tests/test_novelty.py | 561 +++++++ tests/test_parametric_groebner.py | 418 +++++ tests/test_q_root_of_unity.py | 397 +++++ 35 files changed, 10511 insertions(+), 337 deletions(-) create mode 100644 alkahest-core/src/holonomic/qzeil/cyclotomic.rs create mode 100644 alkahest-core/src/holonomic/qzeil/rootofunity.rs create mode 100644 alkahest-core/src/poly/groebner/pairs.rs create mode 100644 alkahest-core/src/poly/groebner/parametric.rs create mode 100644 alkahest-core/src/poly/groebner/paramfield.rs create mode 100644 alkahest-core/src/real/sos/linalg.rs create mode 100644 alkahest-core/src/real/sos/psd.rs create mode 100644 alkahest-core/src/real/sos/sdp.rs create mode 100644 docs/mdbook/src/novelty.md create mode 100644 python/alkahest/experimental/novelty.py create mode 100644 tests/data/oeis_novelty_fixture.json create mode 100644 tests/test_novelty.py create mode 100644 tests/test_parametric_groebner.py create mode 100644 tests/test_q_root_of_unity.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 62244334..0d2c28e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -714,6 +714,65 @@ Both are detailed under *Behaviour changes to plan for*. `qbinomial(pool, N, K)` and `qpochhammer(pool, u, d, v)`. Experimental, so the surface may change; the mathematics it refuses to guess at will not. +- **Root-of-unity specialisation for `q_zeilberger`: + `QZeilbergerCertificate.specialize_at_root_of_unity`** (M4). `q_zeilberger` + proves identities in `Q(q)` with `q` transcendental and said, in every + verdict's `side_conditions`, that specialising `q` to a root of unity — the + step the `q`-supercongruence literature actually needs — was a separate step + this engine did not take. It now does, and takes it as a three-valued + **decision** rather than an assumption, because doing it unconditionally is + the `q`-analogue of the A279013 failure mode transplanted to a new + subsystem: a certificate that is perfectly valid in `Q(q)` and would + silently produce a false statement if evaluated at a point where a + coefficient or a sum value has a pole. + + The arithmetic underneath (`alkahest_core::holonomic::qzeil::cyclotomic`) is + exact throughout: `Φ_d(q)`, the `d`-th cyclotomic polynomial, is built from + `q^d − 1 = ∏_{e∣d} Φ_e(q)` by exact polynomial division over `Q`; `Q(ζ_d) = + Q[q]/(Φ_d(q))` is the residue field, with inversion by the extended + Euclidean algorithm (total on non-zero elements because `Φ_d` is irreducible + over `Q`); and "does `p` vanish at `ζ_d`" is decided as "does `Φ_d` divide + `p`" — a divisibility question over `Q`, never a floating-point evaluation. + The same machinery gives the exact `Φ_d`-adic valuation of any element of + `Q(q)`, which is the `q`-supercongruence statement in its precise form: + `v ≥ r` is exactly `Φ_d(q)^r ∣ S(n)`. + + `spec = cert.specialize_at_root_of_unity(d, n)` returns a + `QRootOfUnitySpecialization` whose `status` is `"specializes"` (every + coefficient and every sum value proved to have non-negative `Φ_d`-adic + valuation, so the specialisation map is defined on all of them, and the + specialised identity was re-checked as an exact statement in `Q(ζ_d)` before + being returned), `"obstructed"` (a pole at `ζ_d` was **exhibited** — no + specialised value is offered, and this is a proof the route is blocked, not + that the specialised identity is false), or `"unknown"` (the generic + boundary verdict was already `"unknown"`). Three further conditions are + reported on a `"specializes"` verdict rather than folded into it: + `is_vacuous` (every coefficient died — always true at `d = 1`, the `q → 1` + limit — so the recurrence is `0 = 0`, still a theorem but an empty one), + `leading_coefficient_survives` (`False` means the specialised recurrence no + longer determines the last value from the earlier ones), and + `support_shrinks` / `effective_support` (the `q`-Lucas phenomenon — + `[2;1]_q = 1 + q` is non-zero in `Q(q)` and zero at `ζ_2`, so the surviving + window at a root of unity can be a strict subset of the generic one, though + it can never grow, since a ring homomorphism sends the generic zero + summands to zero). + + Verified end to end on `Σ_k [n;k]_q²·q^{k²} = [2n;n]_q` specialised at every + `ζ_d` for `d = 1..6`: the returned value checked against a Gaussian binomial + built independently by the Pascal recurrence directly in `Q(ζ_d)`, against + the closed form `[2n;n]_{ζ_d}` predicted by the `q`-Lucas theorem from the + base-`d` digits of `2n` and `n`, and — at the Python surface — against the + same sum recomputed in floating-point `complex` arithmetic at the actual + numeric root of unity `e^{2πi/d}`, evaluated by walking the returned + expression's node tree by hand rather than through anything under test. Two + refusals are exercised concretely rather than merely asserted: a genuine + pole at `ζ_3` (obstructed at every `n` tested, never silently specialised) + and the support-shrinks case above. + + New: `QZeilbergerCertificate.specialize_at_root_of_unity`, + `alkahest.experimental.QRootOfUnitySpecialization`, + `alkahest.experimental.cyclotomic_polynomial`. Experimental. + - **Validated bounds reach five more functions: `asinh`, `acosh`, `atanh`, `erf`, `erfc`.** `bound_on_box` — and everything built on it, `verified_sign`, `verified_no_roots`, `verified_integral` — covered exactly @@ -1092,6 +1151,183 @@ Both are detailed under *Behaviour changes to plan for*. through this accessor. Documented in `representations.md`, which had been showing a `leading_coeff()` *method* that never existed. +- **Gröbner bases over the coefficient field `Q(params)`: `GroebnerBasis.compute(polys, vars, + params=[...])`, `experimental.ParametricGroebnerBasis` / `ParametricGbPoly`** + (M9). Eliminating states from an ODE model's jet equations needs the rate + constants to *not* enter the monomial order — computed over `Q[states, Y, + params]` they are ordinary ring variables and generate S-pairs like any + other, which is exactly the growth the elimination does not need. With + `params=[...]` the same Buchberger engine (identical Gebauer–Möller pair + management from `poly::groebner::pairs`, shared with the ℚ engine so a + specialisation keeps every leading monomial and the whole pair schedule) + runs over `Q(params)[vars]` instead: the parameters live in the + coefficients as elements of `QParam`, a canonical, reduced fraction of + sparse `ParamPoly`s (FLINT's `fmpz_mpoly_gcd` — a Hensel/Zippel hybrid, not + a hand-rolled Euclidean gcd, which is exactly the swelling `holonomic::qfield` + exists to avoid one variable further in) rather than ring monomials. + + Measured on a catenary compartmental model (linear ODE chain, output the + first compartment, `n+1` derivatives eliminating `n` states down to the + input–output relation): at `n = 4` states / 7 rate constants, the + parametric route computes in 0.27s against 4.2s direct (both `Lex`, + `--release`) — roughly 15× — and returns 5 total basis generators against + 25; at `n = 5` states / 9 rate constants the parametric route finishes in + 6.9s while the direct `Q[states, Y, params]` computation had not finished + after 240s (>34× and counting). Numbers are wall-clock on one machine, not + a guaranteed ratio — the point is the qualitative shape (S-pairs among the + parameters are the cost the direct route pays and the parametric route + never generates), not the specific multiplier. + + **The result is generic, and says so.** A leading coefficient in + `Q(params)` can be a non-zero rational function of the parameters and still + vanish at a particular parameter point, and there the basis the algorithm + built is not the basis the same algorithm would have built over ℚ at that + point. Every such assumption is logged as it happens — an inversion + contributes its numerator and its denominator, an input coefficient + contributes its denominator — and `conditions()` reports the union, + factored into irreducible, primitive hypersurfaces so "wrong somewhere on + this degree-12 surface" reads as a list of conditions rather than one + opaque polynomial. `specialize(values)` substitutes and refuses with + `ParamGroebnerError` (`E-PARAMGB-004`) on the locus rather than returning + something that is not a basis; `is_regular_at` / `vanishing_conditions` + check first. The set is sufficient, not necessary by construction — on the + worked linear system `{a·x − y, x + y − 1}`, `a = -1` is a real + disagreement (the direct basis over ℚ there is the unit ideal, not the + 2-generator triangular basis the generic formula predicts), while `a = 0` + is flagged (the algorithm inverts `a` to make `a·x − y` monic) but the + direct computation at `a = 0` agrees with the `a → 0` limit of the generic + answer exactly — a refusal, not a wrong answer, on the conservative side of + the report. + + Reads back the same way `GbPoly` does: `ParametricGbPoly.to_expr` / + `.terms()`, `ParametricGroebnerBasis.to_exprs()`, `.conditions()` as + `Expr`, and `.specialize(...)` returns an ordinary `GroebnerBasis` whose + generators are `GbPoly` — the same read path issue #11 was about, so + nothing here is write-only. `eliminate` has the same `Lex`-with-eliminated- + variables-first contract as `GroebnerBasis.eliminate`, and refuses to + eliminate a coefficient-field parameter (there is nothing to eliminate — it + was never a ring variable) rather than silently ignoring the request. + Errors: `E-PARAMGB-001` no generators, `E-PARAMGB-002` generators disagree + on the variable/parameter shape, `E-PARAMGB-003` wrong specialisation + arity, `E-PARAMGB-004` degenerate point (a result, not a malfunction — see + above). New: `alkahest.experimental.ParametricGbPoly`, + `ParametricGroebnerBasis`, `ParamGroebnerError`. Experimental; requires + `--features groebner`. Tests: Rust `poly::groebner::parametric` and + `poly::groebner::paramfield` (19 unit tests), Python + `tests/test_parametric_groebner.py`, including a + structural-identifiability worked example (two-compartment linear ODE + model, states eliminated with rate constants in `Q(params)`, the recovered + input–output relation checked against the model's characteristic + polynomial `y'' − tr(M)·y' + det(M)·y = 0` at several parameter points). + +- **Novelty filtering against OEIS: `experimental.novelty`** (M11). A search + loop over this library can rediscover a known identity within the hour, and + nothing before this told the difference between "produced 400 certified + recurrences" and "produced three that nobody had". `RecurrenceClaim` puts a + P-recursive relation into normal form — rescaling, sign flips, index shifts, + a different clearing of denominators and a common polynomial factor are all + quotiented out, so `(n+1)·u(n+1) − (4n+2)·u(n) = 0` and the same relation + scaled by −2 and stated about `u(n+7)` hash equal via `claim_hash`, while + genuinely different recurrences do not collide. `check_novelty(claim, + sources, terms=…)` checks a claim against one or more sources — `OeisCache` + offline (file-backed, the committed test fixture format) or `OeisWeb` when + explicitly opted into (never constructed by default, serves from cache + first, rate-limited, degrades to `unavailable` rather than raising when the + network is unreachable) — and returns a `NoveltyVerdict`. + + **`NoveltyVerdict.found` is three-valued**, in the manner of + `relation_confidence`'s tri-state `credible` and `GuessedRecurrence.confirmed`: + `True` a source states the claim, `False` the sources searched do not state + it (not "novel" — "not found in the one place looked"), `None` no source + could answer. There is no `novel` attribute anywhere in the module, and + `bool(verdict)` raises rather than reading `True`, because `if + check_novelty(...):` is the exact sentence this module exists to prevent. + `NoveltyVerdict.report()` carries the scope of the search — entries + examined, statements compared, statements a parser could not use — so a + negative's coverage is visible next to it. `RecurrenceClaim.from_text` + parses OEIS's own prose formula lines by recursive descent over + `+ - * / ^ ( )`, `n` and `a(n±k)`, refusing (returning `None`, never + guessing) anything it does not model — a sum, a generating function, a + reference to another sequence, an inhomogeneous relation — and every parsed + line is re-checked against the entry's own data before it can produce a + match, since a formula line is prose from a third party and the parser is + the weakest link in the chain. No test in the repository requires the + network: the offline path runs against `tests/data/oeis_novelty_fixture.json`, + a cache recorded once from oeis.org (© The OEIS Foundation Inc., CC + BY-NC-SA 4.0) and committed, exercising the four sequences this project + already certifies recurrences for (Apéry, Motzkin, Catalan, central + binomial) plus the session's own novel result (A359643, which OEIS records + only as an unproved conjecture — the distinction `hedged` exists to keep + separate from a proof). + +- **`sos_decompose` searches the general PSD Gram cone and Reznick + multipliers, not just diagonal dominance** (M10). `sos_decompose` already + covered the diagonally dominant subcone (`gram::dsos_search`) — fast, but a + strict subset of SOS, so it refused things that genuinely are SOS just not + DSOS. It now falls back, when DSOS fails, to searching the *entire* PSD + Gram cone (`real::sos::psd::psd_search`) over the same monomial basis, and + — when even that fails on `p` itself — to a Positivstellensatz-lite + multiplier search: trying `(x_1²+…+x_n²)^N·p` for `N = 1..4` + (`MAX_MULTIPLIER_POWER`) and searching the PSD Gram cone of the product, up + to a monomial-count budget (`MAX_MULTIPLIER_BASIS_LEN`). This is the + standard route past DSOS's and even plain-SOS's incompleteness: some + positive-definite forms are not SOS at all (Hilbert 1888), but Reznick's + theorem guarantees `(Σxᵢ²)^N·p` is SOS for *some* `N` — the search just + does not know `N` in advance and is honest when it runs out of budget + before finding it (`multiplier_search_reports_undecided_not_not_sos_when_out_of_budget`): + `SosError::NoCertificate` (`E-SOS-002`), never a claim that no certificate + exists or that `p` is not SOS. + + The PSD Gram search underneath (`real::sos::psd`, `real::sos::sdp`, + `real::sos::linalg` — an exact rational affine-system solver that reports + free parameters on underdetermined systems, a Jacobi eigendecomposition, + and a PSD-cone projection) is a floating-point *suggestion* mechanism only: + it proposes a Gram matrix numerically, rounds it to nearby rationals, and + the rounded result is re-expanded and compared against the target with + exact rational arithmetic before anything is returned — a `Some` here is + always sound regardless of what the numeric search actually converged to, + the same discipline the rest of this module already applies. The search + itself anneals a shrinking sequence of eigenvalue floors with several + random restarts (`psd::FLOOR_SCHEDULE`, `psd::multistart_anneal`) rather + than a single fixed-floor pass, specifically because the certificates this + feature exists for are frequently *tight* — the witnessing Gram matrix is + PSD but singular, sitting exactly on the boundary of the PSD cone rather + than its interior, which a plain fixed-floor search reliably stalls short + of. + + **Honest gap, not a silent one:** the hardest classical textbook examples — + Motzkin's polynomial (`x⁴y²+x²y⁴−3x²y²+1`, PSD but not SOS; classically SOS + after multiplying by `x²+y²`) and Robinson's form — are *not* yet + certified by this search, and the tests say so directly + (`motzkin_reports_undecided_rather_than_a_false_certificate`, + `psd_search_does_not_yet_reach_homogeneous_motzkin_times_sum_of_squares`) + rather than asserting a false positive. This was diagnosed, not merely + observed: a diagnostic trajectory + (`psd::diag::diag_step1_step2_trajectory_and_family_sanity`) shows the + annealed search converging *monotonically* toward Motzkin's boundary + certificate (minimum eigenvalue running from roughly `−1.6` to roughly + `−0.0018` as the floor anneals to `0`) without fully closing the last, + asymptotically slow stretch to exactly `0` — the textbook signature of + alternating projection at a tangential (non-transversal) set intersection, + a known hard case for this class of method, not a bug. That the mechanism + itself is sound was checked independently two ways: an exact sanity check + that the affine Gram-matrix family constructed for Motzkin really does + reproduce the target polynomial at an arbitrary rational point in the + family, and a synthetic planted rank-deficient PSD example of the same + nullspace dimension (`psd::diag::diag_step3_planted_singular_example`), + which *is* found and exactly re-verified. Recording `undecided` on Motzkin + and Robinson is the correct behaviour for now, not a workaround; escaping + a tangential intersection reliably (e.g. Douglas–Rachford with + over-relaxation, or a facial-reduction preprocessing step) is future work. + + Everything reachable today is exact end to end: `verify()` re-expands + every returned certificate with exact rational arithmetic, `to_lean()` + emits a sorry-free Lean sketch, and `PositivityCertificate.multiplier` is + populated exactly when the certificate needed one (`None` for a direct SOS + decomposition). No new public API surface — `sos_decompose` and + `PositivityCertificate` are unchanged in shape; this is entirely a + strengthening of what the existing search covers before it refuses. + ### Performance - **`zeilberger`'s exact `Q(n)(k)` post-processing no longer swells its own diff --git a/alkahest-core/src/errors/codes.rs b/alkahest-core/src/errors/codes.rs index 9b6794ce..4b383e47 100644 --- a/alkahest-core/src/errors/codes.rs +++ b/alkahest-core/src/errors/codes.rs @@ -323,6 +323,13 @@ pub const REGISTRY: &[ErrorSpec] = &[ ErrorSpec { code: "E-SUM-001", class: "SumError", cause: Cause::UserInput, remediation: None }, ErrorSpec { code: "E-SUM-002", class: "SumError", cause: Cause::UserInput, remediation: None }, ErrorSpec { code: "E-SUM-003", class: "SumError", cause: Cause::UserInput, remediation: None }, + // E-PARAMGB — ParamGroebnerError (M9, Gröbner bases over Q(params)) + ErrorSpec { code: "E-PARAMGB-001", class: "ParamGroebnerError", cause: Cause::UserInput, remediation: Some("pass at least one polynomial") }, + ErrorSpec { code: "E-PARAMGB-002", class: "ParamGroebnerError", cause: Cause::UserInput, remediation: Some("build every generator against the same variable and parameter lists") }, + ErrorSpec { code: "E-PARAMGB-003", class: "ParamGroebnerError", cause: Cause::UserInput, remediation: Some("supply exactly one value per parameter, in the parameter list's order") }, + // Not a malfunction: the basis is a *generic* one, and this is it saying so + // at a point where it does not apply, rather than specialising anyway. + ErrorSpec { code: "E-PARAMGB-004", class: "ParamGroebnerError", cause: Cause::Domain, remediation: Some("compute the basis directly over ℚ at that parameter point, or move the vanishing factors into the generators and recompute") }, // E-VALIDATED — ValidatedError ErrorSpec { code: "E-VALIDATED-001", class: "ValidatedError", cause: Cause::Unsupported, remediation: None }, ErrorSpec { code: "E-VALIDATED-002", class: "ValidatedError", cause: Cause::UserInput, remediation: None }, diff --git a/alkahest-core/src/holonomic/mod.rs b/alkahest-core/src/holonomic/mod.rs index 8de2a7c1..30dd9cce 100644 --- a/alkahest-core/src/holonomic/mod.rs +++ b/alkahest-core/src/holonomic/mod.rs @@ -71,8 +71,10 @@ pub use hyperterm::{GammaFactor, ProperTerm}; pub use modular::{binomial_mod, ModularError, ModularEvaluation, ModularRecurrence}; pub use qfield::{PolyK, RatK, Rn}; pub use qzeil::{ - q_boundary_status, q_zeilberger, QBoundaryStatus, QCertificate, QHolonomicError, QProperTerm, - QZeilbergerOpts, QZeilbergerReport, QZeilbergerResult, + cyclotomic_polynomial, q_boundary_status, q_specialize_at_root_of_unity, q_zeilberger, + CycloElem, CycloField, QBoundaryStatus, QCertificate, QHolonomicError, QProperTerm, + QRootOfUnitySpecialization, QRootOfUnityStatus, QZeilbergerOpts, QZeilbergerReport, + QZeilbergerResult, }; pub use zeilberger::{ boundary_side_condition, boundary_term, zeilberger, zeilberger_search, OrderSearch, diff --git a/alkahest-core/src/holonomic/qzeil/cyclotomic.rs b/alkahest-core/src/holonomic/qzeil/cyclotomic.rs new file mode 100644 index 00000000..912c1ddb --- /dev/null +++ b/alkahest-core/src/holonomic/qzeil/cyclotomic.rs @@ -0,0 +1,416 @@ +//! Exact arithmetic in the cyclotomic field `Q(ζ_d) = Q[q]/(Φ_d(q))`, and the +//! `Φ_d`-adic valuation of an element of `Q(q)`. +//! +//! This is the arithmetic layer under [`super::rootofunity`]. Everything here +//! is exact: `Φ_d` is built by exact polynomial division from `q^d − 1`, the +//! residue ring is `Q[q]` reduced by `Φ_d`, and inversion is the extended +//! Euclidean algorithm. **Nothing is evaluated numerically** — a "does this +//! vanish at `ζ_d`" question is answered by polynomial divisibility over `Q`, +//! never by plugging in a floating-point root of unity. +//! +//! # Why divisibility is the right test +//! +//! `Φ_d` is irreducible over `Q` (a classical theorem), so for `p ∈ Q[q]` +//! +//! ```text +//! p(ζ_d) = 0 ⟺ Φ_d | p +//! ``` +//! +//! and the quotient `Q[q]/(Φ_d)` is a *field*, which is what makes [`inv`] +//! total on non-zero elements. Both facts are used below and neither is +//! approximated. +//! +//! # The valuation, and why a caller wants it +//! +//! Every `r ∈ Q(q)` factors as `r = Φ_d^v · (N/D)` with `Φ_d ∤ N`, `Φ_d ∤ D`. +//! That `v = ` [`CycloField::valuation`] is the exact statement "`Φ_d(q)^v` +//! divides `r`", which is the shape a `q`-supercongruence takes. `v ≥ 0` is +//! exactly the condition for `r` to have a value at `ζ_d` at all, so the same +//! computation decides specialisability and measures divisibility. +//! +//! [`inv`]: CycloField::inv + +use crate::matrix::normal_form::RatUniPoly; +use rug::Rational; +use std::collections::BTreeMap; + +/// Largest order of a root of unity this module will build a field for. +/// +/// The cost of the residue-ring arithmetic grows as `φ(d)²`, so the cap is a +/// resource bound rather than a mathematical one. +pub const MAX_CYCLOTOMIC_ORDER: u32 = 512; + +/// `q^e − 1`. +fn q_pow_minus_one(e: u32) -> RatUniPoly { + let mut coeffs = vec![Rational::from(0); e as usize + 1]; + coeffs[0] = Rational::from(-1); + coeffs[e as usize] = Rational::from(1); + RatUniPoly { coeffs } +} + +/// Exact division, or `None` when `b` does not divide `a`. +fn exact_div(a: &RatUniPoly, b: &RatUniPoly) -> Option { + if b.is_zero() { + return None; + } + let (quo, rem) = RatUniPoly::div_rem(a, b); + rem.is_zero().then_some(quo) +} + +/// The `d`-th cyclotomic polynomial `Φ_d(q) ∈ Z[q]`, monic of degree `φ(d)`. +/// +/// Built from `q^d − 1 = ∏_{e | d} Φ_e(q)` by exact division, with the proper +/// divisors memoised so the work is one division per divisor rather than one +/// per divisor *chain*. +/// +/// Returns `Φ_1 = q − 1` at `d = 1`; that is the honest answer — "the primitive +/// first root of unity" is `1`, and specialising there is the classical `q → 1` +/// limit. +pub fn cyclotomic_polynomial(d: u32) -> RatUniPoly { + debug_assert!(d >= 1, "the order of a root of unity is at least 1"); + let d = d.max(1); + let mut memo: BTreeMap = BTreeMap::new(); + for e in 1..=d { + if d % e != 0 { + continue; + } + let mut num = q_pow_minus_one(e); + for (&f, phi_f) in memo.iter() { + if e % f == 0 { + // `Φ_f` divides `q^e − 1` whenever `f | e`, so the division is + // exact; the fallback keeps this total rather than panicking. + num = exact_div(&num, phi_f).unwrap_or(num); + } + } + memo.insert(e, num); + } + memo.remove(&d).unwrap_or_else(RatUniPoly::one) +} + +/// An element of `Q(ζ_d)`, as its canonical representative of degree `< φ(d)`. +/// +/// Two elements are equal exactly when their representatives are, which is what +/// makes `==` on this type an exact decision in the cyclotomic field. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CycloElem { + /// The canonical representative in `Q[q]`, of degree `< φ(d)`. + pub poly: RatUniPoly, +} + +impl CycloElem { + /// Whether this is `0` in `Q(ζ_d)`. + pub fn is_zero(&self) -> bool { + self.poly.is_zero() + } +} + +/// The field `Q(ζ_d) = Q[q]/(Φ_d(q))`, and the `Φ_d`-adic valuation on `Q(q)`. +#[derive(Clone, Debug)] +pub struct CycloField { + d: u32, + phi: RatUniPoly, +} + +impl CycloField { + /// The field of `d`-th roots of unity, or `None` past + /// [`MAX_CYCLOTOMIC_ORDER`] (or at `d = 0`, which names nothing). + pub fn new(d: u32) -> Option { + if d == 0 || d > MAX_CYCLOTOMIC_ORDER { + return None; + } + Some(CycloField { + d, + phi: cyclotomic_polynomial(d), + }) + } + + /// The order `d` of the root of unity. + pub fn order(&self) -> u32 { + self.d + } + + /// `Φ_d(q)`. + pub fn modulus(&self) -> &RatUniPoly { + &self.phi + } + + /// `φ(d)` — the degree of the extension `Q(ζ_d) / Q`. + pub fn degree(&self) -> usize { + self.phi.degree().max(0) as usize + } + + /// The canonical representative of `p mod Φ_d`. + pub fn reduce(&self, p: &RatUniPoly) -> CycloElem { + let (_, rem) = RatUniPoly::div_rem(p, &self.phi); + CycloElem { poly: rem.trim() } + } + + /// `0`. + pub fn zero(&self) -> CycloElem { + CycloElem { + poly: RatUniPoly::zero(), + } + } + + /// `1`. + pub fn one(&self) -> CycloElem { + self.reduce(&RatUniPoly::one()) + } + + /// A rational constant. + pub fn from_rational(&self, c: Rational) -> CycloElem { + self.reduce(&RatUniPoly::constant(c)) + } + + /// `ζ_d^e`, for any sign of `e` — `ζ_d^d = 1`, so the exponent is reduced + /// modulo `d` first and no inversion is needed. + pub fn zeta_pow(&self, e: i64) -> CycloElem { + let r = e.rem_euclid(self.d as i64) as usize; + let mut coeffs = vec![Rational::from(0); r + 1]; + coeffs[r] = Rational::from(1); + self.reduce(&RatUniPoly { coeffs }) + } + + /// `a + b`. + pub fn add(&self, a: &CycloElem, b: &CycloElem) -> CycloElem { + self.reduce(&(&a.poly + &b.poly)) + } + + /// `a − b`. + pub fn sub(&self, a: &CycloElem, b: &CycloElem) -> CycloElem { + self.reduce(&(&a.poly - &b.poly)) + } + + /// `−a`. + pub fn neg(&self, a: &CycloElem) -> CycloElem { + CycloElem { poly: -&a.poly } + } + + /// `a · b`. + pub fn mul(&self, a: &CycloElem, b: &CycloElem) -> CycloElem { + if a.is_zero() || b.is_zero() { + return self.zero(); + } + self.reduce(&(&a.poly * &b.poly)) + } + + /// `a⁻¹`, or `None` at `a = 0`. + /// + /// Total on non-zero elements because `Φ_d` is irreducible over `Q`: the + /// extended Euclidean algorithm returns `gcd(a, Φ_d) = 1` for every `a` the + /// modulus does not divide, and a reduced representative is never divisible + /// by `Φ_d` unless it is zero. + pub fn inv(&self, a: &CycloElem) -> Option { + if a.is_zero() { + return None; + } + let (s, _, g) = RatUniPoly::gcdex(&a.poly, &self.phi); + if g.degree() != 0 { + // Unreachable for an irreducible modulus; refuse rather than + // return a wrong inverse if it ever happens. + return None; + } + let c = g.coeffs.first()?.clone(); + if c == 0 { + return None; + } + let scaled = &s * &RatUniPoly::constant(Rational::from(1) / c); + Some(self.reduce(&scaled)) + } + + /// `Φ_d`-adic valuation of a polynomial, with the cofactor: the unique + /// `(v, p')` with `p = Φ_d^v · p'` and `Φ_d ∤ p'`. `None` at `p = 0`. + fn poly_valuation(&self, p: &RatUniPoly) -> Option<(i64, RatUniPoly)> { + if p.is_zero() { + return None; + } + let mut v = 0_i64; + let mut cur = p.clone(); + while let Some(next) = exact_div(&cur, &self.phi) { + if next.is_zero() { + break; + } + cur = next; + v += 1; + } + Some((v, cur)) + } + + /// The exact `Φ_d`-adic valuation of `r ∈ Q(q)`: the integer `v` with + /// `r = Φ_d^v · N/D` and `Φ_d` dividing neither `N` nor `D`. + /// + /// `None` means `r = 0`, whose valuation is `+∞`. `v ≥ 0` is exactly the + /// condition for [`specialize`](Self::specialize) to succeed, and `v ≥ 1` + /// is exactly the divisibility `Φ_d(q)^v | r` a `q`-supercongruence asserts. + pub fn valuation(&self, r: &crate::holonomic::qfield::Rn) -> Option { + let (vn, _) = self.poly_valuation(&r.num)?; + let (vd, _) = self + .poly_valuation(&r.den) + .unwrap_or((0, RatUniPoly::one())); + Some(vn - vd) + } + + /// `r(ζ_d)` as an element of `Q(ζ_d)`, or `None` when `r` has a **pole** + /// there — i.e. exactly when [`valuation`](Self::valuation) is negative. + /// + /// The numerator and denominator are stripped of their `Φ_d` factors first, + /// so this is correct even if the caller hands over a representation that + /// is not in lowest terms: `(q^d − 1)/(q^d − 1)` specialises to `1`, not to + /// `0/0`. + pub fn specialize(&self, r: &crate::holonomic::qfield::Rn) -> Option { + let Some((vn, num)) = self.poly_valuation(&r.num) else { + return Some(self.zero()); + }; + let (vd, den) = self + .poly_valuation(&r.den) + .unwrap_or((0, RatUniPoly::one())); + match (vn - vd).cmp(&0) { + std::cmp::Ordering::Less => None, + std::cmp::Ordering::Greater => Some(self.zero()), + std::cmp::Ordering::Equal => { + let n = self.reduce(&num); + let d = self.reduce(&den); + let inv = self.inv(&d)?; + Some(self.mul(&n, &inv)) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::holonomic::qfield::{rn_one, rn_poly, Rn}; + + fn poly(coeffs: &[i64]) -> RatUniPoly { + RatUniPoly { + coeffs: coeffs.iter().map(|&c| Rational::from(c)).collect(), + } + .trim() + } + + #[test] + fn cyclotomic_polynomials_match_the_classical_table() { + assert_eq!(cyclotomic_polynomial(1), poly(&[-1, 1])); // q − 1 + assert_eq!(cyclotomic_polynomial(2), poly(&[1, 1])); // q + 1 + assert_eq!(cyclotomic_polynomial(3), poly(&[1, 1, 1])); + assert_eq!(cyclotomic_polynomial(4), poly(&[1, 0, 1])); + assert_eq!(cyclotomic_polynomial(5), poly(&[1, 1, 1, 1, 1])); + assert_eq!(cyclotomic_polynomial(6), poly(&[1, -1, 1])); + assert_eq!(cyclotomic_polynomial(12), poly(&[1, 0, -1, 0, 1])); + // The first cyclotomic polynomial with a coefficient outside {−1,0,1}. + assert_eq!(cyclotomic_polynomial(105).coeffs[7], Rational::from(-2)); + } + + #[test] + fn the_product_over_divisors_is_q_to_the_d_minus_one() { + for d in 1_u32..=24 { + let mut prod = RatUniPoly::one(); + for e in 1..=d { + if d % e == 0 { + prod = &prod * &cyclotomic_polynomial(e); + } + } + assert_eq!( + prod, + q_pow_minus_one(d), + "prod_(e|{d}) Phi_e must be q^{d} − 1" + ); + } + } + + #[test] + fn degree_is_eulers_totient() { + let totient = |d: u32| (1..=d).filter(|&e| gcd(e, d) == 1).count(); + for d in 1_u32..=40 { + let f = CycloField::new(d).expect("in range"); + assert_eq!(f.degree(), totient(d), "deg Phi_{d} must be phi({d})"); + } + } + + fn gcd(a: u32, b: u32) -> u32 { + if b == 0 { + a + } else { + gcd(b, a % b) + } + } + + #[test] + fn zeta_has_order_exactly_d() { + for d in 1_u32..=12 { + let f = CycloField::new(d).expect("in range"); + assert_eq!(f.zeta_pow(d as i64), f.one(), "zeta^{d} must be 1"); + assert_eq!(f.zeta_pow(-1), f.zeta_pow(d as i64 - 1)); + for e in 1..d { + // A primitive d-th root of unity: no smaller power is 1. + assert_ne!(f.zeta_pow(e as i64), f.one(), "zeta^{e} must not be 1"); + } + } + } + + #[test] + fn inverse_is_a_two_sided_inverse() { + for d in [1_u32, 2, 3, 4, 5, 6, 8, 12] { + let f = CycloField::new(d).expect("in range"); + for e in 0..d as i64 { + let a = f.add(&f.zeta_pow(e), &f.from_rational(Rational::from(3))); + if a.is_zero() { + continue; + } + let inv = f.inv(&a).expect("nonzero elements are invertible"); + assert_eq!(f.mul(&a, &inv), f.one()); + } + } + } + + #[test] + fn valuation_counts_the_phi_factors_exactly() { + let f = CycloField::new(3).expect("in range"); + // (q³ − 1) = (q − 1)·Φ_3, so v = 1. + let r: Rn = rn_poly(q_pow_minus_one(3)); + assert_eq!(f.valuation(&r), Some(1)); + // Φ_3² has valuation 2. + let phi2 = &cyclotomic_polynomial(3) * &cyclotomic_polynomial(3); + assert_eq!(f.valuation(&rn_poly(phi2)), Some(2)); + // 1/(q³ − 1) has valuation −1 and no value at ζ_3. + let inv = crate::holonomic::qfield::rn_inv(&r).expect("nonzero"); + assert_eq!(f.valuation(&inv), Some(-1)); + assert!(f.specialize(&inv).is_none(), "a pole must not specialise"); + // 0 has no valuation. + assert_eq!(f.valuation(&crate::holonomic::qfield::rn_zero()), None); + assert_eq!(f.valuation(&rn_one()), Some(0)); + } + + #[test] + fn a_zero_over_zero_representation_still_specialises() { + // `Rn` is kept reduced, but `specialize` must not depend on that: it + // strips the Φ factors from both sides first. + let f = CycloField::new(3).expect("in range"); + let cube = q_pow_minus_one(3); + let r = Rn { + num: cube.clone(), + den: cube, + }; + assert_eq!(f.specialize(&r), Some(f.one())); + assert_eq!(f.valuation(&r), Some(0)); + } + + #[test] + fn specialising_a_root_of_unity_power_agrees_with_zeta_pow() { + for d in 1_u32..=8 { + let f = CycloField::new(d).expect("in range"); + for e in 0..12_i64 { + let r = super::super::field::qq_pow(e); + assert_eq!(f.specialize(&r), Some(f.zeta_pow(e))); + } + } + } + + #[test] + fn out_of_range_orders_are_refused() { + assert!(CycloField::new(0).is_none()); + assert!(CycloField::new(MAX_CYCLOTOMIC_ORDER + 1).is_none()); + assert!(CycloField::new(MAX_CYCLOTOMIC_ORDER).is_some()); + } +} diff --git a/alkahest-core/src/holonomic/qzeil/mod.rs b/alkahest-core/src/holonomic/qzeil/mod.rs index 8c2d2095..be30a3f9 100644 --- a/alkahest-core/src/holonomic/qzeil/mod.rs +++ b/alkahest-core/src/holonomic/qzeil/mod.rs @@ -108,10 +108,16 @@ //! `n ≥ n_min` at which the coefficients `a_i(qⁿ)` are defined, and `q` is //! generic (see above). +pub mod cyclotomic; pub mod field; +pub mod rootofunity; pub mod search; pub mod term; +pub use cyclotomic::{cyclotomic_polynomial, CycloElem, CycloField, MAX_CYCLOTOMIC_ORDER}; +pub use rootofunity::{ + q_specialize_at_root_of_unity, QRootOfUnitySpecialization, QRootOfUnityStatus, +}; pub use search::{q_zeilberger_on_term, QZeilbergerOpts, QZeilbergerReport, QZeilbergerResult}; pub use term::{QProperTerm, QSupport}; @@ -447,11 +453,12 @@ impl QProperTerm { Some(acc) } - /// `S(n₀) = Σ_{k ∈ Z} F(n₀, k)` as an exact element of `Q(q)`. + /// The proved support window in `k` at a concrete `n₀`: integers + /// `lo ≤ k ≤ hi` outside which `F(n₀, k)` was **proved** to be exactly `0`. /// - /// Uses the proved support window, so this is a finite sum whose value is a - /// theorem about the whole `Z`-sum, not a truncation. - pub fn sum_at(&self, n0: i64, n_min: i64) -> Result { + /// Refuses rather than guessing when the window was not established, or + /// when it is wider than the evaluation limit. + pub fn window_at(&self, n0: i64, n_min: i64) -> Result<(i64, i64), QHolonomicError> { let s = self.support(0, n_min); if !s.finite || !s.bounded_above || !s.bounded_below { return Err(QHolonomicError::Unsupported(format!( @@ -473,6 +480,15 @@ impl QProperTerm { hi_v - lo_v ))); } + Ok((lo_v, hi_v)) + } + + /// `S(n₀) = Σ_{k ∈ Z} F(n₀, k)` as an exact element of `Q(q)`. + /// + /// Uses the proved support window, so this is a finite sum whose value is a + /// theorem about the whole `Z`-sum, not a truncation. + pub fn sum_at(&self, n0: i64, n_min: i64) -> Result { + let (lo_v, hi_v) = self.window_at(n0, n_min)?; let mut acc = crate::holonomic::qfield::rn_zero(); for k0 in lo_v..=hi_v { let v = self.value_at(n0, k0).ok_or_else(|| { diff --git a/alkahest-core/src/holonomic/qzeil/rootofunity.rs b/alkahest-core/src/holonomic/qzeil/rootofunity.rs new file mode 100644 index 00000000..9e1e4aa5 --- /dev/null +++ b/alkahest-core/src/holonomic/qzeil/rootofunity.rs @@ -0,0 +1,972 @@ +//! Specialising a proved `Q(q)` identity at a root of unity `ζ_d`. +//! +//! [`super`] proves recurrences with `q` **transcendental**, and says so in +//! every verdict: an identity in `Q(q)` does not by itself license setting +//! `q = ζ_d`. This module takes that step, and it takes it as a *decision* with +//! three outcomes rather than as an assumption. +//! +//! # What is proved +//! +//! Fix a certificate whose boundary verdict is +//! [`QBoundaryStatus::Vanishes`]. Then +//! +//! ```text +//! Σ_{i=0}^{J} a_i(qⁿ)·S(n+i) = 0 in Q(q), for every integer n ≥ n_min +//! ``` +//! +//! with `S(n) = Σ_{k ∈ Z} F(n,k)`, a **finite** sum over the proved support +//! window. Both `S(n)` and `a_i(qⁿ)` are concrete elements of `Q(q)`, so the +//! question "does this survive `q = ζ_d`?" is the question of whether the +//! specialisation homomorphism +//! +//! ```text +//! ev_ζ : Z_(Φ_d) → Q(ζ_d), Z_(Φ_d) = { r ∈ Q(q) : v_{Φ_d}(r) ≥ 0 } +//! ``` +//! +//! is *defined* on each of them — `Z_(Φ_d)` is the localisation of `Q[q]` at +//! the prime `(Φ_d)`, and `ev_ζ` is a ring homomorphism on it. That is a +//! divisibility question over `Q`, decided exactly in +//! [`super::cyclotomic`]: `r` has a value at `ζ_d` iff `Φ_d` does not divide +//! the denominator of `r` in lowest terms. Nothing is evaluated numerically. +//! +//! So the theorem this module discharges is: +//! +//! > If `v_{Φ_d}(a_i(q^{n₀})) ≥ 0` and `v_{Φ_d}(S(n₀+i)) ≥ 0` for every +//! > `i = 0…J`, then `Σ_i a_i(ζ_d^{n₀})·S_ζ(n₀+i) = 0` in `Q(ζ_d)`, where +//! > `S_ζ(m) = ev_ζ(S(m))`. +//! +//! Both hypotheses are checked, the generic identity is re-checked in `Q(q)` at +//! this `n₀`, and the specialised identity is re-checked in `Q(ζ_d)` before +//! anything is returned. If a hypothesis fails, the verdict is +//! [`QRootOfUnityStatus::Obstructed`] and **no** specialised identity is +//! offered. +//! +//! # Three things that go wrong here, and are reported rather than hidden +//! +//! 1. **A pole.** `S(m)` or a coefficient can have `v_{Φ_d} < 0`. Specialising +//! anyway is the failure mode this module exists to prevent, and it is the +//! `q`-analogue of the A279013 mistake: a certificate that re-checks +//! perfectly while the specialised claim is false. Verdict: +//! [`Obstructed`](QRootOfUnityStatus::Obstructed). +//! +//! 2. **Degeneracy.** The recurrence coefficients are rational in `q`, and a +//! root of unity can kill them. For `Σ_k [n;k]_q²q^{k²}` the leading +//! coefficient carries a factor of `1 + q`, so at `ζ_2` and `n = 1` the +//! "recurrence" collapses to the single constraint `a_0·S_ζ(1) = 0` and no +//! longer determines the next value; at `ζ_1` (the classical `q → 1` limit) +//! *every* coefficient dies and the statement is `0 = 0`. Both are true +//! statements and neither is a recurrence, so +//! [`leading_coefficient_survives`] and [`is_vacuous`] report them rather +//! than letting a caller iterate something that is not there. +//! +//! 3. **The window moves.** `[n;k]_q` at `ζ_d` obeys the `q`-Lucas theorem and +//! vanishes at many `k` where it is non-zero generically — `[2;1]_q = 1 + q` +//! is non-zero in `Q(q)` and zero at `ζ_2`. The support can therefore +//! *shrink*, and [`effective_support`] records exactly where the surviving +//! terms are. It can never *grow*: outside the generic window `F(m,k)` is +//! the zero element of `Q(q)`, whose image under a ring homomorphism is `0`. +//! +//! # Two things this module does **not** do +//! +//! - It does not claim `S_ζ(m) = Σ_k ev_ζ(F(m,k))` unless every individual +//! summand is also regular at `ζ_d`. When one is not, `S_ζ(m)` is still +//! correct — it is the image of the exact `Q(q)` sum — but it is not the sum +//! of the specialised summands, and +//! [`is_termwise_regular`](QRootOfUnitySpecialization::is_termwise_regular) +//! says so. +//! - It does not do creative microscoping. There is no free parameter `a` in +//! the supported class to introduce and then send to `q^{-n}`; what is +//! delivered is the specialisation step itself, together with the exact +//! `Φ_d`-adic valuation of each `S(m)` — which is the quantity a +//! `q`-supercongruence `Φ_d(q)^r | S(n)` asserts. +//! +//! [`effective_support`]: QRootOfUnitySpecialization::effective_support +//! [`leading_coefficient_survives`]: QRootOfUnitySpecialization::leading_coefficient_survives +//! [`is_vacuous`]: QRootOfUnitySpecialization::is_vacuous + +use super::cyclotomic::{CycloElem, CycloField, MAX_CYCLOTOMIC_ORDER}; +use super::field::polyx_at_qn; +use super::{QBoundaryStatus, QCertificate, QHolonomicError}; +use crate::holonomic::qfield::{rn_add, rn_is_zero, rn_mul, rn_zero, Rn}; + +/// The verdict on whether a proved `Q(q)` recurrence survives `q = ζ_d`. +/// +/// Three-valued on purpose, and the three are not interchangeable: +/// `Specializes` is a proof, `Obstructed` is a proof that the hypotheses fail +/// (a pole was *exhibited*, not merely suspected), and `Unknown` licenses +/// nothing at all. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum QRootOfUnityStatus { + /// Proved: `Σ_i a_i(ζ_d^{n₀})·S_ζ(n₀+i) = 0` in `Q(ζ_d)`, re-checked in + /// exact cyclotomic arithmetic before being returned. + Specializes, + /// A hypothesis was **proved** to fail: some `a_i(q^{n₀})` or `S(n₀+i)` has + /// a pole at `ζ_d`. Nothing about the specialised sum is claimed — in + /// particular this is not a proof that the sum itself is singular, only + /// that this route to it is blocked. + Obstructed { + /// Which value, and what its `Φ_d`-adic valuation was. + reason: String, + }, + /// Not decided. **Nothing** follows. + Unknown { + /// What stopped the decision. + reason: String, + }, +} + +impl QRootOfUnityStatus { + /// `"specializes"`, `"obstructed"` or `"unknown"` — the stable tag. + pub fn tag(&self) -> &'static str { + match self { + QRootOfUnityStatus::Specializes => "specializes", + QRootOfUnityStatus::Obstructed { .. } => "obstructed", + QRootOfUnityStatus::Unknown { .. } => "unknown", + } + } + + /// Why the verdict came out as it did; empty for `Specializes`. + pub fn reason(&self) -> &str { + match self { + QRootOfUnityStatus::Specializes => "", + QRootOfUnityStatus::Obstructed { reason } | QRootOfUnityStatus::Unknown { reason } => { + reason + } + } + } +} + +/// The result of specialising a `q`-Zeilberger certificate at `ζ_d`. +/// +/// The `Q(q)`-side data (the exact valuations) is filled in whenever it could +/// be computed, *including* under `Obstructed` — a negative valuation is the +/// obstruction, and it is also the most interesting number here, since +/// `v_{Φ_d}(S(n)) ≥ r` is precisely the `q`-supercongruence `Φ_d(q)^r | S(n)`. +#[derive(Debug, Clone)] +pub struct QRootOfUnitySpecialization { + /// The order of the root of unity. + pub d: u32, + /// The `n` the verdict is about; the recurrence relates `n₀ … n₀+J`. + pub n0: i64, + /// The verdict. + pub status: QRootOfUnityStatus, + /// `Φ_d(q)` and the arithmetic of `Q(ζ_d)`. + pub field: CycloField, + /// The proved generic support window `lo ≤ k ≤ hi` at `n₀`. + pub window: Option<(i64, i64)>, + /// `S_ζ(n₀+i)` for `i = 0…J`, when the specialisation went through. + pub sums: Vec, + /// `v_{Φ_d}(S(n₀+i))`; a `None` entry means `S(n₀+i)` is identically zero, + /// whose valuation is `+∞`. + pub sum_valuations: Vec>, + /// `a_i(ζ_d^{n₀})` for `i = 0…J`, when the specialisation went through. + pub coeffs: Vec, + /// The `k` inside the generic window at which `F_ζ(n₀,k) ≠ 0` — the + /// *effective* window at `ζ_d`, which `q`-Lucas can make strictly smaller. + pub effective_support: Vec, + /// How many `k` in the generic window have `F(n₀,k) ≠ 0` in `Q(q)`. + pub generic_support_size: usize, + /// Whether every individual summand in the window is regular at `ζ_d`. + pub termwise_regular: bool, +} + +impl QRootOfUnitySpecialization { + /// Whether a specialised recurrence may be claimed at all. + pub fn specializes(&self) -> bool { + matches!(self.status, QRootOfUnityStatus::Specializes) + } + + /// Whether the specialised recurrence is `0 = 0`. + /// + /// True when every `a_i(ζ_d^{n₀})` vanishes. The statement is still a + /// theorem; it is simply empty, and a caller that reads + /// [`specializes`](Self::specializes) without reading this would be + /// claiming more than it has. + pub fn is_vacuous(&self) -> bool { + self.specializes() && !self.coeffs.is_empty() && self.coeffs.iter().all(CycloElem::is_zero) + } + + /// Whether the *leading* coefficient `a_J(ζ_d^{n₀})` survives, i.e. whether + /// the specialised recurrence still determines `S_ζ(n₀+J)` from its + /// predecessors. + pub fn leading_coefficient_survives(&self) -> bool { + self.specializes() && self.coeffs.last().is_some_and(|c| !c.is_zero()) + } + + /// Whether `S_ζ(m)` is also the sum of the specialised summands. + /// + /// `false` means at least one summand inside the window has a pole at + /// `ζ_d`: [`sums`](Self::sums) is still the correct image of the exact + /// `Q(q)` sum, but writing it as `Σ_k F_ζ(m,k)` would be writing down an + /// undefined expression. + pub fn is_termwise_regular(&self) -> bool { + self.termwise_regular + } + + /// Whether `q`-Lucas killed at least one term the generic identity needs. + pub fn support_shrinks(&self) -> bool { + self.effective_support.len() < self.generic_support_size + } + + /// What is still assumed after this verdict, as plain strings. + pub fn side_conditions(&self) -> Vec { + let d = self.d; + let n0 = self.n0; + match &self.status { + QRootOfUnityStatus::Specializes => { + let mut out = vec![format!( + "every coefficient a_i(q**{n0}) and every sum S({n0}+i) was proved to have \ + non-negative Phi_{d}-adic valuation, so the specialisation map at a primitive \ + {d}-th root of unity is defined on all of them and carries the proved Q(q) \ + recurrence to sum_i a_i(zeta^{n0})*S_zeta({n0}+i) = 0 in Q(zeta_{d}); the \ + specialised identity was re-checked in exact cyclotomic arithmetic" + )]; + if self.is_vacuous() { + out.push(format!( + "the specialised recurrence is VACUOUS: every a_i(zeta^{n0}) vanishes at a \ + primitive {d}-th root of unity, so the statement is 0 = 0 and constrains \ + nothing. The specialised sum values are still correct" + )); + } else if !self.leading_coefficient_survives() { + out.push(format!( + "the leading coefficient a_J(zeta^{n0}) vanishes at a primitive {d}-th \ + root of unity, so the specialised recurrence does not determine the last \ + value from the earlier ones" + )); + } + if !self.termwise_regular { + out.push(format!( + "at least one individual summand F(m, k) inside the window has a pole at a \ + primitive {d}-th root of unity: the reported S_zeta values are the images \ + of the exact Q(q) sums, and are NOT the sums of the specialised summands" + )); + } + if self.support_shrinks() { + out.push(format!( + "the support shrank under specialisation: {} of the {} terms that are \ + non-zero in Q(q) vanish at a primitive {d}-th root of unity (the q-Lucas \ + phenomenon). The sum is still over the same window; the vanishing terms \ + simply contribute nothing", + self.generic_support_size - self.effective_support.len(), + self.generic_support_size + )); + } + out.push( + "zeta_d is any primitive d-th root of unity: the statement is an identity in \ + Q(zeta_d) = Q[q]/(Phi_d(q)), so it holds for every primitive d-th root at \ + once, not for one chosen embedding" + .to_string(), + ); + out + } + QRootOfUnityStatus::Obstructed { reason } => vec![ + format!( + "the specialisation at a primitive {d}-th root of unity is obstructed: \ + {reason}. Nothing about the specialised sum or recurrence follows; the proved \ + statement remains the Q(q) one with q transcendental" + ), + "an obstruction here is a proof that this route is blocked, not a proof that the \ + specialised identity is false" + .to_string(), + ], + QRootOfUnityStatus::Unknown { reason } => vec![format!( + "the specialisation was not decided: {reason}. Nothing follows about q = zeta_{d}" + )], + } + } +} + +/// Specialise a verified `q`-Zeilberger certificate at a primitive `d`-th root +/// of unity, at the index `n₀`. +/// +/// See the [module documentation](self) for the theorem and for the three ways +/// this can come out short of a usable statement. Returns +/// [`QHolonomicError::InvalidInput`] for a malformed request (`d = 0`, `n₀` +/// below the range the boundary verdict covers) and +/// [`QHolonomicError::Unsupported`] when a resource bound is hit; the +/// mathematical outcomes are all carried by +/// [`QRootOfUnityStatus`], never by an error. +/// +/// `d = 1` is allowed and means `ζ_1 = 1`: the classical `q → 1` limit, where +/// `Q(ζ_1) = Q` and Gaussian binomials become ordinary ones. +pub fn q_specialize_at_root_of_unity( + cert: &QCertificate, + d: u32, + n0: i64, +) -> Result { + let field = CycloField::new(d).ok_or_else(|| { + QHolonomicError::InvalidInput(format!( + "the order of the root of unity must be between 1 and {MAX_CYCLOTOMIC_ORDER}, got {d}" + )) + })?; + + let n_min = match &cert.boundary { + QBoundaryStatus::Vanishes { n_min, .. } => *n_min, + QBoundaryStatus::Unknown { reason } => { + return Ok(unknown( + field, + n0, + format!( + "the generic boundary verdict is already \"unknown\", so there is no proved \ + Q(q) recurrence for the sum to specialise: {reason}" + ), + )); + } + }; + if n0 < n_min { + return Err(QHolonomicError::InvalidInput(format!( + "the boundary verdict covers n >= {n_min}, so it cannot be specialised at n = {n0}" + ))); + } + + let order = cert.report.result.order; + let window = cert.term.window_at(n0, n_min)?; + + // ---- The Q(q) side: exact sums, exact coefficients, exact valuations. ---- + let mut generic_sums: Vec = Vec::with_capacity(order + 1); + for i in 0..=order as i64 { + generic_sums.push(cert.term.sum_at(n0 + i, n_min)?); + } + let generic_coeffs: Vec = cert + .report + .result + .coeffs_x + .iter() + .map(|a| polyx_at_qn(a, n0)) + .collect(); + if generic_coeffs.len() != generic_sums.len() { + return Err(QHolonomicError::CertificateVerificationFailed(format!( + "the certificate reports order {order} but carries {} coefficients", + generic_coeffs.len() + ))); + } + + // The premise, re-checked at this n0 rather than assumed from the verdict. + let mut acc = rn_zero(); + for (c, s) in generic_coeffs.iter().zip(generic_sums.iter()) { + acc = rn_add(&acc, &rn_mul(c, s)); + } + if !rn_is_zero(&acc) { + return Err(QHolonomicError::CertificateVerificationFailed(format!( + "the proved Q(q) recurrence does not annihilate the exact q-series sums at n = {n0}; \ + refusing to specialise a premise that does not hold" + ))); + } + + let sum_valuations: Vec> = + generic_sums.iter().map(|s| field.valuation(s)).collect(); + + // ---- The hypotheses of the specialisation theorem, decided exactly. ---- + let mut coeffs = Vec::with_capacity(generic_coeffs.len()); + for (i, c) in generic_coeffs.iter().enumerate() { + match field.specialize(c) { + Some(v) => coeffs.push(v), + None => { + let v = field.valuation(c).unwrap_or(0); + return Ok(obstructed( + field, + n0, + d, + window, + sum_valuations, + format!( + "the recurrence coefficient a_{i}(q**{n0}) has Phi_{d}-adic valuation {v} \ + (< 0), i.e. a pole at a primitive {d}-th root of unity" + ), + )); + } + } + } + let mut sums = Vec::with_capacity(generic_sums.len()); + for (i, s) in generic_sums.iter().enumerate() { + match field.specialize(s) { + Some(v) => sums.push(v), + None => { + let v = field.valuation(s).unwrap_or(0); + let m = n0 + i as i64; + return Ok(obstructed( + field, + n0, + d, + window, + sum_valuations, + format!( + "the sum S({m}) has Phi_{d}-adic valuation {v} (< 0), i.e. a pole at a \ + primitive {d}-th root of unity" + ), + )); + } + } + } + + // ---- Re-check the specialised identity in exact cyclotomic arithmetic. ---- + let mut acc = field.zero(); + for (c, s) in coeffs.iter().zip(sums.iter()) { + acc = field.add(&acc, &field.mul(c, s)); + } + if !acc.is_zero() { + return Err(QHolonomicError::CertificateVerificationFailed(format!( + "the specialised recurrence failed its own re-check in Q(zeta_{d}) at n = {n0}; \ + refusing to return it" + ))); + } + + // ---- Termwise regularity, and where the surviving terms are. ---- + let mut termwise_regular = true; + for i in 0..=order as i64 { + let m = n0 + i; + let (lo, hi) = cert.term.window_at(m, n_min)?; + for k in lo..=hi { + let Some(v) = cert.term.value_at(m, k) else { + termwise_regular = false; + break; + }; + if field.specialize(&v).is_none() { + termwise_regular = false; + break; + } + } + if !termwise_regular { + break; + } + } + + let mut effective_support = Vec::new(); + let mut generic_support_size = 0_usize; + for k in window.0..=window.1 { + let Some(v) = cert.term.value_at(n0, k) else { + continue; + }; + if rn_is_zero(&v) { + continue; + } + generic_support_size += 1; + match field.specialize(&v) { + Some(sv) if !sv.is_zero() => effective_support.push(k), + _ => {} + } + } + + Ok(QRootOfUnitySpecialization { + d, + n0, + status: QRootOfUnityStatus::Specializes, + field, + window: Some(window), + sums, + sum_valuations, + coeffs, + effective_support, + generic_support_size, + termwise_regular, + }) +} + +fn unknown(field: CycloField, n0: i64, reason: String) -> QRootOfUnitySpecialization { + QRootOfUnitySpecialization { + d: field.order(), + n0, + status: QRootOfUnityStatus::Unknown { reason }, + field, + window: None, + sums: Vec::new(), + sum_valuations: Vec::new(), + coeffs: Vec::new(), + effective_support: Vec::new(), + generic_support_size: 0, + termwise_regular: false, + } +} + +fn obstructed( + field: CycloField, + n0: i64, + d: u32, + window: (i64, i64), + sum_valuations: Vec>, + reason: String, +) -> QRootOfUnitySpecialization { + QRootOfUnitySpecialization { + d, + n0, + status: QRootOfUnityStatus::Obstructed { reason }, + field, + window: Some(window), + sums: Vec::new(), + sum_valuations, + coeffs: Vec::new(), + effective_support: Vec::new(), + generic_support_size: 0, + termwise_regular: false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::errors::AlkahestError; + use crate::holonomic::qzeil::cyclotomic::cyclotomic_polynomial; + use crate::holonomic::qzeil::{q_zeilberger, QZeilbergerOpts}; + use crate::kernel::{Domain, ExprId, ExprPool}; + use rug::{Integer, Rational}; + + fn syms(pool: &ExprPool) -> (ExprId, ExprId, ExprId) { + ( + pool.symbol("q", Domain::Real), + pool.symbol("n", Domain::Real), + pool.symbol("k", Domain::Real), + ) + } + + fn qbinom(pool: &ExprPool, top: ExprId, bot: ExprId) -> ExprId { + pool.func("qbinomial", vec![top, bot]) + } + + /// `Σ_k [n;k]_q²·q^{k²}`, whose sum is `[2n;n]_q`. + fn vandermonde_cert(pool: &ExprPool) -> (QCertificate, ExprId, ExprId, ExprId) { + let (q, n, k) = syms(pool); + let b = qbinom(pool, n, k); + let f = pool.mul(vec![b, b, pool.pow(q, pool.mul(vec![k, k]))]); + let cert = q_zeilberger(f, q, n, k, pool, &QZeilbergerOpts::default()) + .expect("the q-Vandermonde square sum must be decided") + .value; + (cert, q, n, k) + } + + // ----------------------------------------------------------------- + // Independent yardsticks. None of these touch the q-Zeilberger + // machinery, the shift quotients, or `QProperTerm::value_at`. + // ----------------------------------------------------------------- + + /// `[N;K]_{ζ_d}` by the **Pascal recurrence** `[N;K] = [N−1;K−1] + + /// ζ^K·[N−1;K]`, built directly in `Q(ζ_d)`. + /// + /// Deliberately a different algorithm from everything under test: no + /// `q`-Pochhammer quotients, no rational functions, no specialisation — + /// cyclotomic arithmetic from the first line. + fn gaussian_binomial_at_zeta(f: &CycloField, nn: i64, kk: i64) -> CycloElem { + if kk < 0 || kk > nn { + return f.zero(); + } + // row[j] = [i; j]_ζ + let mut row = vec![f.one()]; + for _i in 1..=nn { + let mut next = vec![f.zero(); row.len() + 1]; + for (j, cell) in row.iter().enumerate() { + // [i;j] contributes ζ^j·[i−1;j] to [i;j] and [i−1;j] to [i;j+1]. + next[j] = f.add(&next[j], &f.mul(cell, &f.zeta_pow(j as i64))); + next[j + 1] = f.add(&next[j + 1], cell); + } + row = next; + } + row[kk as usize].clone() + } + + /// `[N;K]_{ζ_d}` by the **`q`-Lucas theorem**: + /// `[N;K]_ζ = C(⌊N/d⌋, ⌊K/d⌋)·[N mod d; K mod d]_ζ`. + /// + /// A closed form via base-`d` digits and an *integer* binomial coefficient, + /// with the residual small binomial built from the **product formula** + /// `∏_{i=1}^{k}(1 − ζ^{n−k+i})/(1 − ζ^i)` — every factor of which is + /// invertible because `0 < i ≤ k < d`. Structurally unrelated to the Pascal + /// recurrence above and to everything under test. + fn gaussian_binomial_by_q_lucas(f: &CycloField, nn: i64, kk: i64) -> CycloElem { + let d = f.order() as i64; + if kk < 0 || kk > nn { + return f.zero(); + } + let (n1, n0) = (nn / d, nn % d); + let (k1, k0) = (kk / d, kk % d); + if k0 > n0 || k1 > n1 { + return f.zero(); + } + let mut c = Integer::from(1); + for j in 0..k1 { + c *= Integer::from(n1 - j); + c /= Integer::from(j + 1); + } + let one = f.one(); + let mut small = one.clone(); + for i in 1..=k0 { + let num = f.sub(&one, &f.zeta_pow(n0 - k0 + i)); + let den = f.sub(&one, &f.zeta_pow(i)); + let inv = f.inv(&den).expect("1 − zeta^i is a unit for 0 < i < d"); + small = f.mul(&small, &f.mul(&num, &inv)); + } + f.mul(&small, &f.from_rational(Rational::from(c))) + } + + /// `v_{Φ_d}([N;K]_q) = ⌊N/d⌋ − ⌊K/d⌋ − ⌊(N−K)/d⌋`, from counting the + /// multiples of `d` among the `1 − q^i` factors. Integer floors only. + fn gaussian_binomial_valuation(d: i64, nn: i64, kk: i64) -> i64 { + nn.div_euclid(d) - kk.div_euclid(d) - (nn - kk).div_euclid(d) + } + + #[test] + fn the_yardsticks_agree_with_each_other() { + // Pascal and q-Lucas are independent; if they disagree the flagship + // test below is measuring against a broken ruler. + for d in 1_u32..=6 { + let f = CycloField::new(d).expect("in range"); + for nn in 0..12 { + for kk in 0..=nn { + assert_eq!( + gaussian_binomial_at_zeta(&f, nn, kk), + gaussian_binomial_by_q_lucas(&f, nn, kk), + "Pascal and q-Lucas disagree on [{nn};{kk}]_zeta_{d}" + ); + } + } + } + } + + // ----------------------------------------------------------------- + // The flagship + // ----------------------------------------------------------------- + + /// **`Σ_{k} [n;k]_q²·q^{k²} = [2n;n]_q`, specialised at `ζ_d`.** + /// + /// Checked against three things the specialisation machinery has no part + /// in: + /// + /// 1. the sum recomputed term by term in `Q(ζ_d)` with the Gaussian + /// binomials built from the **Pascal recurrence**; + /// 2. the closed form `[2n;n]_{ζ_d}` predicted by the **`q`-Lucas + /// theorem** from the base-`d` digits of `2n` and `n`; + /// 3. the exact `Φ_d`-adic valuation of the sum, against the integer + /// floor count `⌊2n/d⌋ − 2⌊n/d⌋` of `Φ_d` factors in `[2n;n]_q`. + /// + /// (3) is the `q`-supercongruence content: it is the exact statement + /// `Φ_d(q)^v ∥ Σ_k [n;k]_q²q^{k²}`. + #[test] + fn q_vandermonde_square_sum_at_a_root_of_unity() { + let pool = ExprPool::new(); + let (cert, _q, _n, _k) = vandermonde_cert(&pool); + + for d in 1_u32..=6 { + let f = CycloField::new(d).expect("in range"); + for n0 in 0..9_i64 { + let spec = q_specialize_at_root_of_unity(&cert, d, n0) + .expect("a polynomial summand has no pole anywhere"); + assert_eq!( + spec.status.tag(), + "specializes", + "d = {d}, n = {n0}: {}", + spec.status.reason() + ); + // Every summand is a polynomial in q, so the termwise + // statement holds too. + assert!(spec.is_termwise_regular()); + + // 1. The sum, recomputed in Q(ζ_d) by the Pascal recurrence. + let mut direct = f.zero(); + for k in 0..=n0 { + let b = gaussian_binomial_at_zeta(&f, n0, k); + let sq = f.mul(&b, &b); + direct = f.add(&direct, &f.mul(&sq, &f.zeta_pow(k * k))); + } + assert_eq!( + spec.sums[0], direct, + "d = {d}, n = {n0}: the specialised sum must equal the sum computed \ + independently in Q(zeta_{d})" + ); + + // 2. The closed form, by q-Lucas. + let lucas = gaussian_binomial_by_q_lucas(&f, 2 * n0, n0); + assert_eq!( + spec.sums[0], lucas, + "d = {d}, n = {n0}: sum_k [n;k]^2 q^(k^2) at zeta_{d} must be the q-Lucas \ + value of [2n;n]" + ); + + // 3. The Phi_d-adic valuation — the supercongruence statement. + let want_v = gaussian_binomial_valuation(d as i64, 2 * n0, n0); + assert_eq!( + spec.sum_valuations[0], + Some(want_v), + "d = {d}, n = {n0}: Phi_{d}(q)^{want_v} must divide [2n;n]_q exactly" + ); + // …and the two agree on whether the specialised value is zero. + assert_eq!(want_v > 0, spec.sums[0].is_zero()); + } + } + } + + /// The specialised **recurrence** is checked against the specialised + /// **values**, at every `(d, n)` where it is not vacuous. + #[test] + fn the_specialised_recurrence_annihilates_the_independent_values() { + let pool = ExprPool::new(); + let (cert, _q, _n, _k) = vandermonde_cert(&pool); + let mut nontrivial = 0; + for d in 2_u32..=6 { + let f = CycloField::new(d).expect("in range"); + for n0 in 0..8_i64 { + let spec = q_specialize_at_root_of_unity(&cert, d, n0).expect("no pole"); + if !spec.specializes() || spec.is_vacuous() { + continue; + } + nontrivial += 1; + // Independent values, from q-Lucas rather than from `spec`. + let mut acc = f.zero(); + for (i, c) in spec.coeffs.iter().enumerate() { + let s = gaussian_binomial_by_q_lucas(&f, 2 * (n0 + i as i64), n0 + i as i64); + acc = f.add(&acc, &f.mul(c, &s)); + } + assert!( + acc.is_zero(), + "d = {d}, n = {n0}: the specialised recurrence must annihilate the q-Lucas \ + values, not merely the ones it computed itself" + ); + } + } + assert!(nontrivial >= 10, "the check must not be vacuously empty"); + } + + // ----------------------------------------------------------------- + // The window genuinely changes + // ----------------------------------------------------------------- + + /// `q`-Lucas kills terms: `[2;1]_q = 1 + q` is non-zero in `Q(q)` and zero + /// at `ζ_2`, so the effective window at `d = 2, n = 2` is `{0, 2}` and not + /// `{0, 1, 2}`. + /// + /// This must be **reported**, not silently absorbed — and the sum over the + /// unchanged window must still be right. + #[test] + fn the_support_shrinks_at_a_root_of_unity_and_says_so() { + let pool = ExprPool::new(); + let (cert, _q, _n, _k) = vandermonde_cert(&pool); + let spec = q_specialize_at_root_of_unity(&cert, 2, 2).expect("no pole"); + + assert_eq!(spec.status.tag(), "specializes"); + assert_eq!(spec.window, Some((0, 2))); + assert_eq!(spec.generic_support_size, 3); + assert_eq!( + spec.effective_support, + vec![0, 2], + "the k = 1 term must die at zeta_2, since [2;1]_q = 1 + q" + ); + assert!(spec.support_shrinks()); + assert!(spec + .side_conditions() + .iter() + .any(|s| s.contains("the support shrank under specialisation"))); + + // …and the value is still the right one: [4;2]_{ζ_2} = C(2,1)·[0;0] = 2. + let f = CycloField::new(2).expect("in range"); + assert_eq!(spec.sums[0], f.from_rational(Rational::from(2))); + } + + /// The support can never *grow*: outside the proved window the summand is + /// the zero element of `Q(q)`, and a ring homomorphism sends `0` to `0`. + #[test] + fn the_support_never_grows() { + let pool = ExprPool::new(); + let (cert, _q, _n, _k) = vandermonde_cert(&pool); + for d in 1_u32..=5 { + let f = CycloField::new(d).expect("in range"); + for n0 in 0..6_i64 { + for k in [-3_i64, -1, n0 + 1, n0 + 4] { + let v = cert.term.value_at(n0, k).expect("finite"); + assert_eq!( + f.specialize(&v), + Some(f.zero()), + "d = {d}, n = {n0}, k = {k}: outside the window the specialisation must \ + still be zero" + ); + } + } + } + } + + // ----------------------------------------------------------------- + // Degeneracy + // ----------------------------------------------------------------- + + /// At `d = 1` (i.e. `q → 1`) the leading coefficient is a multiple of + /// `(1 − q^{n+1})²`, so **every** coefficient dies and the specialised + /// recurrence is `0 = 0`. + /// + /// The verdict is still `"specializes"` — the statement is true — and + /// `is_vacuous` is what stops a caller from mistaking it for content. The + /// *values* remain correct: at `q = 1` the identity is + /// `Σ_k C(n,k)² = C(2n,n)`. + #[test] + fn the_classical_limit_is_a_vacuous_recurrence_with_correct_values() { + let pool = ExprPool::new(); + let (cert, _q, _n, _k) = vandermonde_cert(&pool); + let f = CycloField::new(1).expect("in range"); + for n0 in 0..8_i64 { + let spec = q_specialize_at_root_of_unity(&cert, 1, n0).expect("no pole"); + assert_eq!(spec.status.tag(), "specializes"); + assert!( + spec.is_vacuous(), + "at q = 1 the coefficients (1 − q^(n+1))² and (1 − q^(2n+1))(1 − q^(2n+2)) all \ + vanish, so the specialised recurrence says nothing" + ); + assert!(!spec.leading_coefficient_survives()); + assert!(spec.side_conditions().iter().any(|s| s.contains("VACUOUS"))); + + // C(2n, n), computed as an integer. + let mut c = Integer::from(1); + for j in 0..n0 { + c *= Integer::from(2 * n0 - j); + c /= Integer::from(j + 1); + } + assert_eq!( + spec.sums[0], + f.from_rational(Rational::from(c)), + "at q = 1 the sum must be the central binomial coefficient C(2n, n)" + ); + } + } + + /// The partial degeneracy, at a genuine root of unity: at `d = 2, n = 1` + /// the **leading** coefficient `a_1(ζ_2)` vanishes while `a_0(ζ_2) = 4` + /// does not. + /// + /// The specialised recurrence is then not a recurrence at all — it is the + /// single constraint `4·S_ζ(1) = 0`, which no longer determines `S_ζ(2)` + /// from `S_ζ(1)`. A caller that iterated it forwards would divide by zero, + /// so the verdict has to say this out loud even though it is a true + /// statement (and it is: `[2;1]_{ζ_2} = 1 + q|_{q=−1} = 0`). + #[test] + fn a_root_of_unity_can_kill_the_leading_coefficient() { + let pool = ExprPool::new(); + let (cert, _q, _n, _k) = vandermonde_cert(&pool); + let f = CycloField::new(2).expect("in range"); + let spec = q_specialize_at_root_of_unity(&cert, 2, 1).expect("no pole"); + + assert_eq!(spec.status.tag(), "specializes"); + assert!(!spec.is_vacuous(), "a_0 survives, so this is not vacuous"); + assert!( + !spec.leading_coefficient_survives(), + "a_1(zeta_2^1) is a multiple of (1 − q^2)|_{{q=−1}} = 0" + ); + assert!(spec + .side_conditions() + .iter() + .any(|s| s.contains("the leading coefficient"))); + // The surviving constraint really does force the value it claims. + assert!(!spec.coeffs[0].is_zero()); + assert_eq!(spec.sums[0], f.zero()); + assert_eq!(spec.sum_valuations[0], Some(1)); + } + + // ----------------------------------------------------------------- + // Refusals + // ----------------------------------------------------------------- + + /// A **pole** at `ζ_d`: `Σ_k [n;k]_q²q^{k²}/(q³; q³)_1 = [2n;n]_q/(1 − q³)` + /// has `Φ_3`-adic valuation `−1` whenever `3 ∤ ... ` — concretely at every + /// `n` with `n mod 3 ≠ 2`, and since two consecutive `n` cannot both be + /// `≡ 2 (mod 3)`, at *every* `n₀` one of `S(n₀)`, `S(n₀+1)` is singular. + /// + /// This is the A279013 hazard transplanted: the certificate is perfectly + /// valid and re-checks cleanly in `Q(q)`, and specialising it at `ζ_3` + /// anyway would produce a confidently wrong statement. It must be refused. + #[test] + fn a_pole_at_the_root_of_unity_is_obstructed_not_specialised() { + let pool = ExprPool::new(); + let (q, n, k) = syms(&pool); + let b = qbinom(&pool, n, k); + // (q³; q³)_1 = 1 − q³, as a constant q-Pochhammer factor. + let pole = pool.pow( + pool.func( + "qpochhammer", + vec![ + pool.integer(3_i32), + pool.integer(3_i32), + pool.integer(1_i32), + ], + ), + pool.integer(-1_i32), + ); + let f = pool.mul(vec![b, b, pool.pow(q, pool.mul(vec![k, k])), pole]); + let cert = q_zeilberger(f, q, n, k, &pool, &QZeilbergerOpts::default()) + .expect("the certificate itself is fine — the constant factor cancels out of it") + .value; + assert_eq!(cert.boundary.tag(), "vanishes"); + + let mut obstructed = 0; + for n0 in 0..7_i64 { + let spec = + q_specialize_at_root_of_unity(&cert, 3, n0).expect("a verdict, not an error"); + assert_eq!( + spec.status.tag(), + "obstructed", + "n = {n0}: 1/(1 − q³) has a pole at zeta_3 and must not be specialised" + ); + assert!(!spec.specializes()); + assert!(spec.sums.is_empty(), "no specialised value may be offered"); + assert!(spec.status.reason().contains("valuation")); + assert!(spec + .side_conditions() + .iter() + .any(|s| s.contains("obstructed"))); + obstructed += 1; + } + assert_eq!(obstructed, 7); + + // The same certificate at a d where the factor is a unit specialises + // fine — the refusal is about zeta_3, not about the term. + let spec = q_specialize_at_root_of_unity(&cert, 5, 2).expect("no pole"); + assert_eq!(spec.status.tag(), "specializes", "{}", spec.status.reason()); + } + + /// A certificate whose *generic* verdict is `"unknown"` cannot be + /// specialised either: there is no proved `Q(q)` statement to carry over. + #[test] + fn an_unknown_generic_verdict_stays_unknown_at_a_root_of_unity() { + let pool = ExprPool::new(); + let (q, n, k) = syms(&pool); + let f = pool.pow( + pool.func( + "qpochhammer", + vec![ + pool.integer(1_i32), + pool.integer(1_i32), + pool.add(vec![n, pool.mul(vec![k, pool.integer(-1_i32)])]), + ], + ), + pool.integer(-1_i32), + ); + let cert = q_zeilberger(f, q, n, k, &pool, &QZeilbergerOpts::default()) + .expect("the telescoping identity is fine") + .value; + assert_eq!(cert.boundary.tag(), "unknown"); + + let spec = q_specialize_at_root_of_unity(&cert, 3, 2).expect("a verdict, not an error"); + assert_eq!(spec.status.tag(), "unknown"); + assert!(!spec.specializes()); + assert!(spec.sums.is_empty()); + assert!(spec.status.reason().contains("already")); + } + + /// Malformed requests are coded errors, not verdicts. + #[test] + fn malformed_requests_are_refused() { + let pool = ExprPool::new(); + let (cert, _q, _n, _k) = vandermonde_cert(&pool); + + let err = q_specialize_at_root_of_unity(&cert, 0, 3).expect_err("d = 0 names nothing"); + assert_eq!(err.code(), "E-HOLO-023"); + let err = q_specialize_at_root_of_unity(&cert, 100_000, 3).expect_err("past the cap"); + assert_eq!(err.code(), "E-HOLO-023"); + let err = q_specialize_at_root_of_unity(&cert, 3, -1) + .expect_err("below the range the verdict covers"); + assert_eq!(err.code(), "E-HOLO-023"); + } + + /// The modulus really is the cyclotomic polynomial the caller can check + /// against by hand. + #[test] + fn the_modulus_is_exposed_for_independent_checking() { + let pool = ExprPool::new(); + let (cert, _q, _n, _k) = vandermonde_cert(&pool); + let spec = q_specialize_at_root_of_unity(&cert, 6, 3).expect("no pole"); + assert_eq!(spec.field.modulus(), &cyclotomic_polynomial(6)); + assert_eq!(spec.field.degree(), 2); // φ(6) = 2 + } +} diff --git a/alkahest-core/src/lib.rs b/alkahest-core/src/lib.rs index a1082d08..91faa0da 100644 --- a/alkahest-core/src/lib.rs +++ b/alkahest-core/src/lib.rs @@ -190,6 +190,9 @@ pub use poly::groebner::{ compute_groebner_basis_f5, fglm, grevlex_staircase, is_zero_dimensional, GbPoly, GroebnerBasis, MonomialOrder, }; +// M9 — coefficient fields for elimination +#[cfg(feature = "groebner")] +pub use poly::groebner::{ParamGbPoly, ParamGroebnerBasis, ParamGroebnerError, ParamPoly, QParam}; // P1 search plumbing item 4 — budgets, cancellation, determinism pub use budget::{ @@ -382,6 +385,12 @@ pub mod experimental { SeriesSolution, }; pub use crate::plot::{render_dot, render_svg, render_svg_opts}; + /// M9 — Gröbner bases over the coefficient field `Q(params)`, with the + /// specialisation hypotheses reported rather than assumed. + #[cfg(feature = "groebner")] + pub use crate::poly::groebner::{ + ParamGbPoly, ParamGroebnerBasis, ParamGroebnerError, ParamPoly, QParam, + }; pub use crate::poly::{ gcd_sparse_modular, residue, sparse_interpolate, sparse_interpolate_univariate, GaussRat, ResidueError, SparseGcdError, SparseInterpError, diff --git a/alkahest-core/src/poly/groebner/buchberger.rs b/alkahest-core/src/poly/groebner/buchberger.rs index 3ff8578e..14e896fc 100644 --- a/alkahest-core/src/poly/groebner/buchberger.rs +++ b/alkahest-core/src/poly/groebner/buchberger.rs @@ -5,6 +5,9 @@ //! - Sugar selection strategy: process pair with minimum sugar degree first, break ties by lcm degree //! - Incremental basis update: each new element is added before selecting the next pair //! +//! The pair machinery itself is in `super::pairs` (private — internal to this +//! module), shared with the `Q(params)` engine in [`super::parametric`]. +//! //! Reference: Becker & Weispfenning (1993) "Gröbner Bases", Algorithm 6.5 (GROEBNERNEWS2), //! Gebauer & Möller (1988) "On an Installation of Buchberger's Algorithm", //! and Giovini et al. (1991) "One Sugar Cube, Please" for the sugar selection strategy. @@ -13,179 +16,9 @@ use std::collections::BinaryHeap; use crate::poly::groebner::ideal::GbPoly; use crate::poly::groebner::monomial_order::MonomialOrder; +use crate::poly::groebner::pairs::{update_pairs, CriticalPair}; use crate::poly::groebner::reduce::{reduce, s_polynomial}; -// --------------------------------------------------------------------------- -// Monomial helpers -// --------------------------------------------------------------------------- - -#[inline] -fn lcm_exp(a: &[u32], b: &[u32]) -> Vec { - a.iter().zip(b.iter()).map(|(&x, &y)| x.max(y)).collect() -} - -/// True if every component of `a` ≤ corresponding component of `b`. -#[inline] -fn monomial_divides(a: &[u32], b: &[u32]) -> bool { - a.iter().zip(b.iter()).all(|(ai, bi)| ai <= bi) -} - -/// Total degree of an exponent vector. -#[inline] -fn total_deg(e: &[u32]) -> u32 { - e.iter().sum() -} - -// --------------------------------------------------------------------------- -// Critical pair with sugar-ordered comparison (min-heap) -// --------------------------------------------------------------------------- - -#[derive(Clone, Debug, Eq, PartialEq)] -struct CriticalPair { - /// Sugar degree of the pair: lcm_deg + max(ecart_i, ecart_j). - /// Primary sort key — the "sugar" selection strategy (Giovini et al. 1991). - /// For homogeneous systems this equals lcm_deg; for inhomogeneous ones it - /// avoids the late-sugar blowup that the normal strategy suffers. - sugar_deg: u32, - /// Total degree of lcm(LM(basis[i]), LM(basis[j])) — secondary sort key. - lcm_deg: u32, - lcm_exp: Vec, - i: usize, - j: usize, -} - -impl Ord for CriticalPair { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - // Reverse ordering so BinaryHeap (max-heap) acts as a min-heap. - other - .sugar_deg - .cmp(&self.sugar_deg) - .then_with(|| other.lcm_deg.cmp(&self.lcm_deg)) - .then_with(|| self.i.cmp(&other.i)) - .then_with(|| self.j.cmp(&other.j)) - } -} -impl PartialOrd for CriticalPair { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -// --------------------------------------------------------------------------- -// Gebauer-Möller pair update -// --------------------------------------------------------------------------- - -/// Update the critical pair list when `basis[new_idx]` is added to the basis. -/// -/// Applies: -/// - **Criterion M**: Among new pairs (g, h), keep only those whose lcm is -/// not strictly divisible by the lcm of another candidate pair. -/// - **Criterion F**: Discard old pairs (g1, g2) where lm(h) | lcm(lm(g1), lm(g2)) -/// and the pair is truly covered (the two equality conditions from B&W §6.5). -/// -/// `basis_sugar[k]` = max total degree of any term in `basis[k]` (the sugar). -fn update_pairs( - basis: &[GbPoly], - basis_sugar: &[u32], - pairs: &mut Vec, - new_idx: usize, - order: MonomialOrder, -) { - let lh = match basis[new_idx].leading_exp(order) { - Some(e) => e, - None => return, - }; - let lh_deg = total_deg(&lh); - let ecart_h = basis_sugar[new_idx].saturating_sub(lh_deg); - - // ----------------------------------------------------------------------- - // Step 1: build candidate pairs (g, h), filtered by product criterion. - // ----------------------------------------------------------------------- - struct Cand { - g_idx: usize, - lcm: Vec, - ecart_g: u32, - } - - let candidates: Vec = (0..new_idx) - .filter_map(|g_idx| { - let lg = basis[g_idx].leading_exp(order)?; - // Product criterion: coprime LMs ⟹ S-poly = 0, skip. - if lh.iter().zip(lg.iter()).all(|(&a, &b)| a == 0 || b == 0) { - return None; - } - let ecart_g = basis_sugar[g_idx].saturating_sub(total_deg(&lg)); - Some(Cand { - g_idx, - lcm: lcm_exp(&lh, &lg), - ecart_g, - }) - }) - .collect(); - - // ----------------------------------------------------------------------- - // Step 2: Criterion M — keep only minimal candidates. - // Discard (g, h) if ∃ (g', h) ∈ candidates with g' ≠ g and - // lcm(g', h) strictly divides lcm(g, h). - // ----------------------------------------------------------------------- - let c_min: Vec<&Cand> = candidates - .iter() - .filter(|ci| { - !candidates.iter().any(|cj| { - cj.g_idx != ci.g_idx && monomial_divides(&cj.lcm, &ci.lcm) && cj.lcm != ci.lcm - }) - }) - .collect(); - - // ----------------------------------------------------------------------- - // Step 3: Criterion F — remove old pairs subsumed by h. - // Discard (g1, g2) ∈ pairs if: - // lm(h) | lcm(g1, g2) - // AND lcm(g1, h) ≠ lcm(g1, g2) [g1 is not the "cover witness"] - // AND lcm(g2, h) ≠ lcm(g1, g2) [g2 is not the "cover witness"] - // The equality conditions prevent incorrectly discarding pairs whose - // chain-criterion witness is itself degenerate (B&W §6.5). - // ----------------------------------------------------------------------- - pairs.retain(|p| { - let lg1 = match basis[p.i].leading_exp(order) { - Some(e) => e, - None => return false, - }; - let lg2 = match basis[p.j].leading_exp(order) { - Some(e) => e, - None => return false, - }; - let lcm_12 = lcm_exp(&lg1, &lg2); - - if !monomial_divides(&lh, &lcm_12) { - return true; // lm(h) doesn't divide — keep - } - if lcm_exp(&lg1, &lh) == lcm_12 { - return true; // g1 is the witness — keep (pair is not truly covered) - } - if lcm_exp(&lg2, &lh) == lcm_12 { - return true; // g2 is the witness — keep - } - false // discard: h truly subverts this pair - }); - - // ----------------------------------------------------------------------- - // Step 4: add minimal candidates to the pair list with sugar degrees. - // Sugar of pair (g, h) with lcm L = deg(L) + max(ecart(g), ecart(h)). - // ----------------------------------------------------------------------- - for c in c_min { - let lcm_deg = total_deg(&c.lcm); - let sugar_deg = lcm_deg + c.ecart_g.max(ecart_h); - pairs.push(CriticalPair { - sugar_deg, - lcm_deg, - lcm_exp: c.lcm.clone(), - i: c.g_idx, - j: new_idx, - }); - } -} - // --------------------------------------------------------------------------- // Main algorithm // --------------------------------------------------------------------------- @@ -207,15 +40,23 @@ pub fn compute_buchberger_basis(generators: Vec, order: MonomialOrder) - let mut basis: Vec = Vec::with_capacity(initial.len() * 2); let mut basis_sugar: Vec = Vec::with_capacity(initial.len() * 2); + // Leading exponents, kept in step with `basis`. Every element pushed here + // is non-zero (the generators were filtered and reductions are only added + // when they do not vanish), so `leading_exp` always yields a value. + let mut basis_lead: Vec> = Vec::with_capacity(initial.len() * 2); let mut pair_vec: Vec = Vec::new(); // Add initial generators one by one, applying GM update after each. for gen in initial { let sugar = gen.sugar(); + let Some(lead) = gen.leading_exp(order) else { + continue; + }; let new_idx = basis.len(); basis.push(gen); basis_sugar.push(sugar); - update_pairs(&basis, &basis_sugar, &mut pair_vec, new_idx, order); + basis_lead.push(lead); + update_pairs(&basis_lead, &basis_sugar, &mut pair_vec, new_idx); } // Build min-heap (CriticalPair::Ord is reversed for min-heap behaviour). @@ -228,13 +69,17 @@ pub fn compute_buchberger_basis(generators: Vec, order: MonomialOrder) - if !r.is_zero() { let r = r.make_monic(order); let sugar = r.sugar(); + let Some(lead) = r.leading_exp(order) else { + continue; + }; let new_idx = basis.len(); basis.push(r); basis_sugar.push(sugar); + basis_lead.push(lead); // Flatten heap → apply GM update → rebuild heap. let mut pv: Vec = heap.into_vec(); - update_pairs(&basis, &basis_sugar, &mut pv, new_idx, order); + update_pairs(&basis_lead, &basis_sugar, &mut pv, new_idx); heap = BinaryHeap::from(pv); } } diff --git a/alkahest-core/src/poly/groebner/mod.rs b/alkahest-core/src/poly/groebner/mod.rs index 8d7422fc..78bb75bb 100644 --- a/alkahest-core/src/poly/groebner/mod.rs +++ b/alkahest-core/src/poly/groebner/mod.rs @@ -29,6 +29,9 @@ pub mod f5; pub mod fglm; pub mod ideal; pub mod monomial_order; +mod pairs; +pub mod parametric; +pub mod paramfield; pub mod reduce; // Keep f4 as a thin re-export so any external crate that depended on the old @@ -44,6 +47,8 @@ pub use f5::compute_groebner_basis_f5; pub use fglm::{fglm, grevlex_staircase, is_zero_dimensional}; pub use ideal::GbPoly; pub use monomial_order::MonomialOrder; +pub use parametric::{ParamGbPoly, ParamGroebnerBasis, ParamGroebnerError}; +pub use paramfield::{ParamPoly, QParam}; pub use reduce::reduce; /// A computed Gröbner basis. @@ -60,6 +65,18 @@ impl GroebnerBasis { GroebnerBasis { generators, order } } + /// Wrap an already-computed set of generators, without recomputing. + /// + /// The caller asserts that `generators` really is a Gröbner basis under + /// `order`; nothing here checks it. This exists so that the specialisation + /// of a [`parametric::ParamGroebnerBasis`] — which is a Gröbner basis by + /// [`parametric::ParamGroebnerBasis::specialize`]'s contract — can be + /// handed back as an ordinary [`GroebnerBasis`] instead of being recomputed + /// from scratch. + pub fn from_generators(generators: Vec, order: MonomialOrder) -> Self { + GroebnerBasis { generators, order } + } + /// Compute a lex Gröbner basis using the grevlex-then-FGLM strategy. /// /// For 0-dimensional ideals this is typically orders of magnitude faster diff --git a/alkahest-core/src/poly/groebner/pairs.rs b/alkahest-core/src/poly/groebner/pairs.rs new file mode 100644 index 00000000..45ba3cdd --- /dev/null +++ b/alkahest-core/src/poly/groebner/pairs.rs @@ -0,0 +1,159 @@ +//! Critical-pair management for Buchberger's algorithm. +//! +//! Everything here depends on *leading exponents only*, never on coefficients, +//! which is why the same code drives the basis over ℚ +//! ([`super::buchberger`]) and the one over `Q(params)` +//! ([`super::parametric`]). That is not a refactoring convenience: it is the +//! reason a `Q(params)` basis specialises so cleanly. Pair selection, the +//! Gebauer–Möller criteria and the product criterion all read the same +//! monomial data before and after a parameter substitution, so a specialisation +//! that keeps every leading monomial keeps the whole pair schedule too. +//! +//! Reference: Becker & Weispfenning (1993) "Gröbner Bases", Algorithm 6.5 +//! (GROEBNERNEWS2), Gebauer & Möller (1988), and Giovini et al. (1991) +//! "One Sugar Cube, Please" for the sugar selection strategy. + +#[inline] +pub(crate) fn lcm_exp(a: &[u32], b: &[u32]) -> Vec { + a.iter().zip(b.iter()).map(|(&x, &y)| x.max(y)).collect() +} + +/// True if every component of `a` ≤ the corresponding component of `b`. +#[inline] +pub(crate) fn monomial_divides(a: &[u32], b: &[u32]) -> bool { + a.iter().zip(b.iter()).all(|(ai, bi)| ai <= bi) +} + +/// Total degree of an exponent vector. +#[inline] +pub(crate) fn total_deg(e: &[u32]) -> u32 { + e.iter().sum() +} + +/// A critical pair, ordered for a min-heap by sugar degree then lcm degree. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CriticalPair { + /// Sugar degree of the pair: `lcm_deg + max(ecart_i, ecart_j)`. + /// Primary sort key — the "sugar" selection strategy (Giovini et al. 1991). + /// For homogeneous systems this equals `lcm_deg`; for inhomogeneous ones it + /// avoids the late-sugar blowup that the normal strategy suffers. + pub(crate) sugar_deg: u32, + /// Total degree of `lcm(LM(basis[i]), LM(basis[j]))` — secondary sort key. + pub(crate) lcm_deg: u32, + pub(crate) lcm_exp: Vec, + pub(crate) i: usize, + pub(crate) j: usize, +} + +impl Ord for CriticalPair { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + // Reverse ordering so BinaryHeap (max-heap) acts as a min-heap. + other + .sugar_deg + .cmp(&self.sugar_deg) + .then_with(|| other.lcm_deg.cmp(&self.lcm_deg)) + .then_with(|| self.i.cmp(&other.i)) + .then_with(|| self.j.cmp(&other.j)) + } +} + +impl PartialOrd for CriticalPair { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// Update the critical-pair list when basis element `new_idx` is added. +/// +/// `lead[k]` is the leading exponent vector of basis element `k` and +/// `sugar[k]` its sugar (max total degree of any term). Both are indexed in +/// parallel with the basis and must cover `new_idx`. +/// +/// Applies: +/// - **Product criterion**: coprime leading monomials ⟹ the S-polynomial +/// reduces to zero, so the pair is never formed. +/// - **Criterion M**: among the new pairs `(g, h)`, keep only those whose lcm +/// is not strictly divisible by another candidate's lcm. +/// - **Criterion F**: discard old pairs `(g₁, g₂)` where `lm(h) | lcm(g₁, g₂)` +/// and the pair is truly covered (the two equality conditions from B&W §6.5). +pub(crate) fn update_pairs( + lead: &[Vec], + sugar: &[u32], + pairs: &mut Vec, + new_idx: usize, +) { + let lh = &lead[new_idx]; + let lh_deg = total_deg(lh); + let ecart_h = sugar[new_idx].saturating_sub(lh_deg); + + // ----------------------------------------------------------------------- + // Step 1: build candidate pairs (g, h), filtered by the product criterion. + // ----------------------------------------------------------------------- + struct Cand { + g_idx: usize, + lcm: Vec, + ecart_g: u32, + } + + let candidates: Vec = (0..new_idx) + .filter_map(|g_idx| { + let lg = &lead[g_idx]; + if lh.iter().zip(lg.iter()).all(|(&a, &b)| a == 0 || b == 0) { + return None; + } + Some(Cand { + g_idx, + lcm: lcm_exp(lh, lg), + ecart_g: sugar[g_idx].saturating_sub(total_deg(lg)), + }) + }) + .collect(); + + // ----------------------------------------------------------------------- + // Step 2: Criterion M — keep only minimal candidates. + // ----------------------------------------------------------------------- + let c_min: Vec<&Cand> = candidates + .iter() + .filter(|ci| { + !candidates.iter().any(|cj| { + cj.g_idx != ci.g_idx && monomial_divides(&cj.lcm, &ci.lcm) && cj.lcm != ci.lcm + }) + }) + .collect(); + + // ----------------------------------------------------------------------- + // Step 3: Criterion F — remove old pairs subsumed by h. + // The equality conditions prevent incorrectly discarding pairs whose + // chain-criterion witness is itself degenerate (B&W §6.5). + // ----------------------------------------------------------------------- + pairs.retain(|p| { + let lg1 = &lead[p.i]; + let lg2 = &lead[p.j]; + let lcm_12 = lcm_exp(lg1, lg2); + + if !monomial_divides(lh, &lcm_12) { + return true; // lm(h) doesn't divide — keep + } + if lcm_exp(lg1, lh) == lcm_12 { + return true; // g1 is the witness — keep (pair is not truly covered) + } + if lcm_exp(lg2, lh) == lcm_12 { + return true; // g2 is the witness — keep + } + false // discard: h truly subverts this pair + }); + + // ----------------------------------------------------------------------- + // Step 4: add the minimal candidates, with sugar degrees. + // ----------------------------------------------------------------------- + for c in c_min { + let lcm_deg = total_deg(&c.lcm); + pairs.push(CriticalPair { + sugar_deg: lcm_deg + c.ecart_g.max(ecart_h), + lcm_deg, + lcm_exp: c.lcm.clone(), + i: c.g_idx, + j: new_idx, + }); + } +} diff --git a/alkahest-core/src/poly/groebner/parametric.rs b/alkahest-core/src/poly/groebner/parametric.rs new file mode 100644 index 00000000..859a8be6 --- /dev/null +++ b/alkahest-core/src/poly/groebner/parametric.rs @@ -0,0 +1,982 @@ +//! Gröbner bases over the coefficient field `Q(p₁, …, p_m)` (M9). +//! +//! The engine is the same Buchberger loop as [`super::buchberger`] — same +//! Gebauer–Möller pair management from `super::pairs` (private — internal to +//! this module), same sugar selection, +//! same interreduction — with `rug::Rational` replaced by +//! [`QParam`], an element of `Q(params)`. What changes is the *ring*: the +//! parameters are no longer variables, so they do not appear in the monomial +//! order, do not generate S-pairs, and do not enlarge the staircase. For a +//! differential-elimination problem that is the difference between eliminating +//! states from `Q[states, Y, params]` and from `Q(params)[states, Y]`. +//! +//! # The specialisation hypothesis, and why it is reported +//! +//! A basis over `Q(params)` is a basis for *generic* parameter values. A +//! leading coefficient that is a non-zero element of `Q(params)` can still +//! vanish at particular parameter values, and there the computation the +//! algorithm performed is not the computation it would have performed over ℚ. +//! That locus is information the caller needs, so it is computed and returned +//! rather than assumed away — see [`ParamGroebnerBasis::conditions`]. +//! +//! Exactly two things can go wrong under a specialisation `σ: params ↦ p̄`: +//! +//! 1. a coefficient's denominator vanishes at `p̄`, so the coefficient has no +//! value there at all; or +//! 2. a leading coefficient that the algorithm inverted vanishes at `p̄`, so +//! over ℚ the leading monomial would have been a different one and the whole +//! pair schedule downstream would have differed. +//! +//! Both are recorded as they happen: every inversion contributes its numerator +//! *and* its denominator to the condition set, and the input coefficients +//! contribute their denominators. Nothing else can introduce a denominator — +//! addition and multiplication in `Q(params)` stay inside the local ring of +//! functions regular at `p̄`, and division only ever happens by a recorded +//! element — so the recorded set is closed: +//! +//! > **Specialisation.** Let `G` be the returned basis and `C` the returned +//! > conditions. For every `p̄` with `f(p̄) ≠ 0` for all `f ∈ C`, every +//! > coefficient of `G` is regular at `p̄`, and `σ(G)` is precisely the basis +//! > this same algorithm computes over ℚ from `σ(F)` — every leading monomial, +//! > every pair, every reduction agrees step for step. +//! +//! The conditions are **sufficient, not necessary**: the true bad locus can be +//! smaller, because a specialisation can be harmless in a way this bookkeeping +//! cannot see. Erring in that direction is deliberate — the alternative is a +//! basis that is silently wrong on a set of measure zero. + +use std::collections::{BTreeMap, BTreeSet, BinaryHeap}; + +use rug::Rational; + +use crate::errors::AlkahestError; +use crate::poly::groebner::ideal::GbPoly; +use crate::poly::groebner::monomial_order::MonomialOrder; +use crate::poly::groebner::pairs::{lcm_exp, update_pairs, CriticalPair}; +use crate::poly::groebner::paramfield::{ParamPoly, QParam}; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/// Failures specific to the `Q(params)` Gröbner engine. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum ParamGroebnerError { + /// `compute` was called with no generators. + NoGenerators, + /// Generators disagree on the number of variables or parameters. + ShapeMismatch { + /// Shape of the first generator, as `(n_vars, n_params)`. + expected: (usize, usize), + /// Shape of the offending generator. + got: (usize, usize), + }, + /// A specialisation was requested with the wrong number of values. + WrongArity { + /// Number of parameters the basis is written over. + expected: usize, + /// Number of values supplied. + got: usize, + }, + /// The requested parameter point lies on the degeneracy locus: at least one + /// of the basis's conditions vanishes there, so the basis says nothing + /// about that point. + Degenerate { + /// The conditions that vanish at the requested point. + vanishing: Vec, + }, +} + +impl std::fmt::Display for ParamGroebnerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ParamGroebnerError::NoGenerators => { + write!(f, "a parametric Gröbner basis needs at least one generator") + } + ParamGroebnerError::ShapeMismatch { expected, got } => write!( + f, + "generator shape mismatch: expected {} variables and {} parameters, got {} and {}", + expected.0, expected.1, got.0, got.1 + ), + ParamGroebnerError::WrongArity { expected, got } => write!( + f, + "specialisation needs one value per parameter: expected {expected}, got {got}" + ), + ParamGroebnerError::Degenerate { vanishing } => write!( + f, + "parameter point is on the degeneracy locus: {} of the basis's conditions vanish \ + there, so this basis does not describe that point", + vanishing.len() + ), + } + } +} + +impl std::error::Error for ParamGroebnerError {} + +impl AlkahestError for ParamGroebnerError { + fn code(&self) -> &'static str { + match self { + ParamGroebnerError::NoGenerators => "E-PARAMGB-001", + ParamGroebnerError::ShapeMismatch { .. } => "E-PARAMGB-002", + ParamGroebnerError::WrongArity { .. } => "E-PARAMGB-003", + ParamGroebnerError::Degenerate { .. } => "E-PARAMGB-004", + } + } + + fn remediation(&self) -> Option<&'static str> { + match self { + ParamGroebnerError::NoGenerators => Some("pass at least one polynomial"), + ParamGroebnerError::ShapeMismatch { .. } => { + Some("build every generator against the same variable and parameter lists") + } + ParamGroebnerError::WrongArity { .. } => { + Some("supply exactly one value per parameter, in the parameter list's order") + } + ParamGroebnerError::Degenerate { .. } => Some( + "compute the basis directly over ℚ at that parameter point, or move the vanishing \ + factors into the generators and recompute", + ), + } + } +} + +// --------------------------------------------------------------------------- +// ParamGbPoly — a polynomial in the variables with Q(params) coefficients +// --------------------------------------------------------------------------- + +/// A sparse polynomial in the ring variables with coefficients in +/// `Q(p₁, …, p_m)`. +/// +/// The exponent keys index the *variables* only — the parameters live in the +/// coefficients, which is the whole point. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ParamGbPoly { + /// Coefficients keyed by exponent vector over the variables. + pub terms: BTreeMap, QParam>, + /// Number of ring variables. + pub n_vars: usize, + /// Number of parameters in the coefficient field. + pub n_params: usize, +} + +impl ParamGbPoly { + /// The zero polynomial. + pub fn zero(n_vars: usize, n_params: usize) -> Self { + ParamGbPoly { + terms: BTreeMap::new(), + n_vars, + n_params, + } + } + + /// Reinterpret a `GbPoly` over `vars ++ params` as a polynomial in `vars` + /// with coefficients in `Q(params)`. + /// + /// This is the bridge from the existing `Expr → GbPoly` conversion: build + /// the polynomial over the concatenated variable list, then move the + /// trailing `n_params` exponent slots into the coefficient field. + /// + /// Returns `None` when `p.n_vars ≠ n_vars + n_params`. + pub fn from_gbpoly(p: &GbPoly, n_vars: usize, n_params: usize) -> Option { + if p.n_vars != n_vars + n_params { + return None; + } + let mut out = ParamGbPoly::zero(n_vars, n_params); + for (exp, coeff) in &p.terms { + if *coeff == 0 { + continue; + } + let (var_exp, par_exp) = exp.split_at(n_vars); + let mut mono = ParamPoly::zero(n_params); + mono.terms.insert(par_exp.to_vec(), rug::Integer::from(1)); + let c = QParam::from_rational(coeff, n_params).mul(&QParam::from_poly(mono)); + let slot = out + .terms + .entry(var_exp.to_vec()) + .or_insert_with(|| QParam::zero(n_params)); + *slot = slot.add(&c); + } + out.terms.retain(|_, c| !c.is_zero()); + Some(out) + } + + /// True for the zero polynomial. + pub fn is_zero(&self) -> bool { + self.terms.is_empty() + } + + /// Number of non-zero terms. + pub fn n_terms(&self) -> usize { + self.terms.len() + } + + /// Leading term under `order`. + pub fn leading_term(&self, order: MonomialOrder) -> Option<(&Vec, &QParam)> { + self.terms + .iter() + .max_by(|(ea, _), (eb, _)| order.cmp(ea, eb)) + } + + /// Leading exponent under `order`. + pub fn leading_exp(&self, order: MonomialOrder) -> Option> { + self.leading_term(order).map(|(e, _)| e.clone()) + } + + /// Leading coefficient under `order`. + pub fn leading_coeff(&self, order: MonomialOrder) -> Option { + self.leading_term(order).map(|(_, c)| c.clone()) + } + + /// Max total degree over all terms (the "sugar" of the polynomial). + pub fn sugar(&self) -> u32 { + self.terms + .keys() + .map(|e| e.iter().sum::()) + .max() + .unwrap_or(0) + } + + /// `self + other`. + pub fn add(&self, other: &Self) -> Self { + let mut terms = self.terms.clone(); + for (e, c) in &other.terms { + let slot = terms + .entry(e.clone()) + .or_insert_with(|| QParam::zero(self.n_params)); + *slot = slot.add(c); + if slot.is_zero() { + terms.remove(e); + } + } + ParamGbPoly { + terms, + n_vars: self.n_vars, + n_params: self.n_params, + } + } + + /// `-self`. + pub fn neg(&self) -> Self { + ParamGbPoly { + terms: self + .terms + .iter() + .map(|(e, c)| (e.clone(), c.neg())) + .collect(), + n_vars: self.n_vars, + n_params: self.n_params, + } + } + + /// `self - other`. + pub fn sub(&self, other: &Self) -> Self { + self.add(&other.neg()) + } + + /// `self · c` for a field element `c`. + pub fn scale(&self, c: &QParam) -> Self { + if c.is_zero() { + return ParamGbPoly::zero(self.n_vars, self.n_params); + } + ParamGbPoly { + terms: self + .terms + .iter() + .map(|(e, v)| (e.clone(), v.mul(c))) + .collect(), + n_vars: self.n_vars, + n_params: self.n_params, + } + } + + /// `self · c · x^shift`. + pub fn mul_monomial(&self, shift: &[u32], c: &QParam) -> Self { + if c.is_zero() { + return ParamGbPoly::zero(self.n_vars, self.n_params); + } + ParamGbPoly { + terms: self + .terms + .iter() + .map(|(e, v)| { + let ne: Vec = e.iter().zip(shift.iter()).map(|(a, b)| a + b).collect(); + (ne, v.mul(c)) + }) + .collect(), + n_vars: self.n_vars, + n_params: self.n_params, + } + } + + /// Divide through by the leading coefficient, recording the inversion. + fn make_monic(&self, order: MonomialOrder, conds: &mut ConditionLog) -> Self { + let Some(lc) = self.leading_coeff(order) else { + return self.clone(); + }; + if lc.is_one() { + return self.clone(); + } + conds.record_inversion(&lc); + match lc.inv() { + Some(inv) => self.scale(&inv), + None => self.clone(), + } + } + + /// Specialise every coefficient at a rational parameter point. + /// + /// `None` if any coefficient has a pole there. + pub fn specialize(&self, values: &[Rational]) -> Option { + let mut terms = BTreeMap::new(); + for (e, c) in &self.terms { + let v = c.eval(values)?; + if v != 0 { + terms.insert(e.clone(), v); + } + } + Some(GbPoly { + terms, + n_vars: self.n_vars, + }) + } +} + +// --------------------------------------------------------------------------- +// Condition log +// --------------------------------------------------------------------------- + +/// Accumulates the polynomials in the parameters whose non-vanishing the +/// computation assumed. +#[derive(Debug, Default)] +struct ConditionLog { + raw: Vec, +} + +impl ConditionLog { + /// Record that `c` was inverted: both its numerator (which must stay + /// non-zero for the leading monomial to survive) and its denominator + /// (which must stay non-zero for `c` to have a value at all). + fn record_inversion(&mut self, c: &QParam) { + self.push(c.numerator()); + self.push(c.denominator()); + } + + /// Record that `c` must be regular at the parameter point. + fn record_regular(&mut self, c: &QParam) { + self.push(c.denominator()); + } + + fn push(&mut self, p: &ParamPoly) { + if p.is_zero() || p.is_nonzero_constant() { + return; // carries no condition + } + self.raw.push(p.clone()); + } + + /// Split every recorded polynomial into irreducible factors and dedup, so + /// the reported locus is a list of distinct hypersurfaces rather than a + /// pile of products. + fn finish(self) -> Vec { + let mut set: BTreeSet = BTreeSet::new(); + for p in &self.raw { + for f in p.irreducible_factors() { + set.insert(f); + } + } + set.into_iter().collect() + } +} + +// --------------------------------------------------------------------------- +// Reduction and S-polynomials over Q(params) +// --------------------------------------------------------------------------- + +/// Multivariate division of `f` by `gs`, returning the remainder. +fn reduce_param( + f: &ParamGbPoly, + gs: &[ParamGbPoly], + order: MonomialOrder, + conds: &mut ConditionLog, +) -> ParamGbPoly { + let mut p = f.clone(); + let mut r = ParamGbPoly::zero(f.n_vars, f.n_params); + let mut last_divisor: usize = 0; + let is_graded = order.is_graded(); + + 'outer: while !p.is_zero() { + let (lt_exp, lt_coeff) = match p.leading_term(order) { + Some((e, c)) => (e.clone(), c.clone()), + None => break, + }; + let lt_deg: u32 = if is_graded { lt_exp.iter().sum() } else { 0 }; + + for offset in 0..gs.len() { + let idx = (last_divisor + offset) % gs.len(); + let g = &gs[idx]; + if let Some((lg_exp, lg_coeff)) = g.leading_term(order) { + if is_graded && lg_exp.iter().sum::() > lt_deg { + continue; + } + if lt_exp.len() == lg_exp.len() + && lt_exp.iter().zip(lg_exp.iter()).all(|(a, b)| a >= b) + { + let shift: Vec = lt_exp + .iter() + .zip(lg_exp.iter()) + .map(|(a, b)| a - b) + .collect(); + if !lg_coeff.is_one() { + conds.record_inversion(lg_coeff); + } + let Some(coeff) = lt_coeff.div(lg_coeff) else { + continue; + }; + p = p.sub(&g.mul_monomial(&shift, &coeff)); + last_divisor = idx; + continue 'outer; + } + } + } + + // No divisor found — move the leading term to the remainder. + let mut lt = ParamGbPoly::zero(f.n_vars, f.n_params); + lt.terms.insert(lt_exp.clone(), lt_coeff); + r = r.add(<); + p.terms.remove(<_exp); + } + + r +} + +/// The S-polynomial of `f` and `g` under `order`. +fn s_polynomial_param( + f: &ParamGbPoly, + g: &ParamGbPoly, + order: MonomialOrder, + conds: &mut ConditionLog, +) -> ParamGbPoly { + let (Some((lf_exp, lf_coeff)), Some((lg_exp, lg_coeff))) = + (f.leading_term(order), g.leading_term(order)) + else { + return ParamGbPoly::zero(f.n_vars, f.n_params); + }; + let lcm = lcm_exp(lf_exp, lg_exp); + let shift_f: Vec = lcm.iter().zip(lf_exp.iter()).map(|(l, a)| l - a).collect(); + let shift_g: Vec = lcm.iter().zip(lg_exp.iter()).map(|(l, b)| l - b).collect(); + + let one = QParam::one(f.n_params); + if !lf_coeff.is_one() { + conds.record_inversion(lf_coeff); + } + if !lg_coeff.is_one() { + conds.record_inversion(lg_coeff); + } + let (Some(cf), Some(cg)) = (one.div(lf_coeff), one.div(lg_coeff)) else { + return ParamGbPoly::zero(f.n_vars, f.n_params); + }; + + f.mul_monomial(&shift_f, &cf) + .sub(&g.mul_monomial(&shift_g, &cg)) +} + +/// Reduce each basis element by the others and drop the redundant ones. +fn interreduce( + mut basis: Vec, + order: MonomialOrder, + conds: &mut ConditionLog, +) -> Vec { + let mut i = 0; + while i < basis.len() { + let others: Vec = basis + .iter() + .enumerate() + .filter(|&(j, _)| j != i) + .map(|(_, g)| g.clone()) + .collect(); + let reduced = reduce_param(&basis[i], &others, order, conds); + if reduced.is_zero() { + basis.remove(i); + } else { + basis[i] = reduced.make_monic(order, conds); + i += 1; + } + } + basis +} + +// --------------------------------------------------------------------------- +// The basis +// --------------------------------------------------------------------------- + +/// A Gröbner basis computed over the coefficient field `Q(p₁, …, p_m)`. +/// +/// Read the generators back with [`Self::generators`], the hypotheses the +/// computation made with [`Self::conditions`], and check or apply a parameter +/// point with [`Self::vanishing_conditions`] / [`Self::specialize`]. +#[derive(Clone, Debug)] +pub struct ParamGroebnerBasis { + generators: Vec, + order: MonomialOrder, + n_vars: usize, + n_params: usize, + conditions: Vec, +} + +impl ParamGroebnerBasis { + /// Compute a Gröbner basis of `⟨gens⟩ ⊆ Q(params)[vars]` under `order`. + pub fn compute( + gens: Vec, + order: MonomialOrder, + ) -> Result { + let first = gens.first().ok_or(ParamGroebnerError::NoGenerators)?; + let (n_vars, n_params) = (first.n_vars, first.n_params); + for g in &gens { + if (g.n_vars, g.n_params) != (n_vars, n_params) { + return Err(ParamGroebnerError::ShapeMismatch { + expected: (n_vars, n_params), + got: (g.n_vars, g.n_params), + }); + } + } + + let mut conds = ConditionLog::default(); + // The input coefficients have to be regular at the parameter point + // before anything else can be said about them. + for g in &gens { + for c in g.terms.values() { + conds.record_regular(c); + } + } + + let initial: Vec = gens + .into_iter() + .filter(|g| !g.is_zero()) + .map(|g| g.make_monic(order, &mut conds)) + .collect(); + + if initial.is_empty() { + return Ok(ParamGroebnerBasis { + generators: vec![], + order, + n_vars, + n_params, + conditions: conds.finish(), + }); + } + + let mut basis: Vec = Vec::with_capacity(initial.len() * 2); + let mut basis_sugar: Vec = Vec::with_capacity(initial.len() * 2); + let mut basis_lead: Vec> = Vec::with_capacity(initial.len() * 2); + let mut pair_vec: Vec = Vec::new(); + + for gen in initial { + let sugar = gen.sugar(); + let Some(lead) = gen.leading_exp(order) else { + continue; + }; + let new_idx = basis.len(); + basis.push(gen); + basis_sugar.push(sugar); + basis_lead.push(lead); + update_pairs(&basis_lead, &basis_sugar, &mut pair_vec, new_idx); + } + + let mut heap: BinaryHeap = BinaryHeap::from(pair_vec); + + while let Some(pair) = heap.pop() { + let sp = s_polynomial_param(&basis[pair.i], &basis[pair.j], order, &mut conds); + let r = reduce_param(&sp, &basis, order, &mut conds); + + if !r.is_zero() { + let r = r.make_monic(order, &mut conds); + let sugar = r.sugar(); + let Some(lead) = r.leading_exp(order) else { + continue; + }; + let new_idx = basis.len(); + basis.push(r); + basis_sugar.push(sugar); + basis_lead.push(lead); + + let mut pv: Vec = heap.into_vec(); + update_pairs(&basis_lead, &basis_sugar, &mut pv, new_idx); + heap = BinaryHeap::from(pv); + } + } + + let generators = interreduce(basis, order, &mut conds); + Ok(ParamGroebnerBasis { + generators, + order, + n_vars, + n_params, + conditions: conds.finish(), + }) + } + + /// The basis generators, interreduced and monic. + pub fn generators(&self) -> &[ParamGbPoly] { + &self.generators + } + + /// The monomial order the generators are reduced under. + pub fn order(&self) -> MonomialOrder { + self.order + } + + /// Number of ring variables. + pub fn n_vars(&self) -> usize { + self.n_vars + } + + /// Number of parameters in the coefficient field. + pub fn n_params(&self) -> usize { + self.n_params + } + + /// Number of generators. + pub fn len(&self) -> usize { + self.generators.len() + } + + /// True when the basis has no generators. + pub fn is_empty(&self) -> bool { + self.generators.is_empty() + } + + /// The polynomials in the parameters whose non-vanishing this basis + /// assumed, each irreducible, primitive and with a positive leading + /// coefficient. + /// + /// The basis describes exactly those parameter points at which none of + /// them vanishes; the degeneracy locus is the union of the hypersurfaces + /// they cut out. An empty list means the basis holds at every rational + /// parameter point. + /// + /// The list is sufficient, not necessary — see the module documentation. + pub fn conditions(&self) -> &[ParamPoly] { + &self.conditions + } + + /// The conditions that vanish at `values` — empty exactly when the basis + /// applies at that parameter point. + pub fn vanishing_conditions(&self, values: &[Rational]) -> Vec { + self.conditions + .iter() + .filter(|c| c.eval(values) == 0) + .cloned() + .collect() + } + + /// True when `values` is off the degeneracy locus. + pub fn is_regular_at(&self, values: &[Rational]) -> bool { + self.conditions.iter().all(|c| c.eval(values) != 0) + } + + /// Substitute rational values for the parameters. + /// + /// Off the degeneracy locus the result is the reduced Gröbner basis of the + /// specialised ideal under the same order — the same thing computing over + /// ℚ from the specialised generators would produce. On the locus this + /// refuses with [`ParamGroebnerError::Degenerate`] rather than returning a + /// basis that is not one. + pub fn specialize(&self, values: &[Rational]) -> Result, ParamGroebnerError> { + if values.len() != self.n_params { + return Err(ParamGroebnerError::WrongArity { + expected: self.n_params, + got: values.len(), + }); + } + let vanishing = self.vanishing_conditions(values); + if !vanishing.is_empty() { + return Err(ParamGroebnerError::Degenerate { vanishing }); + } + let mut out = Vec::with_capacity(self.generators.len()); + for g in &self.generators { + match g.specialize(values) { + Some(p) => out.push(p), + // Unreachable while the condition set is closed under the + // argument in the module docs; reported rather than papered + // over if it ever is not. + None => { + return Err(ParamGroebnerError::Degenerate { + vanishing: g + .terms + .values() + .filter(|c| c.denominator().eval(values) == 0) + .map(|c| c.denominator().clone()) + .collect(), + }) + } + } + } + Ok(out) + } + + /// Reduce a polynomial modulo this basis and return the remainder. + pub fn reduce(&self, p: &ParamGbPoly) -> ParamGbPoly { + let mut sink = ConditionLog::default(); + reduce_param(p, &self.generators, self.order, &mut sink) + } + + /// Ideal membership: true when [`Self::reduce`] gives zero. + pub fn contains(&self, p: &ParamGbPoly) -> bool { + self.reduce(p).is_zero() + } + + /// The elimination ideal `I ∩ Q(params)[remaining vars]`. + /// + /// Drops every generator whose support mentions one of `vars`, exactly as + /// [`super::GroebnerBasis::eliminate`] does. Under a `Lex` basis with the + /// eliminated variables ordered first, what is left generates the + /// elimination ideal. + pub fn eliminate(&self, vars: &[usize]) -> ParamGroebnerBasis { + let generators: Vec = self + .generators + .iter() + .filter(|g| { + !g.terms + .keys() + .any(|e| vars.iter().any(|&i| e.get(i).copied().unwrap_or(0) > 0)) + }) + .cloned() + .collect(); + ParamGroebnerBasis { + generators, + order: self.order, + n_vars: self.n_vars, + n_params: self.n_params, + conditions: self.conditions.clone(), + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::poly::groebner::GroebnerBasis; + + /// `c · x^var_exp · p^par_exp` as a one-term parametric polynomial. + fn term( + n_vars: usize, + n_params: usize, + var_exp: &[u32], + par_exp: &[u32], + c: i64, + ) -> ParamGbPoly { + let mut mono = ParamPoly::zero(n_params); + mono.terms.insert(par_exp.to_vec(), rug::Integer::from(1)); + let coeff = + QParam::from_rational(&Rational::from(c), n_params).mul(&QParam::from_poly(mono)); + let mut p = ParamGbPoly::zero(n_vars, n_params); + if !coeff.is_zero() { + p.terms.insert(var_exp.to_vec(), coeff); + } + p + } + + fn sum(parts: Vec) -> ParamGbPoly { + let mut it = parts.into_iter(); + let first = it.next().expect("non-empty"); + it.fold(first, |a, b| a.add(&b)) + } + + fn rat(v: i64) -> Rational { + Rational::from(v) + } + + /// The same system written over ℚ with the parameters substituted, as a + /// plain `GbPoly` — the oracle for the specialisation tests. + fn gb_over_q(polys: &[Vec<(Vec, Rational)>], n_vars: usize) -> GroebnerBasis { + let gens: Vec = polys + .iter() + .map(|terms| GbPoly { + terms: terms.iter().cloned().collect(), + n_vars, + }) + .collect(); + GroebnerBasis::compute(gens, MonomialOrder::Lex) + } + + #[test] + fn linear_system_with_a_parametric_coefficient() { + // { a·x - y, x + y - 1 } over Q(a)[x, y], lex with x > y. + let f = term(2, 1, &[1, 0], &[1], 1).sub(&term(2, 1, &[0, 1], &[0], 1)); + let g = sum(vec![ + term(2, 1, &[1, 0], &[0], 1), + term(2, 1, &[0, 1], &[0], 1), + term(2, 1, &[0, 0], &[0], -1), + ]); + let gb = ParamGroebnerBasis::compute(vec![f, g], MonomialOrder::Lex).unwrap(); + assert_eq!(gb.len(), 2, "expected a triangular basis"); + + // y = a/(a+1) is the solution, so (a + 1) must be a reported condition. + assert!( + gb.conditions() + .iter() + .any(|c| *c == ParamPoly::var(0, 1).add(&ParamPoly::one(1))), + "a + 1 must be reported: the system is degenerate at a = -1, got {:?}", + gb.conditions() + ); + assert!(!gb.is_regular_at(&[rat(-1)])); + assert!(gb.is_regular_at(&[rat(3)])); + } + + #[test] + fn specialisation_agrees_with_computing_over_q() { + // { a·x - y, x + y - 1 }, specialised at several values of a. + let f = term(2, 1, &[1, 0], &[1], 1).sub(&term(2, 1, &[0, 1], &[0], 1)); + let g = sum(vec![ + term(2, 1, &[1, 0], &[0], 1), + term(2, 1, &[0, 1], &[0], 1), + term(2, 1, &[0, 0], &[0], -1), + ]); + let gb = ParamGroebnerBasis::compute(vec![f, g], MonomialOrder::Lex).unwrap(); + + for a in [2i64, 3, -5, 7] { + let spec = gb.specialize(&[rat(a)]).expect("regular point"); + let direct = gb_over_q( + &[ + vec![ + (vec![1, 0], Rational::from(a)), + (vec![0, 1], Rational::from(-1)), + ], + vec![ + (vec![1, 0], Rational::from(1)), + (vec![0, 1], Rational::from(1)), + (vec![0, 0], Rational::from(-1)), + ], + ], + 2, + ); + let mut got: Vec<_> = spec.iter().map(|p| p.terms.clone()).collect(); + let mut want: Vec<_> = direct + .generators() + .iter() + .map(|p| p.terms.clone()) + .collect(); + got.sort(); + want.sort(); + assert_eq!(got, want, "specialisation at a = {a} disagrees with ℚ"); + } + } + + #[test] + fn degenerate_point_is_refused_and_is_genuinely_different() { + let f = term(2, 1, &[1, 0], &[1], 1).sub(&term(2, 1, &[0, 1], &[0], 1)); + let g = sum(vec![ + term(2, 1, &[1, 0], &[0], 1), + term(2, 1, &[0, 1], &[0], 1), + term(2, 1, &[0, 0], &[0], -1), + ]); + let gb = ParamGroebnerBasis::compute(vec![f, g], MonomialOrder::Lex).unwrap(); + + let err = gb.specialize(&[rat(-1)]).unwrap_err(); + assert_eq!(err.code(), "E-PARAMGB-004"); + let ParamGroebnerError::Degenerate { vanishing } = &err else { + panic!("expected a degeneracy report, got {err}"); + }; + assert!(!vanishing.is_empty()); + + // And the point really is special: at a = -1 the system {-x - y, x + y - 1} + // is inconsistent, so its basis over ℚ is {1}, which is not the + // specialisation of the generic basis. + let direct = gb_over_q( + &[ + vec![ + (vec![1, 0], Rational::from(-1)), + (vec![0, 1], Rational::from(-1)), + ], + vec![ + (vec![1, 0], Rational::from(1)), + (vec![0, 1], Rational::from(1)), + (vec![0, 0], Rational::from(-1)), + ], + ], + 2, + ); + assert_eq!(direct.len(), 1); + assert!(direct.generators()[0].terms.contains_key(&vec![0, 0])); + } + + #[test] + fn no_parameters_reproduces_the_rational_engine() { + // x² - 1, x - 1 with an empty parameter list. + let f = term(1, 0, &[2], &[], 1).sub(&term(1, 0, &[0], &[], 1)); + let g = term(1, 0, &[1], &[], 1).sub(&term(1, 0, &[0], &[], 1)); + let gb = ParamGroebnerBasis::compute(vec![f, g], MonomialOrder::Lex).unwrap(); + assert_eq!(gb.len(), 1); + assert!(gb.conditions().is_empty()); + let spec = gb.specialize(&[]).unwrap(); + assert_eq!(spec.len(), 1); + assert!(spec[0].terms.contains_key(&vec![1])); + } + + #[test] + fn from_gbpoly_moves_the_trailing_slots_into_the_field() { + // a·x + a²·y over vars [x, y] and params [a]. + let src = GbPoly { + terms: [ + (vec![1u32, 0, 1], Rational::from(1)), + (vec![0, 1, 2], Rational::from(1)), + ] + .into_iter() + .collect(), + n_vars: 3, + }; + let p = ParamGbPoly::from_gbpoly(&src, 2, 1).unwrap(); + assert_eq!(p.n_terms(), 2); + let cx = p.terms.get(&vec![1u32, 0]).unwrap(); + assert_eq!(*cx, QParam::from_poly(ParamPoly::var(0, 1))); + let cy = p.terms.get(&vec![0u32, 1]).unwrap(); + assert_eq!( + *cy, + QParam::from_poly(ParamPoly::var(0, 1).mul(&ParamPoly::var(0, 1))) + ); + assert!(ParamGbPoly::from_gbpoly(&src, 1, 1).is_none()); + } + + #[test] + fn membership_and_elimination() { + // { x - t, y - t² } in Q(a)[t, x, y] — implicitisation, no parameters + // actually used, so the elimination ideal is ⟨y - x²⟩. + let n = 3; + let f = term(n, 1, &[1, 0, 0], &[0], -1).add(&term(n, 1, &[0, 1, 0], &[0], 1)); + let g = term(n, 1, &[2, 0, 0], &[0], -1).add(&term(n, 1, &[0, 0, 1], &[0], 1)); + let gb = ParamGroebnerBasis::compute(vec![f.clone(), g], MonomialOrder::Lex).unwrap(); + assert!(gb.contains(&f)); + let el = gb.eliminate(&[0]); + assert_eq!(el.len(), 1); + let rel = &el.generators()[0]; + assert!(rel.terms.contains_key(&vec![0, 2, 0])); + assert!(rel.terms.contains_key(&vec![0, 0, 1])); + } + + #[test] + fn wrong_arity_is_reported() { + let f = term(1, 2, &[1], &[0, 0], 1); + let gb = ParamGroebnerBasis::compute(vec![f], MonomialOrder::Lex).unwrap(); + let err = gb.specialize(&[rat(1)]).unwrap_err(); + assert_eq!(err.code(), "E-PARAMGB-003"); + } + + #[test] + fn no_generators_is_reported() { + let err = ParamGroebnerBasis::compute(vec![], MonomialOrder::Lex).unwrap_err(); + assert_eq!(err.code(), "E-PARAMGB-001"); + } + + #[test] + fn shape_mismatch_is_reported() { + let f = term(2, 1, &[1, 0], &[0], 1); + let g = term(3, 1, &[1, 0, 0], &[0], 1); + let err = ParamGroebnerBasis::compute(vec![f, g], MonomialOrder::Lex).unwrap_err(); + assert_eq!(err.code(), "E-PARAMGB-002"); + } +} diff --git a/alkahest-core/src/poly/groebner/paramfield.rs b/alkahest-core/src/poly/groebner/paramfield.rs new file mode 100644 index 00000000..27c6ad8e --- /dev/null +++ b/alkahest-core/src/poly/groebner/paramfield.rs @@ -0,0 +1,822 @@ +//! Exact arithmetic in the coefficient field `Q(p₁, …, p_m)`. +//! +//! A parametric Gröbner basis wants the parameters in the *coefficient field*, +//! not as extra ring variables: eliminating states from an ODE model in +//! `Q(params)[states, Y]` is a much smaller computation than the same +//! elimination in `Q[states, Y, params]`, because the parameters never enter +//! the monomial order and never generate S-pairs of their own. +//! +//! Two types live here: +//! +//! * [`ParamPoly`] — a sparse multivariate polynomial in the parameters over +//! ℤ. This is the *denominator ring*; it is also the type in which the +//! degeneracy conditions of a parametric basis are reported. +//! * [`QParam`] — an element of `Q(p₁, …, p_m)`, stored as a reduced pair of +//! [`ParamPoly`]s. +//! +//! # Why this is not a naive fraction type +//! +//! `holonomic::qfield` exists because a rational-function coefficient field +//! implemented with textbook Euclidean gcd swells catastrophically: every +//! division leaves a fresh quotient whose numerator and denominator degrees +//! add, and nothing removes the content that would let them cancel again. The +//! same lesson applies here, one variable further out, so the same two defences +//! are used: +//! +//! 1. **Every value is kept in canonical reduced form** — `gcd(num, den) = 1` +//! in `ℤ[p]` (content included) and `lc(den) > 0`. Canonical form makes +//! structural equality decide field equality, and it is the only thing that +//! stops denominators from compounding. +//! 2. **Cancellation happens before multiplication, not after.** Addition goes +//! through the *lcm* of the two denominators rather than their product, and +//! multiplication cancels crosswise first. Both are exactly the moves +//! `qfield::rn_add` / `rn_mul` make, and both are proved below to leave the +//! result already reduced, so the common case costs no extra gcd at all. +//! +//! The gcd itself is FLINT's multivariate gcd (`fmpz_mpoly_gcd`, a Hensel / +//! Zippel hybrid), which is the part `qfield` had to hand-roll a subresultant +//! PRS for because its coefficients were univariate `Q[n]` and `RatUniPoly::gcd` +//! was the naive Euclidean sequence. + +use crate::flint::mpoly::{FlintMPoly, FlintMPolyCtx, FlintMPolyFactor}; +use rug::{Integer, Rational}; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// An exponent vector over the parameters; always exactly `n_params` long. +pub type ParamExp = Vec; + +// --------------------------------------------------------------------------- +// ParamPoly — sparse ℤ[p₁, …, p_m] +// --------------------------------------------------------------------------- + +/// A sparse multivariate polynomial in the parameters, over ℤ. +/// +/// Exponent keys always have length `n_params` (no trailing-zero stripping), so +/// the `BTreeMap` order is plain lexicographic and the last entry is the +/// lex-leading term. +#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub struct ParamPoly { + /// Integer coefficients keyed by exponent vector. + pub terms: BTreeMap, + /// Number of parameters. + pub n_params: usize, +} + +impl ParamPoly { + /// The zero polynomial. + pub fn zero(n_params: usize) -> Self { + ParamPoly { + terms: BTreeMap::new(), + n_params, + } + } + + /// The constant `c`. + pub fn constant(c: Integer, n_params: usize) -> Self { + let mut terms = BTreeMap::new(); + if c != 0 { + terms.insert(vec![0u32; n_params], c); + } + ParamPoly { terms, n_params } + } + + /// The constant `1`. + pub fn one(n_params: usize) -> Self { + ParamPoly::constant(Integer::from(1), n_params) + } + + /// The parameter `p_i`. + pub fn var(i: usize, n_params: usize) -> Self { + let mut exp = vec![0u32; n_params]; + if i < n_params { + exp[i] = 1; + } + let mut terms = BTreeMap::new(); + terms.insert(exp, Integer::from(1)); + ParamPoly { terms, n_params } + } + + /// True for the zero polynomial. + pub fn is_zero(&self) -> bool { + self.terms.is_empty() + } + + /// True for the constant `1`. + pub fn is_one(&self) -> bool { + matches!(self.as_constant(), Some(c) if *c == 1) + } + + /// The constant value, or `None` if any parameter actually occurs. + pub fn as_constant(&self) -> Option<&Integer> { + match self.terms.len() { + 0 => None, + 1 => { + let (exp, c) = self.terms.iter().next()?; + exp.iter().all(|&e| e == 0).then_some(c) + } + _ => None, + } + } + + /// True when this is a non-zero constant — the case that carries no + /// information as a degeneracy condition. + pub fn is_nonzero_constant(&self) -> bool { + self.as_constant().is_some() + } + + /// Number of non-zero terms. + pub fn n_terms(&self) -> usize { + self.terms.len() + } + + /// Maximum total degree over all terms (`0` for the zero polynomial). + pub fn total_degree(&self) -> u32 { + self.terms + .keys() + .map(|e| e.iter().sum::()) + .max() + .unwrap_or(0) + } + + /// The lex-leading coefficient, or `0`. + fn leading_coeff(&self) -> Integer { + self.terms + .iter() + .next_back() + .map(|(_, c)| c.clone()) + .unwrap_or_else(|| Integer::from(0)) + } + + /// `self + other`. + pub fn add(&self, other: &Self) -> Self { + let mut terms = self.terms.clone(); + for (e, c) in &other.terms { + let slot = terms.entry(e.clone()).or_insert_with(|| Integer::from(0)); + *slot += c; + if *slot == 0 { + terms.remove(e); + } + } + ParamPoly { + terms, + n_params: self.n_params, + } + } + + /// `-self`. + pub fn neg(&self) -> Self { + ParamPoly { + terms: self + .terms + .iter() + .map(|(e, c)| (e.clone(), -c.clone())) + .collect(), + n_params: self.n_params, + } + } + + /// `self - other`. + pub fn sub(&self, other: &Self) -> Self { + self.add(&other.neg()) + } + + /// `self · other`. + pub fn mul(&self, other: &Self) -> Self { + if self.is_zero() || other.is_zero() { + return ParamPoly::zero(self.n_params); + } + if let Some(c) = self.as_constant() { + return other.scale_int(c); + } + if let Some(c) = other.as_constant() { + return self.scale_int(c); + } + let mut terms: BTreeMap = BTreeMap::new(); + for (ea, ca) in &self.terms { + for (eb, cb) in &other.terms { + let e: ParamExp = ea.iter().zip(eb.iter()).map(|(a, b)| a + b).collect(); + let slot = terms.entry(e).or_insert_with(|| Integer::from(0)); + *slot += Integer::from(ca * cb); + } + } + terms.retain(|_, c| *c != 0); + ParamPoly { + terms, + n_params: self.n_params, + } + } + + /// `self · z` for an integer `z`. + pub fn scale_int(&self, z: &Integer) -> Self { + if *z == 0 { + return ParamPoly::zero(self.n_params); + } + ParamPoly { + terms: self + .terms + .iter() + .map(|(e, c)| (e.clone(), Integer::from(c * z))) + .collect(), + n_params: self.n_params, + } + } + + /// Non-negative gcd of the integer coefficients (`0` for zero). + pub fn content(&self) -> Integer { + let mut g = Integer::from(0); + for c in self.terms.values() { + g = Integer::from(g.gcd_ref(c)); + if g == 1 { + break; + } + } + g + } + + /// Primitive part with a positive lex-leading coefficient. + /// + /// This is the canonical representative of the hypersurface `{self = 0}`, + /// which is what makes it the right normalisation for a reported + /// degeneracy condition. + pub fn primitive_part(&self) -> Self { + if self.is_zero() { + return self.clone(); + } + let mut cont = self.content(); + if self.leading_coeff() < 0 { + cont = -cont; + } + ParamPoly { + terms: self + .terms + .iter() + .map(|(e, c)| (e.clone(), Integer::from(c / &cont))) + .collect(), + n_params: self.n_params, + } + } + + /// `gcd(self, other)`, normalised to a positive leading coefficient. + /// + /// Integer content is included, so `gcd(2x, 4) = 2`. Falls back to `1` + /// when FLINT declines (which would only cost efficiency, never + /// correctness, since an unreduced fraction is still the same field + /// element). + pub fn gcd(&self, other: &Self) -> Self { + if self.is_zero() { + return other.primitive_part(); + } + if other.is_zero() { + return self.primitive_part(); + } + // Constant-only fast path: FLINT is not needed to gcd two integers. + if let (Some(a), Some(b)) = (self.as_constant(), other.as_constant()) { + return ParamPoly::constant(Integer::from(a.gcd_ref(b)).abs(), self.n_params); + } + if self.n_params == 0 { + let g = self.content().gcd(&other.content()); + return ParamPoly::constant(g.abs(), 0); + } + let ctx = FlintMPolyCtx::new(self.n_params); + let a = self.to_flint(Arc::clone(&ctx)); + let b = other.to_flint(Arc::clone(&ctx)); + match a.gcd(&b) { + Some(g) => { + let g = ParamPoly::from_flint(&g, self.n_params); + if g.is_zero() { + ParamPoly::one(self.n_params) + } else if g.leading_coeff() < 0 { + g.neg() + } else { + g + } + } + None => ParamPoly::one(self.n_params), + } + } + + /// `Some(self / divisor)` when the division is exact, else `None`. + pub fn exact_div(&self, divisor: &Self) -> Option { + if divisor.is_zero() { + return None; + } + if self.is_zero() { + return Some(ParamPoly::zero(self.n_params)); + } + if let Some(d) = divisor.as_constant() { + if *d == 1 { + return Some(self.clone()); + } + let mut terms = BTreeMap::new(); + for (e, c) in &self.terms { + if !c.is_divisible(d) { + return None; + } + terms.insert(e.clone(), Integer::from(c.div_exact_ref(d))); + } + return Some(ParamPoly { + terms, + n_params: self.n_params, + }); + } + if self.n_params == 0 { + return None; + } + let ctx = FlintMPolyCtx::new(self.n_params); + let a = self.to_flint(Arc::clone(&ctx)); + let b = divisor.to_flint(Arc::clone(&ctx)); + a.divides(&b) + .map(|q| ParamPoly::from_flint(&q, self.n_params)) + } + + /// Value at a rational point. + /// + /// `values` must have one entry per parameter; missing entries are read as + /// zero, which keeps the function total. + pub fn eval(&self, values: &[Rational]) -> Rational { + let mut acc = Rational::from(0); + for (exp, c) in &self.terms { + let mut term = Rational::from(c.clone()); + for (i, &e) in exp.iter().enumerate() { + if e == 0 { + continue; + } + let v = values.get(i).cloned().unwrap_or_else(|| Rational::from(0)); + if v == 0 { + term = Rational::from(0); + break; + } + for _ in 0..e { + term *= v.clone(); + } + } + acc += term; + } + acc + } + + /// The distinct non-constant irreducible factors, each primitive with a + /// positive leading coefficient. + /// + /// The hypersurface `{self = 0}` is the union of the `{f = 0}` over these, + /// so reporting the factors instead of the product is what turns "the + /// basis is wrong somewhere on this degree-12 surface" into a list of + /// conditions a caller can actually read. Multiplicities are dropped — + /// they do not change the zero set. + /// + /// Falls back to `[primitive_part]` when FLINT declines to factor. + pub fn irreducible_factors(&self) -> Vec { + if self.is_zero() || self.is_nonzero_constant() { + return vec![]; + } + if self.n_params == 0 { + return vec![]; + } + let ctx = FlintMPolyCtx::new(self.n_params); + let f = self.to_flint(Arc::clone(&ctx)); + let mut fac = FlintMPolyFactor::new(Arc::clone(&ctx)); + if !fac.factor(&f) { + return vec![self.primitive_part()]; + } + let mut out = Vec::with_capacity(fac.len()); + for i in 0..fac.len() { + let base = ParamPoly::from_flint(&fac.base_at(i), self.n_params); + if base.is_zero() || base.is_nonzero_constant() { + continue; + } + out.push(base.primitive_part()); + } + if out.is_empty() { + vec![self.primitive_part()] + } else { + out + } + } + + fn to_flint(&self, ctx: Arc) -> FlintMPoly { + let nvars = ctx.nvars(); + let mut fp = FlintMPoly::new(ctx); + for (exp, c) in &self.terms { + let mut e = vec![0u64; nvars]; + for (i, &v) in exp.iter().enumerate() { + if i < nvars { + e[i] = v as u64; + } + } + fp.push_term(c, &e); + } + fp.finish(); + fp + } + + fn from_flint(f: &FlintMPoly, n_params: usize) -> Self { + // `FlintMPoly::terms` strips trailing zeros; re-pad so every key has + // the same length and the map order stays lexicographic. + let terms = f + .terms() + .into_iter() + .map(|(mut e, c)| { + e.resize(n_params, 0); + (e, c) + }) + .collect(); + ParamPoly { terms, n_params } + } +} + +// --------------------------------------------------------------------------- +// QParam — an element of Q(p₁, …, p_m) +// --------------------------------------------------------------------------- + +/// An element of `Q(p₁, …, p_m)`, stored as a reduced fraction of +/// [`ParamPoly`]s. +/// +/// Invariants, maintained by every constructor and operation: +/// +/// * `den` is non-zero and `gcd(num, den) = 1` in `ℤ[p]` (integer content +/// included); +/// * the lex-leading coefficient of `den` is positive; +/// * zero is exactly `0 / 1`. +/// +/// Together these make the representation canonical, so `==` decides equality +/// in the field. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct QParam { + num: ParamPoly, + den: ParamPoly, +} + +impl QParam { + /// Zero. + pub fn zero(n_params: usize) -> Self { + QParam { + num: ParamPoly::zero(n_params), + den: ParamPoly::one(n_params), + } + } + + /// One. + pub fn one(n_params: usize) -> Self { + QParam { + num: ParamPoly::one(n_params), + den: ParamPoly::one(n_params), + } + } + + /// A rational constant. + pub fn from_rational(r: &Rational, n_params: usize) -> Self { + let (n, d) = r.clone().into_numer_denom(); + QParam { + num: ParamPoly::constant(n, n_params), + den: ParamPoly::constant(d, n_params), + } + } + + /// A polynomial in the parameters, as an element of the field. + pub fn from_poly(p: ParamPoly) -> Self { + let n_params = p.n_params; + if p.is_zero() { + return QParam::zero(n_params); + } + QParam { + num: p, + den: ParamPoly::one(n_params), + } + } + + /// `num / den`, reduced. `None` when `den` is zero. + pub fn from_ratio(num: ParamPoly, den: ParamPoly) -> Option { + if den.is_zero() { + return None; + } + Some(Self::reduced(num, den)) + } + + /// The numerator in canonical form. + pub fn numerator(&self) -> &ParamPoly { + &self.num + } + + /// The denominator in canonical form. + pub fn denominator(&self) -> &ParamPoly { + &self.den + } + + /// Number of parameters in the ambient field. + pub fn n_params(&self) -> usize { + self.num.n_params + } + + /// True for zero. + pub fn is_zero(&self) -> bool { + self.num.is_zero() + } + + /// True for one. + pub fn is_one(&self) -> bool { + self.num.is_one() && self.den.is_one() + } + + /// True when this element is a rational number (no parameter occurs). + pub fn is_rational(&self) -> bool { + (self.num.is_zero() || self.num.is_nonzero_constant()) && self.den.is_nonzero_constant() + } + + /// The rational value, when [`Self::is_rational`]. + pub fn as_rational(&self) -> Option { + let d = self.den.as_constant()?; + if self.num.is_zero() { + return Some(Rational::from(0)); + } + let n = self.num.as_constant()?; + Some(Rational::from((n.clone(), d.clone()))) + } + + /// Build the canonical form of `num / den` (`den ≠ 0` assumed). + fn reduced(num: ParamPoly, den: ParamPoly) -> Self { + let n_params = den.n_params; + if num.is_zero() { + return QParam::zero(n_params); + } + let g = num.gcd(&den); + let (num, den) = if g.is_one() { + (num, den) + } else { + match (num.exact_div(&g), den.exact_div(&g)) { + (Some(n), Some(d)) => (n, d), + _ => (num, den), + } + }; + Self::fix_sign(num, den) + } + + /// Force a positive leading denominator coefficient. + fn fix_sign(num: ParamPoly, den: ParamPoly) -> Self { + if den.leading_coeff() < 0 { + QParam { + num: num.neg(), + den: den.neg(), + } + } else { + QParam { num, den } + } + } + + /// `self + other`. + /// + /// Goes through `lcm(den₁, den₂)` rather than the product. Two facts make + /// the result reduced without a further gcd in the common case: + /// + /// * with `gcd(d₁, d₂) = 1`, `gcd(n₁d₂ + n₂d₁, d₁) = gcd(n₁d₂, d₁) = 1` and + /// symmetrically for `d₂`, and in a UFD coprimality to both factors is + /// coprimality to the product — so the cross-multiplied form is already + /// in lowest terms; + /// * otherwise every common factor of the sum and the lcm divides + /// `gcd(d₁, d₂)`, which is the smaller polynomial to cancel against. + pub fn add(&self, other: &Self) -> Self { + if self.is_zero() { + return other.clone(); + } + if other.is_zero() { + return self.clone(); + } + if self.den.is_one() && other.den.is_one() { + return QParam::from_poly(self.num.add(&other.num)); + } + if self.den == other.den { + let num = self.num.add(&other.num); + return Self::reduced(num, self.den.clone()); + } + let g = self.den.gcd(&other.den); + if g.is_one() { + let num = self.num.mul(&other.den).add(&other.num.mul(&self.den)); + if num.is_zero() { + return QParam::zero(self.n_params()); + } + return Self::fix_sign(num, self.den.mul(&other.den)); + } + let (Some(a1), Some(b1)) = (self.den.exact_div(&g), other.den.exact_div(&g)) else { + // gcd claimed a factor it cannot divide out — fall back to the + // cross-multiplied form and reduce it in full. + let num = self.num.mul(&other.den).add(&other.num.mul(&self.den)); + return Self::reduced(num, self.den.mul(&other.den)); + }; + let num = self.num.mul(&b1).add(&other.num.mul(&a1)); + if num.is_zero() { + return QParam::zero(self.n_params()); + } + let den = self.den.mul(&b1); + // Only factors of `g` can survive, so cancel against `g`, not `den`. + let h = num.gcd(&g); + if !h.is_one() { + if let (Some(n), Some(d)) = (num.exact_div(&h), den.exact_div(&h)) { + return Self::reduced(n, d); + } + } + Self::fix_sign(num, den) + } + + /// `-self`. + pub fn neg(&self) -> Self { + QParam { + num: self.num.neg(), + den: self.den.clone(), + } + } + + /// `self - other`. + pub fn sub(&self, other: &Self) -> Self { + self.add(&other.neg()) + } + + /// `self · other`. + /// + /// Cancels crosswise first: with both operands reduced, + /// `gcd(n₁n₂, d₁d₂) = gcd(n₁, d₂)·gcd(n₂, d₁)`, so removing those two gcds + /// before multiplying leaves the product already in lowest terms. + pub fn mul(&self, other: &Self) -> Self { + if self.is_zero() || other.is_zero() { + return QParam::zero(self.n_params()); + } + if self.den.is_one() && other.den.is_one() { + return QParam::from_poly(self.num.mul(&other.num)); + } + let (n1, d2) = cross_cancel(&self.num, &other.den); + let (n2, d1) = cross_cancel(&other.num, &self.den); + Self::fix_sign(n1.mul(&n2), d1.mul(&d2)) + } + + /// `1 / self`, or `None` for zero. + pub fn inv(&self) -> Option { + if self.is_zero() { + return None; + } + Some(Self::fix_sign(self.den.clone(), self.num.clone())) + } + + /// `self / other`, or `None` when `other` is zero. + pub fn div(&self, other: &Self) -> Option { + Some(self.mul(&other.inv()?)) + } + + /// Value at a rational parameter point; `None` when the denominator + /// vanishes there. + pub fn eval(&self, values: &[Rational]) -> Option { + let d = self.den.eval(values); + if d == 0 { + return None; + } + Some(self.num.eval(values) / d) + } +} + +/// Divide `gcd(x, y)` out of both, leaving them untouched when it is a unit. +fn cross_cancel(x: &ParamPoly, y: &ParamPoly) -> (ParamPoly, ParamPoly) { + if x.is_one() || y.is_one() { + return (x.clone(), y.clone()); + } + let g = x.gcd(y); + if g.is_one() { + return (x.clone(), y.clone()); + } + match (x.exact_div(&g), y.exact_div(&g)) { + (Some(a), Some(b)) => (a, b), + _ => (x.clone(), y.clone()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(n: usize) -> usize { + n + } + + fn a(n_params: usize) -> ParamPoly { + ParamPoly::var(0, n_params) + } + + fn b(n_params: usize) -> ParamPoly { + ParamPoly::var(1, n_params) + } + + #[test] + fn poly_arithmetic() { + let n = p(2); + let f = a(n).add(&ParamPoly::one(n)); // a + 1 + let g = a(n).sub(&ParamPoly::one(n)); // a - 1 + let prod = f.mul(&g); // a² - 1 + assert_eq!(prod.terms.len(), 2); + assert_eq!(prod.total_degree(), 2); + assert_eq!(prod.eval(&[Rational::from(3), Rational::from(0)]), 8); + } + + #[test] + fn gcd_and_exact_div() { + let n = p(2); + let f = a(n).mul(&a(n)).sub(&ParamPoly::one(n)); // a² - 1 + let g = a(n).sub(&ParamPoly::one(n)); // a - 1 + let d = f.gcd(&g); + assert_eq!(d, g, "gcd(a²-1, a-1) should be a-1"); + let q = f.exact_div(&g).expect("exact division"); + assert_eq!(q, a(n).add(&ParamPoly::one(n))); + } + + #[test] + fn integer_content_is_part_of_the_gcd() { + let n = p(1); + let two_a = a(n).scale_int(&Integer::from(2)); + let four = ParamPoly::constant(Integer::from(4), n); + assert_eq!(two_a.gcd(&four), ParamPoly::constant(Integer::from(2), n)); + } + + #[test] + fn field_is_canonical() { + let n = p(2); + // (a² - 1)/(a - 1) must reduce to (a + 1)/1. + let f = QParam::from_ratio( + a(n).mul(&a(n)).sub(&ParamPoly::one(n)), + a(n).sub(&ParamPoly::one(n)), + ) + .unwrap(); + assert_eq!(f, QParam::from_poly(a(n).add(&ParamPoly::one(n)))); + assert!(f.denominator().is_one()); + } + + #[test] + fn sign_is_normalised() { + let n = p(1); + let neg_den = ParamPoly::one(n).sub(&a(n)); // 1 - a, lex lc is -1 + let f = QParam::from_ratio(ParamPoly::one(n), neg_den).unwrap(); + assert!( + f.denominator().leading_coeff() > 0, + "denominator must be sign-normalised" + ); + // 1/(1-a) + 1/(a-1) = 0 requires the two to be recognised as negatives. + let g = QParam::from_ratio(ParamPoly::one(n), a(n).sub(&ParamPoly::one(n))).unwrap(); + assert!(f.add(&g).is_zero()); + } + + #[test] + fn addition_uses_the_lcm() { + let n = p(2); + // 1/(ab) + 1/(a) = (1 + b)/(ab): denominator degree stays 2, not 3. + let ab = a(n).mul(&b(n)); + let x = QParam::from_ratio(ParamPoly::one(n), ab.clone()).unwrap(); + let y = QParam::from_ratio(ParamPoly::one(n), a(n)).unwrap(); + let s = x.add(&y); + assert_eq!(s.denominator().total_degree(), 2); + assert_eq!(*s.denominator(), ab); + } + + #[test] + fn field_axioms_on_a_sample() { + let n = p(2); + let x = QParam::from_ratio(a(n).add(&b(n)), a(n).sub(&b(n))).unwrap(); + let y = QParam::from_ratio(b(n), a(n).mul(&a(n)).add(&ParamPoly::one(n))).unwrap(); + assert!(x.sub(&x).is_zero()); + assert!(x.mul(&x.inv().unwrap()).is_one()); + assert_eq!(x.add(&y), y.add(&x)); + assert_eq!(x.mul(&y), y.mul(&x)); + assert_eq!(x.mul(&y.add(&x)), x.mul(&y).add(&x.mul(&x))); + } + + #[test] + fn eval_reports_poles() { + let n = p(1); + let f = QParam::from_ratio(ParamPoly::one(n), a(n).sub(&ParamPoly::one(n))).unwrap(); + assert_eq!(f.eval(&[Rational::from(3)]), Some(Rational::from((1, 2)))); + assert_eq!( + f.eval(&[Rational::from(1)]), + None, + "1/(a-1) has a pole at 1" + ); + } + + #[test] + fn irreducible_factors_split_the_locus() { + let n = p(2); + // a(a - b)² → {a, a - b}, multiplicity dropped. + let f = a(n) + .mul(&a(n).sub(&b(n))) + .mul(&a(n).sub(&b(n))) + .scale_int(&Integer::from(-6)); + let mut facs = f.irreducible_factors(); + facs.sort(); + assert_eq!(facs.len(), 2, "got {facs:?}"); + assert!(facs.contains(&a(n))); + assert!(facs + .iter() + .any(|g| *g == a(n).sub(&b(n)) || *g == b(n).sub(&a(n)))); + } + + #[test] + fn constants_are_not_conditions() { + let n = p(2); + assert!(ParamPoly::constant(Integer::from(7), n) + .irreducible_factors() + .is_empty()); + } +} diff --git a/alkahest-core/src/real/sos/cert.rs b/alkahest-core/src/real/sos/cert.rs index 6ce15293..ce68f0a2 100644 --- a/alkahest-core/src/real/sos/cert.rs +++ b/alkahest-core/src/real/sos/cert.rs @@ -100,6 +100,18 @@ pub struct PositivityCertificate { pub degree: u32, /// Terms of the identity; `terms[k]` is `(Π g_i) · σ_k`. pub terms: Vec, + /// A Reznick-style multiplier `σ = (x_1² + … + x_n²)^N`, present exactly + /// when the certificate proves `target ≥ 0` indirectly via `target·σ = + /// Σ terms` rather than directly via `target = Σ terms`. `None` for a + /// direct certificate (the only kind before 3.10.0). + /// + /// `σ` vanishes only at the origin and is strictly positive everywhere + /// else, so `target·σ = Σ terms ≥ 0` gives `target ≥ 0` away from the + /// origin for free; [`Self::verify`] additionally checks `target(0) ≥ + /// 0` directly to close the one point `σ` cannot see, and confirms `σ` + /// really is a power of the sum of squares rather than trusting the + /// search that produced it. + pub multiplier: Option, /// Human-readable audit trail: how the search proceeded. pub log: Vec, } @@ -126,8 +138,14 @@ impl PositivityCertificate { /// Exact verification. Returns `Ok(())` only if /// /// 1. every constraint index is in range, - /// 2. every SOS weight is non-negative, and - /// 3. the re-expanded right-hand side is *identically* the target. + /// 2. every SOS weight is non-negative, + /// 3. the re-expanded right-hand side is *identically* `target` (direct + /// certificates) or *identically* `target · multiplier` (multiplier + /// certificates — see [`Self::multiplier`]), and + /// 4. for a multiplier certificate, `multiplier` really is `(x_1² + … + + /// x_n²)^N` for some `N` (recomputed here, not trusted from the + /// search) and `target` is non-negative at the origin, the one point + /// `multiplier` cannot rule out. /// /// This is called on every path that returns a certificate; a failure here /// is a bug in the search, never something the caller sees as a success. @@ -145,16 +163,56 @@ impl PositivityCertificate { return Err("certificate contains a negative sum-of-squares weight".to_string()); } } - let lhs = &self.target; let rhs = self.expand(); - if *lhs == rhs { - Ok(()) - } else { - let diff = lhs.sub(&rhs); - Err(format!( - "certificate does not re-expand to the target; residual = {}", - diff.display(&self.var_names) - )) + match &self.multiplier { + None => { + if self.target == rhs { + Ok(()) + } else { + let diff = self.target.sub(&rhs); + Err(format!( + "certificate does not re-expand to the target; residual = {}", + diff.display(&self.var_names) + )) + } + } + Some(sigma) => { + let lhs = self.target.mul(sigma); + if lhs != rhs { + let diff = lhs.sub(&rhs); + return Err(format!( + "certificate does not re-expand to target·multiplier; residual = {}", + diff.display(&self.var_names) + )); + } + let deg = sigma.total_degree(); + if deg % 2 != 0 { + return Err( + "multiplier has odd degree, so it cannot be a power of a sum \ + of squares" + .to_string(), + ); + } + let n = deg / 2; + let expected = RatPoly::sum_of_squares(self.nvars()).pow(n); + if *sigma != expected { + return Err( + "multiplier is not recognised as (x_1^2 + ... + x_n^2)^N for \ + any N, so its non-negativity is not established by this \ + checker" + .to_string(), + ); + } + let origin = vec![Rational::from(0); self.nvars()]; + if self.target.eval(&origin) < 0 { + return Err( + "target is negative at the origin, the one point the multiplier \ + (x_1^2 + ... + x_n^2)^N cannot rule out" + .to_string(), + ); + } + Ok(()) + } } } @@ -218,8 +276,18 @@ impl PositivityCertificate { parts.join(" + ") }; // Render the whole identity, not just its right-hand side: the point of - // a certificate is that a reader can check `target = rhs` by expanding. - format!("{} = {}", self.target.display(&self.var_names), rhs) + // a certificate is that a reader can check `target = rhs` (or + // `target * multiplier = rhs`, for a multiplier certificate) by + // expanding. + match &self.multiplier { + None => format!("{} = {}", self.target.display(&self.var_names), rhs), + Some(sigma) => format!( + "({}) * ({}) = {}", + self.target.display(&self.var_names), + sigma.display(&self.var_names), + rhs + ), + } } /// Description of the statement the certificate proves. @@ -276,7 +344,9 @@ impl PositivityCertificate { )); out.push_str(&format!("-- {}\n\n", self.claim_string())); - if self.constraints.is_empty() { + if let Some(sigma) = &self.multiplier { + out.push_str(&self.lean_multiplier_block(&binders, &target, &rhs, sigma)); + } else if self.constraints.is_empty() { out.push_str(&format!( "theorem alkahest_sos_identity {binders}:\n {target} = {rhs} := by\n ring\n\n" )); @@ -306,6 +376,81 @@ impl PositivityCertificate { Some(out) } + /// Lean rendering for a multiplier certificate: `target ≥ 0` follows + /// from `σ·target = rhs ≥ 0` away from the origin (where `σ > 0`), and + /// from a direct numeric check at the origin (where `σ` vanishes). + /// `σ = (x_1² + … + x_n²)^power` by construction — [`Self::verify`] has + /// already confirmed this exactly, so the factored form used here is + /// not an extra trust assumption. + fn lean_multiplier_block( + &self, + binders: &str, + target: &str, + rhs: &str, + sigma: &RatPoly, + ) -> String { + let names = &self.var_names; + let n = names.len(); + let power = sigma.total_degree() / 2; + let sum_sq: String = names + .iter() + .map(|nm| format!("{nm} ^ 2")) + .collect::>() + .join(" + "); + let sigma_factored = if power == 1 { + format!("({sum_sq})") + } else { + format!("({sum_sq}) ^ {power}") + }; + let args = names.join(" "); + + let mut out = String::new(); + out.push_str(&format!( + "theorem alkahest_multiplier_factor {binders}:\n\ + \x20 {sigma_factored} * ({target}) = {rhs} := by\n ring\n\n" + )); + + let body = if n == 1 { + let x = &names[0]; + format!( + "by_cases hz : {x} = 0\n\ + \x20 · subst hz\n\ + \x20 norm_num\n\ + \x20 · have hs : (0 : ℝ) < {sigma_factored} := by positivity\n\ + \x20 nlinarith [alkahest_multiplier_factor {args}, hs]\n" + ) + } else { + let conj: String = names + .iter() + .map(|nm| format!("{nm} = 0")) + .collect::>() + .join(" ∧ "); + let obtain: String = (1..=n) + .map(|i| format!("h{i}")) + .collect::>() + .join(", "); + let substs: String = (1..=n) + .map(|i| format!("subst h{i}")) + .collect::>() + .join("\n "); + format!( + "by_cases hz : {conj}\n\ + \x20 · obtain ⟨{obtain}⟩ := hz\n\ + \x20 {substs}\n\ + \x20 norm_num\n\ + \x20 · have hs : (0 : ℝ) < {sigma_factored} := by\n\ + \x20 {}\n\ + \x20 nlinarith [alkahest_multiplier_factor {args}, hs]\n", + lean_case_split(n, "hz") + ) + }; + + out.push_str(&format!( + "theorem alkahest_nonneg {binders}:\n (0 : ℝ) ≤ {target} := by\n {body}" + )); + out + } + fn lean_rhs(&self) -> String { let names = &self.var_names; let mut parts: Vec = Vec::new(); @@ -354,6 +499,23 @@ impl PositivityCertificate { } } +/// Nested `rcases … with … | …` on `¬(x_1 = 0 ∧ … ∧ x_remaining = 0)`, +/// closing every branch with `positivity` once a single `x_i ≠ 0` is in +/// context — the strict-positivity goal each branch discharges is always a +/// sum of squares containing that term. +fn lean_case_split(remaining: usize, hz: &str) -> String { + if remaining <= 1 { + "positivity".to_string() + } else { + format!( + "rcases not_and_or.mp {hz} with h0 | hz'\n\ + \x20 · positivity\n\ + \x20 · {}", + lean_case_split(remaining - 1, "hz'") + ) + } +} + fn is_lean_ident(name: &str) -> bool { !name.is_empty() && name @@ -407,6 +569,7 @@ mod tests { constraints: vec![], sos, }], + multiplier: None, log: vec![], } } diff --git a/alkahest-core/src/real/sos/linalg.rs b/alkahest-core/src/real/sos/linalg.rs new file mode 100644 index 00000000..603de2fd --- /dev/null +++ b/alkahest-core/src/real/sos/linalg.rs @@ -0,0 +1,307 @@ +//! Exact rational linear algebra for the Gram-matrix search. +//! +//! Two operations are needed, and both must be exact, because their output is +//! what a certificate is built from: +//! +//! * [`solve_affine`] — the complete solution set of a rational linear system, +//! as a particular solution plus a null-space basis. The Gram matrices of a +//! polynomial form exactly such an affine set (one equation per monomial), +//! and pinning down that set is what turns "search for a matrix" into +//! "search over a handful of free parameters". +//! * [`psd_decompose`] — an exact `LDLᵀ` with symmetric pivoting, which both +//! *decides* positive semidefiniteness over ℚ and, when the answer is yes, +//! hands back the decomposition `Q = Σ d_k v_k v_kᵀ` with `d_k > 0`. Each +//! `(d_k, v_k)` is one weighted square of the certificate, so this is the +//! step that converts a matrix into an algebraic identity. +//! +//! Neither routine rounds, and neither is a heuristic: `psd_decompose` +//! returning `None` is a proof that the matrix is *not* PSD, and returning +//! `Some` is a proof that it is. + +use rug::Rational; + +fn zero() -> Rational { + Rational::from(0) +} + +/// The full solution set of a consistent rational linear system: +/// `{ particular + Σ t_k · nullspace[k] : t ∈ ℚ^k }`. +#[derive(Debug, Clone)] +pub struct AffineSolution { + /// One solution of the system. + pub particular: Vec, + /// Basis of the homogeneous solution space; empty iff the solution is unique. + pub nullspace: Vec>, +} + +impl AffineSolution { + /// Number of free parameters (`0` when the solution is unique). + pub fn dimension(&self) -> usize { + self.nullspace.len() + } + + /// The solution at parameter vector `t` (missing entries are taken as 0). + pub fn at(&self, t: &[Rational]) -> Vec { + let mut x = self.particular.clone(); + for (k, dir) in self.nullspace.iter().enumerate() { + let Some(tk) = t.get(k) else { break }; + if *tk == 0 { + continue; + } + for (xi, di) in x.iter_mut().zip(dir) { + *xi += Rational::from(tk * di); + } + } + x + } +} + +/// Solve `rows · x = rhs` exactly by Gauss–Jordan elimination. +/// +/// Returns `None` iff the system is inconsistent. `rows` may be rank +/// deficient and may have more rows than columns; redundant rows are simply +/// eliminated away. +pub fn solve_affine(rows: &[Vec], rhs: &[Rational]) -> Option { + let m = rows.len(); + let ncols = rows.first().map_or(0, |r| r.len()); + if m == 0 { + // No constraints at all: every coordinate is free. + let nullspace = (0..ncols) + .map(|k| { + let mut v = vec![zero(); ncols]; + v[k] = Rational::from(1); + v + }) + .collect(); + return Some(AffineSolution { + particular: vec![zero(); ncols], + nullspace, + }); + } + + // Augmented matrix, reduced in place. + let mut a: Vec> = Vec::with_capacity(m); + for (row, b) in rows.iter().zip(rhs) { + debug_assert_eq!(row.len(), ncols); + let mut r = row.clone(); + r.push(b.clone()); + a.push(r); + } + + let mut pivots: Vec = Vec::new(); + let mut r = 0usize; + for c in 0..ncols { + let Some(p) = (r..m).find(|&i| a[i][c] != 0) else { + continue; + }; + a.swap(r, p); + let inv = a[r][c].clone(); + for v in a[r].iter_mut() { + *v /= &inv; + } + let prow = a[r].clone(); + for (i, row) in a.iter_mut().enumerate() { + if i == r || row[c] == 0 { + continue; + } + let f = row[c].clone(); + for (t, pv) in row.iter_mut().zip(prow.iter()) { + *t -= Rational::from(&f * pv); + } + } + pivots.push(c); + r += 1; + if r == m { + break; + } + } + + // A row `0 = nonzero` means the system has no solution at all. + for row in a.iter().skip(r) { + if row[ncols] != 0 && row[..ncols].iter().all(|v| *v == 0) { + return None; + } + } + + let mut particular = vec![zero(); ncols]; + for (i, &c) in pivots.iter().enumerate() { + particular[c] = a[i][ncols].clone(); + } + + let mut nullspace = Vec::new(); + for free in (0..ncols).filter(|c| !pivots.contains(c)) { + let mut v = vec![zero(); ncols]; + v[free] = Rational::from(1); + for (i, &c) in pivots.iter().enumerate() { + v[c] = -a[i][free].clone(); + } + nullspace.push(v); + } + + Some(AffineSolution { + particular, + nullspace, + }) +} + +/// Exact `LDLᵀ` with symmetric (largest-diagonal) pivoting. +/// +/// Returns `Some(vec![(d_k, v_k)])` with every `d_k > 0` and +/// `Q = Σ_k d_k · v_k v_kᵀ` *exactly*, or `None` when `Q` is not positive +/// semidefinite. `Q` must be square and symmetric. +/// +/// Symmetric pivoting is what makes this work on the boundary of the PSD cone: +/// the Gram matrices that certify Motzkin-like polynomials are singular by +/// necessity (the polynomial has real zeros), so a plain Cholesky that insists +/// on a non-zero leading pivot would fail on precisely the interesting cases. +pub fn psd_decompose(q: &[Vec]) -> Option)>> { + let n = q.len(); + let mut a: Vec> = q.to_vec(); + let mut done = vec![false; n]; + let mut out: Vec<(Rational, Vec)> = Vec::new(); + + for _ in 0..n { + let mut pivot: Option = None; + for i in 0..n { + if done[i] { + continue; + } + // A negative diagonal entry is an immediate refutation: e_iᵀ Q e_i < 0. + if a[i][i] < 0 { + return None; + } + if a[i][i] > 0 && pivot.map_or(true, |p| a[i][i] > a[p][p]) { + pivot = Some(i); + } + } + let Some(p) = pivot else { + // Every remaining diagonal entry is zero; for a PSD matrix that + // forces the whole remaining block to vanish. + for i in (0..n).filter(|&i| !done[i]) { + for j in (0..n).filter(|&j| !done[j]) { + if a[i][j] != 0 { + return None; + } + } + } + break; + }; + + let d = a[p][p].clone(); + let mut v = vec![zero(); n]; + for j in (0..n).filter(|&j| !done[j]) { + v[j] = Rational::from(&a[p][j] / &d); + } + for i in (0..n).filter(|&i| !done[i]) { + if v[i] == 0 { + continue; + } + let scaled = Rational::from(&d * &v[i]); + for j in (0..n).filter(|&j| !done[j]) { + if v[j] == 0 { + continue; + } + a[i][j] -= Rational::from(&scaled * &v[j]); + } + } + done[p] = true; + out.push((d, v)); + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn r(n: i64) -> Rational { + Rational::from(n) + } + + fn mat(rows: &[&[i64]]) -> Vec> { + rows.iter() + .map(|r| r.iter().map(|&v| Rational::from(v)).collect()) + .collect() + } + + #[test] + fn unique_solution_has_no_free_parameters() { + // x + y = 3, x − y = 1 ⇒ (2, 1) + let rows = mat(&[&[1, 1], &[1, -1]]); + let sol = solve_affine(&rows, &[r(3), r(1)]).expect("consistent"); + assert_eq!(sol.dimension(), 0); + assert_eq!(sol.particular, vec![r(2), r(1)]); + } + + #[test] + fn underdetermined_system_reports_its_freedom() { + // x + y + z = 1 + let rows = mat(&[&[1, 1, 1]]); + let sol = solve_affine(&rows, &[r(1)]).expect("consistent"); + assert_eq!(sol.dimension(), 2); + // every point of the reported set really is a solution + let p = sol.at(&[r(5), r(-2)]); + let s: Rational = p.iter().fold(r(0), |a, v| a + v.clone()); + assert_eq!(s, 1); + } + + #[test] + fn inconsistent_system_is_rejected() { + let rows = mat(&[&[1, 1], &[2, 2]]); + assert!(solve_affine(&rows, &[r(1), r(3)]).is_none()); + } + + #[test] + fn redundant_rows_do_not_break_the_solver() { + let rows = mat(&[&[1, 1], &[2, 2], &[1, -1]]); + let sol = solve_affine(&rows, &[r(2), r(4), r(0)]).expect("consistent"); + assert_eq!(sol.dimension(), 0); + assert_eq!(sol.particular, vec![r(1), r(1)]); + } + + #[test] + fn psd_decompose_factors_an_identity_matrix() { + let q = mat(&[&[1, 0], &[0, 1]]); + let d = psd_decompose(&q).expect("PSD"); + assert_eq!(d.len(), 2); + } + + #[test] + fn psd_decompose_rejects_an_indefinite_matrix() { + // [[1, 2], [2, 1]] has eigenvalues 3 and −1. + let q = mat(&[&[1, 2], &[2, 1]]); + assert!(psd_decompose(&q).is_none()); + } + + #[test] + fn psd_decompose_accepts_a_singular_psd_matrix() { + // [[1, 1], [1, 1]] = (e₁ + e₂)(e₁ + e₂)ᵀ, PSD of rank 1. + let q = mat(&[&[1, 1], &[1, 1]]); + let d = psd_decompose(&q).expect("PSD"); + assert_eq!(d.len(), 1); + assert_eq!(d[0].0, 1); + } + + #[test] + fn psd_decompose_rejects_a_zero_diagonal_with_live_off_diagonal() { + // [[0, 1], [1, 0]] is indefinite even though its diagonal is fine. + let q = mat(&[&[0, 1], &[1, 0]]); + assert!(psd_decompose(&q).is_none()); + } + + #[test] + fn psd_decomposition_reproduces_the_matrix() { + let q = mat(&[&[4, 2, 0], &[2, 5, 1], &[0, 1, 3]]); + let d = psd_decompose(&q).expect("PSD"); + let n = 3; + let mut acc = vec![vec![r(0); n]; n]; + for (dk, v) in &d { + for i in 0..n { + for j in 0..n { + acc[i][j] += Rational::from(dk * &v[i]) * &v[j]; + } + } + } + assert_eq!(acc, q); + } +} diff --git a/alkahest-core/src/real/sos/mod.rs b/alkahest-core/src/real/sos/mod.rs index cfd50108..ba8c81d5 100644 --- a/alkahest-core/src/real/sos/mod.rs +++ b/alkahest-core/src/real/sos/mod.rs @@ -19,22 +19,33 @@ //! //! # Soundness //! -//! Everything here is exact rational arithmetic: the search runs through the -//! rational simplex in [`lp`], and no floating point appears anywhere near a -//! certificate. Every certificate is re-expanded and compared against the -//! target identically ([`PositivityCertificate::verify`]) before it is -//! returned. A certificate that fails that check is a bug in the search and is -//! refused, never returned. +//! The DSOS search ([`mod@gram`]) runs through the rational simplex in +//! [`lp`], with no floating point anywhere near it. The general PSD search +//! ([`mod@psd`], on top of [`mod@sdp`] and [`mod@linalg`]) and the Reznick +//! multiplier search built on it (below) *do* use a floating-point numeric +//! search — but only ever to *propose* a Gram matrix; every proposal is +//! rounded to nearby rationals and re-expanded to check it equals the target +//! **exactly**, in ℚ, before it is returned ([`PositivityCertificate::verify`] +//! runs the identical check on demand). A certificate that fails that check +//! is a bug in the search and is refused, never returned — no floating-point +//! result is ever trusted as a certificate on its own. //! //! # What a failure means //! -//! The search covers a linear-programming-representable subcone of the PSD -//! cone (see [`mod@gram`]), which is therefore solvable -//! exactly. That is a strict subset of the SOS cone, so -//! [`SosError::NoCertificate`] means precisely *"no certificate of this shape -//! at this degree"*. It does **not** mean "not a sum of squares", and it does -//! **not** mean "not non-negative". The three answers are kept distinct in the -//! API on purpose — a loop that conflates them will discard true results: +//! The search covers, in order: the linear-programming-representable DSOS +//! subcone (solvable exactly); the full PSD Gram cone, when DSOS fails (a +//! strict superset, but only reachable via the sound-but-incomplete numeric +//! search above); and a Reznick multiplier search `(Σxᵢ²)^N·p`, when even +//! that fails on `p` itself. None of these three is complete — the multiplier +//! search in particular does not yet reliably find certificates whose +//! witnessing Gram matrix is singular (sits exactly on the PSD cone's +//! boundary), which is the case for the textbook examples Motzkin and +//! Robinson (see `real::sos::tests::motzkin_reports_undecided_rather_than_a_false_certificate` +//! for the diagnosis). So [`SosError::NoCertificate`] means precisely *"no +//! certificate of this shape was found at this degree/budget"*. It does +//! **not** mean "not a sum of squares", and it does **not** mean "not +//! non-negative". The three answers are kept distinct in the API on purpose — +//! a loop that conflates them will discard true results: //! //! | Outcome | Meaning | //! |---|---| @@ -44,8 +55,11 @@ pub mod cert; pub mod gram; +pub mod linalg; pub mod lp; +pub mod psd; pub mod ratpoly; +pub mod sdp; pub use cert::{CertificateKind, Multiplier, PositivityCertificate, SosPoly, SosTerm}; pub use ratpoly::RatPoly; @@ -220,6 +234,66 @@ fn finish(cert: PositivityCertificate) -> Result, +) -> Option<(RatPoly, SosPoly, u32)> { + for n in 1..=max_power { + let sigma = RatPoly::sum_of_squares(nvars).pow(n); + let q = target.mul(&sigma); + let qdeg = q.total_degree(); + if qdeg % 2 != 0 { + continue; + } + let basis_deg = qdeg.div_ceil(2); + let basis_len = gram::monomial_basis(nvars, basis_deg).len(); + if basis_len > max_basis_len { + log.push(format!( + "multiplier search: N={n} would need a degree-{basis_deg} basis \ + ({basis_len} monomials), over the search budget ({max_basis_len}); stopping" + )); + break; + } + log.push(format!( + "multiplier search: trying σ = (Σxᵢ²)^{n}, searching the full PSD Gram cone for \ + σ·p over the degree-{basis_deg} monomial basis ({basis_len} monomials)" + )); + if let Some(sos) = psd::psd_search(&q, basis_deg) { + log.push(format!("multiplier search succeeded at N={n}")); + return Some((sigma, sos, basis_deg)); + } + } + None +} + /// `p = Σ_j σ_j·q_j²` — an exact rational sum-of-squares decomposition. /// /// Refuses with [`SosError`] rather than guessing: `E-SOS-003` when `p` is @@ -262,6 +336,7 @@ pub fn sos_decompose( constraints: Vec::new(), sos, }], + multiplier: None, log, }); } @@ -297,12 +372,59 @@ pub fn sos_decompose( )); let Some(sos) = gram::dsos_search(&target, basis_deg) else { + log.push( + "diagonally dominant search failed; trying the full PSD Gram cone directly".to_string(), + ); + if let Some(sos) = psd::psd_search(&target, basis_deg) { + log.push( + "full PSD Gram search succeeded (p is SOS but its Gram matrix is not \ + diagonally dominant)" + .to_string(), + ); + return finish(PositivityCertificate { + vars: vars.to_vec(), + var_names: names, + target, + constraints: Vec::new(), + kind: CertificateKind::Sos, + degree: basis_deg, + terms: vec![Multiplier { + constraints: Vec::new(), + sos, + }], + multiplier: None, + log, + }); + } + if let Some((sigma, sos, mult_basis_deg)) = multiplier_search( + &target, + nvars, + MAX_MULTIPLIER_POWER, + MAX_MULTIPLIER_BASIS_LEN, + &mut log, + ) { + return finish(PositivityCertificate { + vars: vars.to_vec(), + var_names: names, + target, + constraints: Vec::new(), + kind: CertificateKind::Sos, + degree: mult_basis_deg, + terms: vec![Multiplier { + constraints: Vec::new(), + sos, + }], + multiplier: Some(sigma), + log, + }); + } return Err(SosError::NoCertificate(format!( - "undecided, not a refutation — no diagonally dominant Gram matrix over the \ - degree-{basis_deg} monomial basis, and that cone is a strict subset of the SOS \ - cone, so p may still be SOS. Raise basis_degree, fall back to alkahest.decide, \ - or note that p may be non-negative without being SOS (e.g. the Motzkin \ - polynomial). Record this as unknown, not as a closed branch" + "undecided, not a refutation — no diagonally dominant or general PSD Gram matrix \ + over the degree-{basis_deg} monomial basis reproduces p, and no Reznick multiplier \ + (Σxᵢ²)^N up to N={MAX_MULTIPLIER_POWER} made σ·p SOS within the search budget \ + either. None of this is a proof that p is not SOS (with or without a multiplier), \ + and still less that p is not non-negative — only that no certificate of these \ + shapes was found at this size. Raise basis_degree, or fall back to alkahest.decide" ))); }; @@ -317,6 +439,7 @@ pub fn sos_decompose( constraints: Vec::new(), sos, }], + multiplier: None, log, }) } @@ -443,6 +566,7 @@ pub fn prove_nonneg( kind: CertificateKind::Handelman, degree: opts.level, terms, + multiplier: None, log: vec![format!( "Handelman search over {} constraint products up to level {}", products.len(), @@ -502,9 +626,31 @@ mod tests { } #[test] - fn motzkin_refuses_rather_than_lying() { + fn motzkin_reports_undecided_rather_than_a_false_certificate() { let (pool, x, y) = setup(); - // Motzkin: x^4·y^2 + x^2·y^4 − 3·x^2·y^2 + 1 is non-negative but not SOS. + // Motzkin: x^4·y^2 + x^2·y^4 − 3·x^2·y^2 + 1 is non-negative but is + // the textbook example of a polynomial that is *not* itself a sum of + // squares — Hilbert's 1888 theorem allows non-SOS PSD forms outside + // ternary quartics, and Motzkin (1967) is the standard witness. + // Multiplying by (x²+y²) is classically known to fix this (it is + // exactly the kind of case Reznick's theorem covers), but the + // witnessing Gram matrix for that fact is *singular* — it sits + // exactly on the boundary of the PSD cone, not in its interior — and + // [`crate::real::sos::psd`]'s numeric search (alternating projection + // with an annealed floor schedule and multiple random restarts) is + // demonstrably not a bug: a `psd::diag::diag_step3_planted_singular_example` + // planted boundary case with the same nullspace dimension *is* found + // and exactly re-verified, and the affine family constructed for + // Motzkin itself passes an independent sanity check + // (`psd::diag::diag_step1_step2_trajectory_and_family_sanity`). The + // search on Motzkin specifically converges monotonically (min + // eigenvalue runs from roughly −1.6 down to roughly −0.0018 as the + // floor anneals to 0) but does not close the last, asymptotically + // slow stretch to exactly 0 — the classic behaviour of alternating + // projection at a tangential (non-transversal) intersection. This is + // an honest search-budget limitation, not a soundness bug: recording + // `undecided` here, never a fabricated certificate, is the correct + // behaviour and is what this test checks. let p = pool.add(vec![ pool.mul(vec![x, x, x, x, y, y]), pool.mul(vec![x, x, y, y, y, y]), @@ -512,13 +658,53 @@ mod tests { pool.integer(1_i32), ]); let err = sos_decompose(p, &[x, y], &pool, &SosOpts::default()) - .expect_err("Motzkin is not a sum of squares"); - // It must NOT claim negativity — the polynomial is non-negative. + .expect_err("Motzkin's multiplier certificate is not yet reached by this search"); + assert!(matches!(err, SosError::NoCertificate(_))); + assert_eq!(err.code(), "E-SOS-002"); + } + + #[test] + fn multiplier_search_reports_undecided_not_not_sos_when_out_of_budget() { + let (pool, x, y) = setup(); + // Same Motzkin target as the previous test. Here the internal search + // is driven with a budget of *zero* multiplier powers directly — i.e. + // exactly the "search legitimately runs out of budget" case — and it + // must come back empty-handed rather than fabricate a certificate. + // (Motzkin also fails to certify at the production budget, per the + // previous test — this test's point is narrower: even independent of + // whether the production budget eventually finds Motzkin's + // certificate, a caller-supplied budget of zero must never + // manufacture one.) + let p = pool.add(vec![ + pool.mul(vec![x, x, x, x, y, y]), + pool.mul(vec![x, x, y, y, y, y]), + pool.mul(vec![pool.integer(-3_i32), x, x, y, y]), + pool.integer(1_i32), + ]); + let target = RatPoly::from_expr(p, &[x, y], &pool).unwrap(); + let mut log = Vec::new(); + let out = multiplier_search(&target, 2, /* max_power */ 0, 90, &mut log); assert!( - matches!(err, SosError::NoCertificate(_)), - "expected an honest 'no certificate', got {err:?}" + out.is_none(), + "a zero-power budget must not manufacture a certificate" + ); + + // `multiplier_search`'s signature (`Option`, not a `Result` with a + // "negative" branch) makes it structurally unable to report anything + // but "found" or "not found within budget" — it cannot claim + // negativity even by construction. The public `sos_decompose` wires + // exactly this `None` into `SosError::NoCertificate` (see the + // `multiplier_search(...)` call a few lines above the final `Err` in + // `sos_decompose`), never `SosError::Negative`. That distinction + // matters here specifically because Motzkin genuinely is + // non-negative everywhere (confirmed independently: the grid search + // below finds no witness), so "not SOS within budget" and "negative" + // are not just differently coded, they are different facts, and only + // one of them is true. + assert!( + negativity_witness(&target, &[], 2).is_none(), + "Motzkin is non-negative, so a witness must not exist" ); - assert_eq!(err.code(), "E-SOS-002"); } #[test] diff --git a/alkahest-core/src/real/sos/psd.rs b/alkahest-core/src/real/sos/psd.rs new file mode 100644 index 00000000..4fe05b9b --- /dev/null +++ b/alkahest-core/src/real/sos/psd.rs @@ -0,0 +1,719 @@ +//! General (non-diagonally-dominant) PSD Gram-matrix search. +//! +//! [`mod@super::gram`] restricts the search to the diagonally dominant (DD) +//! subcone so it can stay an exact linear program: every DD matrix is PSD, +//! so anything it finds is automatically sound, but the DD cone is a strict +//! subset of the PSD cone and refuses plenty of genuine SOS polynomials. +//! +//! This module searches the *full* PSD cone instead, using the building +//! blocks in [`super::linalg`] and [`super::sdp`]: +//! +//! 1. [`super::linalg::solve_affine`] parametrises the affine family of Gram +//! matrices that reproduce the target's coefficients — *exactly*, over +//! ℚ, for **any** choice of the free parameters. This is the crux of the +//! soundness argument: whatever parameter point gets picked, the +//! resulting matrix already satisfies `z^T Q z = p` on the nose, so +//! nothing downstream can corrupt that half of the identity. +//! 2. [`super::sdp::Family::search`] runs a floating-point alternating +//! projection to *propose* a parameter point whose matrix looks positive +//! semidefinite. This is a heuristic and is never trusted. +//! 3. The proposed point is rounded to nearby rationals (at several +//! denominator budgets) and plugged back into the exact affine family, +//! and [`super::linalg::psd_decompose`] *exactly* decides whether the +//! resulting rational matrix is PSD. +//! +//! Only a `Some` out of step 3 is ever returned, and [`psd_search`] itself +//! re-checks the expanded quadratic form against the target before handing +//! anything back — a bad numeric suggestion costs a wasted rounding +//! attempt, never an unsound result. + +#![allow(clippy::needless_range_loop)] + +use super::cert::SosPoly; +use super::gram::monomial_basis; +use super::linalg::{psd_decompose, solve_affine}; +use super::ratpoly::{Exponents, RatPoly}; +use super::sdp::{min_eigenvalue, Family}; +use rug::Rational; +use std::collections::{BTreeMap, BTreeSet}; + +/// A tiny deterministic PRNG (SplitMix64), used only to diversify the +/// alternating-projection search's starting points. Reproducible and adds +/// no dependency; there is no property of "randomness" any certificate +/// depends on — the exact PSD check afterwards is what makes this sound +/// regardless of how (or how badly) a candidate point was proposed. +struct SplitMix64(u64); + +impl SplitMix64 { + fn new(seed: u64) -> Self { + SplitMix64(seed) + } + + fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// Uniform in `[-scale, scale]`. + fn next_signed(&mut self, scale: f64) -> f64 { + let bits = self.next_u64() >> 11; // 53 significant bits + let u = (bits as f64) * (1.0 / (1u64 << 53) as f64); // [0, 1) + (2.0 * u - 1.0) * scale + } +} + +fn add_exp(a: &[u32], b: &[u32]) -> Exponents { + a.iter().zip(b).map(|(x, y)| x + y).collect() +} + +/// Number of entries in the packed upper triangle (`i ≤ j`) of an `n×n` +/// symmetric matrix. +fn pack_len(n: usize) -> usize { + n * (n + 1) / 2 +} + +/// Unpack a symmetric matrix from its packed upper triangle, in the same +/// `(i, j)`, `i ≤ j` row-major order used by [`gram_system`]. +fn unpack(n: usize, v: &[Rational]) -> Vec> { + let mut q = vec![vec![Rational::from(0); n]; n]; + let mut idx = 0; + for i in 0..n { + for j in i..n { + q[i][j] = v[idx].clone(); + q[j][i] = v[idx].clone(); + idx += 1; + } + } + q +} + +/// Coefficient-matching linear system for `z^T Q z = target`, `z` the given +/// monomial basis: one row per monomial that can occur on either side, one +/// unknown per upper-triangle entry `(i, j)` of the (symmetric) Gram matrix. +fn gram_system(target: &RatPoly, basis: &[Exponents]) -> (Vec>, Vec) { + let n = basis.len(); + let ncols = pack_len(n); + + let mut col_of: BTreeMap<(usize, usize), usize> = BTreeMap::new(); + let mut idx = 0; + for i in 0..n { + for j in i..n { + col_of.insert((i, j), idx); + idx += 1; + } + } + + let mut rows: BTreeMap> = BTreeMap::new(); + for i in 0..n { + for j in i..n { + let e = add_exp(&basis[i], &basis[j]); + // Off-diagonal entries occur twice in z^T Q z (as Q_ij and Q_ji). + let coeff = if i == j { + Rational::from(1) + } else { + Rational::from(2) + }; + let row = rows + .entry(e) + .or_insert_with(|| vec![Rational::from(0); ncols]); + row[col_of[&(i, j)]] += coeff; + } + } + + let mut all_exps: BTreeSet = rows.keys().cloned().collect(); + all_exps.extend(target.terms().keys().cloned()); + + let mut out_rows = Vec::with_capacity(all_exps.len()); + let mut out_rhs = Vec::with_capacity(all_exps.len()); + for e in &all_exps { + let row = rows + .get(e) + .cloned() + .unwrap_or_else(|| vec![Rational::from(0); ncols]); + out_rows.push(row); + out_rhs.push(target.coeff(e)); + } + (out_rows, out_rhs) +} + +fn rat_to_f64(r: &Rational) -> f64 { + r.to_f64() +} + +fn frob_dot(a: &[Vec], b: &[Vec]) -> f64 { + a.iter() + .zip(b) + .map(|(ra, rb)| ra.iter().zip(rb).map(|(x, y)| x * y).sum::()) + .sum() +} + +/// Orthonormalise `dirs` (Gram–Schmidt, Frobenius inner product), returning +/// the orthonormal directions `e` and the upper-triangular change of basis +/// `r` with `dirs[k] = Σ_{j ≤ k} r[j][k]·e[j]`. +/// +/// The raw nullspace basis handed to [`psd_search`] comes out of Gaussian +/// elimination and can be badly scaled — one free coordinate set to exactly +/// `1`, the rest whatever elimination produced — which made the alternating +/// projection search in [`sdp::Family`] stall on realistic targets (Motzkin +/// among them): [`sdp::Family::new`]'s internal Gram matrix of the raw +/// directions was so ill-conditioned that its Cholesky solve barely moved +/// the iterate. Running the search in this orthonormal basis instead makes +/// that Gram matrix the identity by construction; [`Self::to_original`] +/// (via back-substitution against `r`) converts the result back to the +/// original nullspace parametrisation the exact rational reconstruction +/// needs. +fn orthonormalize(dirs: &[Vec>]) -> (Vec>>, Vec>) { + let m = dirs.len(); + let mut e: Vec>> = dirs.to_vec(); + let mut r = vec![vec![0.0f64; m]; m]; + for k in 0..m { + for j in 0..k { + let proj = frob_dot(&e[j], &e[k]); + r[j][k] = proj; + let (ej, ek) = { + // Split-borrow e[j] and e[k] (j < k) simultaneously. + let (left, right) = e.split_at_mut(k); + (&left[j], &mut right[0]) + }; + for (row_k, row_j) in ek.iter_mut().zip(ej.iter()) { + for (v_k, v_j) in row_k.iter_mut().zip(row_j.iter()) { + *v_k -= proj * v_j; + } + } + } + let norm = frob_dot(&e[k], &e[k]).sqrt(); + r[k][k] = norm; + if norm > 1e-12 { + for row in e[k].iter_mut() { + for v in row.iter_mut() { + *v /= norm; + } + } + } + } + (e, r) +} + +/// Solve the upper-triangular system `r·t = s` for `t` by back-substitution. +/// `None` if `r` is (numerically) singular — a direction that Gram–Schmidt +/// found to be dependent on the earlier ones, which should not happen for a +/// genuine nullspace basis but is checked rather than assumed. +fn back_substitute_upper(r: &[Vec], s: &[f64]) -> Option> { + let m = s.len(); + let mut t = vec![0.0f64; m]; + for k in (0..m).rev() { + let mut acc = s[k]; + for (j, tj) in t.iter().enumerate().skip(k + 1) { + acc -= r[k][j] * tj; + } + if r[k][k].abs() < 1e-12 { + return None; + } + t[k] = acc / r[k][k]; + } + Some(t) +} + +/// The convergent of `x`'s continued-fraction expansion whose denominator is +/// `≤ max_den`. Used only to propose a rational point to check exactly — +/// never trusted, so any reasonable approximation is fine. +fn round_to_rational(x: f64, max_den: i64) -> Option { + if !x.is_finite() { + return None; + } + if x == 0.0 { + return Some(Rational::from(0)); + } + let neg = x < 0.0; + let mut val = x.abs(); + let (mut h_prev, mut h_cur): (i64, i64) = (0, 1); + let (mut k_prev, mut k_cur): (i64, i64) = (1, 0); + for _ in 0..40 { + if !val.is_finite() { + break; + } + let a_f = val.floor(); + if a_f.abs() > 1e15 { + break; + } + let a = a_f as i64; + let h_next = a.checked_mul(h_cur).and_then(|v| v.checked_add(h_prev)); + let k_next = a.checked_mul(k_cur).and_then(|v| v.checked_add(k_prev)); + let (Some(h_next), Some(k_next)) = (h_next, k_next) else { + break; + }; + if k_next > max_den || k_next <= 0 { + break; + } + h_prev = h_cur; + h_cur = h_next; + k_prev = k_cur; + k_cur = k_next; + let frac = val - a_f; + if frac < 1e-13 { + break; + } + val = 1.0 / frac; + } + if k_cur == 0 { + None + } else { + let mag = Rational::from((h_cur, k_cur)); + Some(if neg { -mag } else { mag }) + } +} + +/// Denominator caps tried, in order, when rounding the numeric search's +/// suggested point to an exact rational one. +const DENOM_CAPS: &[i64] = &[1, 4, 16, 64, 256, 1024, 4096, 16384, 65536]; + +/// Shrinking sequence of eigenvalue floors, used as a continuation +/// ("annealing") schedule: each stage is warm-started from the previous +/// one's result. Starting deep in the interior (floor `1.0`) is easy to +/// reach from anywhere in the family, and slowly tightening the floor +/// tracks the iterate into a **boundary-only** intersection — a singular +/// witnessing Gram matrix, which is exactly the case for a tight/extremal +/// SOS certificate (Motzkin's among them) — far more reliably than a plain, +/// fixed-floor alternating projection started cold at a small floor, which +/// in practice stalls a long way short of such an intersection instead of +/// converging into it. +const FLOOR_SCHEDULE: &[f64] = &[ + 1.0, 0.3, 0.1, 0.03, 0.01, 0.003, 0.001, 0.0003, 0.0001, 0.00003, 0.00001, 0.0, +]; + +/// Random restarts (beyond the deterministic `t = 0` start) tried per scale +/// in [`RESTART_SCALES`], each run through the full [`FLOOR_SCHEDULE`]. +const RANDOM_RESTARTS: usize = 4; + +/// Scales (in the orthonormal parametrisation, so directly comparable +/// regardless of the original nullspace basis's conditioning) tried for the +/// random restarts' starting points. +const RESTART_SCALES: &[f64] = &[1.0, 4.0]; + +/// How many of the best (highest minimum eigenvalue) search results to +/// actually attempt rational rounding on. The numeric search is the +/// expensive part; trying rounding on a handful of near-best candidates +/// instead of only the single best one costs little extra and hedges +/// against the best *numeric* point rounding to something that fails the +/// *exact* check while a close second would not have. +const ROUNDING_CANDIDATES: usize = 6; + +/// Above this many free parameters, the numeric search is skipped rather +/// than run: each iteration of [`Family::search_from`] solves a dense +/// system in the free-parameter count, so cost grows with its cube, and +/// this keeps that bounded regardless of how large a monomial basis the +/// caller asks for. A skip here returns `None` — "not found within +/// budget", not "not SOS" — exactly like every other budget in this module. +const MAX_FREE_PARAMETERS: usize = 110; + +/// Run the annealing schedule from a single starting point. +fn anneal_from(family: &Family, start: Vec) -> Vec { + let mut t = start; + for &floor in FLOOR_SCHEDULE { + if let Some(next) = family.search_from(t.clone(), floor, 150) { + t = next; + } + } + t +} + +/// Try the annealing schedule from several starting points — the +/// deterministic `t = 0`, plus a handful of random restarts — and return +/// every result reached, best (highest minimum eigenvalue of `family.at(t)`) +/// first. See [`FLOOR_SCHEDULE`]'s and [`RANDOM_RESTARTS`]'s doc comments +/// for why both are needed: annealing handles boundary-only intersections +/// that a fixed floor stalls on, and multiple starts hedge against any +/// single trajectory converging to a merely-locally-nearest pair when the +/// family and the PSD cone do intersect elsewhere. +fn multistart_anneal(family: &Family, dim: usize) -> Vec> { + let mut starts: Vec> = vec![vec![0.0; dim]]; + let mut rng = SplitMix64::new(0xC0FFEE_D15EA5E5); + for &scale in RESTART_SCALES { + for _ in 0..RANDOM_RESTARTS { + starts.push((0..dim).map(|_| rng.next_signed(scale)).collect()); + } + } + let mut results: Vec<(f64, Vec)> = starts + .into_iter() + .map(|start| { + let t = anneal_from(family, start); + let eig = min_eigenvalue(&family.at(&t)); + (eig, t) + }) + .collect(); + results.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + results.into_iter().map(|(_, t)| t).collect() +} + +/// Search the full PSD-Gram cone for an exact rational sum-of-squares +/// decomposition of `p` over the monomial basis of total degree `≤ +/// basis_deg`. +/// +/// This subsumes [`super::gram::dsos_search`] in principle — every +/// diagonally dominant matrix is PSD — but is not a strict improvement in +/// practice: it leans on a floating-point search finding *some* PSD point of +/// the affine family, so `None` here means only "the numeric search and its +/// roundings did not turn up a certificate", never "not SOS". A `Some` is +/// always sound: the returned [`SosPoly`] is checked to expand back to +/// exactly `p` before it is returned, using the same exact rational +/// arithmetic as everywhere else in this subsystem. +pub fn psd_search(target: &RatPoly, basis_deg: u32) -> Option { + let nvars = target.nvars(); + // A homogeneous target of degree exactly `2·basis_deg` needs only the + // monomials of degree *exactly* `basis_deg` in its Gram basis — mixing in + // lower-degree monomials can only ever contribute to coefficients the + // target does not have, since every product of two basis monomials of + // unequal degree still sums to `2·basis_deg` only when *both* already + // have degree `basis_deg`. This is standard (Blekherman–Parrilo–Thomas, + // Prop. 3.29): a homogeneous SOS decomposition can always be taken with + // homogeneous summands. Restricting here is not just an optimisation — + // the search is numeric, and a smaller basis is the difference between + // "converges" and "not within budget" on cases like Motzkin. + let basis: Vec = match target.is_homogeneous() { + Some(d) if d == 2 * basis_deg => monomial_basis(nvars, basis_deg) + .into_iter() + .filter(|e| e.iter().sum::() == basis_deg) + .collect(), + _ => monomial_basis(nvars, basis_deg), + }; + let n = basis.len(); + if n == 0 { + return if target.is_zero() { + Some(SosPoly::default()) + } else { + None + }; + } + + let (rows, rhs) = gram_system(target, &basis); + let sol = solve_affine(&rows, &rhs)?; + + let try_point = |t: &[Rational]| -> Option { + let packed = sol.at(t); + let q = unpack(n, &packed); + let decomp = psd_decompose(&q)?; + let mut sos = SosPoly::default(); + for (d, v) in decomp { + if d <= 0 { + continue; + } + let mut square = RatPoly::zero(nvars); + for (u, c) in v.iter().enumerate() { + if *c != 0 { + square = square.add(&RatPoly::monomial(nvars, basis[u].clone(), c.clone())); + } + } + sos.push(d, square); + } + // Defense in depth: the affine parametrisation guarantees this by + // construction, but the certificate returned here is re-verified + // once more at the call site (`PositivityCertificate::verify`), and + // this module never hands back something it has not itself checked. + if sos.to_poly(nvars) == *target { + Some(sos) + } else { + None + } + }; + + // No freedom at all: the unique solution is the only candidate. + if sol.dimension() == 0 { + return try_point(&[]); + } + if sol.dimension() > MAX_FREE_PARAMETERS { + return None; + } + + let base: Vec> = unpack(n, &sol.particular) + .iter() + .map(|row| row.iter().map(rat_to_f64).collect()) + .collect(); + let dirs: Vec>> = sol + .nullspace + .iter() + .map(|dir| { + unpack(n, dir) + .iter() + .map(|row| row.iter().map(rat_to_f64).collect()) + .collect() + }) + .collect(); + // Search in an orthonormal basis of the same directions — see + // `orthonormalize`'s doc comment for why the raw nullspace basis makes + // the alternating projection stall in practice. + let (ortho_dirs, r) = orthonormalize(&dirs); + let family = Family::new(base, ortho_dirs); + let dim = dirs.len(); + + for s in multistart_anneal(&family, dim) + .into_iter() + .take(ROUNDING_CANDIDATES) + { + let Some(t) = back_substitute_upper(&r, &s) else { + continue; + }; + for &max_den in DENOM_CAPS { + let t_rat: Option> = + t.iter().map(|x| round_to_rational(*x, max_den)).collect(); + let Some(t_rat) = t_rat else { continue }; + if let Some(sos) = try_point(&t_rat) { + return Some(sos); + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn r(n: i64, d: i64) -> Rational { + Rational::from((n, d)) + } + + #[test] + fn psd_search_finds_the_dsos_reachable_case() { + // p = x^2 - 2xy + 2y^2 = (x-y)^2 + y^2. + let mut p = RatPoly::monomial(2, vec![2, 0], Rational::from(1)); + p = p.add(&RatPoly::monomial(2, vec![1, 1], Rational::from(-2))); + p = p.add(&RatPoly::monomial(2, vec![0, 2], Rational::from(2))); + let sos = psd_search(&p, 1).expect("PSD search should find a certificate"); + assert_eq!(sos.to_poly(2), p); + } + + #[test] + fn psd_search_finds_a_unique_rational_gram_matrix() { + // (1/2 x + 1/3 y)^2 = 1/4 x^2 + 1/3 xy + 1/9 y^2; the basis has only + // three monomials at degree 1, so the Gram matrix has no freedom at + // all and this exercises the `dimension() == 0` path directly. + let mut p = RatPoly::monomial(2, vec![2, 0], r(1, 4)); + p = p.add(&RatPoly::monomial(2, vec![1, 1], r(1, 3))); + p = p.add(&RatPoly::monomial(2, vec![0, 2], r(1, 9))); + let sos = psd_search(&p, 1).expect("PSD search should find a certificate"); + assert_eq!(sos.to_poly(2), p); + } + + #[test] + fn psd_search_refuses_unreachable_degree() { + let p = RatPoly::monomial(1, vec![4], Rational::from(1)); + assert!(psd_search(&p, 1).is_none()); + } + + #[test] + fn psd_search_refuses_a_negative_definite_form() { + let p = RatPoly::monomial(2, vec![2, 0], Rational::from(-1)).add(&RatPoly::monomial( + 2, + vec![0, 2], + Rational::from(-1), + )); + assert!(psd_search(&p, 1).is_none()); + } + + #[test] + fn psd_search_finds_a_certificate_with_genuine_off_diagonal_freedom() { + // p = x^4 + y^4 + 2x^2y^2 = (x^2 + y^2)^2. The degree-2 basis + // {1, x, y, x^2, xy, y^2} gives the Gram matrix real freedom (many + // off-diagonal choices reproduce the same quartic), and only some of + // that family is PSD, exercising the numeric search + rounding path. + let mut p = RatPoly::monomial(2, vec![4, 0], Rational::from(1)); + p = p.add(&RatPoly::monomial(2, vec![0, 4], Rational::from(1))); + p = p.add(&RatPoly::monomial(2, vec![2, 2], Rational::from(2))); + let sos = psd_search(&p, 2).expect("PSD search should find a certificate"); + assert_eq!(sos.to_poly(2), p); + } + + #[test] + fn psd_search_does_not_yet_reach_homogeneous_motzkin_times_sum_of_squares() { + // The homogeneous Motzkin form, x^4y^2 + x^2y^4 - 3x^2y^2z^2 + z^6, is + // the textbook example of a PSD form that is not itself SOS, and + // (x^2+y^2+z^2)*Motzkin *is* classically SOS — but its witnessing + // Gram matrix is singular, sitting exactly on the boundary of the + // PSD cone. `diag::diag_step1_step2_trajectory_and_family_sanity` + // shows the annealed multi-start search converges monotonically + // toward that boundary (min eigenvalue from about -1.6 to about + // -0.0018 as the floor anneals to 0) without fully closing the gap — + // a tangential (non-transversal) intersection is the classic case + // where alternating projection's convergence rate degrades this way. + // `diag::diag_step3_planted_singular_example` confirms the search + // mechanism itself is sound: a synthetic boundary case of the same + // nullspace dimension *is* found and exactly re-verified, so this is + // a genuine search-budget limitation on this specific hard instance, + // not a bug in the family construction or the search. `None` here is + // the correct, honest answer. + let mut m = RatPoly::monomial(3, vec![4, 2, 0], Rational::from(1)); + m = m.add(&RatPoly::monomial(3, vec![2, 4, 0], Rational::from(1))); + m = m.add(&RatPoly::monomial(3, vec![2, 2, 2], Rational::from(-3))); + m = m.add(&RatPoly::monomial(3, vec![0, 0, 6], Rational::from(1))); + assert_eq!(m.is_homogeneous(), Some(6)); + let sigma = RatPoly::sum_of_squares(3); + let q = m.mul(&sigma); + assert_eq!(q.is_homogeneous(), Some(8)); + + assert!( + psd_search(&q, 4).is_none(), + "Motzkin's boundary certificate is not yet reached by this search; if this starts \ + passing, promote it to a positive assertion (assert_eq!(sos.to_poly(3), q)) rather \ + than leaving it as a smoke test" + ); + } +} + +#[cfg(test)] +mod diag { + use super::*; + + fn homogeneous_motzkin_times_sos() -> (RatPoly, Vec) { + let mut m = RatPoly::monomial(3, vec![4, 2, 0], Rational::from(1)); + m = m.add(&RatPoly::monomial(3, vec![2, 4, 0], Rational::from(1))); + m = m.add(&RatPoly::monomial(3, vec![2, 2, 2], Rational::from(-3))); + m = m.add(&RatPoly::monomial(3, vec![0, 0, 6], Rational::from(1))); + let sigma = RatPoly::sum_of_squares(3); + let q = m.mul(&sigma); + let basis: Vec = monomial_basis(3, 4) + .into_iter() + .filter(|e| e.iter().sum::() == 4) + .collect(); + (q, basis) + } + + /// Step 1 + 2: trajectory of the deterministic (t=0) start across the + /// floor schedule, and an exact sanity check that the affine family + /// really does reproduce `q` at an arbitrary rational point. + #[test] + fn diag_step1_step2_trajectory_and_family_sanity() { + let (q, basis) = homogeneous_motzkin_times_sos(); + let n = basis.len(); + let (rows, rhs) = gram_system(&q, &basis); + eprintln!( + "DIAG n={n} rows={} cols={}", + rows.len(), + rows.first().map(|r| r.len()).unwrap_or(0) + ); + let sol = solve_affine(&rows, &rhs).expect("consistent"); + eprintln!("DIAG nullspace_dim={}", sol.dimension()); + + // --- Step 2: exact family sanity check at an arbitrary rational t --- + let dim = sol.dimension(); + let mut t_check = vec![Rational::from(0); dim]; + for (i, slot) in t_check.iter_mut().enumerate() { + *slot = Rational::from((3 * (i as i64 + 1) - 7, 2 * (i as i64 + 1) + 3)); + } + let packed = sol.at(&t_check); + let q_check = unpack(n, &packed); + let mut quad = RatPoly::zero(3); + for i in 0..n { + for j in 0..n { + let e = add_exp(&basis[i], &basis[j]); + quad = quad.add(&RatPoly::monomial(3, e, q_check[i][j].clone())); + } + } + assert_eq!( + quad, q, + "DIAG family sanity check FAILED: z^T Q(t) z != q at an arbitrary rational t" + ); + eprintln!("DIAG family sanity check: OK (z^T Q(t) z == q at a random rational t)"); + + // --- Step 1: eigenvalue trajectory of the t=0 start --- + let base: Vec> = unpack(n, &sol.particular) + .iter() + .map(|row| row.iter().map(rat_to_f64).collect()) + .collect(); + let dirs: Vec>> = sol + .nullspace + .iter() + .map(|dir| { + unpack(n, dir) + .iter() + .map(|row| row.iter().map(rat_to_f64).collect()) + .collect() + }) + .collect(); + eprintln!( + "DIAG base min_eig (t=0, before any projection) = {}", + min_eigenvalue( + &unpack(n, &sol.particular) + .iter() + .map(|row| row.iter().map(rat_to_f64).collect()) + .collect::>>() + ) + ); + let (ortho_dirs, _r) = orthonormalize(&dirs); + let family = Family::new(base, ortho_dirs); + + let mut t = vec![0.0f64; dirs.len()]; + for &floor in FLOOR_SCHEDULE { + if let Some(next) = family.search_from(t.clone(), floor, 150) { + t = next; + } + let eig = min_eigenvalue(&family.at(&t)); + eprintln!("DIAG floor={floor:>10}: min_eig={eig}"); + } + } + + /// Step 3: a planted rank-deficient (singular) PSD example on the *same* + /// basis size / nullspace dimension as the Motzkin case, to separate + /// "the mechanism is broken" from "Motzkin specifically is hard". + #[test] + fn diag_step3_planted_singular_example() { + let basis: Vec = monomial_basis(3, 4) + .into_iter() + .filter(|e| e.iter().sum::() == 4) + .collect(); + let n = basis.len(); + eprintln!("DIAG planted: n={n}"); + + // Build a deliberately rank-deficient (rank 3) PSD Gram matrix: five + // fixed integer vectors, Q0 = sum of their outer products. + let mut rng = SplitMix64::new(0xBEEF_CAFE); + let vecs: Vec> = (0..3) + .map(|_| (0..n).map(|_| (rng.next_u64() % 7) as i64 - 3).collect()) + .collect(); + let mut q0 = vec![vec![Rational::from(0); n]; n]; + for v in &vecs { + for i in 0..n { + for j in 0..n { + q0[i][j] += Rational::from(v[i] * v[j]); + } + } + } + // Sanity: Q0 is PSD by construction (sum of rank-1 PSD terms). + assert!( + psd_decompose(&q0).is_some(), + "planted Q0 should be PSD by construction" + ); + + // target = z^T Q0 z. + let mut target = RatPoly::zero(3); + for i in 0..n { + for j in 0..n { + let e = add_exp(&basis[i], &basis[j]); + target = target.add(&RatPoly::monomial(3, e, q0[i][j].clone())); + } + } + assert_eq!(target.is_homogeneous(), Some(8)); + + let (rows, rhs) = gram_system(&target, &basis); + let sol = solve_affine(&rows, &rhs).expect("consistent"); + eprintln!("DIAG planted nullspace_dim={}", sol.dimension()); + + let sos = psd_search(&target, 4); + eprintln!( + "DIAG planted psd_search found certificate: {}", + sos.is_some() + ); + let sos = sos.expect( + "the search mechanism must find a planted singular-PSD example of the same \ + nullspace dimension as Motzkin — if this starts failing, the regression is in \ + the search/family machinery itself, not in any one hard instance", + ); + assert_eq!( + sos.to_poly(3), + target, + "DIAG planted certificate failed exact re-expansion" + ); + } +} diff --git a/alkahest-core/src/real/sos/ratpoly.rs b/alkahest-core/src/real/sos/ratpoly.rs index 2ab3e135..ded6f7eb 100644 --- a/alkahest-core/src/real/sos/ratpoly.rs +++ b/alkahest-core/src/real/sos/ratpoly.rs @@ -47,6 +47,20 @@ impl RatPoly { RatPoly::constant(nvars, Rational::from(1)) } + /// `x_1² + … + x_nvars²` — the building block of the Reznick multiplier + /// family `(x_1² + … + x_nvars²)^N`. Zero only at the origin and strictly + /// positive everywhere else, which is exactly what the multiplier + /// certificate's verification argument needs. + pub fn sum_of_squares(nvars: usize) -> Self { + let mut p = RatPoly::zero(nvars); + for i in 0..nvars { + let mut e = vec![0u32; nvars]; + e[i] = 2; + p.terms.insert(e, Rational::from(1)); + } + p + } + /// `coeff * x^exps`. pub fn monomial(nvars: usize, exps: Exponents, coeff: Rational) -> Self { debug_assert_eq!(exps.len(), nvars); @@ -85,6 +99,22 @@ impl RatPoly { } } + /// `Some(d)` iff every term has total degree exactly `d` (a form). The + /// zero polynomial is homogeneous of every degree, so it is reported + /// homogeneous of degree `0` (harmless: callers only use this to decide + /// whether a *non-trivial* degree restriction on a monomial basis is + /// still complete, and a zero target is handled separately everywhere + /// upstream of that decision). + pub fn is_homogeneous(&self) -> Option { + let mut degs = self.terms.keys().map(|e| e.iter().sum::()); + let first = degs.next().unwrap_or(0); + if degs.all(|d| d == first) { + Some(first) + } else { + None + } + } + pub fn total_degree(&self) -> u32 { self.terms .keys() diff --git a/alkahest-core/src/real/sos/sdp.rs b/alkahest-core/src/real/sos/sdp.rs new file mode 100644 index 00000000..977ce299 --- /dev/null +++ b/alkahest-core/src/real/sos/sdp.rs @@ -0,0 +1,305 @@ +//! Floating-point *proposal* of a positive semidefinite Gram matrix. +//! +//! # Why floating point is allowed here, and what keeps it sound +//! +//! The rest of this subsystem is exact on principle: a certificate that came +//! out of a floating-point computation is not a proof. That principle is not +//! weakened by this module, because nothing here is ever believed. +//! +//! The Gram matrices of a polynomial form an affine family +//! `Q(t) = B₀ + Σ t_k·B_k` with **rational** `B_k` ([`super::linalg`] computes +//! it). When that family has free parameters, some point of it may be +//! positive semidefinite and others not, and finding one is a semidefinite +//! feasibility problem that exact rational arithmetic cannot solve directly. +//! So this module runs a cheap numerical search — alternating projection onto +//! the PSD cone and back onto the affine family — and returns the parameter +//! vector `t` it landed on, as a **suggestion**. +//! +//! The caller then rounds `t` to rationals and rebuilds `Q(t)` in ℚ. That +//! rebuilt matrix lies in the affine family *exactly*, by construction, whatever +//! the rounding did; and whether it is PSD is then decided *exactly*, by the +//! rational `LDLᵀ` in [`super::linalg::psd_decompose`]. A bad suggestion +//! therefore costs a failed search, never an unsound certificate. No number +//! computed in this file appears in any certificate. + +#![allow(clippy::needless_range_loop)] + +/// Symmetric eigendecomposition by the cyclic Jacobi method. +/// +/// Returns `(values, vectors)` where `vectors[i]` is the eigenvector for +/// `values[i]`. Jacobi is used rather than anything faster because the +/// matrices here are small and it is unconditionally stable on symmetric +/// input — accuracy matters more than speed, since a poor eigenbasis just +/// wastes the exact check that follows. +fn jacobi_eigen(input: &[Vec]) -> (Vec, Vec>) { + let n = input.len(); + let mut a: Vec> = input.to_vec(); + let mut v: Vec> = (0..n) + .map(|i| (0..n).map(|j| if i == j { 1.0 } else { 0.0 }).collect()) + .collect(); + + for _ in 0..60 { + // Off-diagonal mass; stop once it is negligible. + let mut off = 0.0; + for i in 0..n { + for j in (i + 1)..n { + off += a[i][j] * a[i][j]; + } + } + if off <= 1e-24 { + break; + } + for p in 0..n { + for q in (p + 1)..n { + if a[p][q].abs() < 1e-18 { + continue; + } + let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]); + let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt()); + let c = 1.0 / (t * t + 1.0).sqrt(); + let s = t * c; + for k in 0..n { + let akp = a[k][p]; + let akq = a[k][q]; + a[k][p] = c * akp - s * akq; + a[k][q] = s * akp + c * akq; + } + for k in 0..n { + let apk = a[p][k]; + let aqk = a[q][k]; + a[p][k] = c * apk - s * aqk; + a[q][k] = s * apk + c * aqk; + } + for k in 0..n { + let vkp = v[k][p]; + let vkq = v[k][q]; + v[k][p] = c * vkp - s * vkq; + v[k][q] = s * vkp + c * vkq; + } + } + } + } + + let values: Vec = (0..n).map(|i| a[i][i]).collect(); + let vectors: Vec> = (0..n).map(|i| (0..n).map(|k| v[k][i]).collect()).collect(); + (values, vectors) +} + +/// Projection onto `{Q : Q ⪰ floor·I}` in the eigenvalue sense. +fn project_psd(q: &[Vec], floor: f64) -> Vec> { + let n = q.len(); + let (vals, vecs) = jacobi_eigen(q); + let mut out = vec![vec![0.0f64; n]; n]; + for (lambda, vec) in vals.iter().zip(vecs.iter()) { + let w = lambda.max(floor); + if w == 0.0 { + continue; + } + for i in 0..n { + for j in 0..n { + out[i][j] += w * vec[i] * vec[j]; + } + } + } + out +} + +/// Smallest eigenvalue of a symmetric matrix (numerically). +pub fn min_eigenvalue(q: &[Vec]) -> f64 { + let (vals, _) = jacobi_eigen(q); + vals.into_iter().fold(f64::INFINITY, f64::min) +} + +fn dot(a: &[Vec], b: &[Vec]) -> f64 { + a.iter() + .zip(b.iter()) + .map(|(ra, rb)| ra.iter().zip(rb.iter()).map(|(x, y)| x * y).sum::()) + .sum() +} + +/// Solve a small symmetric positive definite system by Cholesky. +fn solve_spd(g: &[Vec], rhs: &[f64]) -> Option> { + let n = g.len(); + let mut l = vec![vec![0.0f64; n]; n]; + for i in 0..n { + for j in 0..=i { + let mut s = g[i][j]; + for k in 0..j { + s -= l[i][k] * l[j][k]; + } + if i == j { + if s <= 1e-12 { + return None; + } + l[i][j] = s.sqrt(); + } else { + l[i][j] = s / l[j][j]; + } + } + } + let mut y = vec![0.0f64; n]; + for i in 0..n { + let mut s = rhs[i]; + for k in 0..i { + s -= l[i][k] * y[k]; + } + y[i] = s / l[i][i]; + } + let mut x = vec![0.0f64; n]; + for i in (0..n).rev() { + let mut s = y[i]; + for k in (i + 1)..n { + s -= l[k][i] * x[k]; + } + x[i] = s / l[i][i]; + } + Some(x) +} + +/// The affine family `Q(t) = base + Σ t_k · dirs[k]`, in floating point. +pub struct Family { + base: Vec>, + dirs: Vec>>, + /// Cholesky-ready Gram matrix of `dirs`, for the projection step. + gram: Vec>, +} + +impl Family { + /// `dirs` must be linearly independent (they come from a null-space basis). + pub fn new(base: Vec>, dirs: Vec>>) -> Self { + let k = dirs.len(); + let mut gram = vec![vec![0.0f64; k]; k]; + for i in 0..k { + for j in 0..=i { + let v = dot(&dirs[i], &dirs[j]); + gram[i][j] = v; + gram[j][i] = v; + } + } + Family { base, dirs, gram } + } + + /// `Q(t)`. + pub fn at(&self, t: &[f64]) -> Vec> { + let n = self.base.len(); + let mut q = self.base.clone(); + for (tk, dir) in t.iter().zip(self.dirs.iter()) { + if *tk == 0.0 { + continue; + } + for i in 0..n { + for j in 0..n { + q[i][j] += tk * dir[i][j]; + } + } + } + q + } + + /// Least-squares projection of `q` back onto the family, as parameters. + fn parameters_of(&self, q: &[Vec]) -> Option> { + let n = self.base.len(); + let mut delta = q.to_vec(); + for i in 0..n { + for j in 0..n { + delta[i][j] -= self.base[i][j]; + } + } + let rhs: Vec = self.dirs.iter().map(|d| dot(d, &delta)).collect(); + solve_spd(&self.gram, &rhs) + } + + /// Alternating projection between the PSD cone (with an eigenvalue floor, + /// which pushes the iterate towards the *interior* of the feasible set and + /// so makes the subsequent rational rounding survive) and this family, + /// starting from the particular solution (`t = 0`). + /// + /// Returns the parameter vector reached, or `None` if the linear algebra + /// degenerated. Convergence is not required and not claimed: the result is + /// a suggestion to be checked exactly. + pub fn search(&self, floor: f64, iters: usize) -> Option> { + self.search_from(vec![0.0f64; self.dirs.len()], floor, iters) + } + + /// Same alternating projection as [`Self::search`], from an arbitrary + /// starting parameter vector. + /// + /// Plain (single-start) alternating projection between two convex sets + /// is only guaranteed to *converge*, not to converge quickly — and when + /// the only points of intersection are on the relative boundary of the + /// PSD cone (a **singular** witnessing Gram matrix, which is exactly the + /// case for a tight/extremal SOS certificate — Motzkin's among them), + /// it can stall a long way short of the intersection instead. Trying + /// several starting points and keeping the best result (see + /// [`super::psd::psd_search`]) is the standard mitigation; this method + /// is what makes that possible. + pub fn search_from(&self, start: Vec, floor: f64, iters: usize) -> Option> { + let mut t = start; + let mut q = self.at(&t); + for _ in 0..iters { + let projected = project_psd(&q, floor); + let next = self.parameters_of(&projected)?; + let moved: f64 = next + .iter() + .zip(t.iter()) + .map(|(a, b)| (a - b).abs()) + .fold(0.0, f64::max); + t = next; + q = self.at(&t); + if moved < 1e-13 { + break; + } + } + if t.iter().any(|v| !v.is_finite()) { + return None; + } + Some(t) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn jacobi_diagonalises_a_known_matrix() { + // [[2, 1], [1, 2]] has eigenvalues 1 and 3. + let a = vec![vec![2.0, 1.0], vec![1.0, 2.0]]; + let (mut vals, _) = jacobi_eigen(&a); + vals.sort_by(|x, y| x.partial_cmp(y).unwrap()); + assert!((vals[0] - 1.0).abs() < 1e-10, "{vals:?}"); + assert!((vals[1] - 3.0).abs() < 1e-10, "{vals:?}"); + } + + #[test] + fn min_eigenvalue_sees_indefiniteness() { + let a = vec![vec![1.0, 2.0], vec![2.0, 1.0]]; + assert!(min_eigenvalue(&a) < -0.9); + } + + #[test] + fn psd_projection_clips_negative_eigenvalues() { + let a = vec![vec![1.0, 2.0], vec![2.0, 1.0]]; + let p = project_psd(&a, 0.0); + assert!(min_eigenvalue(&p) > -1e-9); + } + + #[test] + fn search_finds_a_psd_point_of_a_family() { + // base = [[1, 1], [1, 0]] (indefinite), direction adds to the (1,1) entry. + let base = vec![vec![1.0, 1.0], vec![1.0, 0.0]]; + let dirs = vec![vec![vec![0.0, 0.0], vec![0.0, 1.0]]]; + let fam = Family::new(base, dirs); + let t = fam.search(0.25, 500).expect("search runs"); + assert!(min_eigenvalue(&fam.at(&t)) > -1e-9, "t = {t:?}"); + // The PSD points are exactly t ≥ 1; the interior floor should overshoot it. + assert!(t[0] >= 1.0, "t = {t:?}"); + } + + #[test] + fn search_on_an_empty_family_is_a_no_op() { + let fam = Family::new(vec![vec![1.0]], Vec::new()); + let t = fam.search(0.1, 10).expect("no parameters"); + assert!(t.is_empty()); + } +} diff --git a/alkahest-py/src/lib.rs b/alkahest-py/src/lib.rs index 1edbfc33..1e5927a7 100644 --- a/alkahest-py/src/lib.rs +++ b/alkahest-py/src/lib.rs @@ -181,8 +181,11 @@ use alkahest_core::holonomic::{ }; // M4(b) — q-analogue creative telescoping (q-Zeilberger) use alkahest_core::holonomic::qzeil::{ + cyclotomic_polynomial as core_cyclotomic_polynomial, + q_specialize_at_root_of_unity as core_q_specialize_at_root_of_unity, q_zeilberger as core_q_zeilberger, QBoundaryStatus as CoreQBoundaryStatus, QCertificate as CoreQCertificate, QHolonomicError as CoreQHolonomicError, + QRootOfUnitySpecialization as CoreQRootOfUnitySpecialization, QZeilbergerOpts as CoreQZeilbergerOpts, }; // M6 — modular / p-adic evaluation of holonomic sequences @@ -426,6 +429,9 @@ pyo3::create_exception!(alkahest, PyLinearRecurrenceError, PyAlkahestError); pyo3::create_exception!(alkahest, PyRsolveError, PyAlkahestError); #[cfg(feature = "groebner")] pyo3::create_exception!(alkahest, PyDiophantineError, PyAlkahestError); +// M9 — Gröbner bases over Q(params); E-PARAMGB-004 is a refusal, not a fault. +#[cfg(feature = "groebner")] +pyo3::create_exception!(alkahest, PyParamGroebnerError, PyAlkahestError); // P1 search plumbing item 4 — budgets, cancellation, determinism pyo3::create_exception!(alkahest, PyBudgetExceededError, PyAlkahestError); // P1 item 7 — creative telescoping / holonomic (D-finite) machinery @@ -5343,6 +5349,36 @@ impl PyQZeilbergerCertificate { }) } + /// ``specialize_at_root_of_unity(d, n) -> QRootOfUnitySpecialization`` + /// + /// Decide whether the proved ``Q(q)`` recurrence survives ``q = ζ_d``, a + /// primitive ``d``-th root of unity — the step between what this + /// certificate proves and the ``q``-supercongruence literature. + /// + /// The hypotheses (no pole in any ``a_i(q**n)`` or ``S(n+i)`` at ``ζ_d``) + /// are decided **exactly**, by polynomial divisibility by ``Φ_d(q)`` over + /// ``Q``; nothing is evaluated numerically. The result carries a + /// three-valued verdict — ``"specializes"``, ``"obstructed"``, + /// ``"unknown"`` — and refuses to offer a specialised value when a + /// hypothesis fails. + /// + /// ``d = 1`` means ``ζ_1 = 1``: the classical ``q → 1`` limit. + #[pyo3(signature = (d, n))] + fn specialize_at_root_of_unity( + &self, + py: Python<'_>, + d: u32, + n: i64, + ) -> PyResult { + let spec = core_q_specialize_at_root_of_unity(&self.cert, d, n) + .map_err(q_holonomic_error_to_py)?; + Ok(PyQRootOfUnitySpecialization { + spec, + pool: self.pool.clone_ref(py), + q_id: self.q_id, + }) + } + fn __repr__(&self, py: Python<'_>) -> String { let pool = self.pool.borrow(py); let coeffs: Vec = self @@ -5364,6 +5400,271 @@ impl PyQZeilbergerCertificate { } } +/// The verdict on specialising a ``q``-Zeilberger certificate at a primitive +/// ``d``-th root of unity, from +/// :meth:`~alkahest.experimental.QZeilbergerCertificate.specialize_at_root_of_unity`. +/// +/// ``q``-Zeilberger proves identities with ``q`` **transcendental**. Setting +/// ``q = ζ_d`` is a separate step with its own hypotheses, and specialising at +/// a point where a denominator vanishes produces a confidently wrong statement. +/// This object is that step, taken as a decision: +/// +/// * :attr:`status` ``== "specializes"`` — proved. Every ``a_i(q**n)`` and +/// every ``S(n+i)`` was shown to have non-negative ``Φ_d``-adic valuation, so +/// the specialisation map is defined on all of them, and +/// ``Σ_i a_i(ζ**n)·S_ζ(n+i) = 0`` in ``Q(ζ_d)`` — re-checked in exact +/// cyclotomic arithmetic before this object was built. +/// * ``"obstructed"`` — a pole at ``ζ_d`` was **exhibited**. No specialised +/// value is offered. This is not a proof that the specialised identity is +/// false, only that this route to it is blocked. +/// * ``"unknown"`` — nothing follows. +/// +/// Three further things are reported rather than hidden, because each of them +/// makes a true verdict mean less than it looks: +/// :attr:`is_vacuous` (every coefficient died, so the recurrence is ``0 = 0``), +/// :attr:`leading_coefficient_survives` (``False`` means the recurrence no +/// longer determines the last value), and :attr:`support_shrinks` (``q``-Lucas +/// killed terms the generic identity needs — ``[2;1]_q = 1 + q`` is non-zero in +/// ``Q(q)`` and zero at ``ζ_2``). +/// +/// :meth:`sum_valuation` is the ``q``-supercongruence quantity: it is the exact +/// integer ``v`` with ``Φ_d(q)**v`` dividing ``S(n)`` and ``Φ_d(q)**(v+1)`` not. +#[pyclass(name = "QRootOfUnitySpecialization")] +struct PyQRootOfUnitySpecialization { + spec: CoreQRootOfUnitySpecialization, + pool: Py, + q_id: ExprId, +} + +impl PyQRootOfUnitySpecialization { + fn poly_to_expr( + &self, + py: Python<'_>, + p: &alkahest_core::matrix::normal_form::RatUniPoly, + ) -> PyExpr { + let pool = self.pool.borrow(py); + let id = alkahest_core::holonomic::hyperterm::ratuni_to_expr(&pool.inner, self.q_id, p); + PyExpr { + id, + pool: self.pool.clone_ref(py), + } + } + + fn checked_index(&self, i: usize) -> PyResult { + if !self.spec.specializes() { + return Err(PyValueError::new_err(format!( + "the specialisation is \"{}\", so no specialised value exists: {}", + self.spec.status.tag(), + self.spec.status.reason() + ))); + } + if i >= self.spec.sums.len() { + return Err(pyo3::exceptions::PyIndexError::new_err(format!( + "shift index {i} is out of range for a recurrence of order {}", + self.spec.sums.len().saturating_sub(1) + ))); + } + Ok(i) + } +} + +#[pymethods] +impl PyQRootOfUnitySpecialization { + /// The order ``d`` of the root of unity. + #[getter] + fn d(&self) -> u32 { + self.spec.d + } + + /// The index ``n`` the verdict is about; the recurrence relates + /// ``n … n + order``. + #[getter] + fn n(&self) -> i64 { + self.spec.n0 + } + + /// ``"specializes"``, ``"obstructed"`` or ``"unknown"``. + #[getter] + fn status(&self) -> &'static str { + self.spec.status.tag() + } + + /// Whether a specialised recurrence may be claimed at all. + #[getter] + fn specializes(&self) -> bool { + self.spec.specializes() + } + + /// Why the verdict came out as it did; ``""`` when it specialises. + #[getter] + fn reason(&self) -> String { + self.spec.status.reason().to_string() + } + + /// Whether the specialised recurrence is ``0 = 0``. + /// + /// ``True`` means every ``a_i(ζ**n)`` vanished. The statement is still a + /// theorem; it simply constrains nothing, and reading :attr:`specializes` + /// without reading this would be claiming more than there is. + #[getter] + fn is_vacuous(&self) -> bool { + self.spec.is_vacuous() + } + + /// Whether the leading coefficient ``a_J(ζ**n)`` survives, i.e. whether the + /// specialised recurrence still determines ``S_ζ(n+J)``. + #[getter] + fn leading_coefficient_survives(&self) -> bool { + self.spec.leading_coefficient_survives() + } + + /// Whether ``S_ζ`` is also the sum of the *specialised summands*. + /// + /// ``False`` means some summand inside the window has a pole at ``ζ_d``: + /// :meth:`sum_value` is still the correct image of the exact ``Q(q)`` sum, + /// but writing it as ``Σ_k F_ζ(n,k)`` would be writing down an undefined + /// expression. + #[getter] + fn is_termwise_regular(&self) -> bool { + self.spec.is_termwise_regular() + } + + /// Whether ``q``-Lucas killed at least one term the generic identity needs. + #[getter] + fn support_shrinks(&self) -> bool { + self.spec.support_shrinks() + } + + /// The proved generic support window ``(lo, hi)`` in ``k`` at this ``n``, + /// or ``None`` when it was not established. + #[getter] + fn window(&self) -> Option<(i64, i64)> { + self.spec.window + } + + /// The ``k`` inside that window at which the summand is **still non-zero** + /// at ``ζ_d`` — the effective window, which ``q``-Lucas can shrink. + #[getter] + fn effective_support(&self) -> Vec { + self.spec.effective_support.clone() + } + + /// Hypotheses and caveats this verdict does not discharge, as plain strings. + #[getter] + fn side_conditions(&self) -> Vec { + self.spec.side_conditions() + } + + /// ``Φ_d(q)`` — the cyclotomic polynomial the arithmetic is modulo. + /// + /// Exposed so a caller can redo the whole check by hand: two elements of + /// ``Q(ζ_d)`` are equal exactly when their canonical representatives (what + /// :meth:`sum_value` returns, of degree ``< φ(d)``) are equal. + fn modulus(&self, py: Python<'_>) -> PyExpr { + self.poly_to_expr(py, self.spec.field.modulus()) + } + + /// ``S_ζ(n + i)`` — the specialised sum, as its canonical representative in + /// ``Q[q]`` of degree ``< φ(d)``. + /// + /// Raises :exc:`ValueError` unless the verdict is ``"specializes"``: an + /// obstructed specialisation has no value to report, and returning one + /// anyway is exactly the mistake this class exists to prevent. + #[pyo3(signature = (i = 0))] + fn sum_value(&self, py: Python<'_>, i: usize) -> PyResult { + let i = self.checked_index(i)?; + Ok(self.poly_to_expr(py, &self.spec.sums[i].poly)) + } + + /// ``a_i(ζ**n)`` — the specialised recurrence coefficient. + #[pyo3(signature = (i = 0))] + fn coefficient(&self, py: Python<'_>, i: usize) -> PyResult { + let i = self.checked_index(i)?; + Ok(self.poly_to_expr(py, &self.spec.coeffs[i].poly)) + } + + /// ``v_{Φ_d}(S(n + i))`` — the exact ``Φ_d``-adic valuation of the + /// **generic** sum, or ``None`` when ``S(n+i)`` is identically zero. + /// + /// This is the ``q``-supercongruence statement in its exact form: + /// ``v >= r`` is precisely ``Φ_d(q)**r`` divides ``S(n+i)``, and ``v < 0`` + /// is the pole that obstructs specialisation. Available even when the + /// verdict is ``"obstructed"`` — a negative valuation *is* the obstruction. + #[pyo3(signature = (i = 0))] + fn sum_valuation(&self, i: usize) -> PyResult> { + self.spec.sum_valuations.get(i).copied().ok_or_else(|| { + pyo3::exceptions::PyIndexError::new_err(format!("shift index {i} is out of range")) + }) + } + + fn __repr__(&self) -> String { + format!( + "QRootOfUnitySpecialization(d={}, n={}, status={}{}{})", + self.spec.d, + self.spec.n0, + self.spec.status.tag(), + if self.spec.is_vacuous() { + ", vacuous" + } else if self.spec.specializes() && !self.spec.leading_coefficient_survives() { + ", leading coefficient vanishes" + } else { + "" + }, + if self.spec.support_shrinks() { + ", support shrinks" + } else { + "" + } + ) + } +} + +/// `alkahest.experimental.cyclotomic_polynomial(pool, d, var=None) -> Expr` +/// +/// The ``d``-th cyclotomic polynomial ``Φ_d(q)``, monic of degree ``φ(d)``, +/// with exact integer coefficients. +/// +/// This is the modulus the root-of-unity machinery works over: ``Φ_d`` is +/// irreducible over ``Q``, so for a polynomial ``p`` the statement +/// ``p(ζ_d) = 0`` is *exactly* the divisibility ``Φ_d | p``, which is how +/// :meth:`~alkahest.experimental.QZeilbergerCertificate.specialize_at_root_of_unity` +/// decides its hypotheses without evaluating anything numerically. +/// +/// ``var`` names the variable; it defaults to a symbol called ``q``. +#[pyfunction] +#[pyo3(name = "cyclotomic_polynomial", signature = (pool, d, var = None))] +fn py_cyclotomic_polynomial( + py: Python<'_>, + pool: Py, + d: u32, + var: Option>, +) -> PyResult { + if d == 0 { + return Err(PyValueError::new_err( + "the order of a root of unity must be at least 1", + )); + } + if d > alkahest_core::holonomic::qzeil::MAX_CYCLOTOMIC_ORDER { + return Err(PyValueError::new_err(format!( + "the order must be at most {}, got {d}", + alkahest_core::holonomic::qzeil::MAX_CYCLOTOMIC_ORDER + ))); + } + let phi = core_cyclotomic_polynomial(d); + let id = { + let p = pool.borrow(py); + let v = match &var { + Some(v) => v.id, + None => p.inner.symbol("q", alkahest_core::kernel::Domain::Real), + }; + alkahest_core::holonomic::hyperterm::ratuni_to_expr(&p.inner, v, &phi) + }; + Ok(PyExpr { + id, + pool: pool.clone_ref(py), + }) +} + fn q_holonomic_error_to_py(e: CoreQHolonomicError) -> PyErr { Python::with_gil(|py| { let exc_type = py.get_type_bound::(); @@ -11246,7 +11547,8 @@ fn py_cuda_device_count() -> usize { use alkahest_core::{ dae_index_reduce_ranked, expr_to_gbpoly, gbpoly_to_expr, primary_decomposition, radical as core_ideal_radical, rosenfeld_groebner_ranked, DaeIndexReduction, GbPoly, - GroebnerBasis, MonomialOrder, + GroebnerBasis, MonomialOrder, ParamGbPoly, ParamGroebnerBasis, ParamGroebnerError, ParamPoly, + QParam, }; /// A sparse multivariate polynomial over ℚ, as used by the Gröbner machinery. @@ -11532,14 +11834,37 @@ impl PyGroebnerBasis { /// When ``"lex"`` is requested and the ideal is 0-dimensional, the /// grevlex-then-FGLM strategy is used automatically (much faster than /// direct lex Buchberger for 3+ variable systems). + /// params : list[Expr], optional + /// Symbols to put in the **coefficient field** rather than the ring + /// (M9). With ``params`` the computation runs in ``Q(params)[vars]`` + /// instead of ``Q[vars, params]``, and the return type is a + /// :class:`ParametricGroebnerBasis` — the same sequence protocol, plus + /// :meth:`~ParametricGroebnerBasis.conditions` and + /// :meth:`~ParametricGroebnerBasis.specialize`. That class is + /// experimental; :class:`GroebnerBasis` is unchanged when ``params`` is + /// omitted or empty. + /// + /// Example:: + /// + /// # parameters as ring variables — a is a 3rd variable + /// gb = alkahest.GroebnerBasis.compute([a*x - y, x + y - one], [x, y, a]) + /// # parameters in the coefficient field + /// gb = alkahest.GroebnerBasis.compute([a*x - y, x + y - one], [x, y], params=[a]) #[staticmethod] - #[pyo3(signature = (polys, vars, order=None))] + #[pyo3(signature = (polys, vars, order=None, params=None))] fn compute( py: Python<'_>, polys: Vec>, vars: Vec>, order: Option<&str>, - ) -> PyResult { + params: Option>>, + ) -> PyResult { + if let Some(params) = params { + if !params.is_empty() { + let basis = PyParamGroebnerBasis::build(py, polys, vars, params, order)?; + return Ok(Py::new(py, basis)?.into_py(py)); + } + } if polys.is_empty() || vars.is_empty() { return Err(pyo3::exceptions::PyValueError::new_err( "GroebnerBasis.compute requires at least one polynomial and one variable", @@ -11562,11 +11887,15 @@ impl PyGroebnerBasis { MonomialOrder::Lex => GroebnerBasis::compute_lex(gb_polys), other => GroebnerBasis::compute(gb_polys, other), }; - Ok(PyGroebnerBasis { - inner, - pool: Some(pool_py), - var_ids, - }) + Ok(Py::new( + py, + PyGroebnerBasis { + inner, + pool: Some(pool_py), + var_ids, + }, + )? + .into_py(py)) } /// Gröbner basis via Faugère's F5 (signature-based reduction, V2-8). @@ -11827,95 +12156,793 @@ impl PyGroebnerBasis { } } +// --------------------------------------------------------------------------- +// M9 — Gröbner bases over the coefficient field Q(params) +// --------------------------------------------------------------------------- + #[cfg(feature = "groebner")] -fn py_monomial_order_for_dae(order: Option<&str>) -> MonomialOrder { - order - .and_then(MonomialOrder::from_str) - .unwrap_or(MonomialOrder::GRevLex) +fn param_groebner_error_to_py(e: ParamGroebnerError) -> PyErr { + Python::with_gil(|py| { + let exc_type = py.get_type_bound::(); + make_structured_err(py, &exc_type, &e) + }) } -/// V2-13 — result of Rosenfeld–Gröbner-style differential elimination. +/// `ParamPoly` → `Expr` over the parameter symbols. +#[cfg(feature = "groebner")] +fn parampoly_to_expr(p: &ParamPoly, params: &[ExprId], pool: &ExprPool) -> Option { + let mut terms: Vec = Vec::with_capacity(p.terms.len()); + for (exp, coeff) in &p.terms { + let mut factors: Vec = Vec::new(); + for (i, &e) in exp.iter().enumerate() { + if e == 0 { + continue; + } + let v = *params.get(i)?; + factors.push(if e == 1 { + v + } else { + pool.pow(v, pool.integer(e)) + }); + } + if factors.is_empty() || *coeff != 1 { + factors.insert(0, pool.integer(coeff.clone())); + } + terms.push(if factors.len() == 1 { + factors[0] + } else { + pool.mul(factors) + }); + } + Some(match terms.len() { + 0 => pool.integer(0_i32), + 1 => terms[0], + _ => pool.add(terms), + }) +} + +/// `QParam` → `Expr`, as `num` or `num * den**-1`. +#[cfg(feature = "groebner")] +fn qparam_to_expr(c: &QParam, params: &[ExprId], pool: &ExprPool) -> Option { + let num = parampoly_to_expr(c.numerator(), params, pool)?; + if c.denominator().is_one() { + return Some(num); + } + let den = parampoly_to_expr(c.denominator(), params, pool)?; + let inv = pool.pow(den, pool.integer(-1_i32)); + Some(pool.mul(vec![num, inv])) +} + +/// `ParamGbPoly` → `Expr`: the variables carry exponents, the parameters ride +/// in the coefficients. +#[cfg(feature = "groebner")] +fn paramgbpoly_to_expr( + p: &ParamGbPoly, + vars: &[ExprId], + params: &[ExprId], + pool: &ExprPool, +) -> Option { + let mut terms: Vec = Vec::with_capacity(p.terms.len()); + for (exp, coeff) in &p.terms { + let c = qparam_to_expr(coeff, params, pool)?; + let mut factors: Vec = Vec::new(); + for (i, &e) in exp.iter().enumerate() { + if e == 0 { + continue; + } + let v = *vars.get(i)?; + factors.push(if e == 1 { + v + } else { + pool.pow(v, pool.integer(e)) + }); + } + if factors.is_empty() || !coeff.is_one() { + factors.insert(0, c); + } + terms.push(if factors.len() == 1 { + factors[0] + } else { + pool.mul(factors) + }); + } + Some(match terms.len() { + 0 => pool.integer(0_i32), + 1 => terms[0], + _ => pool.add(terms), + }) +} + +/// A polynomial in the ring variables whose coefficients are rational +/// functions of the parameters. /// -/// Returned by :func:`rosenfeld_groebner`. The eliminated relations are in -/// :meth:`final_basis`; read them with ``result.final_basis().to_exprs()``. +/// The parametric counterpart of :class:`GbPoly`: exponent slots name the +/// *variables* only, because the parameters live in the coefficient field. +/// Read it back with :meth:`to_expr` exactly as you would a :class:`GbPoly`. /// /// Attributes /// ---------- -/// consistent : bool -/// ``False`` iff the unit ideal was reached — the system has no common -/// jet solution over ℚ, i.e. the equations are contradictory. -/// truncated : bool -/// ``True`` if prolongation stopped at ``max_prolong_rounds`` rather than -/// because the differential chain saturated. A truncated basis is a -/// *sound* set of consequences of the system but need not be complete, so -/// "not in the basis" does not mean "not a consequence". -/// prolongation_rounds : int -/// Number of prolongation rounds that contributed new relations. +/// is_zero : bool +/// n_vars : int +/// n_params : int +/// n_terms : int #[cfg(feature = "groebner")] -#[pyclass(name = "RosenfeldGroebnerResult")] -struct PyRosenfeldGroebnerResult { - #[pyo3(get)] - consistent: bool, - #[pyo3(get)] - truncated: bool, - #[pyo3(get)] - prolongation_rounds: usize, - working_dae: DAE, - final_basis: Option, - pool: Py, - /// Jet variables indexing the exponent vectors of `final_basis`. +#[pyclass(name = "ParametricGbPoly")] +struct PyParamGbPoly { + inner: ParamGbPoly, + pool: Option>, var_ids: Vec, + param_ids: Vec, } #[cfg(feature = "groebner")] -#[pymethods] -impl PyRosenfeldGroebnerResult { - /// The prolonged :class:`DAE`: the input system plus the derivative jets - /// introduced while differentiating it. - fn working_dae(&self, py: Python<'_>) -> PyDAE { - PyDAE { - inner: self.working_dae.clone(), - pool: self.pool.clone_ref(py), +impl PyParamGbPoly { + fn with_ctx( + py: Python<'_>, + inner: ParamGbPoly, + pool: Option<&Py>, + var_ids: &[ExprId], + param_ids: &[ExprId], + ) -> PyParamGbPoly { + PyParamGbPoly { + inner, + pool: pool.map(|p| p.clone_ref(py)), + var_ids: var_ids.to_vec(), + param_ids: param_ids.to_vec(), } } - /// The jet variables indexing the basis, in exponent-slot order. - /// - /// These are the symbols the elimination actually ran over — the time - /// variable, the declared states and derivatives, and every higher jet - /// (``d2x/dt2``, …) introduced by prolongation. - fn variables(&self, py: Python<'_>) -> Vec { - self.var_ids - .iter() - .map(|&id| PyExpr { - id, - pool: self.pool.clone_ref(py), - }) - .collect() + fn require_pool(&self, what: &str) -> PyResult<&Py> { + self.pool.as_ref().ok_or_else(|| { + pyo3::exceptions::PyValueError::new_err(format!( + "{what} carries no variable context; rebuild it with \ + ParametricGroebnerBasis.compute()" + )) + }) } +} - /// The saturated Gröbner basis, or ``None`` when the system is - /// inconsistent. - /// - /// The returned :class:`GroebnerBasis` knows its variable ordering, so - /// ``final_basis().to_exprs()`` gives the eliminated relations as - /// :class:`Expr`. - /// - /// Example:: - /// - /// r = alkahest.rosenfeld_groebner(dae, max_prolong_rounds=1) - /// for eq in r.final_basis().to_exprs(): - /// print(eq, "= 0") - fn final_basis(&self, py: Python<'_>) -> PyResult>> { - match &self.final_basis { - None => Ok(None), - Some(gb) => Ok(Some(Py::new( - py, - PyGroebnerBasis { - inner: gb.clone(), - pool: Some(self.pool.clone_ref(py)), - var_ids: self.var_ids.clone(), - }, +#[cfg(feature = "groebner")] +#[pymethods] +impl PyParamGbPoly { + /// True if this is the zero polynomial. + #[getter] + fn is_zero(&self) -> bool { + self.inner.is_zero() + } + + /// Number of ring variables. + #[getter] + fn n_vars(&self) -> usize { + self.inner.n_vars + } + + /// Number of parameters in the coefficient field. + #[getter] + fn n_params(&self) -> usize { + self.inner.n_params + } + + /// Number of non-zero terms. + #[getter] + fn n_terms(&self) -> usize { + self.inner.n_terms() + } + + /// The variables naming exponent slots 0, 1, …. + fn variables(&self, py: Python<'_>) -> Vec { + match &self.pool { + None => vec![], + Some(pool) => self + .var_ids + .iter() + .map(|&id| PyExpr { + id, + pool: pool.clone_ref(py), + }) + .collect(), + } + } + + /// The parameters of the coefficient field, in order. + fn parameters(&self, py: Python<'_>) -> Vec { + match &self.pool { + None => vec![], + Some(pool) => self + .param_ids + .iter() + .map(|&id| PyExpr { + id, + pool: pool.clone_ref(py), + }) + .collect(), + } + } + + /// The terms as ``(exponents, coefficient)`` pairs. + /// + /// `exponents` is a tuple of `int` parallel to :meth:`variables`; + /// `coefficient` is an :class:`Expr` in the parameters — a rational + /// function, not necessarily a polynomial. That is the whole difference + /// from :meth:`GbPoly.terms`, whose coefficients are numbers. + fn terms(&self, py: Python<'_>) -> PyResult> { + let pool_py = self.require_pool("ParametricGbPoly")?.clone_ref(py); + let mut out = Vec::with_capacity(self.inner.terms.len()); + { + let pool = pool_py.borrow(py); + for (exp, coeff) in &self.inner.terms { + let exps = pyo3::types::PyTuple::new_bound(py, exp.iter().map(|&e| e as u64)); + let id = qparam_to_expr(coeff, &self.param_ids, &pool.inner).ok_or_else(|| { + pyo3::exceptions::PyValueError::new_err( + "coefficient mentions more parameters than were named", + ) + })?; + out.push(( + exps.into_py(py), + PyExpr { + id, + pool: pool_py.clone_ref(py), + }, + )); + } + } + Ok(out) + } + + /// Convert back to an :class:`Expr`, coefficients and all. + /// + /// Denominators appear as ``den**-1`` factors, so the result is a rational + /// expression in the parameters and a polynomial in the variables. + /// + /// Example:: + /// + /// gb = alkahest.GroebnerBasis.compute([a*x - y, x + y - 1], [x, y], params=[a]) + /// [g.to_expr() for g in gb] + fn to_expr(&self, py: Python<'_>) -> PyResult { + let pool_py = self.require_pool("ParametricGbPoly")?.clone_ref(py); + let id = { + let pool = pool_py.borrow(py); + paramgbpoly_to_expr(&self.inner, &self.var_ids, &self.param_ids, &pool.inner) + }; + match id { + Some(id) => Ok(PyExpr { id, pool: pool_py }), + None => Err(pyo3::exceptions::PyValueError::new_err( + "polynomial is over more variables or parameters than were named", + )), + } + } + + /// Substitute rational values for the parameters, giving a :class:`GbPoly`. + /// + /// Raises `ParamGroebnerError` (``E-PARAMGB-004``) when a coefficient has a + /// pole at that point. + fn specialize(&self, py: Python<'_>, values: Vec>) -> PyResult { + let vals = values + .iter() + .map(py_to_rational) + .collect::>>()?; + if vals.len() != self.inner.n_params { + return Err(param_groebner_error_to_py(ParamGroebnerError::WrongArity { + expected: self.inner.n_params, + got: vals.len(), + })); + } + let p = self.inner.specialize(&vals).ok_or_else(|| { + param_groebner_error_to_py(ParamGroebnerError::Degenerate { vanishing: vec![] }) + })?; + Ok(PyGbPoly::with_ctx(py, p, self.pool.as_ref(), &self.var_ids)) + } + + fn __repr__(&self) -> String { + format!( + "ParametricGbPoly(n_terms={}, n_params={})", + self.inner.n_terms(), + self.inner.n_params + ) + } +} + +/// A Gröbner basis computed with the parameters in the **coefficient field**. +/// +/// `GroebnerBasis.compute(polys, vars)` puts everything in `Q[vars]`, so a +/// parameter has to be declared as one more ring variable; the elimination then +/// runs in `Q[vars, params]`. Here the parameters are moved into `Q(params)` +/// instead: they never enter the monomial order, never generate S-pairs, and +/// never enlarge the staircase. For differential elimination — reading the +/// input-output equations of an ODE model out of a state elimination — that is +/// the difference between a computation that finishes and one that does not. +/// +/// The object is a **sequence** of :class:`ParametricGbPoly`, like +/// :class:`GroebnerBasis`, and supports the same `len()` / indexing / +/// iteration / :meth:`to_exprs` / :meth:`eliminate` read path. +/// +/// **The result is generic.** A leading coefficient can be a non-zero element +/// of `Q(params)` and still vanish for particular parameter values, and there +/// the basis says nothing. :meth:`conditions` lists the polynomials whose +/// non-vanishing was assumed; the basis holds at exactly the parameter points +/// where none of them vanishes. :meth:`specialize` refuses on the rest rather +/// than returning something that is not a basis. +/// +/// Attributes +/// ---------- +/// order : str +/// Monomial order: ``"lex"``, ``"grlex"`` or ``"grevlex"``. +/// n_params : int +/// Number of parameters in the coefficient field. +/// +/// Example:: +/// +/// gb = alkahest.GroebnerBasis.compute([a*x - y, x + y - one], [x, y], params=[a]) +/// [g.to_expr() for g in gb] # coefficients are rational in `a` +/// [c for c in gb.conditions()] # [a + 1] — the basis is silent at a = -1 +/// gb.specialize([3]) # an ordinary GroebnerBasis over ℚ +#[cfg(feature = "groebner")] +#[pyclass(name = "ParametricGroebnerBasis")] +struct PyParamGroebnerBasis { + inner: ParamGroebnerBasis, + pool: Py, + var_ids: Vec, + param_ids: Vec, +} + +#[cfg(feature = "groebner")] +impl PyParamGroebnerBasis { + fn wrap(&self, py: Python<'_>, p: ParamGbPoly) -> PyParamGbPoly { + PyParamGbPoly::with_ctx(py, p, Some(&self.pool), &self.var_ids, &self.param_ids) + } + + /// Shared constructor for `ParametricGroebnerBasis.compute` and for + /// `GroebnerBasis.compute(..., params=[...])`. + fn build( + py: Python<'_>, + polys: Vec>, + vars: Vec>, + params: Vec>, + order: Option<&str>, + ) -> PyResult { + if polys.is_empty() || vars.is_empty() { + return Err(pyo3::exceptions::PyValueError::new_err( + "a parametric Gröbner basis needs at least one polynomial and one variable", + )); + } + let var_ids: Vec = vars.iter().map(|v| v.id).collect(); + let param_ids: Vec = params.iter().map(|p| p.id).collect(); + if let Some(clash) = param_ids.iter().find(|p| var_ids.contains(p)) { + let _ = clash; + return Err(pyo3::exceptions::PyValueError::new_err( + "a symbol cannot be both a ring variable and a coefficient-field parameter", + )); + } + let mut all_ids = var_ids.clone(); + all_ids.extend_from_slice(¶m_ids); + + let pool_py = polys[0].pool.clone_ref(py); + let mut gens = Vec::with_capacity(polys.len()); + { + let pool = pool_py.borrow(py); + for p in &polys { + let gbp = expr_to_gbpoly(p.id, &all_ids, &pool.inner) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let pg = ParamGbPoly::from_gbpoly(&gbp, var_ids.len(), param_ids.len()) + .ok_or_else(|| { + pyo3::exceptions::PyValueError::new_err( + "internal: polynomial arity does not match vars + params", + ) + })?; + gens.push(pg); + } + } + let parsed_order = order + .and_then(MonomialOrder::from_str) + .unwrap_or(MonomialOrder::Lex); + let inner = + ParamGroebnerBasis::compute(gens, parsed_order).map_err(param_groebner_error_to_py)?; + Ok(PyParamGroebnerBasis { + inner, + pool: pool_py, + var_ids, + param_ids, + }) + } + + fn rational_values(&self, values: &[Bound<'_, PyAny>]) -> PyResult> { + let vals = values + .iter() + .map(py_to_rational) + .collect::>>()?; + if vals.len() != self.inner.n_params() { + return Err(param_groebner_error_to_py(ParamGroebnerError::WrongArity { + expected: self.inner.n_params(), + got: vals.len(), + })); + } + Ok(vals) + } +} + +#[cfg(feature = "groebner")] +#[pymethods] +impl PyParamGroebnerBasis { + /// Compute a Gröbner basis in ``Q(params)[vars]``. + /// + /// Parameters + /// ---------- + /// polys : list[Expr] + /// Polynomial expressions, each meaning ``p = 0``. They must be + /// polynomial in *vars* and in *params*. + /// vars : list[Expr] + /// The ring variables — the ones the monomial order sees. + /// params : list[Expr] + /// Symbols to place in the coefficient field. Must be disjoint from + /// *vars*. + /// order : str, optional + /// ``"lex"`` (default), ``"grevlex"`` or ``"grlex"``. Lex is what + /// elimination needs: order the variables to eliminate first. + #[staticmethod] + #[pyo3(signature = (polys, vars, params, order=None))] + fn compute( + py: Python<'_>, + polys: Vec>, + vars: Vec>, + params: Vec>, + order: Option<&str>, + ) -> PyResult { + PyParamGroebnerBasis::build(py, polys, vars, params, order) + } + + /// The monomial order the generators are reduced under. + #[getter] + fn order(&self) -> &'static str { + self.inner.order().as_str() + } + + /// Number of parameters in the coefficient field. + #[getter] + fn n_params(&self) -> usize { + self.inner.n_params() + } + + /// The ring variables naming exponent slots 0, 1, …. + fn variables(&self, py: Python<'_>) -> Vec { + self.var_ids + .iter() + .map(|&id| PyExpr { + id, + pool: self.pool.clone_ref(py), + }) + .collect() + } + + /// The parameters of the coefficient field, in order. + fn parameters(&self, py: Python<'_>) -> Vec { + self.param_ids + .iter() + .map(|&id| PyExpr { + id, + pool: self.pool.clone_ref(py), + }) + .collect() + } + + /// The polynomials in the parameters whose non-vanishing this basis + /// assumed, as :class:`Expr`. + /// + /// Each is irreducible, primitive and has a positive leading coefficient. + /// The basis is valid at exactly the parameter points where **none** of + /// them vanishes; the degeneracy locus is the union of the hypersurfaces + /// they cut out. An empty list means the basis holds everywhere. + /// + /// The list is *sufficient, not necessary* — a point on the locus may still + /// be fine, but this computation cannot see that, and the honest report is + /// the hypothesis it actually used. + /// + /// Example:: + /// + /// gb = alkahest.GroebnerBasis.compute([a*x - y, x + y - one], [x, y], params=[a]) + /// [str(c) for c in gb.conditions()] # ['a + 1'] + fn conditions(&self, py: Python<'_>) -> PyResult> { + let pool = self.pool.borrow(py); + let mut out = Vec::with_capacity(self.inner.conditions().len()); + for c in self.inner.conditions() { + let id = parampoly_to_expr(c, &self.param_ids, &pool.inner).ok_or_else(|| { + pyo3::exceptions::PyValueError::new_err( + "condition mentions more parameters than were named", + ) + })?; + out.push(PyExpr { + id, + pool: self.pool.clone_ref(py), + }); + } + Ok(out) + } + + /// The conditions that vanish at *values* — empty exactly when the basis + /// applies at that parameter point. + fn vanishing_conditions( + &self, + py: Python<'_>, + values: Vec>, + ) -> PyResult> { + let vals = self.rational_values(&values)?; + let pool = self.pool.borrow(py); + let mut out = Vec::new(); + for c in self.inner.vanishing_conditions(&vals) { + let id = parampoly_to_expr(&c, &self.param_ids, &pool.inner).ok_or_else(|| { + pyo3::exceptions::PyValueError::new_err( + "condition mentions more parameters than were named", + ) + })?; + out.push(PyExpr { + id, + pool: self.pool.clone_ref(py), + }); + } + Ok(out) + } + + /// True when *values* lies off the degeneracy locus. + fn is_regular_at(&self, values: Vec>) -> PyResult { + let vals = self.rational_values(&values)?; + Ok(self.inner.is_regular_at(&vals)) + } + + /// Substitute rational values for the parameters, giving an ordinary + /// :class:`GroebnerBasis` over ℚ. + /// + /// Off the degeneracy locus the result is exactly what + /// :meth:`GroebnerBasis.compute` would return for the specialised system. + /// On it, this raises `ParamGroebnerError` with ``.code == + /// "E-PARAMGB-004"`` rather than handing back something that is not a + /// basis — check first with :meth:`is_regular_at` if that is a normal + /// outcome for your caller. + fn specialize( + &self, + py: Python<'_>, + values: Vec>, + ) -> PyResult { + let vals = self.rational_values(&values)?; + let gens = self + .inner + .specialize(&vals) + .map_err(param_groebner_error_to_py)?; + Ok(PyGroebnerBasis { + inner: GroebnerBasis::from_generators(gens, self.inner.order()), + pool: Some(self.pool.clone_ref(py)), + var_ids: self.var_ids.clone(), + }) + } + + /// The elimination ideal `I ∩ Q(params)[remaining vars]`. + /// + /// Same contract as :meth:`GroebnerBasis.eliminate`: under a ``"lex"`` + /// basis with the eliminated variables ordered **first**, the generators + /// free of them generate the elimination ideal. The conditions travel with + /// the result — eliminating does not make the hypotheses go away. + fn eliminate( + &self, + py: Python<'_>, + vars: Vec>, + ) -> PyResult { + let mut indices = Vec::with_capacity(vars.len()); + for v in &vars { + match self.var_ids.iter().position(|&id| id == v.id) { + Some(i) => indices.push(i), + None => { + return Err(pyo3::exceptions::PyValueError::new_err( + "eliminate() was given a symbol this basis is not written over; \ + parameters cannot be eliminated — they are in the coefficient field", + )) + } + } + } + Ok(PyParamGroebnerBasis { + inner: self.inner.eliminate(&indices), + pool: self.pool.clone_ref(py), + var_ids: self.var_ids.clone(), + param_ids: self.param_ids.clone(), + }) + } + + /// The generators as :class:`ParametricGbPoly`. Equivalent to ``list(basis)``. + fn polynomials(&self, py: Python<'_>) -> Vec { + self.inner + .generators() + .iter() + .map(|p| self.wrap(py, p.clone())) + .collect() + } + + /// The generators as :class:`Expr`, each meaning ``g = 0``. + fn to_exprs(&self, py: Python<'_>) -> PyResult> { + let ids: Option> = { + let pool = self.pool.borrow(py); + self.inner + .generators() + .iter() + .map(|g| paramgbpoly_to_expr(g, &self.var_ids, &self.param_ids, &pool.inner)) + .collect() + }; + match ids { + Some(ids) => Ok(ids + .into_iter() + .map(|id| PyExpr { + id, + pool: self.pool.clone_ref(py), + }) + .collect()), + None => Err(pyo3::exceptions::PyValueError::new_err( + "basis is over more variables or parameters than were named", + )), + } + } + + /// Reduce a polynomial modulo this basis; the remainder is a + /// :class:`ParametricGbPoly`. + /// + /// Accepts a :class:`ParametricGbPoly` or an :class:`Expr`. + fn reduce(&self, py: Python<'_>, p: &Bound<'_, PyAny>) -> PyResult { + let poly = self.coerce(py, p)?; + Ok(self.wrap(py, self.inner.reduce(&poly))) + } + + /// Ideal membership: true exactly when :meth:`reduce` gives zero. + fn contains(&self, py: Python<'_>, p: &Bound<'_, PyAny>) -> PyResult { + let poly = self.coerce(py, p)?; + Ok(self.inner.contains(&poly)) + } + + fn __len__(&self) -> usize { + self.inner.len() + } + + /// Generator `i`; negative indices count from the end. + fn __getitem__(&self, py: Python<'_>, index: isize) -> PyResult { + let n = self.inner.len() as isize; + let i = if index < 0 { index + n } else { index }; + if i < 0 || i >= n { + return Err(pyo3::exceptions::PyIndexError::new_err( + "ParametricGroebnerBasis index out of range", + )); + } + Ok(self.wrap(py, self.inner.generators()[i as usize].clone())) + } + + /// Iterate over the generators. + fn __iter__(&self, py: Python<'_>) -> PyResult { + let list = pyo3::types::PyList::empty_bound(py); + for p in self.inner.generators() { + list.append(Py::new(py, self.wrap(py, p.clone()))?)?; + } + Ok(list.as_any().iter()?.into_py(py)) + } + + fn __repr__(&self) -> String { + format!( + "ParametricGroebnerBasis(n_generators={}, n_params={}, n_conditions={})", + self.inner.len(), + self.inner.n_params(), + self.inner.conditions().len() + ) + } +} + +#[cfg(feature = "groebner")] +impl PyParamGroebnerBasis { + /// Accept a `ParametricGbPoly` as-is, or convert an `Expr` against this + /// basis's variable and parameter lists. + fn coerce(&self, py: Python<'_>, p: &Bound<'_, PyAny>) -> PyResult { + if let Ok(pg) = p.downcast::() { + return Ok(pg.borrow().inner.clone()); + } + if let Ok(expr) = p.downcast::() { + let mut all_ids = self.var_ids.clone(); + all_ids.extend_from_slice(&self.param_ids); + let pool = self.pool.borrow(py); + let gbp = expr_to_gbpoly(expr.borrow().id, &all_ids, &pool.inner) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + return ParamGbPoly::from_gbpoly(&gbp, self.var_ids.len(), self.param_ids.len()) + .ok_or_else(|| { + pyo3::exceptions::PyValueError::new_err( + "internal: polynomial arity does not match vars + params", + ) + }); + } + Err(pyo3::exceptions::PyTypeError::new_err( + "expected a ParametricGbPoly or an Expr", + )) + } +} + +#[cfg(feature = "groebner")] +fn py_monomial_order_for_dae(order: Option<&str>) -> MonomialOrder { + order + .and_then(MonomialOrder::from_str) + .unwrap_or(MonomialOrder::GRevLex) +} + +/// V2-13 — result of Rosenfeld–Gröbner-style differential elimination. +/// +/// Returned by :func:`rosenfeld_groebner`. The eliminated relations are in +/// :meth:`final_basis`; read them with ``result.final_basis().to_exprs()``. +/// +/// Attributes +/// ---------- +/// consistent : bool +/// ``False`` iff the unit ideal was reached — the system has no common +/// jet solution over ℚ, i.e. the equations are contradictory. +/// truncated : bool +/// ``True`` if prolongation stopped at ``max_prolong_rounds`` rather than +/// because the differential chain saturated. A truncated basis is a +/// *sound* set of consequences of the system but need not be complete, so +/// "not in the basis" does not mean "not a consequence". +/// prolongation_rounds : int +/// Number of prolongation rounds that contributed new relations. +#[cfg(feature = "groebner")] +#[pyclass(name = "RosenfeldGroebnerResult")] +struct PyRosenfeldGroebnerResult { + #[pyo3(get)] + consistent: bool, + #[pyo3(get)] + truncated: bool, + #[pyo3(get)] + prolongation_rounds: usize, + working_dae: DAE, + final_basis: Option, + pool: Py, + /// Jet variables indexing the exponent vectors of `final_basis`. + var_ids: Vec, +} + +#[cfg(feature = "groebner")] +#[pymethods] +impl PyRosenfeldGroebnerResult { + /// The prolonged :class:`DAE`: the input system plus the derivative jets + /// introduced while differentiating it. + fn working_dae(&self, py: Python<'_>) -> PyDAE { + PyDAE { + inner: self.working_dae.clone(), + pool: self.pool.clone_ref(py), + } + } + + /// The jet variables indexing the basis, in exponent-slot order. + /// + /// These are the symbols the elimination actually ran over — the time + /// variable, the declared states and derivatives, and every higher jet + /// (``d2x/dt2``, …) introduced by prolongation. + fn variables(&self, py: Python<'_>) -> Vec { + self.var_ids + .iter() + .map(|&id| PyExpr { + id, + pool: self.pool.clone_ref(py), + }) + .collect() + } + + /// The saturated Gröbner basis, or ``None`` when the system is + /// inconsistent. + /// + /// The returned :class:`GroebnerBasis` knows its variable ordering, so + /// ``final_basis().to_exprs()`` gives the eliminated relations as + /// :class:`Expr`. + /// + /// Example:: + /// + /// r = alkahest.rosenfeld_groebner(dae, max_prolong_rounds=1) + /// for eq in r.final_basis().to_exprs(): + /// print(eq, "= 0") + fn final_basis(&self, py: Python<'_>) -> PyResult>> { + match &self.final_basis { + None => Ok(None), + Some(gb) => Ok(Some(Py::new( + py, + PyGroebnerBasis { + inner: gb.clone(), + pool: Some(self.pool.clone_ref(py)), + var_ids: self.var_ids.clone(), + }, )?)), } } @@ -13769,7 +14796,9 @@ fn alkahest(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(py_zeilberger, m)?)?; // M4(b) — q-analogue creative telescoping m.add_class::()?; + m.add_class::()?; m.add_function(wrap_pyfunction!(py_q_zeilberger, m)?)?; + m.add_function(wrap_pyfunction!(py_cyclotomic_polynomial, m)?)?; // M6 — modular / p-adic evaluation of holonomic sequences m.add_class::()?; m.add_class::()?; @@ -13950,6 +14979,9 @@ fn alkahest(m: &Bound<'_, PyModule>) -> PyResult<()> { { m.add_class::()?; m.add_class::()?; + // M9 — coefficient fields for elimination + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; @@ -14084,6 +15116,11 @@ fn alkahest(m: &Bound<'_, PyModule>) -> PyResult<()> { "DiophantineError", m.py().get_type_bound::(), )?; + #[cfg(feature = "groebner")] + m.add( + "ParamGroebnerError", + m.py().get_type_bound::(), + )?; // P1 search plumbing item 4 — budgets, cancellation, determinism m.add( "BudgetExceededError", diff --git a/alkahest-skill/alkahest.md b/alkahest-skill/alkahest.md index 59c40017..7586d3a3 100644 --- a/alkahest-skill/alkahest.md +++ b/alkahest-skill/alkahest.md @@ -586,6 +586,23 @@ for s in solutions: # Certified enclosures / residuals: solve_numerical(eqs, vars, ...) ``` +### Coefficient fields for elimination: `Q(params)` (M9, experimental) + +`GroebnerBasis.compute(polys, vars, params=[...])` puts the listed symbols in the **coefficient field** instead of the ring — they never enter the monomial order or generate S-pairs. This is the difference between eliminating states from `Q[states, Y, params]` and `Q(params)[states, Y]`: for a differential-elimination / structural-identifiability problem (states eliminated from an ODE model's jet equations, rate constants left symbolic), the parametric route can be an order of magnitude faster and produce far fewer basis generators than treating the parameters as ring variables — see `docs/mdbook/src/solving.md` for measured numbers on a worked example. + +```python +gb = alkahest.GroebnerBasis.compute([a*x - y, x + y - one], [x, y], params=[a]) +type(gb) # ParametricGroebnerBasis (alkahest.experimental) +[g.to_expr() for g in gb] # coefficients are rational functions of a + +gb.conditions() # [a + 1] — hypersurfaces the basis assumed non-zero +gb.is_regular_at([-1]) # False +gb.specialize([3]) # ordinary GroebnerBasis over Q +gb.specialize([-1]) # raises ParamGroebnerError, code "E-PARAMGB-004" +``` + +**The basis is generic, not universal.** A leading coefficient in `Q(params)` can be non-zero as a rational function and still vanish at a specific parameter point; `conditions()` reports that locus (sufficient, not necessary — it can flag a point that turns out fine, never miss one that is genuinely wrong), and `specialize` refuses on it rather than returning something that is not a basis. Reads back the same way `GroebnerBasis` does — `ParametricGbPoly.to_expr()` / `.terms()`, `ParametricGroebnerBasis.to_exprs()` — so nothing here is write-only. `eliminate(vars)` has the same `Lex`-eliminated-variables-first contract and refuses to eliminate a coefficient-field parameter. `alkahest.experimental.ParametricGroebnerBasis.compute(polys, vars, params, order=None)` is the equivalent direct constructor. Requires `--features groebner` (default in all PyPI wheels); the class itself is experimental. + --- ## JIT compilation and numeric evaluation @@ -1408,10 +1425,16 @@ reg.coverage_report_markdown() # same, rendered as a Markdown table 23. **`asymptotics_from_recurrence` separates what the recurrence *proves* from what the terms *fitted*** (since 3.9). Hand it a `ZeilbergerCertificate`, a `GuessedRecurrence`, or a bare list of coefficient polynomials and it returns `growth_rate` / `polynomial_exponent` — derived by Poincaré–Perron, and **exact** as `growth_rate_exact` / `polynomial_exponent_exact` when the root is rational — plus `connection_constant`, which is **fitted** from the terms and is not implied by the recurrence at all. Quote the constant only with `connection_constant_converged`; `evidence()` returns the two halves under separate `derived` / `fitted` keys for exactly this reason. `verdict != "single_dominant_root"` means the hypotheses failed (`equal_modulus_roots`, `repeated_dominant_root`, `degenerate_leading_coefficient`, `eventually_zero`) and `growth_rate` is `None` — no root is reported as if it had won. `follows_dominant_root is False` is a real answer, not an error: the sequence's dominant component vanishes and it grows more slowly than the recurrence's generic solution. -24. **`q`-sums need `experimental.q_zeilberger`, not `zeilberger`** (since 3.9). Gaussian binomials and `q`-Pochhammer symbols are *not* proper hypergeometric terms in `(n,k)`, so `zeilberger` refuses them with `E-HOLO-001` — correctly, and that refusal is not a statement about the sum. Build the summand with `qbinomial(pool, N, K)` / `qpochhammer(pool, u, d, v)` and call `q_zeilberger(term, q, n, k)`. Three things differ from the classical engine and all three matter: `cert.boundary` is **two-valued** (`"vanishes"` or `"unknown"` — there is no inhomogeneous arm, so an unbounded summand yields no claim at all); the sum it is about is `S(n) = Σ_{k ∈ Z} F(n,k)`, a finite sum over the proved window in `cert.support`; and **`q` is transcendental**, so a verdict is an identity in `Q(q)` and does *not* license specialising `q` to a root of unity — the step `q`-supercongruence work depends on. `cert.sum_term(n0)` gives the exact `q`-series value from the definition of the `q`-Pochhammer symbol, so check a returned recurrence against it rather than trusting the certificate alone. `E-HOLO-024` is a permanent refusal, not a budget one: the input is in the shape of the class but its shift quotient is an infinite product (e.g. `(q; q**2)_k` shifted in `k`). +24. **`q`-sums need `experimental.q_zeilberger`, not `zeilberger`** (since 3.9). Gaussian binomials and `q`-Pochhammer symbols are *not* proper hypergeometric terms in `(n,k)`, so `zeilberger` refuses them with `E-HOLO-001` — correctly, and that refusal is not a statement about the sum. Build the summand with `qbinomial(pool, N, K)` / `qpochhammer(pool, u, d, v)` and call `q_zeilberger(term, q, n, k)`. Three things differ from the classical engine and all three matter: `cert.boundary` is **two-valued** (`"vanishes"` or `"unknown"` — there is no inhomogeneous arm, so an unbounded summand yields no claim at all); the sum it is about is `S(n) = Σ_{k ∈ Z} F(n,k)`, a finite sum over the proved window in `cert.support`; and **`q` is transcendental**, so a verdict is an identity in `Q(q)` and does *not* license specialising `q` to a root of unity — the step `q`-supercongruence work depends on, taken separately by `cert.specialize_at_root_of_unity` (item 29). `cert.sum_term(n0)` gives the exact `q`-series value from the definition of the `q`-Pochhammer symbol, so check a returned recurrence against it rather than trusting the certificate alone. `E-HOLO-024` is a permanent refusal, not a budget one: the input is in the shape of the class but its shift quotient is an infinite product (e.g. `(q; q**2)_k` shifted in `k`). 25. **Evaluate a holonomic sequence mod `p^k` with `ModularRecurrence`, not big integers** (since 3.9). `ModularRecurrence(coeffs, initial, *, rhs=None, start=0).value_mod(n, p, k)` runs `Σ_i a_i(n)·S(n+i) = b(n)` forward in `Z/p^K` — machine words, `O(1)` memory — instead of building an `S(n)` with `Θ(n)` digits and reducing it. `coeffs[i]` is lowest-degree-first, the convention `GuessedRecurrence.coeffs` already returns, so *guess → certify → sweep* composes with no reshaping. `supercongruence_sweep(rec, primes, k, index=…, expect=…)` is the loop; its `sharp` is the only thing a sweep can actually settle (some prime hits `v_p` exactly `k`, so `p^(k+1)` is **false**), and `holds` is falsification failing, not a proof. Measured on Apéry `A(p−1) mod p⁴` for the 237 primes below 1500: 95 ms against 3.47 s for the incremental-binomial route, and the gap widens quadratically. 26. **A singular index is the failure mode to plan for, and it is reported, not hidden** (since 3.9). Stepping forward divides by `a_J(n)`, which need not be a unit mod `p` — for Apéry `a_2(n) = (n+2)³` vanishes at every `n ≡ −2 (mod p)`, exactly the index a sweep crosses to reach `A(p)`. Alkahest measures the total `p`-adic precision loss before computing anything and runs the forward pass at `p^(k+loss)`; `ModularEvaluation.singular_indices()` and `.working_precision` say what it cost. Three refusals, none of which ever return a residue instead: `E-HOLO-006` (modulus not a supported prime power), `E-HOLO-007` (**permanent** — the step has no `p`-adic integer answer: `a_J(n) = 0` there, or the sequence leaves `Z_p` as `H_p = H_{p−1} + 1/p` does), `E-HOLO-008` (**resource** — `k + loss` needs a modulus past `2**62`; record `unknown`, and note that `supercongruence_sweep` puts these in `skipped()` and carries on rather than counting them as successes). 27. **`binomial_mod(a, b, p, k)` is Lucas at `k = 1` and Granville above it** (since 3.9). Cost is `O(p·k³ + log_p(a)·p·k)`, so `a` far larger than `p` is the ordinary case, not the hard one; `b > a` and `b < 0` return `0` rather than raising. Refuses with `E-HOLO-006` for a composite base or `p**k >= 2**62`, and `E-HOLO-008` when the one pass over `1 … p−1` is unaffordable. + +28. **Check a fitted recurrence against OEIS with `experimental.novelty.check_novelty` before calling it new** (since 3.9). Build `RecurrenceClaim.from_recurrence(cert_or_guess, var=n)` from a `ZeilbergerCertificate` or `GuessedRecurrence`; it normalises away rescaling, sign flips, index shifts and a common polynomial factor, so `claim_hash` is equal for two presentations of the same relation and different for genuinely different ones. `check_novelty(claim, sources, terms=…)` returns a `NoveltyVerdict` whose `found` is **three-valued**, exactly like `relation_confidence`'s `credible`: `True` a source states the claim, `False` means *not found in the sources actually searched* — not "novel" — and `None` means no source could answer. There is no `novel` attribute anywhere on the type and `bool(verdict)` raises, so `if check_novelty(...):` cannot compile into the overclaim this API exists to prevent; branch on `verdict.status` (`"recorded"`, `"recorded_conjecturally"`, `"not_found"`, `"unavailable"`) or `verdict.found`. `verdict.hedged` is the difference between OEIS stating a recurrence as a theorem and marking it `Conjecture`/`Empirical` — restating the latter is not a result, proving it is. Sources are explicit and there is no default: `OeisCache` (file-backed, offline, what every test in this repository uses) or `OeisWeb` (opt-in, rate-limited, serves its cache first, degrades to `unavailable` rather than raising when there is no network) — pass `[cache]` or `[cache, web]` yourself. `RecurrenceClaim.from_text` parses OEIS's own `a(n) = …` formula lines and returns `None`, never a guess, for anything outside a homogeneous linear recurrence with polynomial coefficients (a sum, a generating function, another sequence, an inhomogeneous relation). + +29. **`cert.specialize_at_root_of_unity(d, n)` is the decision that carries a `q_zeilberger` verdict to `q = ζ_d`, and it is three-valued** (since 3.9). A proved `Q(q)` recurrence does not by itself license setting `q` to a primitive `d`-th root of unity — a coefficient or a sum value can have a pole there, and specialising anyway is the `q`-analogue of the A279013 failure (item 22): a certificate that re-checks perfectly while the specialised claim is false. The hypotheses (no pole in any `a_i(qⁿ)` or `S(n+i)` at `ζ_d`) are decided **exactly**, by polynomial divisibility by `Φ_d(q)` over `Q` in the cyclotomic field `Q(ζ_d) = Q[q]/(Φ_d(q))` — never numerically — and `cyclotomic_polynomial(pool, d)` exposes `Φ_d(q)` itself so a caller can redo the check by hand. `status` is `"specializes"` (proved, and re-checked as an exact identity in `Q(ζ_d)` before being returned), `"obstructed"` (a pole was **exhibited** — `sum_value`/`coefficient` raise, but `sum_valuation(i)` is still available since the negative valuation *is* the obstruction — and this is not a claim the specialised identity is false, only that this route is blocked), or `"unknown"` (the generic boundary verdict was already `"unknown"`, so there is nothing to specialise). Three things a `"specializes"` verdict does **not** by itself mean, each with its own accessor: `is_vacuous` (every coefficient died — always true at `d = 1`, the `q → 1` limit — so the recurrence is `0 = 0`, still true, but empty), `leading_coefficient_survives` (`False` means the specialised recurrence no longer determines the last value from the earlier ones), and `support_shrinks` (`q`-Lucas killing terms — `[2;1]_q = 1 + q` is non-zero in `Q(q)` and zero at `ζ_2` — reported via `effective_support`, which can shrink but never grow). `sum_valuation(i)` is the `q`-supercongruence content itself: the exact integer `v` with `Φ_d(q)^v ∥ S(n+i)`, so `v ≥ r` is precisely `Φ_d(q)^r | S(n)`. + +30. **`sos_decompose` now tries the full PSD Gram cone and a Reznick multiplier search before refusing, but still cannot certify the hardest classical boundary cases** (since 3.9). Past diagonal dominance (`E-SOS-002` from DSOS alone) it searches the general PSD Gram cone, and past that — when `p` itself is not SOS — tries `(x_1²+…+x_n²)^N·p` for `N = 1..4` and searches *that* cone; a witness for `p < 0` still refuses separately with `E-SOS-003`, unaffected. Every certificate this returns is exact end to end: the numeric search only ever proposes a Gram matrix, which is rounded to nearby rationals and re-expanded to check it equals the target exactly before anything is returned — a `Some`/returned certificate is always sound regardless of what the float search converged to. Budget exhaustion is still `E-SOS-002`, undecided, never "not SOS" — say so, don't paraphrase it as a disproof. **What it does not yet do:** Motzkin's polynomial and Robinson's form — the textbook PSD-not-SOS examples whose multiplier certificates are *singular* Gram matrices sitting exactly on the boundary of the PSD cone — are not found by the current search; it's a diagnosed convergence limitation of the annealed alternating-projection method on tangential intersections (verified sound on a planted boundary example of the same size), not a soundness bug, and the tests record `undecided` rather than a false certificate. Raise `basis_degree`, or fall back to `alkahest.decide`, exactly as for any other `E-SOS-002`. diff --git a/docs/features.md b/docs/features.md index 6bb3054e..74fcb480 100644 --- a/docs/features.md +++ b/docs/features.md @@ -95,6 +95,7 @@ Current stable feature surface. - Regular chains / triangular decomposition (`triangularize`, `RegularChain`) - Primary decomposition and radical (`primary_decomposition`, `radical`) - Differential algebra / Rosenfeld–Gröbner for polynomial DAEs (`rosenfeld_groebner`) +- Gröbner bases over the coefficient field `Q(params)` (`GroebnerBasis.compute(polys, vars, params=[...])`, experimental `ParametricGroebnerBasis` / `ParametricGbPoly`) — parameters live in the coefficients, not the ring, so they never enter the monomial order or generate S-pairs; the basis is generic and reports the parameter hypersurfaces (`conditions()`) it assumed non-zero - Numerical algebraic geometry: total-degree homotopy continuation with Smale certification (`solve_numerical`, `CertifiedSolution`) - Eigenvalues, eigenvectors, diagonalization for symbolic matrices (`eigenvals`, `eigenvects`, `diagonalize`) diff --git a/docs/mdbook/src/SUMMARY.md b/docs/mdbook/src/SUMMARY.md index e61194fc..3db7ebac 100644 --- a/docs/mdbook/src/SUMMARY.md +++ b/docs/mdbook/src/SUMMARY.md @@ -32,6 +32,7 @@ - [Creative telescoping (Zeilberger)](./telescoping.md) - [Guessing recurrences](./guessing.md) - [Supercongruences: sequences modulo p^k](./supercongruences.md) + - [Novelty filtering against OEIS](./novelty.md) - [Ansatz families and conjecture generation](./ansatz.md) - [Cross-CAS differential testing](./crosscheck.md) - [SMT/SAT bridge](./smt.md) diff --git a/docs/mdbook/src/novelty.md b/docs/mdbook/src/novelty.md new file mode 100644 index 00000000..934249bb --- /dev/null +++ b/docs/mdbook/src/novelty.md @@ -0,0 +1,160 @@ +# Novelty filtering against OEIS + +A search loop over this library can rediscover a known identity within the +hour — the mathematics is not the hard part. The difference between "produced +400 certified recurrences" and "produced three that nobody had" is a filter +that puts every claim into a canonical form, hashes it, and asks whether it is +already written down somewhere *before* anything calls it a finding. +`alkahest.experimental.novelty` is that filter, for P-recursive recurrences +checked against [OEIS](https://oeis.org). + +## Normal form and the hash + +`RecurrenceClaim` takes a recurrence `Σ_i p_i(n)·u(n+i) = 0` and quotients out +everything that is presentation rather than content: + +```python +from alkahest.experimental.novelty import RecurrenceClaim + +# (n+1)·u(n+1) − (4n+2)·u(n) = 0 — central binomial coefficients +a = RecurrenceClaim([(-2, -4), (1, 1)]) + +# the same relation, scaled by −2, stated about u(n+7) and u(n+8) +b = RecurrenceClaim([(-60, -8), (16, 2)], offset=7) + +a.claim_hash == b.claim_hash # True +``` + +Four things are quotiented out: **scale** (multiplying every coefficient by a +nonzero rational — denominators are cleared and the integer content divided +out, sign fixed by making the first nonzero coefficient positive), **index +shift** (the window is moved to start at `u(n)`), **a common polynomial +factor** (`(n+1)·L` and `L` are the same recurrence up to the finitely many +`n` where the factor vanishes), and **zero-padding** at either end of the +window. What is *not* quotiented out: a genuinely different relation, a +different-order operator that happens to be a left multiple, or the range of +`n` a source claims the relation holds on — two sources stating the same +recurrence from different starting indices agree here, which is what a +novelty filter wants and is not a claim that the statements are +interchangeable at small `n`. + +Build a claim straight from what [`zeilberger`](./telescoping.md) or +[`guess_holonomic`](./guessing.md) already produced: + +```python +guess = ak.guess_holonomic(terms, max_order=3, max_degree=4) +claim = RecurrenceClaim.from_recurrence(guess) +``` + +`RecurrenceClaim.from_text` reads OEIS's own `a(n) = …` formula lines by +recursive descent over `+ - * / ^ ( )`, `n` and `a(n±k)`. It refuses — returns +`None`, never a guess — anything outside that shape: a reference to another +sequence (`a(n) = a(n-1) + A002026(n-1)`), a sum, a generating function, an +inhomogeneous relation, a nonlinear one. A parser that guesses at prose +invents claims nobody made, so a line the parser does not fully cover is +counted as unusable rather than truncated into a shorter claim that happens to +parse. + +### `holds_for` / `confirmations`, and what `start` means + +Both exactly re-check a claim's normal form against concrete terms — +`fractions.Fraction` arithmetic throughout, so `True` is a fact about the +terms given, not a tolerance. `holds_for` requires every window to check out; +`confirmations` counts only the **trailing** run, because a recurrence is +routinely stated only for `n` past some initial segment and a mismatch at +`n = 0` says nothing about whether it is the relation that was meant. + +`start` is the true index of `terms[0]` — nothing more forgiving than that. +Because coefficients here are genuine polynomials in `n` (this is +P-recursive, not constant-coefficient), a wrong `start` is not a small numeric +error that shifts a few results: it evaluates every coefficient at the wrong +point and generically fails the *entire* array, trailing windows included, +even ones that never touch whatever made `start` wrong. If you slice, drop, or +prepend elements relative to some original indexing, adjust `start` by the +same amount — `[junk, *real]` needs `start=-1`, not the default `0`, because +`junk` sits where `u(-1)` would. + +## Checking a claim + +```python +from alkahest.experimental.novelty import OeisCache, OeisEntry, check_novelty + +cache = OeisCache("my_oeis_cache.json") +verdict = check_novelty(claim, [cache], terms=terms[:12]) +``` + +`sources` has **no default** — a check with an empty list, or no source able +to answer, is `unavailable`, and nothing here reaches for the network on your +behalf. Two source types: + +- **`OeisCache`** — file-backed, offline. Holds entries *and* the queries + already put to OEIS, keyed by what was asked — the second is what makes an + honest negative possible: a cache that only stores hits can never + distinguish "asked and OEIS had nothing" from "never asked", and reporting + the second as the first is exactly the overclaim this module exists to + prevent. +- **`OeisWeb`** — live lookup, **opt-in**: nothing constructs one for you. It + serves from its own `OeisCache` before touching the network, sleeps between + requests, sends an identifying User-Agent, and **returns `unavailable` + rather than raising** when the network is not there. + +```python +web = OeisWeb(cache=OeisCache()) +web.lookup(ids=["A005259"]) +web.cache.save("tests/data/oeis_novelty_fixture.json") +``` + +records a fixture once so later runs — and CI, which has no network guarantee +— never need to touch oeis.org at all. + +## Reading the verdict + +`NoveltyVerdict.found` is **three-valued**, in the manner of +[`relation_confidence`](./guessing.md)'s tri-state `credible` and +`GuessedRecurrence.confirmed`: + +| `found` | `status` | Means | +|---|---|---| +| `True` | `"recorded"` / `"recorded_conjecturally"` | a source states this claim; `hedged` says whether as a theorem or a conjecture | +| `False` | `"not_found"` | the sources searched do not state it — **not** "novel" | +| `None` | `"unavailable"` | no source could answer; nothing was established either way | + +There is deliberately no `novel` attribute anywhere on `NoveltyVerdict`, and +`bool(verdict)` **raises** rather than silently reading `True`, because +`if check_novelty(...):` is the exact sentence this module exists to prevent: + +```python +bool(verdict) +# TypeError: a NoveltyVerdict has no truth value: `if verdict:` would read as +# 'is this novel?' and there is no such answer here. Test verdict.status (...) +# or verdict.found, which is True/False/None and whose False means 'not in +# the sources searched', not 'new' +``` + +`verdict.hedged` is the distinction the whole filter exists for. OEIS marks a +formula `Conjecture` or `Empirical` when it was fitted rather than proved — +restating a hedged recurrence is not a result, *proving* it is: + +```python +verdict = check_novelty(recorded_claim, [cache], terms=terms[:12]) +verdict.status # "recorded_conjecturally" +verdict.hedged # True — OEIS has this, but never proved it +``` + +`verdict.report()` carries the scope of the search — `entries_examined`, +`statements_compared`, `statements_unusable` — so the size of a negative is +visible next to it: a `"not_found"` against zero entries examined means +something quite different from one against fifty. + +## Testing without the network + +`tests/test_novelty.py` never constructs `OeisWeb`; every OEIS-backed test +runs against `tests/data/oeis_novelty_fixture.json`, a cache recorded once +from oeis.org (© The OEIS Foundation Inc., licensed CC BY-NC-SA 4.0 — the +license travels with every cache this module saves) and committed. The +fixture carries the sequences this project already certifies recurrences for +— Apéry (A005259), Motzkin (A001006), Catalan (A000108), central binomial +coefficients (A000984) — plus A359643, a result this project's own search +found and which OEIS records only as an unproved `Conjecture`: the recorded +statement is `verdict.hedged is True`, and a claim one order lower that OEIS +does not have at all comes back `"not_found"`. diff --git a/docs/mdbook/src/positivity.md b/docs/mdbook/src/positivity.md index 3dc7c9b1..e8e87bc1 100644 --- a/docs/mdbook/src/positivity.md +++ b/docs/mdbook/src/positivity.md @@ -82,30 +82,52 @@ except ak.SosError as e: A sum-of-squares decomposition `p = zᵀQz` over the monomial basis `z` exists iff there is a **positive semidefinite** Gram matrix `Q` matching `p`'s coefficients. Deciding general PSD feasibility is a semidefinite programme, -and no floating-point SDP solver is allowed anywhere near a certificate here — -a rounded `Q` is not a proof. - -So the search covers a **linear-programming-representable subcone**: the -non-negative combinations of squares of a fixed generator set, - -```text -(e_i)², (a·e_i ± b·e_j)² for small coprime (a, b) -``` - -solved with the exact rational simplex in `real::sos::lp` (Bland's rule, so -termination is unconditional and there are no epsilon tolerances). Every -generator is *literally a square*, so any feasible point is a sound certificate -by construction. - -The `(1,1)` case alone is the classical diagonally-dominant (DSOS) cone. It is -not enough on its own: diagonal dominance is not invariant under scaling the -basis, so a perfect square as ordinary as `(x/2 + 1/3)²` has a Gram matrix — -its only one — that is PSD but not DD. The extra ratios widen the cone enough -to catch these while keeping the problem an LP. - -The cone is still a strict subset of the SOS cone, which is exactly why -`E-SOS-002` is phrased as a statement about the search rather than about the -polynomial. +and no floating-point SDP solver is ever trusted with a certificate here — a +rounded `Q` is not a proof. + +The search tries three things, in order, before refusing: + +1. **The diagonally-dominant (DSOS) subcone** — non-negative combinations of + squares of a fixed generator set, `(e_i)²` and `(a·e_i ± b·e_j)²` for small + coprime `(a, b)`, solved with the exact rational simplex in `real::sos::lp` + (Bland's rule, so termination is unconditional and there are no epsilon + tolerances). Every generator is *literally a square*, so a feasible point + is sound by construction — but the cone is a strict subset of the SOS + cone: diagonal dominance is not invariant under scaling the basis, so a + perfect square as ordinary as `(x/2 + 1/3)²` has a Gram matrix — its only + one — that is PSD but not DD. +2. **The full PSD Gram cone**, when DSOS fails (`real::sos::psd::psd_search`). + This subsumes DSOS but is not free: it leans on a floating-point search + (Jacobi eigendecomposition, PSD-cone projection, an annealed schedule of + shrinking eigenvalue floors with several random restarts — + `real::sos::sdp`) to *propose* a Gram matrix, which is then rounded to + nearby rationals and re-expanded to check it equals `p` exactly before + anything is returned. A `Some` here is always sound regardless of what the + numeric search converged to; a `None` means only "the search did not turn + up a certificate", never "not SOS". +3. **A Reznick multiplier search**, when even step 2 fails on `p` itself: + tries `(x_1²+…+x_n²)^N·p` for `N = 1..4` and reruns step 2 on the product. + Some positive-definite forms are not SOS at all (Hilbert 1888 — this is + what Motzkin's polynomial witnesses), but Reznick's theorem guarantees + `(Σxᵢ²)^N·p` is SOS for *some* `N`; the search does not know `N` in advance + and reports budget exhaustion honestly rather than a disproof. + +`E-SOS-002` at the end of all three is phrased as a statement about the +search, not the polynomial — the search's incompleteness, at any step. + +**A known, diagnosed gap in step 3:** Motzkin's polynomial and Robinson's +form — the textbook PSD-not-SOS examples — are not yet certified even with a +multiplier, because their witnessing Gram matrices are *singular*, sitting +exactly on the boundary of the PSD cone rather than its interior. The +annealed search converges toward that boundary (monotonically, confirmed by a +diagnostic trajectory) but does not reliably close the last, asymptotically +slow stretch — the textbook behaviour of alternating projection at a +tangential (non-transversal) intersection. This was checked to be a +convergence limitation and not a bug in the search machinery: an independent +sanity check confirms the affine Gram-matrix family is constructed correctly, +and a synthetic planted example with a singular Gram matrix of the same size +*is* found and exactly re-verified. The tests for Motzkin record `undecided` +rather than a false certificate. ## Constrained certificates @@ -204,10 +226,18 @@ statement is good enough. ## Scope of this release -Shipped: exact rational SOS over the generator cone above, Handelman -certificates on basic semialgebraic sets, exact verification, and Lean export. - -Not yet shipped: full SDP-based SOS (which needs an exact or rationally-rounded -semidefinite solver), and Putinar-style certificates with genuine SOS — rather -than non-negative constant — multipliers. `CertificateKind::Putinar` exists in -the certificate type so those can be added without a shape change. +Shipped: exact rational SOS over the DSOS generator cone, a general PSD Gram +search (floating-point proposal, exact rational verification) for cases DSOS +alone refuses, a Reznick multiplier search (`(Σxᵢ²)^N·p` for `N ≤ 4`) on top +of that, Handelman certificates on basic semialgebraic sets, exact +verification, and Lean export. + +Not yet shipped: reliable certification of the hardest classical +boundary-case examples (Motzkin, Robinson — see above; the multiplier search +finds them for neither, diagnosed as an alternating-projection convergence +limitation at a tangential PSD-cone intersection, not a soundness gap), a +proper interior-point or facial-reduction-based solver that would close that +gap, and Putinar-style certificates with genuine SOS — rather than +non-negative constant — multipliers on the *constraints*. +`CertificateKind::Putinar` exists in the certificate type so those can be +added without a shape change. diff --git a/docs/mdbook/src/search-plumbing.md b/docs/mdbook/src/search-plumbing.md index 1d886829..e4714157 100644 --- a/docs/mdbook/src/search-plumbing.md +++ b/docs/mdbook/src/search-plumbing.md @@ -15,6 +15,7 @@ to the mathematics: | Propose a parametric family and fit it | `alkahest.ansatz` (`polynomial`, `rational`, `fit`, …) | [Ansatz families](./ansatz.md) | | Differential-test a result against another CAS | `alkahest.crosscheck` (`check`, `sweep`) | [Cross-CAS testing](./crosscheck.md) | | Hand a discrete / mixed int-real subproblem to a solver | `alkahest.smt` (`to_smtlib`, `solve`, `supported`) | [SMT bridge](./smt.md) | +| Ask whether a fitted recurrence is already in OEIS before calling it new | `alkahest.experimental.novelty` (`RecurrenceClaim`, `check_novelty`) | [Novelty filtering](./novelty.md) | A minimal loop shape: diff --git a/docs/mdbook/src/solving.md b/docs/mdbook/src/solving.md index ae807e40..a4949a1c 100644 --- a/docs/mdbook/src/solving.md +++ b/docs/mdbook/src/solving.md @@ -132,6 +132,38 @@ implicit.to_exprs() # [((y * -1) + x^2)] — y = x**2 Note the variable order passed to `compute`: `t` comes first, so `lex` eliminates it. `eliminate` requires the basis to know its variables (`gb.variables()`), and rejects a variable it is not written over. +## Coefficient fields: `Q(params)` instead of `Q[vars, params]` + +`GroebnerBasis.compute(polys, vars, params=[...])` moves the listed symbols into the **coefficient field** `Q(params)` instead of the polynomial ring. They never enter the monomial order and never generate S-pairs, which is the difference between eliminating states from `Q[states, Y, params]` and from `Q(params)[states, Y]` — the parameter count no longer inflates the staircase. + +```python +from alkahest import GroebnerBasis + +# a lives in the coefficient field Q(a), not the ring Q[x, y, a] +gb = GroebnerBasis.compute([a*x - y, x + y - one], [x, y], params=[a]) +type(gb) # ParametricGroebnerBasis +[g.to_expr() for g in gb] # coefficients are rational functions of a +``` + +Measured on a catenary compartmental ODE model (a linear chain of `n` states, output the first compartment, eliminating the states from the jet equations down to the input–output relation): at `n = 4` states / 7 rate constants the parametric route runs in 0.27s against 4.2s putting the rate constants in the ring (`Lex`, `--release`, ~15×) and leaves 5 total basis generators against 25; at `n = 5` states / 9 rate constants the parametric route finishes in 6.9s while the direct computation had not finished after 240s. These are wall-clock numbers on one machine, illustrating the shape of the difference (S-pairs among the parameters are exactly what the ring route pays for and the coefficient-field route never generates) rather than a promised ratio. + +**The result is generic.** A leading coefficient in `Q(params)` can be a non-zero rational function of the parameters and still vanish at a specific parameter point, and there the basis this computation built is not the one the same algorithm would build over ℚ at that point: + +```python +gb.conditions() # [a + 1] — the basis says nothing at a = -1 +gb.is_regular_at([3]) # True +gb.is_regular_at([-1]) # False + +gb.specialize([3]) # an ordinary GroebnerBasis over Q +gb.specialize([-1]) # raises ParamGroebnerError, code "E-PARAMGB-004" +``` + +`conditions()` lists the hypersurfaces the computation assumed non-vanishing — every leading-coefficient inversion contributes its numerator and denominator, every input coefficient contributes its denominator — factored into irreducible, primitive pieces so the report is a list of conditions rather than one opaque polynomial in many parameters. The list is **sufficient, not necessary**: it can flag a point that turns out fine (a removable coincidence the bookkeeping cannot see), but it never misses a point where the generic basis is genuinely wrong. `specialize` refuses on the flagged locus with `ParamGroebnerError` (`E-PARAMGB-004`) rather than returning something that is not a basis; check `is_regular_at` first if a degenerate point is a normal outcome for your caller. + +The read path matches `GroebnerBasis`: the object is a sequence of `ParametricGbPoly`, each with `to_expr()` / `terms()`, and the basis itself has `to_exprs()`, `eliminate(vars)` (same `Lex`-with-eliminated-variables-first contract, refuses to eliminate a coefficient-field parameter since there is nothing to eliminate), `reduce`, and `contains`. `GroebnerBasis.compute(..., params=None)` or `params=[]` is the unmodified `Q[vars]` path; `ParametricGroebnerBasis.compute(polys, vars, params, order=None)` is the equivalent direct constructor in `alkahest.experimental`. + +This surface is experimental (`alkahest.experimental.ParametricGroebnerBasis` / `ParametricGbPoly`) and requires `--features groebner`. + ## Performance On the `solve_circle_line` benchmark (2-variable quadratic system), Alkahest is approximately **40× faster** than SymPy due to the FLINT-backed polynomial arithmetic and the compiled F4 core. diff --git a/docs/mdbook/src/telescoping.md b/docs/mdbook/src/telescoping.md index 06dcb946..cc089ad8 100644 --- a/docs/mdbook/src/telescoping.md +++ b/docs/mdbook/src/telescoping.md @@ -391,7 +391,72 @@ claim about its sum, not a guessed inhomogeneity. One more caveat, and it is on every verdict's `side_conditions`: `q` is treated as **transcendental**. Everything here is an identity in `Q(q)`. Specialising `q` to a root of unity — which is what the `q`-supercongruence literature does — -is a separate step with its own hypotheses, and this engine does not take it. +is a separate step with its own hypotheses. + +### Specialising at a root of unity (`specialize_at_root_of_unity`) + +A proved `Q(q)` recurrence does not, by itself, license setting `q = ζ_d` for a +primitive `d`-th root of unity: a coefficient or a sum value can have a pole +there, and specialising anyway is exactly the `q`-analogue of the A279013 +failure mode — a certificate that re-checks perfectly while the specialised +claim is false. `QZeilbergerCertificate.specialize_at_root_of_unity(d, n)` +takes that step as a **decision**, not an assumption: + +```python +from alkahest.experimental import cyclotomic_polynomial, q_zeilberger, qbinomial + +pool = ak.ExprPool() +q, n, k = pool.symbol("q"), pool.symbol("n"), pool.symbol("k") +b = qbinomial(pool, n, k) +cert = q_zeilberger(b * b * q ** (k * k), q, n, k) + +spec = cert.specialize_at_root_of_unity(3, 2) # q = zeta_3, at n = 2 +spec.status # "specializes" / "obstructed" / "unknown" +spec.sum_value(0) # S_zeta(2), the canonical rep in Q[q]/(Phi_3) +spec.sum_valuation(0) # the exact Phi_3-adic valuation of S(2) +spec.modulus() # Phi_3(q) = q^2 + q + 1, exposed for a by-hand check +``` + +The hypotheses — no pole in any coefficient `a_i(qⁿ)` or sum value `S(n+i)` at +`ζ_d` — are decided **exactly**, by polynomial divisibility by `Φ_d(q)` over +`Q` in the cyclotomic field `Q(ζ_d) = Q[q]/(Φ_d(q))`; nothing is evaluated +numerically at any stage. `Φ_d` is irreducible over `Q`, so "does `p` vanish at +`ζ_d`" is exactly "does `Φ_d` divide `p`", which is what makes the valuation — +and therefore the decision — exact rather than approximate. `cyclotomic_polynomial(pool, d)` +returns `Φ_d(q)` directly, so a caller can redo the whole check by hand. + +`status` is three-valued, and the three are not interchangeable: + +* **`"specializes"`** — proved: every coefficient and every sum value has + non-negative `Φ_d`-adic valuation, so the specialisation map is defined on + all of them, and the specialised identity was re-checked as an exact + statement in `Q(ζ_d)` before being returned. Three further things are + reported on this verdict rather than folded into it, because each of them + makes a true verdict mean less than it looks: + * `is_vacuous` — every coefficient died at `ζ_d` (the `q → 1` limit at + `d = 1` is always like this), so the recurrence is `0 = 0`. Still a + theorem; it constrains nothing. + * `leading_coefficient_survives` — `False` means the specialised recurrence + no longer determines the last value from the earlier ones, even though it + is not vacuous. + * `support_shrinks` / `effective_support` — the `q`-Lucas phenomenon: + `[2;1]_q = 1 + q` is non-zero in `Q(q)` and zero at `ζ_2`, so the + surviving window at a root of unity can be a strict subset of the generic + one. It can never grow. +* **`"obstructed"`** — a pole at `ζ_d` was **exhibited**: some coefficient or + sum value has negative `Φ_d`-adic valuation (available via `sum_valuation` + even on this verdict, since a negative valuation *is* the obstruction). + Nothing is offered — `sum_value` and `coefficient` raise — and this is not a + claim that the specialised identity is false, only that this route to it is + blocked. +* **`"unknown"`** — the generic boundary verdict was already `"unknown"`, so + there is no proved `Q(q)` statement to specialise in the first place. + +`sum_valuation(i)` is the `q`-supercongruence content in its exact form: it is +the integer `v` with `Φ_d(q)^v` dividing `S(n+i)` and `Φ_d(q)^{v+1}` not — so +`v ≥ r` is precisely the divisibility statement `Φ_d(q)^r | S(n)` that a +`q`-supercongruence asserts, decided exactly rather than checked at finitely +many numeric points. ## Method @@ -420,12 +485,19 @@ Shipped: Zeilberger's algorithm with exact certificate verification, the `Q(n)` / `Q(n)(k)` arithmetic tower it rests on, proper-hypergeometric recognition, the three-valued boundary verdict over a stated summation range, explicit minimal-order certification, `guess_holonomic` — recurrence guessing -from finite data — and the `q`-analogue `q_zeilberger` over -`Q(q)(qⁿ)(q^k)` with its own two-valued boundary verdict. - -Not shipped on the `q` side: multivariate (`q`-)telescoping, an inhomogeneous -boundary arm, and specialisation of `q` to a root of unity. A `q`-sum whose -support cannot be bounded is answered `"unknown"`, never guessed. +from finite data — the `q`-analogue `q_zeilberger` over `Q(q)(qⁿ)(q^k)` with +its own two-valued boundary verdict, and `specialize_at_root_of_unity` — the +step from a `Q(q)` identity to `q = ζ_d`, decided exactly in the cyclotomic +field `Q(ζ_d)` with its own three-valued verdict. + +Not shipped on the `q` side: multivariate (`q`-)telescoping and an +inhomogeneous boundary arm. A `q`-sum whose support cannot be bounded is +answered `"unknown"`, never guessed. Root-of-unity specialisation covers a +single certificate at a single `(d, n)` pair — it is the mechanical step the +`q`-supercongruence literature needs, not a search over `d` or a prover for +the wider congruence statements (e.g. uniform-in-`n` supercongruences, or +`p`-adic statements not phrased as `Φ_d`-adic valuations) that literature +contains. Not yet shipped, and tracked as follow-up work: Ore-operator closure properties for D-finite functions (sums and products of holonomic objects) and the diff --git a/python/alkahest/__init__.py b/python/alkahest/__init__.py index 6bce6076..dd95e7ce 100644 --- a/python/alkahest/__init__.py +++ b/python/alkahest/__init__.py @@ -371,6 +371,7 @@ ModularError, NumberTheoryError, OdeError, + ParamGroebnerError, ParseError, PoolError, ProductError, @@ -420,6 +421,9 @@ "ModularError", "NumberTheoryError", "OdeError", + # M9 — only on `groebner` builds; the overlay skips what the module does + # not define, so catching it still works on a wheel without the feature. + "ParamGroebnerError", "PoolError", "ProductError", "PslqError", @@ -2187,6 +2191,8 @@ def wrapper(*args, **kwargs): "NumberTheoryError", "OdeError", "Or", + # M9 — coefficient fields for elimination + "ParamGroebnerError", "ParseError", "PoolError", "Port", diff --git a/python/alkahest/exceptions.py b/python/alkahest/exceptions.py index 29ec4bdc..10d954ea 100644 --- a/python/alkahest/exceptions.py +++ b/python/alkahest/exceptions.py @@ -671,6 +671,33 @@ def __init__( super().__init__(message, code="E-MOD-001", remediation=remediation, span=span) +class ParamGroebnerError(AlkahestError): + """A Gröbner basis over ``Q(params)`` refused or was called wrongly (M9). + + Raised by :class:`~alkahest.experimental.ParametricGroebnerBasis` and by + ``GroebnerBasis.compute(..., params=[...])``. The native class exists as + ``PyParamGroebnerError``; this wrapper is what makes it catchable by name. + + - ``E-PARAMGB-001`` — no generators were supplied. + - ``E-PARAMGB-002`` — the generators disagree on the variable/parameter + lists. + - ``E-PARAMGB-003`` — a specialisation was given the wrong number of values. + - ``E-PARAMGB-004`` — **a result, not a malfunction.** The requested + parameter point is on the degeneracy locus: one of the conditions the + basis assumed vanishes there, so the generic basis says nothing about it. + Read ``ParametricGroebnerBasis.conditions()`` to see which hypersurfaces + those are, or compute over ℚ directly at that point. + """ + + def __init__( + self, + message: str, + remediation: str | None = None, + span: tuple[int, int] | None = None, + ): + super().__init__(message, code="E-PARAMGB-004", remediation=remediation, span=span) + + class AnsatzError(AlkahestError): """An ansatz family could not be built, or could not be fitted. diff --git a/python/alkahest/experimental/__init__.py b/python/alkahest/experimental/__init__.py index c56c5f86..84c0a033 100644 --- a/python/alkahest/experimental/__init__.py +++ b/python/alkahest/experimental/__init__.py @@ -46,6 +46,32 @@ and a two-valued verdict on whether it carries over to the sum - :func:`qbinomial`, :func:`qpochhammer` — builders for the two function heads the engine recognises +- :meth:`~alkahest.experimental.QZeilbergerCertificate.specialize_at_root_of_unity` + / :class:`QRootOfUnitySpecialization` (M4) — the step from a ``Q(q)`` + identity to ``q = ζ_d``, a primitive ``d``-th root of unity: the + ``q``-supercongruence literature. Pole and vanishing hypotheses are decided + exactly by polynomial divisibility by ``Φ_d(q)`` over ``Q``, never + numerically, and the verdict is three-valued (``"specializes"``, + ``"obstructed"``, ``"unknown"``) rather than a silent specialisation +- :func:`cyclotomic_polynomial` — ``Φ_d(q)``, the modulus the root-of-unity + arithmetic works over, exposed so a caller can redo the divisibility check + by hand + +Novelty filtering (:mod:`alkahest.experimental.novelty`): +- :class:`RecurrenceClaim` — a recurrence in a normal form two presentations + of the same fact share, plus a stable ``claim_hash`` to dedupe on +- :func:`check_novelty` / :class:`NoveltyVerdict` — was this claim already + written down? Three-valued, and a negative is never reported as "novel" +- :class:`OeisCache` (offline, the tested path) and :class:`OeisWeb` (opt-in + network) as sources + +Coefficient fields for elimination (M9): +- :class:`ParametricGroebnerBasis` / :class:`ParametricGbPoly` — a Gröbner + basis in ``Q(params)[vars]`` rather than ``Q[vars, params]``, reachable as + ``GroebnerBasis.compute(polys, vars, params=[...])``. The basis is generic, + so it reports the hypersurfaces its leading coefficients assumed non-zero + (``conditions()``) and refuses to ``specialize()`` on them instead of + returning something that is not a basis Numeric ODE integrators (Phase 16b): - :func:`ode_integrate_rk4` — fixed-step 4th-order Runge–Kutta integrator @@ -92,10 +118,12 @@ AsymptoticReport, Fps, OdeTrajectory, + QRootOfUnitySpecialization, QZeilbergerCertificate, asymptotic_expand, # P1 item 10 — asymptotic expansion at scale coefficient_asymptotics, + cyclotomic_polynomial, dirac_delta, dsolve, euler_maclaurin, @@ -113,6 +141,20 @@ z_transform, ) +# M11 — novelty filtering. Claim normalisation, a stable hash, and a lookup +# whose negative is never reported as "novel". Pure Python by CONTRIBUTING's +# rule: HTTP, JSON, and parsing a third party's prose. +from alkahest.experimental import novelty +from alkahest.experimental.novelty import ( + NoveltyMatch, + NoveltyVerdict, + OeisCache, + OeisEntry, + OeisWeb, + RecurrenceClaim, + check_novelty, +) + with contextlib.suppress(ImportError): from alkahest import to_lean @@ -122,6 +164,11 @@ with contextlib.suppress(ImportError): from alkahest import GbPoly, GroebnerBasis, solve +# M9 — Gröbner bases over the coefficient field Q(params). Registered by the +# extension only on `groebner` builds, hence the suppressed import. +with contextlib.suppress(ImportError): + from alkahest.alkahest import ParametricGbPoly, ParametricGroebnerBasis + with contextlib.suppress(ImportError): from alkahest.alkahest import CudaCompiledFn, compile_cuda @@ -134,21 +181,39 @@ "Fps", "GbPoly", "GroebnerBasis", + # M11 — novelty filtering + "NoveltyMatch", + "NoveltyVerdict", "OdeTrajectory", + # M11 — novelty filtering + "OeisCache", + "OeisEntry", + "OeisWeb", + # M9 — coefficient fields for elimination + "ParametricGbPoly", + "ParametricGroebnerBasis", + # M4 — root-of-unity specialisation + "QRootOfUnitySpecialization", # M4(b) — q-analogue creative telescoping "QZeilbergerCertificate", # M5 — recurrence -> asymptotics "RecurrenceAsymptotics", + # M11 — novelty filtering + "RecurrenceClaim", "arg", "asymptotic_expand", # M5 — recurrence -> asymptotics "asymptotics_from_recurrence", "bessel_j0", "bessel_j1", + # M11 — novelty filtering + "check_novelty", # P1 item 10 — asymptotic expansion at scale "coefficient_asymptotics", "compile_cuda", "conjugate", + # M4 — root-of-unity specialisation + "cyclotomic_polynomial", "digamma", "dirac_delta", "dsolve", @@ -163,6 +228,9 @@ "lambert_w", "laplace_transform", "multilimit", + # M11 — novelty filtering (the module itself, for `novelty.RecordedRecurrence` + # and the status tables) + "novelty", "ode_integrate_rk4", "ode_integrate_rk45", # M4(b) — q-analogue creative telescoping diff --git a/python/alkahest/experimental/novelty.py b/python/alkahest/experimental/novelty.py new file mode 100644 index 00000000..ef65d92c --- /dev/null +++ b/python/alkahest/experimental/novelty.py @@ -0,0 +1,1490 @@ +"""Novelty filtering — normalise a claim, hash it, look it up before reporting it. + +A search loop over this library rediscovers Vandermonde's identity within the +hour. The difference between "produced 400 certified recurrences" and "produced +three that nobody had" is not the mathematics; it is a filter that puts every +claim into a canonical form, hashes it, and asks whether it is already written +down somewhere before anything calls it a finding. + +Three pieces, in order: + +1. :class:`RecurrenceClaim` — a P-recursive recurrence in **normal form**. + Rescaling, sign flips, index shifts, a different clearing of denominators + and a stray polynomial factor are all presentation, not content, so they are + quotiented out (see :attr:`RecurrenceClaim.normal_form`). +2. :attr:`RecurrenceClaim.claim_hash` — a stable content address of that normal + form, so a loop can dedupe its own output with a ``set``. +3. :func:`check_novelty` — the claim against one or more sources + (:class:`OeisCache` offline, :class:`OeisWeb` when explicitly opted into), + returning a :class:`NoveltyVerdict`. + +What a negative verdict is allowed to claim +------------------------------------------- + +**Nothing about novelty.** ``status == "not_found"`` means *this claim was not +found in the sources that were actually searched* — one encyclopaedia of +integer sequences, through the formula lines of the entries that matched the +terms it was given, through a parser that understands a fraction of what those +lines can say. The literature is not OEIS. :attr:`NoveltyVerdict.found` is +therefore three-valued in the manner of :func:`alkahest.relation_confidence`'s +``credible`` and :attr:`alkahest.GuessedRecurrence.confirmed`: + +* ``True`` — a source states this claim (:attr:`~NoveltyVerdict.hedged` says + whether it states it as a theorem or as a conjecture). +* ``False`` — the sources searched do not state it. Not "novel". +* ``None`` — no source could answer. Never a pass. + +There is deliberately no ``novel`` attribute anywhere in this module, and +``bool(verdict)`` raises rather than silently reading ``True``, because +``if check_novelty(...):`` is the exact sentence this file exists to prevent. +:meth:`NoveltyVerdict.report` carries the scope of the search — entries +examined, statements compared, statements that could not be used — so the size +of a negative is visible next to it. + +Why this is Python and not Rust +------------------------------- + +``CONTRIBUTING.md`` § *Rust vs Python*: this is HTTP, JSON, text parsing of a +third party's prose formula lines, and an experimental API that will change +shape as more sources are added — rows 2, 3 and 5 of the Python column. The +arithmetic it does (exact polynomial normalisation in ``ℚ[n]``) is +:mod:`fractions` over degree-≤10 polynomials, not a hot path. + +The kernel's own content-primitive scaling (``clear_denominators`` in +``alkahest-core/src/holonomic/qfield.rs``) is internal to the Rust holonomic +module and not on the Python surface, and :func:`alkahest.poly_normal` refuses +rational coefficients outright (``E-POLY-002``) — which is precisely the input +a differently-scaled presentation arrives as. So the scaling here is local, and +small enough to read in one sitting. + +Being a good citizen against oeis.org +------------------------------------- + +:class:`OeisWeb` is opt-in, never constructed by default, serves from its cache +before it touches the network, sleeps between requests, and returns +``unavailable`` rather than raising when the network is not there. **No test in +this repository requires the network**; the offline path is +:class:`OeisCache`, whose fixtures are recorded once and committed. OEIS data +is © The OEIS Foundation Inc., licensed CC BY-NC-SA 4.0 — a cache written by +this module records that in the file. +""" + +from __future__ import annotations + +import json +import re +import time +import urllib.parse +import urllib.request +from dataclasses import dataclass +from fractions import Fraction +from math import gcd +from pathlib import Path +from typing import TYPE_CHECKING, Any, ClassVar + +from alkahest.research import claim_id as _claim_id + +if TYPE_CHECKING: # pragma: no cover - typing only + from collections.abc import Iterable, Sequence + +__all__ = [ + "NOVELTY_STATUSES", + "STATUS_MEANINGS", + "NoveltyMatch", + "NoveltyVerdict", + "OeisCache", + "OeisEntry", + "OeisWeb", + "RecordedRecurrence", + "RecurrenceClaim", + "SourceAnswer", + "check_novelty", +] + +#: Every verdict a novelty check can reach. +NOVELTY_STATUSES = ("recorded", "recorded_conjecturally", "not_found", "unavailable") + +#: Deliberately unflattering glosses, in the spirit of +#: :data:`alkahest.research.STATUS_BADGES`: a reader must not be able to mistake +#: "not in the one place I looked" for "new". +STATUS_MEANINGS = { + "recorded": "a source states this claim; it is not new", + "recorded_conjecturally": ( + "a source states this claim but marks it conjectural or empirical; " + "proving it is a result, restating it is not" + ), + "not_found": ( + "not found in the sources searched — this is not evidence of novelty, " + "only the absence of evidence from the places actually looked at" + ), + "unavailable": ("no source could answer; nothing was established either way"), +} + +#: Trailing windows of the entry's own data a parsed recurrence must satisfy +#: before it is believed to be what the formula line meant. +_MIN_CONFIRMATIONS = 3 + +# --------------------------------------------------------------------------- +# Univariate polynomials over ℚ. +# +# A polynomial is a tuple of `Fraction` coefficients, lowest degree first, with +# trailing zeros trimmed; the zero polynomial is `()`. Everything is exact. +# --------------------------------------------------------------------------- + + +def _trim(coeffs: Iterable[Fraction]) -> tuple: + out = list(coeffs) + while out and out[-1] == 0: + out.pop() + return tuple(out) + + +def _p_add(a: tuple, b: tuple) -> tuple: + n = max(len(a), len(b)) + return _trim( + (a[i] if i < len(a) else Fraction(0)) + (b[i] if i < len(b) else Fraction(0)) + for i in range(n) + ) + + +def _p_neg(a: tuple) -> tuple: + return tuple(-c for c in a) + + +def _p_sub(a: tuple, b: tuple) -> tuple: + return _p_add(a, _p_neg(b)) + + +def _p_mul(a: tuple, b: tuple) -> tuple: + if not a or not b: + return () + out = [Fraction(0)] * (len(a) + len(b) - 1) + for i, x in enumerate(a): + if x == 0: + continue + for j, y in enumerate(b): + out[i + j] += x * y + return _trim(out) + + +def _p_pow(a: tuple, e: int) -> tuple: + out = (Fraction(1),) + for _ in range(e): + out = _p_mul(out, a) + return out + + +def _p_eval(a: tuple, x: Fraction) -> Fraction: + total = Fraction(0) + for c in reversed(a): + total = total * x + c + return total + + +def _p_shift(a: tuple, s: int) -> tuple: + """``p(n + s)``, exactly.""" + if s == 0: + return a + linear = (Fraction(s), Fraction(1)) + out: tuple = () + for c in reversed(a): + out = _p_add(_p_mul(out, linear), (c,)) + return _trim(out) + + +def _p_divmod(a: tuple, b: tuple) -> tuple: + """Exact quotient and remainder of *a* by *b* over ``ℚ``.""" + if not b: + raise ZeroDivisionError("polynomial division by zero") + quotient = [Fraction(0)] * max(1, len(a) - len(b) + 1) + rem = list(a) + while len(rem) >= len(b) and _trim(rem): + shift = len(rem) - len(b) + factor = rem[-1] / b[-1] + quotient[shift] = factor + for i, c in enumerate(b): + rem[shift + i] -= factor * c + rem = list(_trim(rem)) + return _trim(quotient), _trim(rem) + + +def _p_gcd(a: tuple, b: tuple) -> tuple: + """Monic gcd over ``ℚ``; ``()`` only when both are zero.""" + while b: + a, b = b, _p_divmod(a, b)[1] + if not a: + return () + lead = a[-1] + return tuple(c / lead for c in a) + + +def _p_text(a: tuple) -> str: + """Canonical text for a polynomial: descending powers, explicit signs.""" + if not a: + return "0" + parts = [] + for power in range(len(a) - 1, -1, -1): + c = a[power] + if c == 0: + continue + monomial = "n" if power == 1 else f"n^{power}" + if power == 0: + parts.append(str(c)) + elif c == 1: + parts.append(monomial) + elif c == -1: + parts.append(f"-{monomial}") + else: + parts.append(f"{c}*{monomial}") + body = parts[0] + for part in parts[1:]: + body += f" - {part[1:]}" if part.startswith("-") else f" + {part}" + return body + + +# --------------------------------------------------------------------------- +# Linear forms in the shifts of one unknown sequence, over ℚ(n). +# --------------------------------------------------------------------------- + + +class _Unsupported(Exception): + """The text says something this module deliberately does not model.""" + + +class _Form: + """``(poly + Σ_j shifts[j]·u(n+j)) / den``, all polynomials over ``ℚ``.""" + + __slots__ = ("den", "poly", "shifts") + + def __init__(self, poly: tuple, shifts: dict | None = None, den: tuple | None = None): + self.poly = poly + self.shifts = shifts or {} + self.den = (Fraction(1),) if den is None else den + + @staticmethod + def constant(value: Fraction) -> _Form: + return _Form(_trim((value,))) + + @staticmethod + def variable() -> _Form: + return _Form((Fraction(0), Fraction(1))) + + @staticmethod + def sequence_term(shift: int) -> _Form: + return _Form((), {shift: (Fraction(1),)}) + + @property + def is_linear_free(self) -> bool: + return not any(self.shifts.values()) + + def __add__(self, other: _Form) -> _Form: + den = _p_mul(self.den, other.den) + poly = _p_add(_p_mul(self.poly, other.den), _p_mul(other.poly, self.den)) + shifts: dict = {} + for j, p in self.shifts.items(): + shifts[j] = _p_mul(p, other.den) + for j, p in other.shifts.items(): + shifts[j] = _p_add(shifts.get(j, ()), _p_mul(p, self.den)) + return _Form(poly, shifts, den) + + def __neg__(self) -> _Form: + return _Form(_p_neg(self.poly), {j: _p_neg(p) for j, p in self.shifts.items()}, self.den) + + def __sub__(self, other: _Form) -> _Form: + return self + (-other) + + def __mul__(self, other: _Form) -> _Form: + if not self.is_linear_free and not other.is_linear_free: + raise _Unsupported("product of two sequence terms — not a linear recurrence") + if self.is_linear_free: + self, other = other, self + # `other` is now free of sequence terms. + den = _p_mul(self.den, other.den) + poly = _p_mul(self.poly, other.poly) + shifts = {j: _p_mul(p, other.poly) for j, p in self.shifts.items()} + return _Form(poly, shifts, den) + + def __truediv__(self, other: _Form) -> _Form: + if not other.is_linear_free: + raise _Unsupported("division by a sequence term — not a linear recurrence") + if not other.poly: + raise _Unsupported("division by zero") + den = _p_mul(self.den, other.poly) + poly = _p_mul(self.poly, other.den) + shifts = {j: _p_mul(p, other.den) for j, p in self.shifts.items()} + return _Form(poly, shifts, den) + + def power(self, exponent: int) -> _Form: + if exponent == 1: + return self + if not self.is_linear_free: + raise _Unsupported("a sequence term raised to a power — not linear") + if exponent < 0: + return _Form.constant(Fraction(1)) / self.power(-exponent) + return _Form(_p_pow(self.poly, exponent), {}, _p_pow(self.den, exponent)) + + def as_integer(self) -> int: + """The exponent this form denotes, or refuse.""" + if self.shifts or len(self.den) != 1 or len(self.poly) > 1: + raise _Unsupported("exponent is not an integer constant") + value = self.poly[0] / self.den[0] if self.poly else Fraction(0) + if value.denominator != 1: + raise _Unsupported("exponent is not an integer constant") + return int(value) + + +# --------------------------------------------------------------------------- +# Normalisation. +# --------------------------------------------------------------------------- + + +def _normalise(shifts: dict) -> tuple: + """Put ``Σ_j c_j(n)·u(n+j) = 0`` into normal form. + + Returns ``(p_0, …, p_J)``, each a tuple of ``int`` coefficients lowest + degree first, for the equivalent claim ``Σ_i p_i(n)·u(n+i) = 0``. + """ + live = {j: p for j, p in shifts.items() if p} + if len(live) < 2: + raise ValueError( + "a recurrence claim needs at least two sequence terms with nonzero " + f"coefficients, got {len(live)}" + ) + low, high = min(live), max(live) + # (1) Move the window to start at 0: the claim at index n+low is the same + # claim, so substitute n → n - low. + polys = [_p_shift(live.get(low + i, ()), -low) for i in range(high - low + 1)] + # (2) Divide out a common polynomial factor: `(n+1)·L` and `L` are the same + # recurrence written twice, up to the finitely many n where the factor + # vanishes. + common: tuple = () + for p in polys: + common = _p_gcd(common, p) + if len(common) > 1: + polys = [_p_divmod(p, common)[0] for p in polys] + # (3) Clear denominators, then divide by the integer content: this is where + # "×(−2)" and "the same thing over a common denominator" become equal. + multiplier = 1 + for p in polys: + for c in p: + multiplier = multiplier * c.denominator // gcd(multiplier, c.denominator) + integral = [[int(c * multiplier) for c in p] for p in polys] + content = 0 + for p in integral: + for c in p: + content = gcd(content, abs(c)) + if content > 1: + integral = [[c // content for c in p] for p in integral] + # (4) Sign: the first nonzero coefficient, scanning shifts upwards and + # degrees downwards, is positive. + for p in integral: + lead = next((c for c in reversed(p) if c != 0), 0) + if lead != 0: + if lead < 0: + integral = [[-c for c in q] for q in integral] + break + return tuple(tuple(p) for p in integral) + + +def _poly_from_expr(expr: Any, var: str) -> tuple: + """Read a polynomial in *var* out of an :class:`alkahest.Expr`, exactly.""" + node = expr.node() + head = node[0] + if head == "integer": + return _trim((Fraction(int(node[1])),)) + if head == "rational": + return _trim((Fraction(int(node[1]), int(node[2])),)) + if head == "symbol": + if node[1] != var: + raise ValueError( + f"coefficient mentions the symbol {node[1]!r}, but a recurrence " + f"coefficient must be a polynomial in {var!r} alone" + ) + return (Fraction(0), Fraction(1)) + if head == "add": + out: tuple = () + for child in node[1]: + out = _p_add(out, _poly_from_expr(child, var)) + return out + if head == "mul": + out = (Fraction(1),) + for child in node[1]: + out = _p_mul(out, _poly_from_expr(child, var)) + return out + if head == "pow": + exponent = _poly_from_expr(node[2], var) + if len(exponent) > 1 or (exponent and exponent[0].denominator != 1) or not exponent: + raise ValueError("a recurrence coefficient may only use non-negative integer powers") + power = int(exponent[0]) + if power < 0: + raise ValueError("a recurrence coefficient may only use non-negative integer powers") + return _p_pow(_poly_from_expr(node[1], var), power) + raise ValueError(f"{expr} is not a polynomial in {var!r}; a recurrence coefficient must be one") + + +def _coerce_poly(value: Any, var: str | None) -> tuple: + """One coefficient polynomial, from an ``Expr`` or a sequence of rationals.""" + if hasattr(value, "node"): + if var is None: + raise TypeError( + "coefficients given as Expr need the index variable: pass " + "var=n (the symbol the coefficients are polynomials in)" + ) + return _poly_from_expr(value, var) + if isinstance(value, (int, Fraction)): + return _trim((Fraction(value),)) + return _trim(Fraction(c) for c in value) + + +# --------------------------------------------------------------------------- +# The claim. +# --------------------------------------------------------------------------- + + +class RecurrenceClaim: + """A P-recursive recurrence, in a normal form two presentations share. + + The claim is ``Σ_{i} p_i(n)·u(n+i) = 0`` — a *homogeneous* linear relation + with polynomial coefficients, the thing :func:`alkahest.zeilberger` and + :func:`alkahest.guess_holonomic` both produce. + + What the normal form quotients out, and therefore what + :attr:`claim_hash` proves equal: + + 1. **Scale.** Multiplying every coefficient by a nonzero rational — so + ``×(−2)`` and "cleared by a different denominator" agree. Denominators + are cleared and the integer content divided out; the sign is fixed by + making the first nonzero coefficient positive, scanning shifts upwards + and degrees downwards. + 2. **Index shift.** ``Σ p_i(n)·u(n+i) = 0`` and the same relation written + about ``u(n−1)`` or ``u(n+1)`` are one statement re-indexed, so the + window is moved to start at ``u(n)`` and the coefficients substituted + accordingly. + 3. **A common polynomial factor.** ``(n+1)·L`` and ``L`` are the same + recurrence, up to the finitely many ``n`` where the factor vanishes. + 4. **Zero coefficients at either end** of the window, which only pad the + stated order. + + What it does **not** quotient out: a genuinely different relation, an + operator of different order that happens to be a left multiple, or the + range of ``n`` a source claims the relation on. The normal form is a + statement about the relation, not about its domain of validity — two + sources stating the same recurrence from different starting indices agree + here, which is what a novelty filter wants and is *not* a proof that the + two statements are interchangeable at small ``n``. + + >>> from alkahest.experimental.novelty import RecurrenceClaim + >>> # (n+1)·u(n+1) − (4n+2)·u(n) = 0 — central binomial coefficients + >>> a = RecurrenceClaim([(-2, -4), (1, 1)]) + >>> # the same relation, scaled by −2, stated about u(n+7) and u(n+8) + >>> b = RecurrenceClaim([(-60, -8), (16, 2)], offset=7) + >>> a.claim_hash == b.claim_hash + True + >>> a.order, a.degree + (1, 1) + """ + + __slots__ = ("_coefficients", "_hash", "_normal_form") + + def __init__(self, coefficients: Sequence[Any], *, offset: int = 0, var: Any = None): + """ + :param coefficients: ``[c_0, …, c_J]``, the coefficient of ``u(n+offset+i)``. + Each is an :class:`alkahest.Expr` polynomial in *var*, or a sequence + of exact rationals lowest degree first. + :param offset: index of the first coefficient's shift; ``coefficients[0]`` + multiplies ``u(n + offset)``. + :param var: the index symbol, required when the coefficients are + :class:`alkahest.Expr`. An ``Expr`` or a name. + :raises ValueError: when fewer than two coefficients are nonzero — a + claim with one term is not a recurrence. + """ + name = None + if var is not None: + name = var if isinstance(var, str) else var.node()[1] + shifts = {offset + i: _coerce_poly(c, name) for i, c in enumerate(coefficients)} + self._coefficients = _normalise(shifts) + self._normal_form = "recurrence/1 " + " + ".join( + f"({_p_text(tuple(Fraction(c) for c in p))})*u(n+{i})" + for i, p in enumerate(self._coefficients) + ) + self._hash = _claim_id(self._normal_form, method="recurrence") + + @classmethod + def from_recurrence(cls, rec: Any, var: Any = None) -> RecurrenceClaim: + """From a :class:`~alkahest.ZeilbergerCertificate`, a + :class:`~alkahest.GuessedRecurrence`, or a raw coefficient list. + + Duck-typed on ``.coeffs`` exactly as + :func:`alkahest.experimental.asymptotics_from_recurrence` is, so a + wrapper around either still works. *var* is required when the + coefficients are expressions. + """ + coeffs = getattr(rec, "coeffs", None) + return cls(list(rec) if coeffs is None else list(coeffs), var=var) + + @classmethod + def from_text(cls, text: str) -> RecurrenceClaim | None: + """Parse one prose formula line, e.g. an OEIS ``a(n) = …`` statement. + + Returns ``None`` — never a guess — when the line is not a homogeneous + linear recurrence with polynomial coefficients in the single sequence + ``a``: a sum, a generating function, a nonlinear relation, a reference + to another sequence, an inhomogeneous relation, or a statement the + parser simply does not cover. Callers that need to know how often that + happened should count the ``None``s; :meth:`NoveltyVerdict.report` + does. + """ + try: + relation = _parse_relation(text) + except _Unsupported: + return None + if relation is None: + return None + try: + return cls([relation[j] for j in sorted(relation)], offset=min(relation)) + except ValueError: + return None + + @property + def order(self) -> int: + """``J`` — the span of the window in normal form.""" + return len(self._coefficients) - 1 + + @property + def degree(self) -> int: + """Largest degree in ``n`` of any coefficient in normal form.""" + return max((len(p) - 1 for p in self._coefficients if p), default=0) + + @property + def normal_form(self) -> str: + """The canonical text the hash is taken of. + + Versioned (``recurrence/1``): a change to the normal form changes every + hash, so the tag is part of what is hashed. + """ + return self._normal_form + + @property + def claim_hash(self) -> str: + """Content address of :attr:`normal_form`, e.g. ``'clm_9f1c0b2a7d4e5f60'``. + + Computed by :func:`alkahest.research.claim_id`, so this library has one + content-addressing scheme rather than two. Equal hashes mean equal + normal forms; a loop can dedupe with ``seen.add(claim.claim_hash)``. + """ + return self._hash + + def coefficients(self) -> tuple: + """``(p_0, …, p_J)`` in normal form, each a tuple of ``int``, ascending.""" + return self._coefficients + + def holds_for(self, terms: Sequence[Any], *, start: int = 0) -> bool: + """Exactly re-check the normal form against terms, ``terms[0] = u(start)``. + + Arithmetic is exact, so ``True`` is a fact about those terms — not a + tolerance, and not a proof about the sequence. Requires every window + the terms provide to hold; see :meth:`confirmations` for the lenient + count a recurrence stated only for large ``n`` needs. + + **What *start* means, precisely — read this before passing anything + other than the default:** *start* is the true index of ``terms[0]``, + full stop — not of some other element, not of "the real data" if + *terms* happens to carry a padding element you consider bogus. The + window at array position ``row`` is checked at ``n = start + row``; + every coefficient here can be a genuine polynomial in ``n`` (this is + P-recursive, not just P-recursive-in-name), so a wrong *start* is not + a small numeric error that shifts a few results — it evaluates every + coefficient at the wrong point and generically makes the *entire* + array fail, trailing windows included, even ones that never touch the + element you meant to exclude. There is no way for this method to + recover the right offset on its own: if you slice, drop, or prepend + elements relative to some original indexing, you must adjust *start* + by the same amount, e.g. ``terms = [junk, *real]`` needs + ``start=-1`` (``junk`` sits where ``u(-1)`` would), not ``start=0``. + """ + windows = len(terms) - self.order + return windows > 0 and self.confirmations(terms, start=start) == windows + + def confirmations(self, terms: Sequence[Any], *, start: int = 0) -> int: + """How many **trailing** consecutive windows of *terms* the claim holds on. + + Trailing, because a recurrence is routinely stated only for ``n`` past + some initial segment, and an exception at ``n = 0`` says nothing about + whether the relation is the one that was meant. "Lenient" describes + *which* windows are required to hold (only the trailing run, not the + front), never the meaning of *start*: see :meth:`holds_for` for what + *start* must denote and why getting it wrong corrupts every window, + not only the ones near the misindexed element — this method cannot + detect or route around a caller's off-by-one, it can only be lenient + about a relation that is *correctly* indexed but genuinely does not + hold for small ``n``. + """ + values = [Fraction(t) for t in terms] + count = 0 + for row in range(len(values) - self.order - 1, -1, -1): + index = Fraction(start + row) + total = Fraction(0) + for i, poly in enumerate(self._coefficients): + total += _p_eval(tuple(Fraction(c) for c in poly), index) * values[row + i] + if total != 0: + break + count += 1 + return count + + def __eq__(self, other: object) -> bool: + if not isinstance(other, RecurrenceClaim): + return NotImplemented + return self._normal_form == other._normal_form + + def __hash__(self) -> int: + return hash(self._normal_form) + + def __repr__(self) -> str: + return ( + f"RecurrenceClaim(order={self.order}, degree={self.degree}, claim_hash={self._hash!r})" + ) + + +# --------------------------------------------------------------------------- +# Parsing prose formula lines. +# --------------------------------------------------------------------------- + +_TOKEN_RE = re.compile(r"([A-Za-z_][A-Za-z0-9_]*|\d+|\*\*|\S)") +_OPERATORS = frozenset("+-*/^") +_OPENERS = frozenset("([{") +_CLOSERS = frozenset(")]}") +#: A statement worth handing to the parser at all: it mentions a shifted term. +_LOOKS_LIKE_RECURRENCE = re.compile(r"a\(\s*n\s*[-+]\s*\d+\s*\)") +#: OEIS's own hedges. An entry that marks a formula this way is telling you the +#: recurrence was fitted and never proved — which is the whole reason a novelty +#: filter over OEIS is worth anything. +_HEDGE_RE = re.compile( + r"\b(conjectur\w*|empirical\w*|apparently|seems? to|guessed|unproved|unproven)\b", + re.IGNORECASE, +) + + +def _tokenise(text: str) -> list: + return _TOKEN_RE.findall(text) + + +def _is_word(token: str) -> bool: + return token[0].isalpha() or token[0] == "_" + + +class _Parser: + """Recursive descent over ``+ - * / ^ ( )``, integers, ``n`` and ``a(n±k)``. + + Everything else — another sequence's ``A123456(n)``, ``Sum_{…}``, a symbol + that is not the index — raises :class:`_Unsupported`. Refusing is the point: + a parser that guesses at prose invents claims that were never made. + """ + + def __init__(self, tokens: Sequence[str]): + self.tokens = tokens + self.pos = 0 + + def peek(self) -> str | None: + return self.tokens[self.pos] if self.pos < len(self.tokens) else None + + def expression(self) -> _Form: + node = self.term() + while self.peek() in ("+", "-"): + op = self.tokens[self.pos] + self.pos += 1 + rhs = self.term() + node = node + rhs if op == "+" else node - rhs + return node + + def term(self) -> _Form: + node = self.factor() + while self.peek() in ("*", "/"): + op = self.tokens[self.pos] + self.pos += 1 + rhs = self.factor() + node = node * rhs if op == "*" else node / rhs + return node + + def factor(self) -> _Form: + if self.peek() in ("+", "-"): + op = self.tokens[self.pos] + self.pos += 1 + node = self.factor() + return -node if op == "-" else node + node = self.atom() + if self.peek() in ("^", "**"): + self.pos += 1 + return node.power(self.factor().as_integer()) + return node + + def atom(self) -> _Form: + token = self.peek() + if token is None: + raise _Unsupported("expression ended early") + self.pos += 1 + if token == "(": + node = self.expression() + if self.peek() != ")": + raise _Unsupported("unbalanced parenthesis") + self.pos += 1 + return node + if token.isdigit(): + return _Form.constant(Fraction(int(token))) + if token == "n": + return _Form.variable() + if token == "a": + if self.peek() != "(": + raise _Unsupported("sequence name not applied to an index") + self.pos += 1 + index = self.expression() + if self.peek() != ")": + raise _Unsupported("unbalanced parenthesis in a sequence index") + self.pos += 1 + return _Form.sequence_term(_shift_of(index)) + raise _Unsupported(f"unsupported token {token!r}") + + +def _shift_of(index: _Form) -> int: + """``k`` from an index that reads ``n + k``; refuse anything else. + + ``a(2*n)`` and ``a(0)`` are not shifts of the running index: the first is a + different sequence, the second an initial condition. Both must be refused + rather than approximated into a shift. + """ + if index.shifts or len(index.den) != 1: + raise _Unsupported("sequence index is not of the form n+k") + poly = index.poly + if len(poly) != 2 or poly[1] != 1 or poly[0].denominator != 1: + raise _Unsupported("sequence index is not of the form n+k") + return int(poly[0]) + + +def _clean(text: str) -> str: + """Strip the wrapping OEIS puts around a formula line.""" + text = text.split(" - _", 1)[0] + text = text.replace("(Start)", " ").replace("(End)", " ") + return text.strip().rstrip(".") + + +def _top_level_equals(tokens: Sequence[str]) -> int | None: + depth = 0 + for i, token in enumerate(tokens): + if token in _OPENERS: + depth += 1 + elif token in _CLOSERS: + depth -= 1 + elif token == "=" and depth == 0: + return i + return None + + +def _parse_all(tokens: Sequence[str]) -> _Form | None: + parser = _Parser(tokens) + try: + form = parser.expression() + except _Unsupported: + return None + return form if parser.pos == len(tokens) else None + + +def _boundary_ok(token: str | None) -> bool: + """Whether prose may legitimately abut the formula at this token. + + A word (``with``, ``for``, ``unless``), punctuation, or the end of the line + ends the formula. An arithmetic operator or a bracket does **not** — it + means the expression continues and the parser stopped inside it, and + truncating there would silently invent a different, shorter claim. + ``a(n) = a(n-1) + A002026(n-1)`` must be refused, not read as + ``a(n) = a(n-1)``. + """ + if token is None: + return True + if token in _OPERATORS or token in _OPENERS or token in _CLOSERS: + return False + return _is_word(token) or token in {".", ",", ";", ":", "=", "!"} + + +def _parse_relation(text: str) -> dict | None: + """``{shift: coefficient polynomial}`` for a prose linear recurrence, or ``None``.""" + tokens = _tokenise(_clean(text)) + split = _top_level_equals(tokens) + if split is None: + return None + lhs = None + for start in range(split): + if not _boundary_ok(tokens[start - 1] if start else None): + continue + lhs = _parse_all(tokens[start:split]) + if lhs is not None: + break + if lhs is None: + return None + rhs = None + for stop in range(len(tokens), split, -1): + if not _boundary_ok(tokens[stop] if stop < len(tokens) else None): + continue + rhs = _parse_all(tokens[split + 1 : stop]) + if rhs is not None: + break + if rhs is None: + return None + form = lhs - rhs + if form.poly: + # Inhomogeneous: `a(n) = a(n-1) + 1` is a different kind of claim and + # is not silently truncated into a homogeneous one. + return None + live = {j: p for j, p in form.shifts.items() if p} + return live or None + + +# --------------------------------------------------------------------------- +# Sources. +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class RecordedRecurrence: + """A recurrence a source states, and how it states it.""" + + claim: RecurrenceClaim + statement: str + hedged: bool + confirmations: int + + +class OeisEntry: + """One OEIS entry, reduced to what a recurrence claim can be checked against.""" + + __slots__ = ("_scan", "id", "name", "offset", "statements", "terms") + + def __init__( + self, + id: str, + name: str = "", + terms: Sequence[int] = (), + statements: Sequence[str] = (), + offset: int = 0, + ): + self.id = id + self.name = name + self.terms = tuple(int(t) for t in terms) + self.statements = tuple(statements) + self.offset = offset + self._scan: tuple | None = None + + @classmethod + def from_oeis_json(cls, payload: dict) -> OeisEntry: + """From one element of ``https://oeis.org/search?…&fmt=json``. + + Only the formula and comment lines that mention a shifted ``a(n±k)`` + are kept: the rest cannot state a recurrence, and a cache that keeps + them is a cache nobody commits. + """ + offset = 0 + raw_offset = str(payload.get("offset", "0")).split(",")[0].strip() + if raw_offset.lstrip("-").isdigit(): + offset = int(raw_offset) + lines = list(payload.get("formula") or ()) + list(payload.get("comment") or ()) + return cls( + id=f"A{int(payload['number']):06d}", + name=payload.get("name", ""), + terms=[int(t) for t in str(payload.get("data", "")).split(",") if t.strip()], + statements=[ln for ln in lines if _LOOKS_LIKE_RECURRENCE.search(ln)], + offset=offset, + ) + + def to_json(self) -> dict: + return { + "id": self.id, + "name": self.name, + "offset": self.offset, + "terms": list(self.terms), + "statements": list(self.statements), + } + + def _scanned(self) -> tuple: + if self._scan is None: + usable, unusable = [], [] + for statement in self.statements: + claim = RecurrenceClaim.from_text(statement) + if claim is None: + unusable.append(statement) + continue + # The line is only believed once it reproduces the entry's own + # terms. This is what stops a mis-read of somebody's prose from + # entering the index as a claim they never made. + confirmations = claim.confirmations(self.terms, start=self.offset) + if confirmations < min(_MIN_CONFIRMATIONS, len(self.terms) - claim.order): + unusable.append(statement) + continue + usable.append( + RecordedRecurrence( + claim=claim, + statement=statement.strip(), + hedged=bool(_HEDGE_RE.search(statement)), + confirmations=confirmations, + ) + ) + self._scan = (tuple(usable), tuple(unusable)) + return self._scan + + def recurrences(self) -> tuple: + """Every recurrence this entry states that also reproduces its own terms.""" + return self._scanned()[0] + + def unusable_statements(self) -> tuple: + """Lines that mention ``a(n±k)`` but could not be turned into a claim. + + Either the parser does not cover them or they failed the check against + the entry's own data. They are counted into + :meth:`NoveltyVerdict.report` so that the width of a ``not_found`` is + visible: a claim can only be found in a statement that was understood. + """ + return self._scanned()[1] + + def __repr__(self) -> str: + return f"OeisEntry({self.id!r}, {self.name[:40]!r}, terms={len(self.terms)})" + + +@dataclass(frozen=True) +class SourceAnswer: + """What a source was able to say. + + ``exhaustive`` is the honest half: ``True`` means these are *all* the + entries the source has for the query, so a claim missing from them is + genuinely missing from the source. ``False`` means the source found what it + had locally but cannot promise there is nothing else, and a non-match must + therefore be reported as ``unavailable`` rather than ``not_found``. + """ + + entries: tuple + exhaustive: bool + + +def _query_key(terms: Sequence[int] | None, ids: Sequence[str] | None) -> str: + if ids: + return "id:" + ",".join(sorted(ids)) + return "seq:" + ",".join(str(int(t)) for t in terms or ()) + + +class OeisCache: + """A file-backed OEIS cache — the offline path, and the test fixture format. + + Holds two things: **entries**, keyed by A-number, and **queries** already + put to OEIS, keyed by what was asked. The second is what makes an honest + negative possible: a cache that only stores hits can never distinguish "I + asked and OEIS had nothing" from "I never asked", and reporting the second + as the first is exactly the overclaim this module is for. + + >>> from alkahest.experimental.novelty import OeisCache, OeisEntry + >>> cache = OeisCache() + >>> cache.add(OeisEntry("A000045", "Fibonacci", terms=[1, 1, 2, 3, 5, 8])) + >>> cache.record_query(terms=[1, 1, 2, 3, 5, 8], ids=None, found=["A000045"]) + >>> cache.n_entries + 1 + """ + + #: Written into every file this class saves. + LICENSE = ( + "Sequence data and formula lines are from the On-Line Encyclopedia of " + "Integer Sequences (https://oeis.org), (c) The OEIS Foundation Inc., " + "licensed CC BY-NC-SA 4.0." + ) + + def __init__(self, path: Any = None): + """:param path: a JSON file to load, if it exists.""" + self.path = Path(path) if path is not None else None + self._entries: dict = {} + self._queries: dict = {} + if self.path is not None and self.path.exists(): + self.load(self.path) + + @property + def name(self) -> str: + """Identifies this source in a verdict.""" + return "oeis-cache" + + @property + def n_entries(self) -> int: + """Entries held.""" + return len(self._entries) + + @property + def n_queries(self) -> int: + """Queries whose full result is known, including the empty ones.""" + return len(self._queries) + + def add(self, entry: OeisEntry) -> None: + """Store *entry*, replacing any entry with the same A-number.""" + self._entries[entry.id] = entry + + def record_query( + self, + *, + terms: Sequence[int] | None = None, + ids: Sequence[str] | None = None, + found: Sequence[str] = (), + ) -> None: + """Record that this exact query was put to OEIS and returned *found*. + + An empty *found* is meaningful — it is the only way a cache can support + a ``not_found`` verdict. + """ + self._queries[_query_key(terms, ids)] = list(found) + + def load(self, path: Any) -> None: + payload = json.loads(Path(path).read_text(encoding="utf-8")) + for raw in payload.get("entries", {}).values(): + self.add( + OeisEntry( + id=raw["id"], + name=raw.get("name", ""), + terms=raw.get("terms", ()), + statements=raw.get("statements", ()), + offset=raw.get("offset", 0), + ) + ) + self._queries.update(payload.get("queries", {})) + + def save(self, path: Any = None) -> None: + target = Path(path) if path is not None else self.path + if target is None: + raise ValueError("no path to save to: construct with one or pass one here") + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + json.dumps( + { + "kind": "alkahest.oeis_cache", + "version": 1, + "license": self.LICENSE, + "entries": {k: v.to_json() for k, v in sorted(self._entries.items())}, + "queries": {k: sorted(v) for k, v in sorted(self._queries.items())}, + }, + indent=1, + sort_keys=False, + ) + + "\n", + encoding="utf-8", + ) + + def lookup( + self, + *, + terms: Sequence[int] | None = None, + ids: Sequence[str] | None = None, + ) -> SourceAnswer | None: + """Entries for a query, or ``None`` when this cache cannot answer it. + + A **hit** is real evidence wherever it comes from, so a stored entry + whose data contains the queried run is returned even if that exact + query was never recorded. A **miss** is only reported as exhaustive + when the query itself was recorded, because otherwise all it means is + that the cache is small. + """ + key = _query_key(terms, ids) + recorded = self._queries.get(key) + if ids: + entries = tuple(self._entries[i] for i in ids if i in self._entries) + if entries or recorded is not None: + missing = [i for i in ids if i not in self._entries] + return SourceAnswer(entries, exhaustive=recorded is not None or not missing) + return None + wanted = tuple(int(t) for t in terms or ()) + if not wanted: + return None + hits = {e.id: e for e in self._entries.values() if _contains_run(e.terms, wanted)} + if recorded is not None: + # Everything the source returned for this exact query, whether or + # not the stored data happens to contain the run verbatim — OEIS + # matches on more than a literal prefix. + for entry_id in recorded: + if entry_id in self._entries: + hits.setdefault(entry_id, self._entries[entry_id]) + elif not hits: + return None + return SourceAnswer(tuple(hits[k] for k in sorted(hits)), exhaustive=recorded is not None) + + def __repr__(self) -> str: + return f"OeisCache(entries={self.n_entries}, queries={self.n_queries})" + + +def _contains_run(haystack: Sequence[int], needle: Sequence[int]) -> bool: + if not needle or len(needle) > len(haystack): + return False + first = needle[0] + for start in range(len(haystack) - len(needle) + 1): + if haystack[start] == first and tuple(haystack[start : start + len(needle)]) == tuple( + needle + ): + return True + return False + + +class OeisWeb: + """Live OEIS lookup. **Opt-in**: nothing constructs one for you. + + Serves from its :class:`OeisCache` before it touches the network, sleeps + ``min_interval`` seconds between requests, sends a User-Agent that says who + is calling, and **returns ``None`` instead of raising** when the network is + not there — so an offline run degrades to ``unavailable``, which is the + honest verdict, rather than to an exception or, far worse, to a negative. + + No test in this repository constructs one. Record a fixture once:: + + web = OeisWeb(cache=OeisCache()) + web.lookup(ids=["A005259"]) + web.cache.save("tests/data/oeis_novelty_fixture.json") + """ + + #: Shared across instances so several sources cannot bypass the interval. + _last_request: ClassVar[list] = [0.0] + + def __init__( + self, + cache: OeisCache | None = None, + *, + min_interval: float = 1.0, + timeout: float = 30.0, + user_agent: str = "alkahest-novelty/1.0 (+https://github.com/alkahest-cas/alkahest)", + max_results: int = 10, + ): + self.cache = cache if cache is not None else OeisCache() + self.min_interval = float(min_interval) + self.timeout = float(timeout) + self.user_agent = user_agent + self.max_results = int(max_results) + #: Why the last lookup came back ``None``, for a report. + self.last_error: str | None = None + + @property + def name(self) -> str: + """Identifies this source in a verdict.""" + return "oeis" + + def lookup( + self, + *, + terms: Sequence[int] | None = None, + ids: Sequence[str] | None = None, + ) -> SourceAnswer | None: + cached = self.cache.lookup(terms=terms, ids=ids) + if cached is not None and cached.exhaustive: + return cached + query = ( + " ".join(f"id:{i}" for i in ids) if ids else ",".join(str(int(t)) for t in terms or ()) + ) + if not query: + return None + payload = self._fetch(query) + if payload is None: + return cached + entries = [] + for raw in payload[: self.max_results]: + try: + entry = OeisEntry.from_oeis_json(raw) + except (KeyError, TypeError, ValueError): + continue + self.cache.add(entry) + entries.append(entry) + self.cache.record_query(terms=terms, ids=ids, found=[e.id for e in entries]) + return SourceAnswer(tuple(entries), exhaustive=True) + + def _fetch(self, query: str) -> list | None: + self.last_error = None + wait = self.min_interval - (time.monotonic() - self._last_request[0]) + if wait > 0: + time.sleep(wait) + url = "https://oeis.org/search?" + urllib.parse.urlencode({"q": query, "fmt": "json"}) + request = urllib.request.Request(url, headers={"User-Agent": self.user_agent}) + try: + with urllib.request.urlopen(request, timeout=self.timeout) as handle: + body = handle.read().decode("utf-8") + except Exception as exc: + self.last_error = f"{type(exc).__name__}: {exc}" + return None + finally: + self._last_request[0] = time.monotonic() + try: + decoded = json.loads(body) + except json.JSONDecodeError as exc: + self.last_error = f"JSONDecodeError: {exc}" + return None + if isinstance(decoded, dict): + decoded = decoded.get("results") or [] + return decoded if isinstance(decoded, list) else [] + + def __repr__(self) -> str: + return f"OeisWeb(cache={self.cache!r}, min_interval={self.min_interval})" + + +# --------------------------------------------------------------------------- +# The verdict. +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class NoveltyMatch: + """One place a source states the claim.""" + + source: str + entry: str + statement: str + hedged: bool + + def to_json(self) -> dict: + return { + "source": self.source, + "entry": self.entry, + "statement": self.statement, + "hedged": self.hedged, + } + + +class NoveltyVerdict: + """The result of a novelty check. Read :attr:`status`, never ``bool()``. + + See the module docstring for what a negative is allowed to claim. In short: + :attr:`found` is ``True``/``False``/``None`` and ``False`` means "not in + the sources searched", which is not novelty. + """ + + __slots__ = ( + "_claim_hash", + "_consulted", + "_entries", + "_matches", + "_statements", + "_unavailable", + "_unusable", + ) + + def __init__( + self, + *, + claim_hash: str, + matches: Sequence[NoveltyMatch], + consulted: Sequence[str], + unavailable: Sequence[str], + entries: int, + statements: int, + unusable: int, + ): + self._claim_hash = claim_hash + self._matches = tuple(matches) + self._consulted = tuple(consulted) + self._unavailable = tuple(unavailable) + self._entries = entries + self._statements = statements + self._unusable = unusable + + @property + def status(self) -> str: + """One of :data:`NOVELTY_STATUSES`.""" + if self._matches: + return "recorded_conjecturally" if self.hedged else "recorded" + if self._consulted: + return "not_found" + return "unavailable" + + @property + def found(self) -> bool | None: + """``True`` / ``False`` / ``None`` — see the module docstring. + + ``None`` is not a pass and ``False`` is not novelty; both mean the + question is open, for different reasons. + """ + if self._matches: + return True + return False if self._consulted else None + + @property + def hedged(self) -> bool | None: + """Whether every source that states the claim marks it conjectural. + + ``None`` when nothing states it. OEIS labels its guessed formulas + (``Conjecture``, ``Empirical``), and that label is the difference + between "this is known" and "this is believed" — proving a hedged + recurrence is a result; restating an unhedged one is not. + """ + if not self._matches: + return None + return all(m.hedged for m in self._matches) + + @property + def claim_hash(self) -> str: + """Content address of the claim that was checked.""" + return self._claim_hash + + @property + def entries_examined(self) -> int: + """Source entries whose statements were compared against the claim.""" + return self._entries + + @property + def statements_compared(self) -> int: + """Statements that were parsed into a claim and compared.""" + return self._statements + + @property + def statements_unusable(self) -> int: + """Statements that mention a recurrence but could not be compared. + + Every one of these is a way this verdict could be wrong in the + ``not_found`` direction, which is why it is reported rather than + swallowed. + """ + return self._unusable + + @property + def means(self) -> str: + """The one-line gloss of :attr:`status` from :data:`STATUS_MEANINGS`.""" + return STATUS_MEANINGS[self.status] + + def matches(self) -> tuple: + """Every :class:`NoveltyMatch` found.""" + return self._matches + + def sources_consulted(self) -> tuple: + """Names of the sources that were able to answer.""" + return self._consulted + + def sources_unavailable(self) -> tuple: + """Names of the sources that were not.""" + return self._unavailable + + def report(self) -> dict: + """The verdict and everything that went into it, for a log. + + Sibling of :meth:`alkahest.GuessedRecurrence.evidence` and + :func:`alkahest.relation_confidence`'s return value: a research loop + should be able to record *why* a claim was reported, not only that it + was. + """ + return { + "status": self.status, + "found": self.found, + "hedged": self.hedged, + "means": self.means, + "claim_hash": self._claim_hash, + "matches": [m.to_json() for m in self._matches], + "sources_consulted": list(self._consulted), + "sources_unavailable": list(self._unavailable), + "entries_examined": self._entries, + "statements_compared": self._statements, + "statements_unusable": self._unusable, + } + + def __bool__(self) -> bool: + raise TypeError( + "a NoveltyVerdict has no truth value: `if verdict:` would read as " + "'is this novel?' and there is no such answer here. Test " + "verdict.status ('recorded', 'recorded_conjecturally', " + "'not_found', 'unavailable') or verdict.found, which is " + "True/False/None and whose False means 'not in the sources " + "searched', not 'new'" + ) + + def __repr__(self) -> str: + return ( + f"NoveltyVerdict(status={self.status!r}, matches={len(self._matches)}, " + f"entries_examined={self._entries}, sources_consulted=" + f"{list(self._consulted)})" + ) + + +def check_novelty( + claim: RecurrenceClaim, + sources: Sequence[Any], + *, + terms: Sequence[int] | None = None, + ids: Sequence[str] | None = None, +) -> NoveltyVerdict: + """Look *claim* up in *sources* and report what was found. + + :param claim: the normalised claim — build it with + :meth:`RecurrenceClaim.from_recurrence` from a + :class:`~alkahest.ZeilbergerCertificate` or a + :class:`~alkahest.GuessedRecurrence`. + :param sources: objects with a ``name`` and a + ``lookup(*, terms=None, ids=None)`` returning a :class:`SourceAnswer` + or ``None``. :class:`OeisCache` offline, :class:`OeisWeb` live. + **There is no default**: a check with no source returns + ``unavailable``, and this module will not quietly reach for the network + on your behalf. + :param terms: exact leading terms of the sequence, to identify it. Give + enough that the identification is not accidental — ten is plenty for a + sequence that grows. + :param ids: source-specific identifiers to check instead, e.g. + ``["A005259"]``. + + :returns: a :class:`NoveltyVerdict`. Never raises for a missing source or a + dead network; those are ``unavailable``. + :raises TypeError: when *claim* is not a :class:`RecurrenceClaim`. + :raises ValueError: when neither *terms* nor *ids* is given. + + >>> from alkahest.experimental.novelty import ( + ... OeisCache, OeisEntry, RecurrenceClaim, check_novelty) + >>> entry = OeisEntry( + ... "A000984", "Central binomial coefficients", + ... terms=[1, 2, 6, 20, 70, 252, 924, 3432], + ... statements=["D-finite with recurrence: n*a(n) + 2*(1-2*n)*a(n-1)=0."]) + >>> cache = OeisCache() + >>> cache.add(entry) + >>> claim = RecurrenceClaim([(-2, -4), (1, 1)]) # (n+1)u(n+1) = (4n+2)u(n) + >>> verdict = check_novelty(claim, [cache], terms=[1, 2, 6, 20, 70, 252]) + >>> verdict.status, verdict.found, verdict.hedged + ('recorded', True, False) + + The same lookup with nothing to look in is ``unavailable``, not novel: + + >>> check_novelty(claim, [], terms=[1, 2, 6, 20]).found is None + True + """ + if not isinstance(claim, RecurrenceClaim): + raise TypeError( + "claim must be a RecurrenceClaim; build one with " + "RecurrenceClaim.from_recurrence(certificate, var=n) so that what " + "is looked up is the normal form, not one presentation of it" + ) + if not terms and not ids: + raise ValueError( + "give terms= (the sequence's leading terms) or ids= (source " + "identifiers); there is nothing to look up otherwise" + ) + matches, consulted, unavailable = [], [], [] + seen_entries: dict = {} + statements = unusable = 0 + for source in sources: + name = getattr(source, "name", type(source).__name__) + answer = source.lookup(terms=terms, ids=ids) + if answer is None or not answer.exhaustive: + unavailable.append(name) + if answer is None: + continue + else: + consulted.append(name) + for entry in answer.entries: + if entry.id in seen_entries: + continue + seen_entries[entry.id] = True + unusable += len(entry.unusable_statements()) + for record in entry.recurrences(): + statements += 1 + if record.claim.claim_hash == claim.claim_hash: + matches.append( + NoveltyMatch( + source=name, + entry=entry.id, + statement=record.statement, + hedged=record.hedged, + ) + ) + return NoveltyVerdict( + claim_hash=claim.claim_hash, + matches=matches, + consulted=consulted, + unavailable=unavailable, + entries=len(seen_entries), + statements=statements, + unusable=unusable, + ) diff --git a/tests/data/oeis_novelty_fixture.json b/tests/data/oeis_novelty_fixture.json new file mode 100644 index 00000000..f3104eb3 --- /dev/null +++ b/tests/data/oeis_novelty_fixture.json @@ -0,0 +1,657 @@ +{ + "kind": "alkahest.oeis_cache", + "version": 1, + "license": "Sequence data and formula lines are from the On-Line Encyclopedia of Integer Sequences (https://oeis.org), (c) The OEIS Foundation Inc., licensed CC BY-NC-SA 4.0.", + "entries": { + "A000108": { + "id": "A000108", + "name": "Catalan numbers: C(n) = binomial(2n,n)/(n+1) = (2n)!/(n!(n+1)!).", + "offset": 0, + "terms": [ + 1, + 1, + 2, + 5, + 14, + 42, + 132, + 429, + 1430, + 4862, + 16796, + 58786, + 208012, + 742900, + 2674440, + 9694845, + 35357670, + 129644790, + 477638700, + 1767263190, + 6564120420, + 24466267020, + 91482563640, + 343059613650, + 1289904147324, + 4861946401452, + 18367353072152, + 69533550916004, + 263747951750360, + 1002242216651368, + 3814986502092304 + ], + "statements": [ + "Recurrence: a(n) = 2*(2*n-1)*a(n-1)/(n+1) with a(0) = 1.", + "a(n+1) = Sum_{i} binomial(n, 2*i)*2^(n-2*i)*a(i). - Touchard", + "a(n+1) = (1/(n+1))*Sum_{k=0..n} a(n-k)*binomial(2k+1, k+1). - _Philippe Del\u00e9ham_, Jan 24 2004", + "a(n) = a(n-1)*(4-6/(n+1)). a(n) = 2a(n-1)*(8a(n-2)+a(n-1))/(10a(n-2)-a(n-1)). - _Franklin T. Adams-Watters_, Feb 08 2006", + "a(n+1) = A214292(2*n+1,n) = A214292(2*n+2,n). - _Reinhard Zumkeller_, Jul 12 2012", + "a(n-1) = Sum_{t1+2*t2+...+n*tn=n} (-1)^(1+t1+t2+...+tn)*multinomial(t1+t2 +...+tn,t1,t2,...,tn)*a(1)^t1*a(2)^t2*...*a(n)^tn. - _Mircea Merca_, Feb 27 2014", + "0 = a(n)*(16*a(n+1) - 10*a(n+2)) + a(n+1)*(2*a(n+1) + a(n+2)) for all n>=0. - _Michael Somos_, Dec 12 2022", + "a(n-1) is the number of ways of expressing an n-cycle (123...n) in the symmetric group S_n as a product of n-1 transpositions (u_1,v_1)*(u_2,v_2)*...*(u_{n-1},v_{n-1}) where u_ioo} a(n)/a(n-1) = 4. - Francesco Antoni (francesco_antoni(AT)yahoo.com), Nov 24 2008", + "Consider a set of A000217(n) balls of n colors in which, for each integer k = 1 to n, exactly one color appears in the set a total of k times. (Each ball has exactly one color and is indistinguishable from other balls of the same color.) a(n+1) equals the number of ways to choose 0 or more balls of each color while satisfying the following conditions: 1. No two colors are chosen the same positive number of times. 2. For any two colors (c, d) that are chosen at least once, color c is chosen more times than color d iff color c appears more times in the original set than color d.", + "One class of generalized Catalan numbers can be defined by g.f. A(x) = (1-sqrt(1-q*4*x*(1-(q-1)*x)))/(2*q*x) with nonzero parameter q. Recurrence: (n+3)*a(n+2) -2*q*(2*n+3)*a(n+1) +4*q*(q-1)*n*a(n) = 0 with a(0)=1, a(1)=1.", + "See Haran & Tabachnikov link for a video discussing Conway-Coxeter friezes. The Conway-Coxeter friezes with n nontrivial rows are generated by the counts of triangles at each vertex in the triangulations of regular (n+3)-gons, of which there are a(n+1). - _Charles R Greathouse IV_, Sep 28 2019", + "For n >= 1, a(n-1) is the number of interpretations of x^n is an algebra where power-associativity is not assumed. For example, for n = 4 there are a(3) = 5 interpretations: x(x(xx)), x((xx)x), (xx)(xx), (x(xx))x, ((xx)x)x. See the link \"Non-associate powers and a functional equation\" from I. M. H. Etherington and the page \"Nonassociative Product\" from Eric Weisstein's World of Mathematics for detailed information. See also A001190 for the case where multiplication is commutative. - _Jianing Song_, Apr 29 2022", + "a(n-1) is the number of assembly trees of the path graph P_n, for n >= 1. - _Eric W. Weisstein_, Jul 03 2026" + ] + }, + "A000984": { + "id": "A000984", + "name": "Central binomial coefficients: binomial(2*n,n) = (2*n)!/(n!)^2.", + "offset": 0, + "terms": [ + 1, + 2, + 6, + 20, + 70, + 252, + 924, + 3432, + 12870, + 48620, + 184756, + 705432, + 2704156, + 10400600, + 40116600, + 155117520, + 601080390, + 2333606220, + 9075135300, + 35345263800, + 137846528820, + 538257874440, + 2104098963720, + 8233430727600, + 32247603683100, + 126410606437752, + 495918532948104, + 1946939425648112 + ], + "statements": [ + "a(n+1) = 2*A001700(n) = A030662(n) + 1. a(2*n) = A001448(n), a(2*n+1) = 2*A002458(n) =A099976.", + "D-finite with recurrence: n*a(n) + 2*(1-2*n)*a(n-1)=0.", + "a(n) = a(n-1)*(4-2/n) = Product_{k=1..n} (4-2/k) = 4*a(n-1) + A002420(n) = A000142(2*n)/(A000142(n)^2) = A001813(n)/A000142(n) = sqrt(A002894(n)) = A010050(n)/A001044(n) = (n+1)*A000108(n) = -A005408(n-1)*A002420(n). - _Henry Bottomley_, Nov 10 2000", + "a(n+1) = Sum_{j=n..n*2+1} binomial(j, n). E.g., a(4) = C(7,3) + C(6,3) + C(5,3) + C(4,3) + C(3,3) = 35 + 20 + 10 + 4 + 1 = 70. - _Jon Perry_, Jan 20 2004", + "a(n-1) = (1/2)*(-1)^n*Sum_{0<=i, j<=n}(-1)^(i+j)*binomial(2n, i+j). - _Benoit Cloitre_, Jun 18 2005", + "a(n) = Sum_{k>=0} A039599(n, k). a(n) = Sum_{k>=0} A050165(n, k). a(n) = Sum_{k>=0} A059365(n, k)*2^k, n>0. a(n+1) = Sum_{k>=0} A009766(n, k)*2^(n-k+1). - _Philippe Del\u00e9ham_, Jan 01 2004", + "0 = a(n)*(16*a(n+1) - 6*a(n+2)) + a(n+1)*(-2*a(n+1) + a(n+2)) for all n in Z. - _Michael Somos_, Sep 17 2014", + "a(n+1) = 4*a(n) - 2*A000108(n). Also a(n) = 4^n*Product_{k=1..n}(1-1/(2*k)). - _Stanislav Sykora_, Aug 09 2014", + "a(n-1) = number of subsets of 2n-1 distinct elements taken n at a time that contain a given element. E.g., n=4 -> a(3)=20 and if we consider the subsets of 7 taken 4 at a time with a 1 we get (1234, 1235, 1236, 1237, 1245, 1246, 1247, 1256, 1257, 1267, 1345, 1346, 1347, 1356, 1357, 1367, 1456, 1457, 1467, 1567) and there are 20 of them. - _Jon Perry_, Jan 20 2004", + "The differences between consecutive terms of the sequence of the quotients between consecutive terms of this sequence form a sequence containing the reciprocals of the triangular numbers. In other words, a(n+1)/a(n)-a(n)/a(n-1) = 2/(n*(n+1)). - _Christian Schulz_, Jun 08 2013", + "Expansion of G.f. A(x) = 1/(1+q*x*c(x)), where parameter q is positive or negative (except q=-1), and c(x) is the g.f. of A000108 for Catalan numbers. The case of q=-1 recovers the g.f. of A000108 as xA^2-A+1=0. The present sequence A000984 refers to q=-2. Recurrence: (1+q)*(n+2)*a(n+2) + ((q*q-4*q-4)*n + 2*(q*q-q-1))*a(n+1) - 2*q*q*(2*n+1)*a(n) = 0, a(0)=1, a(1)=-q. Asymptotics: a(n) ~ ((q+2)/(q+1))*(q^2/(-q-1))^n, q<=-3, a(n) ~ (-1)^n*((q+2)/(q+1))*(q^2/(q+1))^n, q>=5, and a(n) ~ -Kq*2^(2*n)/sqrt(Pi*n^3), where the multiplicative constant Kq is given by K1=1/9 (q=1), K2=1/8 (q=2), K3=3/25 (q=3), K4=1/9 (q=4). These formulas apply to existing sequences A126983 (q=1), A126984 (q=2), A126982 (q=3), A126986 (q=4), A126987 (q=5), A127017 (q=6), A127016 (q=7), A126985 (q=8), A127053 (q=9), and to A007854 (q=-3), A076035 (q=-4), A076036 (q=-5), A127628 (q=-6), A126694 (q=-7), A115970 (q=-8). (End)" + ] + }, + "A001006": { + "id": "A001006", + "name": "Motzkin numbers: number of ways of drawing any number of nonintersecting chords joining n (labeled) points on a circle.", + "offset": 0, + "terms": [ + 1, + 1, + 2, + 4, + 9, + 21, + 51, + 127, + 323, + 835, + 2188, + 5798, + 15511, + 41835, + 113634, + 310572, + 853467, + 2356779, + 6536382, + 18199284, + 50852019, + 142547559, + 400763223, + 1129760415, + 3192727797, + 9043402501, + 25669818476, + 73007772802, + 208023278209, + 593742784829, + 1697385471211 + ], + "statements": [ + "Limit_{n->infinity} a(n)/a(n-1) = 3. [Aigner]", + "a(n+2) - a(n+1) = a(0)*a(n) + a(1)*a(n-1) + ... + a(n)*a(0). [Bernhart]", + "D-finite with recurrence: (n+2)*a(n) = (2*n+1)*a(n-1) + (3*n-3)*a(n-2). (End)", + "0 = a(n) * (9*a(n+1) + 15*a(n+2) - 12*a(n+3)) + a(n+1) * ( -3*a(n+1) + 10*a(n+2) - 5*a(n+3)) + a(n+2) * (a(n+2) + a(n+3)) unless n=-2. - _Michael Somos_, Mar 23 2012", + "a(n) = a(n-1) + A002026(n-1). Number of Motzkin paths that start with an F step plus number of Motzkin paths that start with an U step. - _R. J. Mathar_, Jul 25 2017", + "a(n+1) = Sum_{k = 0..n} (-1)^(n-k)*binomial(n, k)*A000245(k+1).", + "a(n)/a(n-1) tends to 3.0 as N->infinity: (1+2*cos(2*Pi/N)) relating to longest odd N regular polygon diagonals, by way of example, N=7: Using the tridiagonal generator [cf. comment of Jan 07 2009], for polygon N=7, we extract an (N-1)/2 = 3 X 3 matrix, [0,1,0; 1,1,1; 0,1,1] with an e-val of 2.24697...; the longest Heptagon diagonal with edge = 1. As N tends to infinity, the diagonal lengths tend to 3.0, the convergent of the sequence. - _Gary W. Adamson_, Jun 08 2011", + "A series created using 2*a(n) + a(n+1) has Hankel transform of F(2n), offset 3, F being the Fibonacci bisection, A001906 (empirical observation).", + "A series created using 2*a(n) + 3*a(n+1) + a(n+2) gives the Hankel transform of Sum_{k=0..n} k*Fibonacci(2*k), offset 3, A197649 (empirical observation). (End)" + ] + }, + "A005259": { + "id": "A005259", + "name": "Apery (Ap\u00e9ry) numbers: Sum_{k=0..n} (binomial(n,k)*binomial(n+k,k))^2.", + "offset": 0, + "terms": [ + 1, + 5, + 73, + 1445, + 33001, + 819005, + 21460825, + 584307365, + 16367912425, + 468690849005, + 13657436403073, + 403676083788125, + 12073365010564729, + 364713572395983725, + 11111571997143198073, + 341034504521827105445, + 10534522198396293262825, + 327259338516161442321485 + ], + "statements": [ + "D-finite with recurrence (n+1)^3*a(n+1) = (34*n^3 + 51*n^2 + 27*n + 5)*a(n) - n^3*a(n-1), n >= 1.", + "The expansions of exp( Sum_{n >= 1} a(n)*x^n/n ) = 1 + 5*x + 49*x^2 + 685*x^3 + 11807*x^4 + 232771*x^5 + ... and exp( Sum_{n >= 1} a(n-1)*x^n/n ) = 1 + 3*x + 27*x^2 + 390*x^3 + 7038*x^4 + 144550*x^5 + ... both appear to have integer coefficients. See A267220. - _Peter Bala_, Jan 12 2016" + ] + }, + "A086246": { + "id": "A086246", + "name": "Expansion of (1 + x - sqrt(1 - 2*x - 3*x^2)) / 2 in powers of x.", + "offset": 0, + "terms": [ + 0, + 1, + 1, + 1, + 2, + 4, + 9, + 21, + 51, + 127, + 323, + 835, + 2188, + 5798, + 15511, + 41835, + 113634, + 310572, + 853467, + 2356779, + 6536382, + 18199284, + 50852019, + 142547559, + 400763223, + 1129760415, + 3192727797, + 9043402501, + 25669818476, + 73007772802, + 208023278209, + 593742784829, + 1697385471211, + 4859761676391, + 13933569346707 + ], + "statements": [ + "a(n) + a(n-1) = a(0)*a(n) + a(1)*a(n-1) + ... + a(n)*a(0), n > 2.", + "0 = a(n) * (9*a(n+1) + 15*a(n+2) - 12*a(n+3)) + a(n+1) * (-3*a(n+1) + 10*a(n+2) - 5*a(n+3)) + a(n+2) * (a(n+2) + a(n+3)) if n>0. - _Michael Somos_, Jan 25 2014", + "D-finite with recurrence: (3*n-3)*a(n)+(1+2*n)*a(n+1)+(-n-2)*a(n+2)=0 for n >= 1. - _Robert Israel_, May 01 2018" + ] + }, + "A094287": { + "id": "A094287", + "name": "Number of (s(0), s(1), ..., s(n)) such that 0 < s(i) < 7 and |s(i) - s(i-1)| <= 1 for i = 1,2,...,n, s(0) = 1, s(n) = 1.", + "offset": 0, + "terms": [ + 1, + 1, + 2, + 4, + 9, + 21, + 51, + 127, + 323, + 835, + 2188, + 5798, + 15510, + 41822, + 113531, + 309937, + 850118, + 2340918, + 6466953, + 17913087, + 49726649, + 138287113, + 385126811, + 1073832695, + 2996974774, + 8370739326, + 23394528640, + 65415732100, + 182989086965, + 512046072481, + 1433197869570 + ], + "statements": [ + "Conjecture: a(n)= +6*a(n-1) -10*a(n-2) +9*a(n-4) -2*a(n-5) -a(n-6) with g.f. -x*(-1+4*x-2*x^2-5*x^3+2*x^4+x^5) / ( (x^3+3*x^2-4*x+1)*(x^3-x^2-2*x+1) ). - _R. J. Mathar_, Dec 20 2011" + ] + }, + "A094288": { + "id": "A094288", + "name": "Number of (s(0), s(1), ..., s(n)) such that 0 < s(i) < 8 and |s(i) - s(i-1)| <= 1 for i = 1,2,...,n, s(0) = 1, s(n) = 1.", + "offset": 0, + "terms": [ + 1, + 1, + 2, + 4, + 9, + 21, + 51, + 127, + 323, + 835, + 2188, + 5798, + 15511, + 41835, + 113633, + 310557, + 853333, + 2355861, + 6531062, + 18171848, + 50722229, + 141973073, + 398351055, + 1120056347, + 3155043447, + 8901325751, + 25147423616, + 71127785002, + 201381834019, + 570655858439, + 1618256772285 + ], + "statements": [ + "Conjecture: a(n)= +7*a(n-1) -15*a(n-2) +5*a(n-3) +15*a(n-4) -9*a(n-5) -3*a(n-6) +a(n-7) with g.f. -x*(1-5*x+5*x^2+6*x^3-7*x^4-2*x^5+x^6) / ( (x-1)*(x^2+2*x-1)*(x^4-4*x^3-2*x^2+4*x-1) ). - _R. J. Mathar_, Dec 20 2011" + ] + }, + "A115140": { + "id": "A115140", + "name": "O.g.f. inverse of Catalan A000108 o.g.f.", + "offset": 0, + "terms": [ + 1, + -1, + -1, + -2, + -5, + -14, + -42, + -132, + -429, + -1430, + -4862, + -16796, + -58786, + -208012, + -742900, + -2674440, + -9694845, + -35357670, + -129644790, + -477638700, + -1767263190, + -6564120420, + -24466267020, + -91482563640, + -343059613650, + -1289904147324, + -4861946401452, + -18367353072152 + ], + "statements": [ + "D-finite with recurrence n*a(n) +2*(-2*n+3)*a(n-1)=0. a(n) = A002420(n)/2, n>0. - _R. J. Mathar_, Aug 09 2015" + ] + }, + "A120588": { + "id": "A120588", + "name": "G.f. is 1 + x*c(x), where c(x) is the g.f. of the Catalan numbers (A000108).", + "offset": 0, + "terms": [ + 1, + 1, + 1, + 2, + 5, + 14, + 42, + 132, + 429, + 1430, + 4862, + 16796, + 58786, + 208012, + 742900, + 2674440, + 9694845, + 35357670, + 129644790, + 477638700, + 1767263190, + 6564120420, + 24466267020, + 91482563640, + 343059613650, + 1289904147324, + 4861946401452, + 18367353072152 + ], + "statements": [ + "D-finite with recurrence n*a(n) +2*(-2*n+3)*a(n-1)=0. - _R. J. Mathar_, Nov 22 2024" + ] + }, + "A166587": { + "id": "A166587", + "name": "A signed variant of the Motzkin numbers.", + "offset": 0, + "terms": [ + 1, + 1, + -1, + 2, + -4, + 9, + -21, + 51, + -127, + 323, + -835, + 2188, + -5798, + 15511, + -41835, + 113634, + -310572, + 853467, + -2356779, + 6536382, + -18199284, + 50852019, + -142547559, + 400763223, + -1129760415, + 3192727797, + -9043402501, + 25669818476, + -73007772802, + 208023278209, + -593742784829, + 1697385471211 + ], + "statements": [ + "Conjecture: n*(n+1)*a(n) + n*(n+1)*a(n-1) - (5*n-3)*(n-2)*a(n-2) + 3*(n-2)*(n-3)*a(n-3) = 0. - _R. J. Mathar_, Nov 15 2012", + "G.f. G(x) satisfies (3*x^3 - 2*x^2 - x)*G'(x) - (x+1)*G(x) + 3*x + 1 = 0, from which follows 3*n*a(n) + (-3-2*n)*a(1+n) + (-3-n)*a(n+2) = 0 as well as Mathar's conjecture. - _Robert Israel_, May 17 2016", + "[a(n+1)] = [1,-1,2,-4,9,...] is the inverse binomial transform of A126120. - _Philippe Del\u00e9ham_, Nov 29 2009" + ] + }, + "A168049": { + "id": "A168049", + "name": "Expansion of (3 -x -sqrt(1-2*x-3*x^2))/2.", + "offset": 0, + "terms": [ + 1, + 0, + 1, + 1, + 2, + 4, + 9, + 21, + 51, + 127, + 323, + 835, + 2188, + 5798, + 15511, + 41835, + 113634, + 310572, + 853467, + 2356779, + 6536382, + 18199284, + 50852019, + 142547559, + 400763223, + 1129760415, + 3192727797, + 9043402501, + 25669818476, + 73007772802, + 208023278209 + ], + "statements": [ + "D-finite with recurrence: n*a(n) +(3-2n)*a(n-1) +3(3-n)*a(n-2)=0. - _R. J. Mathar_, Dec 20 2011", + "0 = a(n)*(+9*a(n+1) + 15*a(n+2) - 12*a(n+3)) + a(n+1)*(-3*a(n+1) + 10*a(n+2) - 5*a(n+3)) + a(n+2)*(+a(n+2) + a(n+3)) if n>0. - _Michael Somos_, Jan 31 2014" + ] + }, + "A168051": { + "id": "A168051", + "name": "Expansion of (1+x+sqrt(1-2x-3x^2))/2.", + "offset": 0, + "terms": [ + 1, + 0, + -1, + -1, + -2, + -4, + -9, + -21, + -51, + -127, + -323, + -835, + -2188, + -5798, + -15511, + -41835, + -113634, + -310572, + -853467, + -2356779, + -6536382, + -18199284, + -50852019, + -142547559, + -400763223, + -1129760415, + -3192727797, + -9043402501, + -25669818476 + ], + "statements": [ + "D-finite with recurrence: n*a(n) -(2n-3)*a(n-1) -3*(n-3)*a(n-2)=0 if n>2. - _R. J. Mathar_, Dec 20 2011 [Edited by _Michael Somos_, Jan 25 2014]", + "0 = a(n) * (9*a(n+1) + 15*a(n+2) - 12*a(n+3)) + a(n+1) * (-3*a(n+1) + 10*a(n+2) - 5*a(n+3)) + a(n+2) * (a(n+2) + a(n+3)) if n>0. - _Michael Somos_, Jan 25 2014" + ] + }, + "A168491": { + "id": "A168491", + "name": "a(n) = (-1)^n*Catalan(n).", + "offset": 0, + "terms": [ + 1, + -1, + 2, + -5, + 14, + -42, + 132, + -429, + 1430, + -4862, + 16796, + -58786, + 208012, + -742900, + 2674440, + -9694845, + 35357670, + -129644790, + 477638700, + -1767263190, + 6564120420, + -24466267020, + 91482563640, + -343059613650, + 1289904147324, + -4861946401452, + 18367353072152, + -69533550916004 + ], + "statements": [ + "D-finite with recurrence (n+1)*a(n) +2*(2*n - 1)*a(n-1) = 0. - _R. J. Mathar_, Oct 06 2012" + ] + }, + "A292440": { + "id": "A292440", + "name": "Expansion of (1 - x + sqrt(1 - 2*x - 3*x^2))/2 in powers of x.", + "offset": 0, + "terms": [ + 1, + -1, + -1, + -1, + -2, + -4, + -9, + -21, + -51, + -127, + -323, + -835, + -2188, + -5798, + -15511, + -41835, + -113634, + -310572, + -853467, + -2356779, + -6536382, + -18199284, + -50852019, + -142547559, + -400763223, + -1129760415, + -3192727797, + -9043402501, + -25669818476, + -73007772802 + ], + "statements": [ + "D-finite with recurrence: n*a(n) +(-2*n+3)*a(n-1) +3*(-n+3)*a(n-2)=0. - _R. J. Mathar_, Jan 23 2020" + ] + }, + "A359643": { + "id": "A359643", + "name": "a(n) = Sum_{k=0..n} binomial(n,k) * binomial(4*k,k).", + "offset": 0, + "terms": [ + 1, + 5, + 37, + 317, + 2885, + 27105, + 259765, + 2523813, + 24768069, + 244941833, + 2437083697, + 24367722725, + 244639635749, + 2464477467769, + 24899468129405, + 252202062544617, + 2560119328830725, + 26038134699958233, + 265278657849511561, + 2706809063101138409, + 27657194997231516145, + 282941098708193905485 + ], + "statements": [ + "Conjecture D-finite with recurrence +81*n*(3*n-1)*(3*n-2)*a(n) +3*(243*n^3-8433*n^2+14984*n-7064)*a(n-1) +2*(-58607*n^3+297306*n^2-491401*n+269124)*a(n-2) +6*(n-2)*(56663*n^2-237722*n+252221)*a(n-3) -3*(n-2)*(n-3)*(111625*n-286402)*a(n-4) +110653*(n-2)*(n-3)*(n-4)*a(n-5)=0. - _R. J. Mathar_, Jan 09 2023" + ] + } + }, + "queries": { + "seq:1,1,2,4,9,21,51,127,323,835,2188,5798": [ + "A001006", + "A086246", + "A094287", + "A094288", + "A166587", + "A168049", + "A168051", + "A292440" + ], + "seq:1,1,2,5,14,42,132,429,1430,4862,16796,58786": [ + "A000108", + "A115140", + "A120588", + "A168491" + ], + "seq:1,2,6,20,70,252,924,3432,12870,48620,184756,705432": [ + "A000984" + ], + "seq:1,5,37,317,2885,27105,259765,2523813,24768069,244941833,2437083697,24367722725": [ + "A359643" + ], + "seq:1,5,73,1445,33001,819005,21460825,584307365,16367912425,468690849005": [ + "A005259" + ], + "seq:1,7,41,2393,168413,9930001,700000009,42000000023,3100000000007": [] + } +} diff --git a/tests/test_novelty.py b/tests/test_novelty.py new file mode 100644 index 00000000..5b5337f8 --- /dev/null +++ b/tests/test_novelty.py @@ -0,0 +1,561 @@ +"""M11 — novelty filtering: normal form, hash, lookup, and what a negative means. + +Nothing here touches the network. The OEIS half runs against +``tests/data/oeis_novelty_fixture.json``, a cache recorded once from +https://oeis.org (© The OEIS Foundation Inc., CC BY-NC-SA 4.0) and committed; +:class:`~alkahest.experimental.novelty.OeisWeb` is never constructed by a test. +To re-record it:: + + from alkahest.experimental.novelty import OeisCache, OeisWeb + web = OeisWeb(cache=OeisCache(), min_interval=1.5, max_results=8) + for terms in (...): # the exact term lists the tests query with + web.lookup(terms=terms) + web.cache.save("tests/data/oeis_novelty_fixture.json") + +The recorded queries matter as much as the recorded entries: a cache that only +stores hits cannot tell "OEIS was asked and had nothing" from "nobody asked", +and reporting the second as the first is the overclaim this module exists to +prevent. +""" + +from __future__ import annotations + +import math +from fractions import Fraction +from pathlib import Path + +import alkahest as ak +import pytest +from alkahest.experimental import novelty +from alkahest.experimental.novelty import ( + NoveltyVerdict, + OeisCache, + OeisEntry, + RecurrenceClaim, + check_novelty, +) + +FIXTURE = Path(__file__).resolve().parent / "data" / "oeis_novelty_fixture.json" + +# --------------------------------------------------------------------------- +# The sequences this project has already certified recurrences for, computed +# here from their definitions rather than read out of the fixture — the point +# of the end-to-end test is that a claim derived independently lands on the +# same normal form as the one OEIS records. +# --------------------------------------------------------------------------- + + +def motzkin(count: int) -> list[int]: + return [ + sum(math.comb(n, 2 * k) * math.comb(2 * k, k) // (k + 1) for k in range(n // 2 + 1)) + for n in range(count) + ] + + +def catalan(count: int) -> list[int]: + return [math.comb(2 * n, n) // (n + 1) for n in range(count)] + + +def central_binomial(count: int) -> list[int]: + return [math.comb(2 * n, n) for n in range(count)] + + +def apery(count: int) -> list[int]: + return [ + sum((math.comb(n, k) * math.comb(n + k, k)) ** 2 for k in range(n + 1)) + for n in range(count) + ] + + +def a359643(count: int) -> list[int]: + """``Sum_{k=0..n} C(n,k)*C(4k,k)`` — the session's novel result.""" + return [sum(math.comb(n, k) * math.comb(4 * k, k) for k in range(n + 1)) for n in range(count)] + + +#: The A359643 recurrence OEIS records, verbatim, marked "Conjecture". +A359643_OEIS_LINE = ( + "Conjecture D-finite with recurrence +81*n*(3*n-1)*(3*n-2)*a(n) " + "+3*(243*n^3-8433*n^2+14984*n-7064)*a(n-1) " + "+2*(-58607*n^3+297306*n^2-491401*n+269124)*a(n-2) " + "+6*(n-2)*(56663*n^2-237722*n+252221)*a(n-3) " + "-3*(n-2)*(n-3)*(111625*n-286402)*a(n-4) " + "+110653*(n-2)*(n-3)*(n-4)*a(n-5)=0. - _R. J. Mathar_, Jan 09 2023" +) + + +@pytest.fixture(scope="module") +def cache() -> OeisCache: + return OeisCache(FIXTURE) + + +# --------------------------------------------------------------------------- +# 1. Normalisation: presentations of one recurrence hash equal. +# --------------------------------------------------------------------------- + +#: (n+1)·u(n+1) − (4n+2)·u(n) = 0, the central binomial recurrence, written six +#: ways. Every one of them is the same statement about the same sequence. +CENTRAL_BINOMIAL_PRESENTATIONS = { + "as fitted": ([(-2, -4), (1, 1)], 0), + "scaled by -2": ([(4, 8), (-2, -2)], 0), + "scaled by 7": ([(-14, -28), (7, 7)], 0), + "over a common denominator": ( + [(Fraction(-1), Fraction(-2)), (Fraction(1, 2), Fraction(1, 2))], + 0, + ), + "with a padded window": ([(0,), (-2, -4), (1, 1), ()], -1), + "times the polynomial (n+2)": ([(-4, -10, -4), (2, 3, 1)], 0), + "stated about u(n+7)": ([(-60, -8), (16, 2)], 7), +} + + +@pytest.mark.parametrize("label", sorted(CENTRAL_BINOMIAL_PRESENTATIONS)) +def test_presentations_of_one_recurrence_hash_equal(label: str) -> None: + reference = RecurrenceClaim(*CENTRAL_BINOMIAL_PRESENTATIONS["as fitted"][:1]) + coefficients, offset = CENTRAL_BINOMIAL_PRESENTATIONS[label] + claim = RecurrenceClaim(coefficients, offset=offset) + assert claim.claim_hash == reference.claim_hash, ( + f"{label!r} normalised to {claim.normal_form!r}, not {reference.normal_form!r}" + ) + assert claim == reference + + +def test_an_index_shift_is_not_a_different_claim() -> None: + """Stating the relation about u(n+7) is re-indexing, not a new fact.""" + base = RecurrenceClaim([(-2, -4), (1, 1)]) + # The same relation applied at index n+7, then scaled by −2. + shifted = RecurrenceClaim([(-60, -8), (16, 2)], offset=7) + assert shifted.claim_hash == base.claim_hash + # ... and stated about u(n−3), the way OEIS usually writes one. + backwards = RecurrenceClaim([(-10, 4), (2, -1)], offset=-3) + assert backwards.claim_hash == base.claim_hash + + +def test_genuinely_different_recurrences_do_not_collide() -> None: + """The four certified sequences, plus two near misses, are six hashes.""" + claims = { + "central binomial": RecurrenceClaim([(-2, -4), (1, 1)]), + "catalan": RecurrenceClaim([(-2, -4), (2, 1)]), + "one coefficient off": RecurrenceClaim([(-2, -4), (1, 2)]), + "motzkin": RecurrenceClaim([(3, 3), (5, 2), (-4, -1)]), + "fibonacci": RecurrenceClaim([(1,), (1,), (-1,)]), + "apery": RecurrenceClaim.from_text( + "(n+1)^3*a(n+1) = (34*n^3 + 51*n^2 + 27*n + 5)*a(n) - n^3*a(n-1), n >= 1." + ), + } + hashes = {name: claim.claim_hash for name, claim in claims.items()} + assert len(set(hashes.values())) == len(hashes), hashes + + +def test_normal_form_is_versioned_and_readable() -> None: + claim = RecurrenceClaim([(-2, -4), (1, 1)]) + assert claim.normal_form == "recurrence/1 (4*n + 2)*u(n+0) + (-n - 1)*u(n+1)" + assert claim.claim_hash.startswith("clm_") + assert claim.order == 1 + assert claim.degree == 1 + + +def test_a_claim_needs_two_terms() -> None: + with pytest.raises(ValueError, match="at least two sequence terms"): + RecurrenceClaim([(1, 1)]) + with pytest.raises(ValueError, match="at least two sequence terms"): + RecurrenceClaim([(0,), (1, 1), (0,)]) + + +def test_claims_dedupe_in_a_set() -> None: + """The hash exists so a loop can dedupe its own output cheaply.""" + seen = set() + for coefficients, offset in CENTRAL_BINOMIAL_PRESENTATIONS.values(): + seen.add(RecurrenceClaim(coefficients, offset=offset).claim_hash) + assert len(seen) == 1 + + +def test_claims_from_expressions_and_from_a_guess_agree() -> None: + pool = ak.ExprPool() + n = pool.symbol("n") + one = pool.integer(1) + from_exprs = RecurrenceClaim([-(pool.integer(4) * n + pool.integer(2)), n + one], var=n) + guess = ak.guess_holonomic(central_binomial(20)) + assert RecurrenceClaim.from_recurrence(guess).claim_hash == from_exprs.claim_hash + + +def test_expression_coefficients_need_the_variable() -> None: + pool = ak.ExprPool() + n = pool.symbol("n") + with pytest.raises(TypeError, match="index variable"): + RecurrenceClaim([n, n + pool.integer(1)]) + + +# --------------------------------------------------------------------------- +# 2. Parsing OEIS prose — and refusing to guess at it. +# --------------------------------------------------------------------------- + + +def test_parses_the_recurrences_oeis_actually_writes() -> None: + lines = { + # A000984, homogeneous and equal to zero + "D-finite with recurrence: n*a(n) + 2*(1-2*n)*a(n-1)=0.": "central binomial", + # A000108, a division and a trailing initial condition + "Recurrence: a(n) = 2*(2*n-1)*a(n-1)/(n+1) with a(0) = 1.": "catalan", + # A001006, prose prefix and an (End) marker + "D-finite with recurrence: (n+2)*a(n) = (2*n+1)*a(n-1) + (3*n-3)*a(n-2). (End)": "motzkin", + # A005259, a trailing range condition + ( + "D-finite with recurrence (n+1)^3*a(n+1) = " + "(34*n^3 + 51*n^2 + 27*n + 5)*a(n) - n^3*a(n-1), n >= 1." + ): "apery", + } + for line in lines: + assert RecurrenceClaim.from_text(line) is not None, line + + # A000108 states the same recurrence twice, in two shapes. They are one + # claim, which is exactly what the normal form is for. + first = RecurrenceClaim.from_text("Recurrence: a(n) = 2*(2*n-1)*a(n-1)/(n+1) with a(0) = 1.") + second = RecurrenceClaim.from_text("a(n) = a(n-1)*(4-6/(n+1)).") + assert first.claim_hash == second.claim_hash + + +@pytest.mark.parametrize( + "line", + [ + # Another sequence on the right-hand side: truncating at `a(n-1)` would + # invent a claim nobody made. + "a(n) = a(n-1) + A002026(n-1). - _R. J. Mathar_, Jul 25 2017", + # Nonlinear. + "0 = a(n)*(16*a(n+1) - 10*a(n+2)) + a(n+1)*(2*a(n+1) + a(n+2)) for all n>=0.", + # Convolution, and an absolute index a(0). + "a(n+2) - a(n+1) = a(0)*a(n) + a(1)*a(n-1) + ... + a(n)*a(0).", + # A sum, not a recurrence. + "a(n) = Sum_{k=0..n} (-1)^(n-k)*binomial(n, k)*A000108(k+1).", + # Inhomogeneous. + "a(n) = a(n-1) + 1", + # Not an equation at all. + "Limit_{n->infinity} a(n)/a(n-1) = 3. [Aigner]", + # An index that is not a shift of the running one. + "a(2*n) = a(n-1)*a(n+1)", + ], +) +def test_refuses_lines_it_does_not_understand(line: str) -> None: + assert RecurrenceClaim.from_text(line) is None + + +def test_a_parsed_recurrence_is_checked_against_the_entrys_own_data() -> None: + """A line that does not reproduce the entry's terms is not indexed. + + The parser is the weakest link in the chain, so its output is confirmed + against the data OEIS ships with the entry before it can produce a match. + """ + honest = OeisEntry( + "A000984", + terms=central_binomial(12), + statements=["D-finite with recurrence: n*a(n) + 2*(1-2*n)*a(n-1)=0."], + ) + assert len(honest.recurrences()) == 1 + assert honest.unusable_statements() == () + + mistyped = OeisEntry( + "A000984", + terms=central_binomial(12), + statements=["D-finite with recurrence: n*a(n) + 3*(1-2*n)*a(n-1)=0."], + ) + assert mistyped.recurrences() == () + assert len(mistyped.unusable_statements()) == 1 + + +def test_holds_for_is_exact_and_trailing_confirmations_are_lenient() -> None: + claim = RecurrenceClaim([(-2, -4), (1, 1)]) + terms = central_binomial(15) + assert claim.holds_for(terms) + assert claim.confirmations(terms) == len(terms) - 1 + # A recurrence stated only for large n fails at the front and is still + # confirmed by the tail — the distinction `confirmations` exists for. + # `start` names the true index of element 0 of the array *given to this + # call*, always — prepending `99` shifts every real element one array + # position to the right, so the array's own index-0 is now where `u(-1)` + # would be: start=-1, not the default 0. Get that wrong (as `start=0` + # does here, silently claiming `99 = u(0)`) and every window's `n` is + # off by one, so *none* of them confirm — not just the one touching `99` + # — which is exactly why `start` is load-bearing, not cosmetic. + padded = [99, *terms] + assert not claim.holds_for(padded, start=-1) + assert claim.confirmations(padded, start=-1) == len(terms) - 1 + assert claim.confirmations(padded, start=0) == 0, ( + "a caller who forgets to adjust start for the prepended element gets " + "a meaningless count, not a merely-approximate one" + ) + + +# --------------------------------------------------------------------------- +# 3. End to end against real OEIS data, offline. +# --------------------------------------------------------------------------- + +CERTIFIED = { + "A005259": (apery, 10), # Apéry numbers + "A001006": (motzkin, 12), # Motzkin numbers + "A000108": (catalan, 12), # Catalan numbers + "A000984": (central_binomial, 12), # central binomial coefficients +} + + +@pytest.mark.parametrize("entry_id", sorted(CERTIFIED)) +def test_recurrences_this_project_certifies_are_already_in_oeis( + cache: OeisCache, entry_id: str +) -> None: + """Guess the recurrence, normalise it, and find it recorded — unhedged.""" + build, query_length = CERTIFIED[entry_id] + terms = build(30) + guess = ak.guess_holonomic(terms, max_order=3, max_degree=4) + claim = RecurrenceClaim.from_recurrence(guess) + + verdict = check_novelty(claim, [cache], terms=terms[:query_length]) + assert verdict.status == "recorded", verdict.report() + assert verdict.found is True + assert verdict.hedged is False, "OEIS states these as theorems, not conjectures" + assert entry_id in {m.entry for m in verdict.matches()} + assert verdict.entries_examined >= 1 + assert verdict.claim_hash == claim.claim_hash + + +def test_a359643_is_recorded_but_only_as_a_conjecture(cache: OeisCache) -> None: + """The distinction the whole filter is for. + + OEIS carries this recurrence marked "Conjecture", i.e. fitted by a guessing + package and never proved. A run that certifies it has a result; a run that + merely restates it does not, and only the hedge tells them apart. + """ + terms = a359643(40) + recorded = RecurrenceClaim.from_text(A359643_OEIS_LINE) + assert recorded.order == 5 + assert recorded.holds_for(terms), "the parse must reproduce the sequence" + + verdict = check_novelty(recorded, [cache], terms=terms[:12]) + assert verdict.status == "recorded_conjecturally", verdict.report() + assert verdict.found is True + assert verdict.hedged is True + match = verdict.matches()[0] + assert match.entry == "A359643" + assert "Conjecture" in match.statement + + +def test_the_order_four_relation_for_a359643_is_not_in_oeis(cache: OeisCache) -> None: + """A claim OEIS does not have — reported as *not found*, never as novel. + + ``guess_holonomic`` fits an order-4 relation to the same sequence, one + order below the order-5 recurrence OEIS records as a conjecture. Both hold + on every term computed here; they are different claims, and the filter says + so without saying anything about the literature. + """ + terms = a359643(80) + guess = ak.guess_holonomic(terms, max_order=5, max_degree=4) + claim = RecurrenceClaim.from_recurrence(guess) + assert claim.order == 4 + assert claim.holds_for(terms) + assert claim.claim_hash != RecurrenceClaim.from_text(A359643_OEIS_LINE).claim_hash + + verdict = check_novelty(claim, [cache], terms=terms[:12]) + assert verdict.status == "not_found" + assert verdict.found is False + assert verdict.entries_examined == 1 + assert verdict.statements_compared >= 1 + + +def test_a_sequence_oeis_does_not_have_is_not_found_not_unavailable(cache: OeisCache) -> None: + """A recorded empty result is a real negative; an unrecorded query is not.""" + absent = [1, 7, 41, 2393, 168413, 9930001, 700000009, 42000000023, 3100000000007] + verdict = check_novelty(RecurrenceClaim([(-2, -4), (1, 1)]), [cache], terms=absent) + assert verdict.status == "not_found" + assert verdict.entries_examined == 0 + + never_asked = check_novelty( + RecurrenceClaim([(-2, -4), (1, 1)]), [cache], terms=[3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5] + ) + assert never_asked.status == "unavailable" + assert never_asked.found is None + + +def test_lookup_by_identifier(cache: OeisCache) -> None: + claim = RecurrenceClaim.from_text( + "D-finite with recurrence: n*a(n) + 2*(1-2*n)*a(n-1)=0.", + ) + verdict = check_novelty(claim, [cache], ids=["A000984"]) + assert verdict.status == "recorded" + assert check_novelty(claim, [cache], ids=["A999999"]).status == "unavailable" + + +# --------------------------------------------------------------------------- +# 4. What a negative is allowed to claim. +# --------------------------------------------------------------------------- + + +def test_a_negative_verdict_never_says_novel(cache: OeisCache) -> None: + """The one property this module must not have.""" + terms = a359643(80) + guess = ak.guess_holonomic(terms, max_order=5, max_degree=4) + verdict = check_novelty(RecurrenceClaim.from_recurrence(guess), [cache], terms=terms[:12]) + assert verdict.status == "not_found" + + for attribute in ("novel", "is_novel", "new", "unpublished", "original"): + assert not hasattr(verdict, attribute), ( + f"NoveltyVerdict.{attribute} would turn 'not in the one place I " + f"looked' into a claim about the literature" + ) + report = verdict.report() + assert "novel" not in report + assert not any("novel" in str(v).lower() for v in report.values() if isinstance(v, bool)) + # The gloss that travels with the verdict says what it is worth. + assert "not evidence of novelty" in verdict.means + assert verdict.means == novelty.STATUS_MEANINGS["not_found"] + + +def test_a_verdict_has_no_truth_value(cache: OeisCache) -> None: + """`if verdict:` is the mistake; it raises instead of reading True.""" + verdict = check_novelty(RecurrenceClaim([(-2, -4), (1, 1)]), [], terms=[1, 2, 6, 20]) + with pytest.raises(TypeError, match="no truth value"): + bool(verdict) + with pytest.raises(TypeError, match="no truth value"): # noqa: PT012 - the `if` itself is the point + if verdict: # pragma: no cover - the raise is the assertion + pass + + +def test_no_sources_is_unavailable_not_a_pass() -> None: + verdict = check_novelty(RecurrenceClaim([(-2, -4), (1, 1)]), [], terms=[1, 2, 6, 20]) + assert verdict.status == "unavailable" + assert verdict.found is None + assert verdict.hedged is None + assert verdict.sources_consulted() == () + + +def test_a_source_that_cannot_promise_completeness_cannot_produce_a_negative() -> None: + """A local hit is evidence; a local miss is not, unless the query was asked. + + A cache holding an entry that was never the recorded answer to this query + can confirm a claim but must not refute one — it does not know what else + OEIS has. + """ + cache = OeisCache() + cache.add( + OeisEntry( + "A000984", + terms=central_binomial(12), + statements=["D-finite with recurrence: n*a(n) + 2*(1-2*n)*a(n-1)=0."], + ) + ) + query = central_binomial(12) + + found = check_novelty(RecurrenceClaim([(-2, -4), (1, 1)]), [cache], terms=query) + assert found.status == "recorded", "a hit is a hit wherever it came from" + + missing = check_novelty(RecurrenceClaim([(3, 3), (5, 2), (-4, -1)]), [cache], terms=query) + assert missing.status == "unavailable", "an unrecorded query cannot support a negative" + assert missing.sources_unavailable() == ("oeis-cache",) + + cache.record_query(terms=query, found=["A000984"]) + now_negative = check_novelty(RecurrenceClaim([(3, 3), (5, 2), (-4, -1)]), [cache], terms=query) + assert now_negative.status == "not_found" + + +def test_report_carries_the_scope_of_the_search(cache: OeisCache) -> None: + """A negative comes with how far the search actually reached.""" + terms = motzkin(30) + guess = ak.guess_holonomic(terms, max_order=3, max_degree=4) + claim = RecurrenceClaim.from_recurrence(guess) + report = check_novelty(claim, [cache], terms=terms[:12]).report() + assert set(report) == { + "status", + "found", + "hedged", + "means", + "claim_hash", + "matches", + "sources_consulted", + "sources_unavailable", + "entries_examined", + "statements_compared", + "statements_unusable", + } + assert report["sources_consulted"] == ["oeis-cache"] + # OEIS says a great deal this parser cannot read, and the count of what it + # could not use is part of the answer rather than a swallowed detail. + assert report["statements_unusable"] > 0 + assert report["status"] in novelty.NOVELTY_STATUSES + + +def test_check_novelty_refuses_a_raw_recurrence() -> None: + with pytest.raises(TypeError, match="must be a RecurrenceClaim"): + check_novelty([(-2, -4), (1, 1)], [], terms=[1, 2, 6]) + with pytest.raises(ValueError, match="nothing to look up"): + check_novelty(RecurrenceClaim([(-2, -4), (1, 1)]), []) + + +# --------------------------------------------------------------------------- +# 5. The offline/online split, and the API surface. +# --------------------------------------------------------------------------- + + +def test_the_fixture_records_queries_as_well_as_entries(cache: OeisCache) -> None: + assert cache.n_entries >= 5 + assert cache.n_queries >= 6, "without recorded queries no negative is possible" + assert cache.name == "oeis-cache" + + +def test_cache_round_trips_through_a_file(tmp_path: Path) -> None: + original = OeisCache(FIXTURE) + target = tmp_path / "nested" / "copy.json" + original.save(target) + assert "CC BY-NC-SA" in target.read_text(encoding="utf-8"), "OEIS attribution travels with it" + reloaded = OeisCache(target) + assert reloaded.n_entries == original.n_entries + assert reloaded.n_queries == original.n_queries + + +def test_web_source_is_opt_in_and_never_default() -> None: + """No default reaches for the network; the type is not even constructed.""" + import inspect + + signature = inspect.signature(check_novelty) + assert signature.parameters["sources"].default is inspect.Parameter.empty + source = inspect.getsource(novelty.check_novelty) + assert "OeisWeb(" not in source + + +def test_experimental_exports() -> None: + from alkahest import experimental + + for name in ("RecurrenceClaim", "NoveltyVerdict", "OeisCache", "OeisWeb", "check_novelty"): + assert name in experimental.__all__ + assert hasattr(experimental, name) + assert "novelty" in experimental.__all__ + assert issubclass(NoveltyVerdict, object) + + +def test_accessor_convention_holds_for_the_new_types() -> None: + """Zero-argument O(1) scalars are properties; collections are methods.""" + for cls, scalars, collections in ( + (RecurrenceClaim, ("order", "degree", "normal_form", "claim_hash"), ("coefficients",)), + ( + NoveltyVerdict, + ( + "status", + "found", + "hedged", + "claim_hash", + "entries_examined", + "statements_compared", + "statements_unusable", + "means", + ), + ("matches", "sources_consulted", "sources_unavailable", "report"), + ), + (OeisCache, ("name", "n_entries", "n_queries"), ("lookup", "save", "load", "add")), + (OeisEntry, (), ("recurrences", "unusable_statements", "to_json")), + ): + for name in scalars: + assert isinstance(inspect_static(cls, name), property), f"{cls.__name__}.{name}" + for name in collections: + assert callable(inspect_static(cls, name)), f"{cls.__name__}.{name}" + assert not isinstance(inspect_static(cls, name), property), f"{cls.__name__}.{name}" + + +def inspect_static(cls: type, name: str) -> object: + import inspect + + return inspect.getattr_static(cls, name) diff --git a/tests/test_parametric_groebner.py b/tests/test_parametric_groebner.py new file mode 100644 index 00000000..9d030e98 --- /dev/null +++ b/tests/test_parametric_groebner.py @@ -0,0 +1,418 @@ +"""M9 — Gröbner bases over the coefficient field ``Q(params)``. + +``GroebnerBasis.compute(polys, vars, params=[...])`` moves the listed symbols +into the *coefficient field* instead of the polynomial ring: they never enter +the monomial order, never generate S-pairs, and never enlarge the staircase. +The trade is that the basis is only correct where its leading coefficients — +now elements of ``Q(params)`` rather than ``Q`` — do not vanish, so every +computation also returns the hypersurfaces it assumed non-zero +(:meth:`~alkahest.experimental.ParametricGroebnerBasis.conditions`) and +refuses to :meth:`~alkahest.experimental.ParametricGroebnerBasis.specialize` +on them. + +These tests check three things the Rust unit tests (`alkahest-core/src/poly/ +groebner/parametric.rs`) already cover at the data-structure level, but not +through the Python surface a caller actually uses: + +1. specialising the generic basis at a regular point agrees with computing + the basis directly over ℚ at that same point — the actual mathematical + content of the ``Q(params)`` result; +2. the degeneracy locus is genuinely a locus of *disagreement*, not a + conservative label that never fires — both by exhibiting a point where the + direct and specialised bases differ, and a point that is flagged but + happens to agree anyway (the "sufficient, not necessary" clause in the + docs); +3. the whole thing reads back as :class:`~alkahest.Expr`, end to end, on a + structural-identifiability example — the reason the feature exists at all. +""" + +from __future__ import annotations + +from fractions import Fraction + +import alkahest as ak +import alkahest.experimental as ake +import pytest + +pytestmark = pytest.mark.skipif( + not hasattr(ake, "ParametricGroebnerBasis"), + reason="native module built without groebner feature", +) + + +@pytest.fixture +def pool(): + return ak.ExprPool() + + +def _exact_value(pool, expr, mapping): + """Evaluate *expr* at exact (int/Fraction) bindings, as an exact + :class:`Fraction`. + + ``eval_expr`` returns an IEEE double, which is the wrong tool here: the + input-output relation's coefficients are large exact rationals, and + comparing them via ``float`` rounds away the distinctions this test + exists to check. ``ak.evaluate`` with :class:`Fraction` bindings routes + through the ``exact_rational`` backend and returns an exact + :class:`Fraction`, never a float approximation. + """ + del pool # kept for a uniform call signature across the test file + result = ak.evaluate(expr, mapping) + assert result.backend == "exact_rational", ( + f"expected the exact-rational backend, got {result.backend!r}" + ) + return result.value + + +# --------------------------------------------------------------------------- +# 1. The basic shape: conditions, specialize, vanishing_conditions +# --------------------------------------------------------------------------- + + +def test_compute_with_params_returns_a_parametric_basis(pool): + a, x, y = pool.symbol("a"), pool.symbol("x"), pool.symbol("y") + one = pool.integer(1) + + gb = ak.GroebnerBasis.compute([a * x - y, x + y - one], [x, y], params=[a]) + + assert isinstance(gb, ake.ParametricGroebnerBasis) + assert gb.n_params == 1 + assert gb.order == "lex" + assert [str(v) for v in gb.variables()] == ["x", "y"] + assert [str(p) for p in gb.parameters()] == ["a"] + + +def test_omitting_params_returns_the_ordinary_basis(pool): + a, x, y = pool.symbol("a"), pool.symbol("x"), pool.symbol("y") + one = pool.integer(1) + + # No params kwarg, and params=[] both take the Q[vars] path unchanged. + gb1 = ak.GroebnerBasis.compute([a * x - y, x + y - one], [x, y, a]) + gb2 = ak.GroebnerBasis.compute([a * x - y, x + y - one], [x, y, a], params=[]) + assert isinstance(gb1, ak.GroebnerBasis) + assert isinstance(gb2, ak.GroebnerBasis) + + +def test_conditions_flag_the_actual_singular_locus(pool): + a, x, y = pool.symbol("a"), pool.symbol("x"), pool.symbol("y") + one = pool.integer(1) + + gb = ak.GroebnerBasis.compute([a * x - y, x + y - one], [x, y], params=[a]) + conds = [str(c) for c in gb.conditions()] + + # y = a/(a+1), x = 1/(a+1): the system is singular exactly at a = -1. + assert "(a + 1)" in conds + assert gb.is_regular_at([3]) is True + assert gb.is_regular_at([-1]) is False + assert [str(c) for c in gb.vanishing_conditions([-1])] != [] + assert [str(c) for c in gb.vanishing_conditions([3])] == [] + + +# --------------------------------------------------------------------------- +# 2. Correctness cross-check: specialize vs. computing over Q directly +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("a_val", [2, 3, -5, 7, Fraction(1, 2), Fraction(-3, 4)]) +def test_specialize_agrees_with_direct_computation_at_regular_points(pool, a_val): + a, x, y = pool.symbol("a"), pool.symbol("x"), pool.symbol("y") + one = pool.integer(1) + + gb = ak.GroebnerBasis.compute([a * x - y, x + y - one], [x, y], params=[a]) + assert gb.is_regular_at([a_val]) + + spec = gb.specialize([a_val]) + spec_generators = sorted(str(g) for g in spec.to_exprs()) + + a_expr = ( + pool.rational(a_val.numerator, a_val.denominator) + if isinstance(a_val, Fraction) + else pool.integer(a_val) + ) + direct = ak.GroebnerBasis.compute([a_expr * x - y, x + y - one], [x, y]) + direct_generators = sorted(str(g) for g in direct.to_exprs()) + + assert spec_generators == direct_generators + + +def test_degenerate_point_is_refused_and_genuinely_disagrees(pool): + """a = -1 is flagged *and* is a real disagreement, not a false positive. + + At a = -1 the system {-x - y, x + y - 1} is inconsistent (its basis over + Q is the unit ideal {1}), which is not the specialisation of the generic + 2-generator basis — so refusing here is the only correct answer, and the + degeneracy report is not merely conservative at this particular point. + """ + a, x, y = pool.symbol("a"), pool.symbol("x"), pool.symbol("y") + one = pool.integer(1) + + gb = ak.GroebnerBasis.compute([a * x - y, x + y - one], [x, y], params=[a]) + assert len(gb) == 2, "expected a triangular 2-generator generic basis" + + with pytest.raises(ak.ParamGroebnerError) as excinfo: + gb.specialize([-1]) + assert excinfo.value.code == "E-PARAMGB-004" + + neg_one = pool.integer(-1) + direct = ak.GroebnerBasis.compute([neg_one * x - y, x + y - one], [x, y]) + assert len(direct) == 1 + # The unit ideal: 1 = 0, i.e. inconsistent — definitely not a 2-generator + # triangular basis, so the generic formula really does break down here. + assert str(direct.to_exprs()[0]) == "1" + + +def test_flagged_point_can_still_agree_by_a_removable_coincidence(pool): + """conditions() is sufficient, not necessary: a = 0 is flagged (the + algorithm inverts `a` to make `a*x - y` monic) but the specialised system + is still consistent there, and the direct computation over Q confirms + the *value* the generic formula predicts in the limit is exactly right — + the conservative report costs a refusal, not a wrong answer. + """ + a, x, y = pool.symbol("a"), pool.symbol("x"), pool.symbol("y") + one, zero = pool.integer(1), pool.integer(0) + + gb = ak.GroebnerBasis.compute([a * x - y, x + y - one], [x, y], params=[a]) + assert not gb.is_regular_at([0]) + with pytest.raises(ak.ParamGroebnerError): + gb.specialize([0]) + + # Direct computation at a = 0 still succeeds (a different leading + # monomial for the first generator, but a perfectly good basis) and its + # solution matches the a -> 0 limit of the generic formula x = 1/(a+1), + # y = a/(a+1) -> x = 1, y = 0. + direct = ak.GroebnerBasis.compute([zero * x - y, x + y - one], [x, y]) + values = {str(g).replace(" ", "") for g in direct.to_exprs()} + assert values == {"y", "(x+-1)"} + + +# --------------------------------------------------------------------------- +# 3. Reading back as Expr: ParametricGbPoly.to_expr / terms / specialize +# --------------------------------------------------------------------------- + + +def test_generators_read_back_as_expr_not_write_only(pool): + """Issue #11 was a write-only bug: a result nothing could read back. + ParametricGbPoly must support the same read path as GbPoly. + """ + a, x, y = pool.symbol("a"), pool.symbol("x"), pool.symbol("y") + one = pool.integer(1) + + gb = ak.GroebnerBasis.compute([a * x - y, x + y - one], [x, y], params=[a]) + + polys = gb.polynomials() + assert len(polys) == len(gb) == 2 + for p in polys: + e = p.to_expr() + assert isinstance(e, ak.Expr) + # terms() must also work and be non-empty, with Expr coefficients. + terms = p.terms() + assert terms + for exps, coeff in terms: + assert isinstance(exps, tuple) + assert isinstance(coeff, ak.Expr) + + # to_exprs() at the basis level is the one-call form. + exprs = gb.to_exprs() + assert len(exprs) == 2 + assert all(isinstance(e, ak.Expr) for e in exprs) + + # Indexing and iteration both produce the same ParametricGbPoly type. + assert isinstance(gb[0], ake.ParametricGbPoly) + assert [p.to_expr() for p in gb] == [p.to_expr() for p in polys] + + +def test_specialize_generators_readable_as_gbpoly_expr(pool): + a, x, y = pool.symbol("a"), pool.symbol("x"), pool.symbol("y") + one = pool.integer(1) + + gb = ak.GroebnerBasis.compute([a * x - y, x + y - one], [x, y], params=[a]) + spec = gb.specialize([3]) + assert isinstance(spec, ak.GroebnerBasis) + for g in spec: + e = g.to_expr() + assert isinstance(e, ak.Expr) + + +def test_single_polynomial_specialize_raises_on_pole(pool): + a, x = pool.symbol("a"), pool.symbol("x") + one = pool.integer(1) + + gb = ak.GroebnerBasis.compute([a * x - one], [x], params=[a]) + p = gb.polynomials()[0] + # x - 1/a: specializing at a = 0 hits the pole in the coefficient. + with pytest.raises(ak.ParamGroebnerError): + p.specialize([0]) + spec = p.specialize([2]) + assert isinstance(spec, ak.GbPoly) + + +# --------------------------------------------------------------------------- +# 4. Errors +# --------------------------------------------------------------------------- + + +def test_wrong_arity_is_reported(pool): + a, b, x = pool.symbol("a"), pool.symbol("b"), pool.symbol("x") + gb = ak.GroebnerBasis.compute([a * x - b], [x], params=[a, b]) + with pytest.raises(ak.ParamGroebnerError) as excinfo: + gb.specialize([1]) + assert excinfo.value.code == "E-PARAMGB-003" + + +def test_no_polys_or_vars_is_a_value_error(pool): + a = pool.symbol("a") + with pytest.raises(ValueError): + ak.GroebnerBasis.compute([], [], params=[a]) + + +def test_param_and_var_clash_is_rejected(pool): + a, x = pool.symbol("a"), pool.symbol("x") + with pytest.raises(ValueError): + ak.GroebnerBasis.compute([a * x - x], [x, a], params=[a]) + + +# --------------------------------------------------------------------------- +# 5. Elimination ideal over Q(params) +# --------------------------------------------------------------------------- + + +def test_eliminate_drops_generators_and_keeps_conditions(pool): + # Implicitize (t, t^2) with an unused parameter `a` in the field: the + # elimination ideal is regardless of a, and has no conditions. + a, t, x, y = pool.symbol("a"), pool.symbol("t"), pool.symbol("x"), pool.symbol("y") + + gb = ak.GroebnerBasis.compute([x - t, y - t * t], [t, x, y], params=[a]) + el = gb.eliminate([t]) + assert len(el) == 1 + rel = str(el.to_exprs()[0]).replace(" ", "") + # y - x^2 = 0 (up to sign/ordering of terms). + assert "y" in rel + assert "x^2" in rel + assert el.conditions() == [] + + +def test_eliminate_rejects_a_parameter(pool): + a, x, y = pool.symbol("a"), pool.symbol("x"), pool.symbol("y") + one = pool.integer(1) + gb = ak.GroebnerBasis.compute([a * x - y, x + y - one], [x, y], params=[a]) + with pytest.raises(ValueError): + gb.eliminate([a]) + + +# --------------------------------------------------------------------------- +# 6. Structural identifiability, end to end: a two-compartment ODE model +# --------------------------------------------------------------------------- +# +# Linear catenary two-compartment model, output = first compartment: +# +# x1' = -(k01 + k21)*x1 + k12*x2 +# x2' = k21 *x1 - k12 *x2 +# y = x1 +# +# The classical input-output equation (e.g. Bellman & Åström 1970; DAISY's +# worked examples) for a linear system x' = M x, y = c^T x is the +# characteristic polynomial of M applied to y: +# +# y'' - tr(M)*y' + det(M)*y = 0 +# +# which for this M (tr = -(k01+k21+k12), det = k01*k12) works out to +# +# y'' + (k01 + k21 + k12)*y' + k01*k12*y = 0. +# +# This is derived here purely by eliminating the two internal states x1, x2 +# from the jet equations {y - x1, y' - x1', y'' - x1''} (with x1', x1'' +# substituted from the ODE), with the rate constants in the coefficient +# field — exactly the M9 use case the module docs describe. + + +def _two_compartment_io_equation(pool): + x1, x2 = pool.symbol("x1"), pool.symbol("x2") + y, yp, ypp = pool.symbol("y"), pool.symbol("yp"), pool.symbol("ypp") + k01, k12, k21 = pool.symbol("k01"), pool.symbol("k12"), pool.symbol("k21") + + a_ = -(k01 + k21) # dx1/dt coefficient of x1 + b_ = k12 # dx1/dt coefficient of x2 + c_ = k21 # dx2/dt coefficient of x1 + d_ = -k12 # dx2/dt coefficient of x2 + + eq_y = x1 - y + eq_yp = (a_ * x1 + b_ * x2) - yp + eq_ypp = ((a_ * a_ + b_ * c_) * x1 + (a_ * b_ + b_ * d_) * x2) - ypp + + gb = ak.GroebnerBasis.compute( + [eq_y, eq_yp, eq_ypp], + [x1, x2, y, yp, ypp], + params=[k01, k12, k21], + order="lex", + ) + el = gb.eliminate([x1, x2]) + return el, (y, yp, ypp, k01, k12, k21) + + +def test_two_compartment_identifiability_end_to_end(pool): + el, (y, yp, ypp, k01, k12, k21) = _two_compartment_io_equation(pool) + + assert len(el) == 1, "expected a single input-output relation" + io_expr = el.to_exprs()[0] + assert isinstance(io_expr, ak.Expr) + + # Check the relation numerically at several (k01, k12, k21, y, yp, ypp) + # points that lie on the surface it defines: pick k's and an assumed + # solution trajectory value (y, yp, ypp) satisfying + # ypp + (k01+k21+k12)*yp + k01*k12*y = 0, then confirm io_expr evaluates + # to 0 there, and to something non-zero off the surface. + for k01v, k12v, k21v, yv, ypv in [ + (2, 3, 5, 7, 11), + (1, 1, 1, 2, -3), + (Fraction(1, 2), Fraction(3, 2), 4, -1, 6), + ]: + yppv = -(k01v + k21v + k12v) * ypv - k01v * k12v * yv + env = {k01: k01v, k12: k12v, k21: k21v, y: yv, yp: ypv, ypp: yppv} + val = _exact_value(pool, io_expr, env) + assert val == 0, f"IO relation should vanish on the trajectory: got {val}" + + # Off the surface (perturb ypp by 1) it must not vanish. + env_off = dict(env) + env_off[ypp] = yppv + 1 + val_off = _exact_value(pool, io_expr, env_off) + assert val_off != 0 + + +def test_two_compartment_io_equation_matches_trace_and_determinant(pool): + """The IO relation's coefficients, cleared of denominators, are exactly + the characteristic polynomial of the state matrix: 1, tr(M), det(M). + """ + el, (y, yp, ypp, k01, k12, k21) = _two_compartment_io_equation(pool) + io_expr = el.to_exprs()[0] + + for k01v, k12v, k21v in [(2, 3, 5), (1, 1, 1), (7, 2, 4)]: + tr = -(k01v + k21v + k12v) # tr(M) + det = k01v * k12v # det(M) + + # y'' - tr*y' + det*y should be proportional to io_expr (same + # coefficients up to one overall nonzero scalar). Reading off the + # coefficient of each of y, y', y'' individually (by zeroing the + # other two) needs only that io_expr is linear in (y, yp, ypp), + # which it is by construction. + c_y = _exact_value(pool, io_expr, {y: 1, yp: 0, ypp: 0, k01: k01v, k12: k12v, k21: k21v}) + c_yp = _exact_value(pool, io_expr, {y: 0, yp: 1, ypp: 0, k01: k01v, k12: k12v, k21: k21v}) + c_ypp = _exact_value(pool, io_expr, {y: 0, yp: 0, ypp: 1, k01: k01v, k12: k12v, k21: k21v}) + assert c_ypp != 0 + # (c_y, c_yp, c_ypp) proportional to (det, -tr, 1). + assert c_y / c_ypp == Fraction(det) + assert c_yp / c_ypp == Fraction(-tr) + + +# --------------------------------------------------------------------------- +# 7. No-parameters path reproduces the plain-Q basis exactly +# --------------------------------------------------------------------------- + + +def test_empty_params_list_is_the_ordinary_engine(pool): + x = pool.symbol("x") + one = pool.integer(1) + + gb_param = ak.GroebnerBasis.compute([x * x - one], [x], params=[]) + gb_plain = ak.GroebnerBasis.compute([x * x - one], [x]) + assert isinstance(gb_param, ak.GroebnerBasis) + assert [str(g) for g in gb_param.to_exprs()] == [str(g) for g in gb_plain.to_exprs()] diff --git a/tests/test_q_root_of_unity.py b/tests/test_q_root_of_unity.py new file mode 100644 index 00000000..e581d499 --- /dev/null +++ b/tests/test_q_root_of_unity.py @@ -0,0 +1,397 @@ +"""M4 — root-of-unity specialisation of a proved ``q``-Zeilberger certificate. + +:func:`alkahest.experimental.q_zeilberger` proves an identity in ``Q(q)`` with +``q`` **transcendental**. ``QZeilbergerCertificate.specialize_at_root_of_unity`` +takes the further step of setting ``q = ζ_d`` for a primitive ``d``-th root of +unity — the step to the ``q``-supercongruence literature — and it takes that +step as a three-valued *decision* +(``"specializes"`` / ``"obstructed"`` / ``"unknown"``) rather than an +assumption: the pole and vanishing hypotheses are decided exactly by +polynomial divisibility by ``Φ_d(q)`` over ``Q``, never numerically. + +These tests cover, at the Python surface: + +* an end-to-end classical identity, specialised at several roots of unity and + checked against a value computed **independently of everything under + test** — a Gaussian binomial built from the Pascal recurrence in raw Python + ``complex`` arithmetic, evaluated at a floating-point root of unity by + walking the returned :class:`~alkahest.Expr`'s node tree by hand. Nothing in + this check touches the Rust cyclotomic-field arithmetic, the specialisation + machinery, or ``sympy``; +* the two refusals that matter most: a genuine pole at ``ζ_d`` (the + ``q``-analogue of the A279013 failure mode — a certificate that is perfectly + valid in ``Q(q)`` and would silently produce a false statement if + specialised anyway), and a summation window that changes shape under + specialisation (``q``-Lucas killing terms); +* the degenerate-but-true verdicts (``q → 1``, a leading coefficient that + dies) that must be reported rather than presented as ordinary content; +* malformed requests, which are coded errors, not verdicts; +* the accessor convention and repr. +""" + +import cmath + +import alkahest as ak +import pytest +from alkahest.experimental import ( + QRootOfUnitySpecialization, + cyclotomic_polynomial, + q_zeilberger, + qbinomial, + qpochhammer, +) + + +def _syms(pool): + return pool.symbol("q"), pool.symbol("n"), pool.symbol("k") + + +def _q_vandermonde_term(pool, q, n, k): + """``[n;k]_q² · q^{k²}`` — the summand of ``Σ_k [n;k]_q²q^{k²} = [2n;n]_q``.""" + b = qbinomial(pool, n, k) + return b * b * q ** (k * k) + + +def _vandermonde_cert(pool): + q, n, k = _syms(pool) + return q_zeilberger(_q_vandermonde_term(pool, q, n, k), q, n, k), q, n, k + + +# --------------------------------------------------------------------------- +# An independent numeric yardstick. +# +# `Expr.node()` exposes the expression tree as plain Python data +# (`["add", [child, ...]]`, `["mul", [...]]`, `["pow", base, exp]`, +# `["integer", "3"]`, `["rational", "1", "3"]`, `["symbol", "q"]`). Walking it +# by hand with `complex` arithmetic never touches the Rust cyclotomic field, +# the specialisation machinery, or sympy — an independent check of the exact +# algebra against floating-point evaluation at an actual root of unity. +# --------------------------------------------------------------------------- + + +def _eval_at(expr, zeta: complex) -> complex: + tag, *rest = expr.node() + if tag == "integer": + return complex(int(rest[0])) + if tag == "rational": + return complex(int(rest[0])) / complex(int(rest[1])) + if tag == "symbol": + assert rest[0] == "q", f"unexpected symbol {rest[0]!r}" + return zeta + if tag == "add": + return sum((_eval_at(c, zeta) for c in rest[0]), complex(0)) + if tag == "mul": + acc = complex(1) + for c in rest[0]: + acc *= _eval_at(c, zeta) + return acc + if tag == "pow": + base, exp = rest + if hasattr(exp, "node"): + exp = round(_eval_at(exp, zeta).real) + return _eval_at(base, zeta) ** exp + raise ValueError(f"unhandled node tag {tag!r} in {expr.node()!r}") + + +def _gaussian_binomial_pascal(zeta: complex, n: int, k: int) -> complex: + """``[n;k]_ζ`` via the Pascal recurrence ``[n;k] = [n−1;k−1] + ζ^k·[n−1;k]``. + + Deliberately unrelated to the returned certificate, the exact cyclotomic + arithmetic under test, or the ``q``-Pochhammer expansion the kernel used + to build the summand: plain floating-point complex arithmetic from the + first line. + """ + if k < 0 or k > n: + return complex(0) + row = [complex(1)] + for _i in range(1, n + 1): + nxt = [complex(0)] * (len(row) + 1) + for j, cell in enumerate(row): + nxt[j] += cell * (zeta**j) + nxt[j + 1] += cell + row = nxt + return row[k] + + +# --------------------------------------------------------------------------- +# The flagship: an end-to-end identity, independently checked. +# --------------------------------------------------------------------------- + + +def test_q_vandermonde_square_sum_specializes_and_matches_independent_numerics(): + """``Σ_k [n;k]_q²q^{k²} = [2n;n]_q`` at ``q = ζ_d``, for every ``d`` up to 6 + and every ``n`` up to 5: the returned specialised value must equal the + Gaussian-binomial sum recomputed from scratch in floating-point ``complex`` + arithmetic at the actual numeric root of unity ``e^{2πi/d}``. + """ + pool = ak.ExprPool() + cert, _q, _n, _k = _vandermonde_cert(pool) + + checked = 0 + for d in range(1, 7): + zeta = cmath.exp(2j * cmath.pi / d) + for n0 in range(6): + spec = cert.specialize_at_root_of_unity(d, n0) + assert spec.status == "specializes", f"d={d}, n={n0}: {spec.reason}" + assert spec.is_termwise_regular # every summand is a polynomial in q + + got = _eval_at(spec.sum_value(0), zeta) + want = sum( + _gaussian_binomial_pascal(zeta, n0, kk) ** 2 * zeta ** (kk * kk) + for kk in range(n0 + 1) + ) + assert abs(got - want) < 1e-6, ( + f"d={d}, n={n0}: got {got}, independently computed {want}" + ) + checked += 1 + assert checked == 6 * 6 + + +def test_the_specialised_recurrence_matches_independent_numerics_too(): + """Not just the sum values: the specialised **coefficients**, evaluated the + same independent way, must annihilate the independently-computed sums — + whenever the recurrence is not vacuous.""" + pool = ak.ExprPool() + cert, _q, _n, _k = _vandermonde_cert(pool) + + nontrivial = 0 + for d in range(2, 7): + zeta = cmath.exp(2j * cmath.pi / d) + for n0 in range(6): + spec = cert.specialize_at_root_of_unity(d, n0) + if not spec.specializes or spec.is_vacuous: + continue + nontrivial += 1 + order = cert.order + acc = complex(0) + for i in range(order + 1): + c = _eval_at(spec.coefficient(i), zeta) + s = sum( + _gaussian_binomial_pascal(zeta, n0 + i, kk) ** 2 * zeta ** (kk * kk) + for kk in range(n0 + i + 1) + ) + acc += c * s + assert abs(acc) < 1e-6, ( + f"d={d}, n={n0}: specialised recurrence does not annihilate the independent values" + ) + assert nontrivial >= 10, "the check must not be vacuously empty" + + +# --------------------------------------------------------------------------- +# Degeneracy — true but reported rather than hidden. +# --------------------------------------------------------------------------- + + +def test_classical_limit_q_to_one_is_vacuous_but_values_are_correct(): + """At ``d = 1`` (``q → 1``) every coefficient carries a factor of + ``(1 − q)``, so the specialised recurrence is ``0 = 0``. The verdict is + still ``"specializes"`` and the *values* are the classical central + binomial coefficients, but ``is_vacuous`` must say the recurrence itself + is empty.""" + pool = ak.ExprPool() + cert, _q, _n, _k = _vandermonde_cert(pool) + + for n0 in range(6): + spec = cert.specialize_at_root_of_unity(1, n0) + assert spec.status == "specializes" + assert spec.is_vacuous + assert not spec.leading_coefficient_survives + assert any("VACUOUS" in s for s in spec.side_conditions) + + # C(2n, n), computed independently. + c = 1 + for j in range(n0): + c = c * (2 * n0 - j) // (j + 1) + got = _eval_at(spec.sum_value(0), complex(1)) + assert abs(got - c) < 1e-9 + + +def test_a_root_of_unity_can_kill_the_leading_coefficient(): + """At ``d = 2, n = 1`` the leading coefficient dies while the recurrence + is not vacuous — a true statement that is not a usable recurrence, and + ``leading_coefficient_survives`` must say so.""" + pool = ak.ExprPool() + cert, _q, _n, _k = _vandermonde_cert(pool) + + spec = cert.specialize_at_root_of_unity(2, 1) + assert spec.status == "specializes" + assert not spec.is_vacuous + assert not spec.leading_coefficient_survives + assert any("leading coefficient" in s for s in spec.side_conditions) + + +def test_the_support_shrinks_at_a_root_of_unity_and_says_so(): + """``q``-Lucas kills terms: ``[2;1]_q = 1 + q`` is non-zero in ``Q(q)`` and + zero at ``ζ_2``, so at ``d = 2, n = 2`` the effective window is ``{0, 2}`` + and not ``{0, 1, 2}`` — and this must be reported, not silently absorbed.""" + pool = ak.ExprPool() + cert, _q, _n, _k = _vandermonde_cert(pool) + + spec = cert.specialize_at_root_of_unity(2, 2) + assert spec.status == "specializes" + assert spec.window == (0, 2) + assert spec.effective_support == [0, 2] + assert spec.support_shrinks + assert any("support shrank" in s for s in spec.side_conditions) + + # The value is still correct: [4;2]_zeta_2 = C(2,1)*[0;0] = 2. + got = _eval_at(spec.sum_value(0), complex(-1)) + assert abs(got - 2) < 1e-9 + + +# --------------------------------------------------------------------------- +# Refusals — the part that must actually be reachable, not merely claimed. +# --------------------------------------------------------------------------- + + +def test_a_pole_at_the_root_of_unity_is_obstructed_not_specialized(): + """The A279013 hazard, transplanted: a certificate that is perfectly valid + in ``Q(q)`` (it re-checks cleanly) carries a summand with a genuine pole at + ``ζ_3``. Specialising it anyway would produce a confidently wrong + statement, so the verdict must be ``"obstructed"`` and no specialised + value may be offered — at every ``n`` tested, not just one.""" + pool = ak.ExprPool() + q, n, k = _syms(pool) + b = qbinomial(pool, n, k) + # (q^3; q^3)_1 = 1 - q^3, a constant q-Pochhammer factor with a pole at zeta_3. + pole = qpochhammer(pool, 3, 3, 1) ** pool.integer(-1) + term = b * b * q ** (k * k) * pole + cert = q_zeilberger(term, q, n, k) + assert cert.boundary == "vanishes" + + obstructed = 0 + for n0 in range(7): + spec = cert.specialize_at_root_of_unity(3, n0) + assert spec.status == "obstructed", f"n={n0}: must be obstructed at zeta_3" + assert not spec.specializes + assert "valuation" in spec.reason + assert any("obstructed" in s for s in spec.side_conditions) + with pytest.raises(ValueError): + spec.sum_value(0) + with pytest.raises(ValueError): + spec.coefficient(0) + # The valuation is still available for both shifts -- one of them is + # exactly the exhibited obstruction (S(n0) or S(n0+1), depending on + # n0 mod 3), and neither shift's pole is ever hidden. + valuations = [spec.sum_valuation(i) for i in range(cert.order + 1)] + assert any(v is not None and v < 0 for v in valuations), (n0, valuations) + obstructed += 1 + assert obstructed == 7 + + # The same certificate at a d where the factor is a unit specialises fine: + # the refusal is about zeta_3, not about the term in general. + spec = cert.specialize_at_root_of_unity(5, 2) + assert spec.status == "specializes", spec.reason + + +def test_an_unknown_generic_verdict_stays_unknown_at_a_root_of_unity(): + """A certificate whose *generic* boundary verdict is ``"unknown"`` has no + proved ``Q(q)`` statement to specialise, so the root-of-unity verdict must + stay ``"unknown"`` too — never silently promoted to a claim.""" + pool = ak.ExprPool() + q, n, k = _syms(pool) + term = qpochhammer(pool, 1, 1, n - k) ** pool.integer(-1) + cert = q_zeilberger(term, q, n, k) + assert cert.boundary == "unknown" + + spec = cert.specialize_at_root_of_unity(3, 2) + assert spec.status == "unknown" + assert not spec.specializes + assert "already" in spec.reason + + +def test_malformed_requests_are_coded_errors(): + pool = ak.ExprPool() + cert, _q, _n, _k = _vandermonde_cert(pool) + + with pytest.raises(ak.HolonomicError) as excinfo: + cert.specialize_at_root_of_unity(0, 3) + assert excinfo.value.code == "E-HOLO-023" + + with pytest.raises(ak.HolonomicError) as excinfo: + cert.specialize_at_root_of_unity(100_000, 3) + assert excinfo.value.code == "E-HOLO-023" + + with pytest.raises(ak.HolonomicError) as excinfo: + cert.specialize_at_root_of_unity(3, -1) + assert excinfo.value.code == "E-HOLO-023" + + +# --------------------------------------------------------------------------- +# The modulus, cyclotomic_polynomial, and API shape. +# --------------------------------------------------------------------------- + + +def test_cyclotomic_polynomial_matches_the_classical_table(): + """Checked via the same independent node-walker, at points that pin down + each small-degree integer polynomial (e.g. a monic quadratic is + determined by its values at two points).""" + pool = ak.ExprPool() + + # Phi_1 = q - 1: Phi_1(0) = -1, Phi_1(2) = 1. + phi1 = cyclotomic_polynomial(pool, 1) + assert _eval_at(phi1, complex(0)) == complex(-1) + assert _eval_at(phi1, complex(2)) == complex(1) + # Phi_2 = q + 1. + phi2 = cyclotomic_polynomial(pool, 2) + assert _eval_at(phi2, complex(0)) == complex(1) + # Phi_3 = q^2 + q + 1. + phi3 = cyclotomic_polynomial(pool, 3) + assert _eval_at(phi3, complex(0)) == complex(1) + assert _eval_at(phi3, complex(1)) == complex(3) + # Phi_4 = q^2 + 1. + phi4 = cyclotomic_polynomial(pool, 4) + assert _eval_at(phi4, complex(0)) == complex(1) + assert _eval_at(phi4, complex(1)) == complex(2) + + +def test_cyclotomic_polynomial_rejects_out_of_range_orders(): + pool = ak.ExprPool() + with pytest.raises(ValueError): + cyclotomic_polynomial(pool, 0) + with pytest.raises(ValueError): + cyclotomic_polynomial(pool, 100_000) + + +def test_the_modulus_is_exposed_for_independent_checking(): + """``spec.modulus()`` must be exactly ``Φ_d(q)`` — the same polynomial + :func:`cyclotomic_polynomial` returns — so a caller can redo the whole + divisibility check by hand.""" + pool = ak.ExprPool() + cert, _q, _n, _k = _vandermonde_cert(pool) + spec = cert.specialize_at_root_of_unity(6, 3) + + modulus = spec.modulus() + phi6 = cyclotomic_polynomial(pool, 6) + zeta6 = cmath.exp(2j * cmath.pi / 6) + # Independent numeric check that the two polynomials agree (both should + # be q^2 - q + 1): compare at two points, which pins down a quadratic + # given it is known monic of degree 2. + for point in (complex(0), complex(2), complex(-1)): + assert abs(_eval_at(modulus, point) - _eval_at(phi6, point)) < 1e-9 + assert abs(_eval_at(modulus, zeta6)) < 1e-9 # Phi_6(zeta_6) = 0 + + +def test_accessors_are_properties_and_repr_is_informative(): + pool = ak.ExprPool() + cert, _q, _n, _k = _vandermonde_cert(pool) + spec = cert.specialize_at_root_of_unity(3, 2) + + assert isinstance(spec, QRootOfUnitySpecialization) + # Zero-arg O(1) scalars are properties, not bound methods. + assert isinstance(spec.d, int) + assert isinstance(spec.n, int) + assert isinstance(spec.status, str) + assert isinstance(spec.specializes, bool) + assert isinstance(spec.reason, str) + assert isinstance(spec.is_vacuous, bool) + assert isinstance(spec.leading_coefficient_survives, bool) + assert isinstance(spec.is_termwise_regular, bool) + assert isinstance(spec.support_shrinks, bool) + assert isinstance(spec.side_conditions, list) + assert spec.window is None or isinstance(spec.window, tuple) + assert isinstance(spec.effective_support, list) + + r = repr(spec) + assert "QRootOfUnitySpecialization(d=3, n=2" in r + assert "specializes" in r From 2360823a0494779f414d9c256346864b93b84997 Mon Sep 17 00:00:00 2001 From: Areg Gevorgyan Date: Mon, 17 Aug 2026 02:26:52 +0000 Subject: [PATCH 2/2] fix: avoid a semver break by deriving PositivityCertificate's multiplier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cargo-semver-checks correctly flagged the previous commit: adding a `multiplier` field to PositivityCertificate — a fully-public, exhaustively-constructible struct — is a breaking change regardless of the field's own visibility. Confirmed empirically against three attempts (a pub field, #[non_exhaustive], and a pub(crate)/private field), each tripping a different but equivalent cargo-semver-checks lint. Fix: don't store it. `multiplier()` is now a method that re-derives the answer by brute-force search over the known-small N range (does target·(Σxᵢ²)^N equal the re-expanded identity, for N up to the search's own budget) — the same "recompute, never trust the search" discipline verify() already applies everywhere else in this module. Adding a method is never a semver break. Confirmed locally: `cargo semver-checks check-release -p alkahest-cas --baseline-rev origin/main --only-explicit-features --features groebner` now passes (223/223 checks, no update required). Full test suite, clippy, and rustdoc all clean. Co-Authored-By: Claude Sonnet 5 --- alkahest-core/src/real/sos/cert.rs | 53 +++++++++++++++++++----------- alkahest-core/src/real/sos/mod.rs | 9 ++--- 2 files changed, 36 insertions(+), 26 deletions(-) diff --git a/alkahest-core/src/real/sos/cert.rs b/alkahest-core/src/real/sos/cert.rs index ce68f0a2..40758fc2 100644 --- a/alkahest-core/src/real/sos/cert.rs +++ b/alkahest-core/src/real/sos/cert.rs @@ -100,18 +100,6 @@ pub struct PositivityCertificate { pub degree: u32, /// Terms of the identity; `terms[k]` is `(Π g_i) · σ_k`. pub terms: Vec, - /// A Reznick-style multiplier `σ = (x_1² + … + x_n²)^N`, present exactly - /// when the certificate proves `target ≥ 0` indirectly via `target·σ = - /// Σ terms` rather than directly via `target = Σ terms`. `None` for a - /// direct certificate (the only kind before 3.10.0). - /// - /// `σ` vanishes only at the origin and is strictly positive everywhere - /// else, so `target·σ = Σ terms ≥ 0` gives `target ≥ 0` away from the - /// origin for free; [`Self::verify`] additionally checks `target(0) ≥ - /// 0` directly to close the one point `σ` cannot see, and confirms `σ` - /// really is a power of the sum of squares rather than trusting the - /// search that produced it. - pub multiplier: Option, /// Human-readable audit trail: how the search proceeded. pub log: Vec, } @@ -121,6 +109,34 @@ impl PositivityCertificate { self.vars.len() } + /// A Reznick-style multiplier `σ = (x_1² + … + x_n²)^N`, present exactly + /// when the certificate proves `target ≥ 0` indirectly via `target·σ = + /// Σ terms` rather than directly via `target = Σ terms`. `None` for a + /// direct certificate (the only kind before 3.10.0). + /// + /// Not stored: derived here by literally re-deriving `N` (trying every + /// `N` up to the search's own budget and checking `target·(Σxᵢ²)^N ==` + /// the re-expanded identity) rather than trusted from whatever the search + /// that produced this certificate happened to remember — the same + /// "recompute, never trust the search" discipline [`Self::verify`] + /// already applies to everything else in this type, extended one step + /// further so this struct's field set never needs to record which route + /// a certificate took, only the identity it proves. + pub fn multiplier(&self) -> Option { + let rhs = self.expand(); + if self.target == rhs { + return None; + } + let sigma_base = RatPoly::sum_of_squares(self.nvars()); + for n in 1..=super::MAX_MULTIPLIER_POWER { + let sigma = sigma_base.pow(n); + if self.target.mul(&sigma) == rhs { + return Some(sigma); + } + } + None + } + /// Expand the right-hand side of the certificate identity, exactly. pub fn expand(&self) -> RatPoly { let n = self.vars.len(); @@ -164,7 +180,7 @@ impl PositivityCertificate { } } let rhs = self.expand(); - match &self.multiplier { + match self.multiplier() { None => { if self.target == rhs { Ok(()) @@ -177,7 +193,7 @@ impl PositivityCertificate { } } Some(sigma) => { - let lhs = self.target.mul(sigma); + let lhs = self.target.mul(&sigma); if lhs != rhs { let diff = lhs.sub(&rhs); return Err(format!( @@ -195,7 +211,7 @@ impl PositivityCertificate { } let n = deg / 2; let expected = RatPoly::sum_of_squares(self.nvars()).pow(n); - if *sigma != expected { + if sigma != expected { return Err( "multiplier is not recognised as (x_1^2 + ... + x_n^2)^N for \ any N, so its non-negativity is not established by this \ @@ -279,7 +295,7 @@ impl PositivityCertificate { // a certificate is that a reader can check `target = rhs` (or // `target * multiplier = rhs`, for a multiplier certificate) by // expanding. - match &self.multiplier { + match self.multiplier() { None => format!("{} = {}", self.target.display(&self.var_names), rhs), Some(sigma) => format!( "({}) * ({}) = {}", @@ -344,8 +360,8 @@ impl PositivityCertificate { )); out.push_str(&format!("-- {}\n\n", self.claim_string())); - if let Some(sigma) = &self.multiplier { - out.push_str(&self.lean_multiplier_block(&binders, &target, &rhs, sigma)); + if let Some(sigma) = self.multiplier() { + out.push_str(&self.lean_multiplier_block(&binders, &target, &rhs, &sigma)); } else if self.constraints.is_empty() { out.push_str(&format!( "theorem alkahest_sos_identity {binders}:\n {target} = {rhs} := by\n ring\n\n" @@ -569,7 +585,6 @@ mod tests { constraints: vec![], sos, }], - multiplier: None, log: vec![], } } diff --git a/alkahest-core/src/real/sos/mod.rs b/alkahest-core/src/real/sos/mod.rs index ba8c81d5..37acec87 100644 --- a/alkahest-core/src/real/sos/mod.rs +++ b/alkahest-core/src/real/sos/mod.rs @@ -241,7 +241,7 @@ fn finish(cert: PositivityCertificate) -> Result